feat: push selected events to a phone over ntfy

Adds a Notifications tab to Settings so an operator can pick which events
reach their phone, published to a single self-hosted ntfy topic.

The notifier subscribes to the realtime event hub the dashboard already
consumes, so every notifiable event keeps exactly one publisher. Inbound
media had no event, so `media.received` is added and published from the
existing durable `gotImage` gate in the chat service — one notification
per conversation, never repeated when the room re-syncs. It feeds the
in-app notification bell as well.

- Best-effort delivery: a rejecting or unreachable ntfy server is logged
  and never propagated into the path that produced the event.
- Events buffered before the notifier attaches are skipped, so a restart
  cannot replay a backlog to somebody's phone.
- Settings are applied field by field onto the live config object, so a
  save takes effect without a restart.
- The access token is environment-owned (NTFY_AUTH_TOKEN) like the other
  secrets: stripped before the settings record is written, never returned
  by the API, which reports only `hasAuthToken`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 13:55:00 +01:00
1 parent 827eb3a1e0
commit 38d175e4a1
34 files changed
+1728 -8

No files matched your search

+11
View File
@@ -77,6 +77,17 @@ export function notificationForEvent(event: DashboardEvent): NotificationDraft |
tone: 'success',
href: `/conversations/${encodeURIComponent(event.payload.conversationId)}`,
};
case 'media.received':
return {
id: event.id,
occurredAt: event.occurredAt,
title: `Image received from u/${event.payload.sender}`,
message: event.payload.count > 1
? `${event.payload.count} images are attached to this conversation.`
: 'An image is attached to this conversation.',
tone: 'success',
href: `/conversations/${encodeURIComponent(event.payload.conversationId)}`,
};
case 'message.sent':
return {
id: event.id,
@@ -10,6 +10,7 @@ export const DASHBOARD_EVENT_TYPES = [
'message.failed',
'conversation.updated',
'reply.received',
'media.received',
'sync.started',
'sync.progress',
'sync.completed',
@@ -122,6 +123,8 @@ export function queryKeysForDashboardEvent(event: DashboardEvent): readonly Quer
case 'conversation.updated':
case 'reply.received':
return [['conversations'], ['operations', 'activity']];
case 'media.received':
return [['conversations'], ['media'], ['operations', 'activity']];
case 'sync.started':
case 'sync.progress':
return [['operations', 'sync-status']];
@@ -0,0 +1,46 @@
import type { ZodType } from 'zod';
import {
ErrorResponseSchema,
PushNotificationSettingsResponseSchema,
PushNotificationSettingsUpdateSchema,
PushNotificationTestResponseSchema,
type PushNotificationSettingsResponse,
type PushNotificationSettingsUpdate,
type PushNotificationTestResponse,
} from '../../../../../packages/api-contracts/src/index.js';
import { dashboardRequest } from '../../api/request.js';
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const parsedError = ErrorResponseSchema.safeParse(payload);
throw new Error(parsedError.success ? parsedError.data.error : `Request failed (${response.status})`);
}
return schema.parse(payload);
}
export function fetchPushNotifications(): Promise<PushNotificationSettingsResponse> {
return dashboardRequest('/api/push-notifications')
.then((response) => parseResponse(response, PushNotificationSettingsResponseSchema));
}
export async function savePushNotifications(
updates: PushNotificationSettingsUpdate,
): Promise<PushNotificationSettingsResponse> {
const body = PushNotificationSettingsUpdateSchema.parse(updates);
return parseResponse(
await dashboardRequest('/api/push-notifications', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
PushNotificationSettingsResponseSchema,
);
}
export async function sendTestPushNotification(): Promise<PushNotificationTestResponse> {
return parseResponse(
await dashboardRequest('/api/push-notifications/test', { method: 'POST' }),
PushNotificationTestResponseSchema,
);
}
@@ -0,0 +1,271 @@
import { useEffect, useState } from 'react';
import {
NTFY_TOPIC_PATTERN,
PUSH_NOTIFICATION_EVENT_KEYS,
type PushNotificationEventKey,
type PushNotificationSettingsResponse,
} from '../../../../../packages/api-contracts/src/index.js';
import {
usePushNotificationsQuery,
useSavePushNotificationsMutation,
useTestPushNotificationMutation,
} from './settings-queries.js';
type Priority = PushNotificationSettingsResponse['priority'];
interface PushDraft {
enabled: boolean;
serverUrl: string;
topic: string;
priority: Priority;
events: Record<PushNotificationEventKey, boolean>;
}
const eventLabels: Record<PushNotificationEventKey, { label: string; description: string }> = {
imageReceived: { label: 'Image received', description: 'Someone sends an image into a conversation.' },
replyReceived: { label: 'Reply received', description: 'A conversation gets its first reply.' },
messageFailed: { label: 'Message failed', description: 'An outbound message could not be delivered.' },
botStopped: { label: 'Bot stopped', description: 'Processing stops, whether requested or not.' },
rateLimited: { label: 'Rate limit reached', description: 'Sending pauses until the limit resets.' },
sessionExpired: { label: 'Account needs attention', description: 'A Reddit session expired or failed.' },
syncFailed: { label: 'Sync failed', description: 'A conversation or media sync did not complete.' },
};
const priorities: ReadonlyArray<{ value: Priority; label: string }> = [
{ value: 'min', label: 'Min' },
{ value: 'low', label: 'Low' },
{ value: 'default', label: 'Default' },
{ value: 'high', label: 'High' },
{ value: 'max', label: 'Max' },
];
function toDraft(settings: PushNotificationSettingsResponse): PushDraft {
return {
enabled: settings.enabled,
serverUrl: settings.serverUrl,
topic: settings.topic,
priority: settings.priority,
events: { ...settings.events },
};
}
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function validate(draft: PushDraft): Record<string, string> {
const errors: Record<string, string> = {};
const serverUrl = draft.serverUrl.trim();
if (serverUrl === '') {
errors.serverUrl = 'Server URL is required.';
} else {
try {
const url = new URL(serverUrl);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
errors.serverUrl = 'Server URL must be an http:// or https:// address.';
}
} catch {
errors.serverUrl = 'Server URL must be an http:// or https:// address.';
}
}
const topic = draft.topic.trim();
if (topic === '') {
if (draft.enabled) errors.topic = 'A topic is required before notifications can be enabled.';
} else if (!NTFY_TOPIC_PATTERN.test(topic)) {
errors.topic = 'Topic may only contain letters, numbers, hyphens and underscores.';
}
return errors;
}
function isDirty(draft: PushDraft, persisted: PushDraft): boolean {
return JSON.stringify(draft) !== JSON.stringify(persisted);
}
export function PushNotificationsPanel() {
const settingsQuery = usePushNotificationsQuery();
const saveMutation = useSavePushNotificationsMutation();
const testMutation = useTestPushNotificationMutation();
const settings = settingsQuery.data;
const [state, setState] = useState<{ persisted: PushDraft; draft: PushDraft } | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
// Seed on first load, and reconcile server refetches: adopt them into a
// clean editor, keep the operator's edits in a dirty one.
useEffect(() => {
if (!settings) return;
const serverDraft = toDraft(settings);
setState((current) => {
if (!current) return { persisted: serverDraft, draft: serverDraft };
if (!isDirty(serverDraft, current.persisted)) return current;
return isDirty(current.draft, current.persisted)
? { persisted: serverDraft, draft: current.draft }
: { persisted: serverDraft, draft: serverDraft };
});
}, [settings]);
if (settingsQuery.isError) {
return (
<section className="panel error-panel" role="alert">
<strong>Notification settings unavailable</strong><span>{errorMessage(settingsQuery.error)}</span>
<button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry notifications</button>
</section>
);
}
if (!state) {
return (
<section aria-label="Loading notifications" className="panel panel-loading" role="status">
<span className="spinner" />Loading notification settings…
</section>
);
}
const { draft, persisted } = state;
function update(next: Partial<PushDraft>) {
saveMutation.reset();
testMutation.reset();
setErrors({});
setState((current) => (current ? { ...current, draft: { ...current.draft, ...next } } : current));
}
function toggleEvent(key: PushNotificationEventKey, value: boolean) {
update({ events: { ...draft.events, [key]: value } });
}
function save() {
const validation = validate(draft);
setErrors(validation);
if (Object.keys(validation).length > 0) return;
saveMutation.mutate({
enabled: draft.enabled,
serverUrl: draft.serverUrl.trim(),
topic: draft.topic.trim(),
priority: draft.priority,
events: draft.events,
}, {
onSuccess(response) {
const next = toDraft(response);
setState({ persisted: next, draft: next });
},
});
}
const dirty = isDirty(draft, persisted);
const selectedCount = PUSH_NOTIFICATION_EVENT_KEYS.filter((key) => draft.events[key]).length;
return (
<section aria-labelledby="push-notifications-heading" className="panel settings-section">
<div className="panel-heading">
<div>
<span className="eyebrow">Phone push</span>
<h2 id="push-notifications-heading">Notifications</h2>
</div>
<span className={draft.enabled ? 'pill success' : 'pill warning'}>{draft.enabled ? 'Enabled' : 'Disabled'}</span>
</div>
<p className="settings-copy">
Selected events are published to one ntfy topic. Subscribe to the same topic in the ntfy app on your
phone to receive them. Anyone who knows the topic name can read it, so treat it as a secret.
</p>
<label className="toggle-field">
<input
checked={draft.enabled}
onChange={(event) => update({ enabled: event.target.checked })}
type="checkbox"
/>
<span><strong>Send push notifications</strong><small>Turn off to silence every event without losing your selection.</small></span>
</label>
<div className="settings-grid">
<label className="settings-field">
<span>Server URL</span>
<input
aria-invalid={errors.serverUrl ? 'true' : undefined}
onChange={(event) => update({ serverUrl: event.target.value })}
placeholder="https://ntfy.example.com"
type="url"
value={draft.serverUrl}
/>
{errors.serverUrl ? <small className="field-error">{errors.serverUrl}</small> : <small>Your self-hosted ntfy instance.</small>}
</label>
<label className="settings-field">
<span>Topic</span>
<input
aria-invalid={errors.topic ? 'true' : undefined}
onChange={(event) => update({ topic: event.target.value })}
placeholder="redditbot-7f3a9c21"
type="text"
value={draft.topic}
/>
{errors.topic ? <small className="field-error">{errors.topic}</small> : <small>Letters, numbers, hyphens and underscores.</small>}
</label>
<label className="settings-field">
<span>Priority</span>
<select onChange={(event) => update({ priority: event.target.value as Priority })} value={draft.priority}>
{priorities.map((priority) => (
<option key={priority.value} value={priority.value}>{priority.label}</option>
))}
</select>
<small>How insistently the ntfy app alerts you.</small>
</label>
</div>
<p className="settings-copy">
{settings?.hasAuthToken
? 'An access token is configured, so protected topics will accept these notifications.'
: 'No access token is configured. Set NTFY_AUTH_TOKEN in the environment if your topic requires one.'}
</p>
<fieldset className="settings-events">
<legend>Events to notify me about ({selectedCount} of {PUSH_NOTIFICATION_EVENT_KEYS.length})</legend>
{PUSH_NOTIFICATION_EVENT_KEYS.map((key) => (
<label className="toggle-field" key={key}>
<input
checked={draft.events[key]}
onChange={(event) => toggleEvent(key, event.target.checked)}
type="checkbox"
/>
<span><strong>{eventLabels[key].label}</strong><small>{eventLabels[key].description}</small></span>
</label>
))}
</fieldset>
<div className="settings-save-bar">
<div aria-live="polite">
{dirty ? <span role="status">You have unsaved changes.</span> : <span>All changes saved.</span>}
{saveMutation.isSuccess && !dirty ? <strong className="save-success">Notification settings saved.</strong> : null}
{saveMutation.isError ? <strong className="save-error" role="alert">{errorMessage(saveMutation.error)}</strong> : null}
{testMutation.isSuccess ? (
<strong className={testMutation.data.delivered ? 'save-success' : 'save-error'} role="status">
{testMutation.data.delivered
? 'Test notification sent — check your phone.'
: `Test notification not sent: ${testMutation.data.reason ?? 'unknown reason'}`}
</strong>
) : null}
{testMutation.isError ? <strong className="save-error" role="alert">{errorMessage(testMutation.error)}</strong> : null}
</div>
<div className="header-actions">
<button
className="button secondary"
disabled={dirty || !draft.enabled || testMutation.isPending}
onClick={() => testMutation.mutate()}
title={dirty ? 'Save your changes before sending a test.' : undefined}
type="button"
>
{testMutation.isPending ? 'Sending test…' : 'Send test notification'}
</button>
<button
aria-busy={saveMutation.isPending ? 'true' : undefined}
className="button primary"
disabled={!dirty || saveMutation.isPending}
onClick={save}
type="button"
>
{saveMutation.isPending ? 'Saving…' : 'Save notifications'}
</button>
</div>
</div>
</section>
);
}
@@ -3,6 +3,7 @@ import { useBlocker } from 'react-router-dom';
import type { ConfigResponse, ConfigUpdateRequest } from '../../../../../packages/api-contracts/src/index.js';
import { Modal } from '../../components/modal.js';
import { AccessPanel } from './access-panel.js';
import { PushNotificationsPanel } from './push-notifications-panel.js';
import { RiskDialog } from './risk-dialog.js';
import { useSaveSettingsMutation } from './settings-queries.js';
import {
@@ -180,9 +181,11 @@ export function SettingsEditor({ config, section }: SettingsEditorProps) {
{section === 'monitoring' ? <MonitoringSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'rate-limits' ? <RateLimitsSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'behaviour' ? <BehaviourSection config={config} draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'notifications' ? <PushNotificationsPanel /> : null}
{section === 'access' ? <AccessPanel /> : null}
{section !== 'access' ? (
{/* Both panels own their own persistence, so the shared save bar is theirs to skip. */}
{section !== 'access' && section !== 'notifications' ? (
<div className="settings-save-bar">
<div aria-live="polite">
{isDirty ? <span role="status">You have unsaved changes.</span> : <span>All changes saved.</span>}
@@ -2,7 +2,7 @@ import { SettingsEditor } from './settings-editor.js';
import { useSettingsQuery } from './settings-queries.js';
import './settings.css';
export type SettingsSection = 'monitoring' | 'rate-limits' | 'behaviour' | 'access';
export type SettingsSection = 'monitoring' | 'rate-limits' | 'behaviour' | 'notifications' | 'access';
interface SettingsPageProps {
section: SettingsSection;
@@ -13,6 +13,7 @@ const sections: ReadonlyArray<{ id: SettingsSection; label: string; description:
{ id: 'monitoring', label: 'Monitoring', description: 'Post discovery and recency' },
{ id: 'rate-limits', label: 'Rate limits', description: 'Sending pace and safeguards' },
{ id: 'behaviour', label: 'Behaviour', description: 'Dry run and subreddits' },
{ id: 'notifications', label: 'Notifications', description: 'Phone push via ntfy' },
{ id: 'access', label: 'Access', description: 'Extension session dump' },
];
@@ -1,13 +1,37 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { fetchLoginStatus, fetchRedditAccounts, fetchSettings, importRedditSession, saveSettings } from './settings-api.js';
import {
fetchPushNotifications,
savePushNotifications,
sendTestPushNotification,
} from './push-notifications-api.js';
export const settingsQueryKeys = {
config: ['dashboard', 'config'] as const,
loginStatus: ['dashboard', 'login', 'status'] as const,
accounts: ['dashboard', 'reddit-accounts'] as const,
pushNotifications: ['dashboard', 'push-notifications'] as const,
};
export function usePushNotificationsQuery() {
return useQuery({ queryKey: settingsQueryKeys.pushNotifications, queryFn: fetchPushNotifications });
}
export function useSavePushNotificationsMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: savePushNotifications,
onSuccess(response) {
queryClient.setQueryData(settingsQueryKeys.pushNotifications, response);
},
});
}
export function useTestPushNotificationMutation() {
return useMutation({ mutationFn: sendTestPushNotification });
}
export function useSettingsQuery() {
return useQuery({ queryKey: settingsQueryKeys.config, queryFn: fetchSettings });
}
@@ -1,7 +1,7 @@
import { useSearchParams } from 'react-router-dom';
import { SettingsPage, type SettingsSection } from './settings-page.js';
const validSections: readonly SettingsSection[] = ['monitoring', 'rate-limits', 'behaviour', 'access'];
const validSections: readonly SettingsSection[] = ['monitoring', 'rate-limits', 'behaviour', 'notifications', 'access'];
function parseSection(value: string | null): SettingsSection {
return validSections.find((section) => section === value) ?? 'monitoring';
@@ -18,6 +18,9 @@
.toggle-field input { width: 1.15rem; height: 1.15rem; margin-top: .15rem; accent-color: var(--accent); }
.toggle-field span { display: grid; gap: .2rem; }
.toggle-field small { color: var(--muted); }
.settings-events { display: grid; gap: .6rem; padding: 1rem; border: 1px solid var(--border); border-radius: .75rem; }
.settings-events legend { padding: 0 .4rem; color: var(--accent); font-weight: 800; }
.subreddit-heading { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; padding-top: .5rem; }
.subreddit-heading h3 { margin: 0 0 .25rem; }
.subreddit-heading p { margin: 0; color: var(--muted); }
@@ -45,7 +48,7 @@
}
@media (min-width: 70rem) {
.settings-navigation { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.settings-navigation { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
.reddit-account-list {
display: grid;
+16
View File
@@ -59,6 +59,22 @@ conversations:
killConfidence: 0.8
flagConfidence: 0.6
pushNotifications:
# Phone push via ntfy. The topic is world-readable to anyone who guesses it,
# so use an unguessable name. Set NTFY_AUTH_TOKEN for a protected topic.
enabled: false
serverUrl: "https://ntfy.sh" # Point at your self-hosted instance
topic: "" # e.g. redditbot-7f3a9c21
priority: default # min | low | default | high | max
events:
imageReceived: true
replyReceived: false
messageFailed: false
botStopped: false
rateLimited: false
sessionExpired: true
syncFailed: false
openrouter:
imageAnalysisModel: "google/gemini-2.5-flash-lite"
imageAnalysisFallbackModels:
+1
View File
@@ -17,6 +17,7 @@ services:
- WEB_DASHBOARD_TOKEN=${WEB_DASHBOARD_TOKEN}
- REDDIT_BOT_AGENT_READ_TOKEN=${REDDIT_BOT_AGENT_READ_TOKEN:-}
- REDDIT_BOT_AGENT_CONTROL_TOKEN=${REDDIT_BOT_AGENT_CONTROL_TOKEN:-}
- NTFY_AUTH_TOKEN=${NTFY_AUTH_TOKEN:-}
volumes:
reddit_bot_data:
+3
View File
@@ -103,6 +103,9 @@ an administrator credential.
| `GET` | `/api/outreach` | Get active outreach candidates and funnel stages | read | no |
| `GET` | `/api/config` | Get non-secret application configuration | read | no |
| `PUT` | `/api/config` | Update application configuration | admin | yes |
| `GET` | `/api/push-notifications` | Get push notification settings | read | no |
| `PUT` | `/api/push-notifications` | Update push notification settings | admin | yes |
| `POST` | `/api/push-notifications/test` | Send a test push notification | admin | yes |
| `GET` | `/api/login/status` | Get Reddit session state | read | no |
| `POST` | `/api/login/session-import` | Import Reddit and Matrix credentials | admin | yes |
| `GET` | `/api/accounts` | List Reddit accounts | read | no |
+7
View File
@@ -18,6 +18,7 @@ After the initial seed, editing `config.yaml` does not replace an existing `app_
| `OPENROUTER_API_KEY` | Yes | OpenRouter authentication. |
| `WEB_DASHBOARD_TOKEN` | When web is enabled | Administrator UI/API credential. |
| `DATABASE_PATH` | No | SQLite location; defaults to `./data/reddit-bot.db`. |
| `NTFY_AUTH_TOKEN` | No | Bearer token for a protected ntfy topic. |
| `REDDIT_BOT_AGENT_READ_TOKEN` | No | Read-only API/SSE bearer credential. |
| `REDDIT_BOT_AGENT_CONTROL_TOKEN` | No | Read plus versioned control/job bearer credential. |
| `REDDIT_BOT_API_URL` | MCP only | Base URL; defaults to `http://127.0.0.1:3000`. |
@@ -73,6 +74,12 @@ Contains `path`, but runtime ownership is `DATABASE_PATH`, not the persisted app
Controls periodic sync enablement and interval. Media work remains authentication-bound.
### `pushNotifications`
Publishes selected application events to a single ntfy topic so they reach a phone. Holds the enabled flag, server URL, topic, ntfy priority, and a boolean per event (`imageReceived`, `replyReceived`, `messageFailed`, `botStopped`, `rateLimited`, `sessionExpired`, `syncFailed`). Editable from Settings → Notifications, which writes through `PUT /api/push-notifications`; `POST /api/push-notifications/test` sends a fixed test notification. The access token is environment-owned (`NTFY_AUTH_TOKEN`), is never persisted to the settings record, and is never returned by the API — the response only reports `hasAuthToken`.
Delivery is best-effort: an unreachable or rejecting ntfy server is logged and never propagated into the path that produced the event.
### `strategies` and `chatStrategies`
Each has exploration rate, minimum mature samples, response window, and weighted variants. Variants include an ID and prompt override. Image and chat sources use separate sections so their results are not compared as one experiment.
+9
View File
@@ -83,6 +83,15 @@ export const DashboardEventSchema = z.discriminatedUnion('type', [
preview: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('media.received'),
payload: z.strictObject({
conversationId: z.string(),
sender: z.string(),
count: z.number(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('sync.started'),
+1
View File
@@ -7,5 +7,6 @@ export * from './events.js';
export * from './media.js';
export * from './messages.js';
export * from './outreach.js';
export * from './push-notifications.js';
export * from './session.js';
export * from './stats.js';
@@ -0,0 +1,93 @@
import { z } from 'zod';
/**
* Events an operator can subscribe to for phone push notifications. Keys are
* shared verbatim between the config schema, the API and the dashboard so a
* new event only has to be added in one place.
*/
export const PUSH_NOTIFICATION_EVENT_KEYS = [
'imageReceived',
'replyReceived',
'messageFailed',
'botStopped',
'rateLimited',
'sessionExpired',
'syncFailed',
] as const;
export type PushNotificationEventKey = (typeof PUSH_NOTIFICATION_EVENT_KEYS)[number];
export const PushNotificationPrioritySchema = z.union([
z.literal('min'),
z.literal('low'),
z.literal('default'),
z.literal('high'),
z.literal('max'),
]);
export const PushNotificationEventsSchema = z.strictObject({
imageReceived: z.boolean(),
replyReceived: z.boolean(),
messageFailed: z.boolean(),
botStopped: z.boolean(),
rateLimited: z.boolean(),
sessionExpired: z.boolean(),
syncFailed: z.boolean(),
});
/** ntfy restricts topic names to this alphabet. */
export const NTFY_TOPIC_PATTERN = /^[-_A-Za-z0-9]{1,64}$/;
export const PushNotificationServerUrlSchema = z
.string()
.trim()
.refine((value) => {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}, 'Server URL must be an http:// or https:// address.');
export const PushNotificationTopicSchema = z
.string()
.trim()
.refine(
(value) => value === '' || NTFY_TOPIC_PATTERN.test(value),
'Topic may only contain letters, numbers, hyphens and underscores.',
);
export const PushNotificationSettingsSchema = z.strictObject({
enabled: z.boolean(),
serverUrl: PushNotificationServerUrlSchema,
topic: PushNotificationTopicSchema,
priority: PushNotificationPrioritySchema,
events: PushNotificationEventsSchema,
});
export const PushNotificationSettingsResponseSchema = PushNotificationSettingsSchema.extend({
/**
* Whether an access token is configured. The token itself is
* environment-owned and is never returned over the API.
*/
hasAuthToken: z.boolean(),
});
export const PushNotificationSettingsUpdateSchema = z.strictObject({
enabled: z.boolean().optional(),
serverUrl: PushNotificationServerUrlSchema.optional(),
topic: PushNotificationTopicSchema.optional(),
priority: PushNotificationPrioritySchema.optional(),
events: PushNotificationEventsSchema.partial().optional(),
});
export const PushNotificationTestResponseSchema = z.strictObject({
delivered: z.boolean(),
reason: z.string().optional(),
});
export type PushNotificationSettings = z.infer<typeof PushNotificationSettingsSchema>;
export type PushNotificationSettingsResponse = z.infer<typeof PushNotificationSettingsResponseSchema>;
export type PushNotificationSettingsUpdate = z.infer<typeof PushNotificationSettingsUpdateSchema>;
export type PushNotificationTestResponse = z.infer<typeof PushNotificationTestResponseSchema>;
+14
View File
@@ -39,6 +39,7 @@ import { MediaSyncService } from './services/media-sync.js';
import { RateLimiter } from './utils/rate-limiter.js';
import { WebServer } from './web/server.js';
import { reportReply } from './web/event-reporting.js';
import { PushNotifier } from './services/push-notifier.js';
import { RedditSessionImporter } from './services/reddit-session-importer.js';
import { AccountAuthContext, authenticateCurrentAccount } from './services/account-auth-context.js';
import { ConfigStore } from './config/persistence.js';
@@ -88,6 +89,7 @@ export interface AppContainer {
mediaSyncService: MediaSyncService;
mediaLibraryService: MediaLibraryService;
rateLimiter: RateLimiter;
pushNotifier: PushNotifier;
webServer: WebServer | null;
}
@@ -117,6 +119,11 @@ export async function bootstrap(
attachLogSink(logger, broadcaster);
}
// Phone push notifications ride the same typed event stream the dashboard
// consumes, so every notifiable event has exactly one publisher.
const pushNotifier = new PushNotifier(() => config.pushNotifications, logger);
pushNotifier.attach(broadcaster);
// Database
const db = existingDatabase ?? createDatabase(config);
runMigrations(db);
@@ -423,6 +430,11 @@ export async function bootstrap(
lastSyncProgress = null;
}
});
chatService.setMediaReceivedListener(({ conversationId, username, count }) => {
webServer!.logActivity('response', `Image received from u/${username}`, username);
webServer!.pushLog('info', `🖼️ Image from u/${username}`);
broadcaster.dashboardEvents.mediaReceived({ conversationId, username, count });
});
chatService.setNewReplyListener(({ conversationId, replyId, username, replySnippet }) => {
webServer!.logActivity(
'response',
@@ -453,6 +465,7 @@ export async function bootstrap(
webServer.setConversationReadStateRepository(conversationReadStateRepo);
webServer.setCandidateQueueRepository(candidateQueueRepo);
webServer.setConfigStore(new ConfigStore(db));
webServer.setPushNotifier(pushNotifier);
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
}
@@ -500,6 +513,7 @@ export async function bootstrap(
mediaSyncService,
mediaLibraryService,
rateLimiter,
pushNotifier,
webServer,
};
}
+49
View File
@@ -147,6 +147,50 @@ export const ConfigSchema = z.object({
killOnSellerKeyword: true,
aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
}),
pushNotifications: z.object({
enabled: z.boolean().default(false),
/** Base URL of the ntfy server, e.g. https://ntfy.example.com. */
serverUrl: z.string().default('https://ntfy.sh'),
/** Single ntfy topic every selected event is published to. */
topic: z.string().default(''),
/**
* Bearer token for a protected topic. Environment-owned (NTFY_AUTH_TOKEN)
* like the other secrets, so it is never written to the settings record.
*/
authToken: z.string().optional(),
priority: z.enum(['min', 'low', 'default', 'high', 'max']).default('default'),
events: z.object({
imageReceived: z.boolean().default(true),
replyReceived: z.boolean().default(false),
messageFailed: z.boolean().default(false),
botStopped: z.boolean().default(false),
rateLimited: z.boolean().default(false),
sessionExpired: z.boolean().default(true),
syncFailed: z.boolean().default(false),
}).default({
imageReceived: true,
replyReceived: false,
messageFailed: false,
botStopped: false,
rateLimited: false,
sessionExpired: true,
syncFailed: false,
}),
}).default({
enabled: false,
serverUrl: 'https://ntfy.sh',
topic: '',
priority: 'default',
events: {
imageReceived: true,
replyReceived: false,
messageFailed: false,
botStopped: false,
rateLimited: false,
sessionExpired: true,
syncFailed: false,
},
}),
openrouter: z.object({
apiKey: z.string(),
imageAnalysisModel: z.string().default('google/gemini-3-flash-preview'),
@@ -245,6 +289,11 @@ export function loadConfig(store?: ConfigPersistence): Config {
// its path is an environment-owned bootstrap value rather than app config.
rawConfig.database.path = process.env.DATABASE_PATH ?? './data/reddit-bot.db';
if (process.env.NTFY_AUTH_TOKEN?.trim()) {
if (!rawConfig.pushNotifications) rawConfig.pushNotifications = {};
rawConfig.pushNotifications.authToken = process.env.NTFY_AUTH_TOKEN;
}
if (process.env.WEB_DASHBOARD_TOKEN !== undefined) {
if (!rawConfig.web) rawConfig.web = {};
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN;
+9 -2
View File
@@ -1,9 +1,10 @@
import type Database from 'better-sqlite3';
import { ConfigSchema, type Config } from './index.js';
type StoredConfig = Omit<Config, 'database' | 'openrouter' | 'web'> & {
type StoredConfig = Omit<Config, 'database' | 'openrouter' | 'web' | 'pushNotifications'> & {
openrouter: Omit<Config['openrouter'], 'apiKey'>;
web: Omit<Config['web'], 'authToken' | 'agentReadToken' | 'agentControlToken'>;
pushNotifications: Omit<Config['pushNotifications'], 'authToken'>;
};
/**
@@ -51,7 +52,8 @@ function toStoredConfig(config: Config): StoredConfig {
agentControlToken: _agentControlToken,
...web
} = applicationConfig.web;
return { ...applicationConfig, openrouter, web };
const { authToken: _ntfyToken, ...pushNotifications } = applicationConfig.pushNotifications;
return { ...applicationConfig, openrouter, web, pushNotifications };
}
function withBootstrapSettings(stored: unknown, environment: NodeJS.ProcessEnv): unknown {
@@ -59,8 +61,13 @@ function withBootstrapSettings(stored: unknown, environment: NodeJS.ProcessEnv):
const record = stored as Record<string, unknown>;
const openrouter = objectValue(record.openrouter);
const web = objectValue(record.web);
const pushNotifications = objectValue(record.pushNotifications);
return {
...record,
pushNotifications: {
...pushNotifications,
authToken: optionalSecret(environment.NTFY_AUTH_TOKEN),
},
database: { path: environment.DATABASE_PATH ?? './data/reddit-bot.db' },
openrouter: { ...openrouter, apiKey: environment.OPENROUTER_API_KEY },
web: {
+2
View File
@@ -7,6 +7,8 @@ export type {
SyncPhase,
SyncCompletion,
SyncProgressListener,
NewReplyListener,
MediaReceivedListener,
ConversationStartResult,
} from './service.js';
export type {
+28 -2
View File
@@ -135,6 +135,13 @@ export type NewReplyListener = (info: {
replySnippet: string;
}) => void;
/** Fired the first time a conversation we opened receives inbound media. */
export type MediaReceivedListener = (info: {
conversationId: string;
username: string;
count: number;
}) => void;
export class ChatService {
private cache: ChatCache;
private queue: Promise<unknown> = Promise.resolve();
@@ -147,6 +154,7 @@ export class ChatService {
private progress: SyncProgress | null = null;
private progressListener: SyncProgressListener | null = null;
private newReplyListener: NewReplyListener | null = null;
private mediaReceivedListener: MediaReceivedListener | null = null;
private invitationCooldown: InvitationCooldown | null = null;
constructor(
@@ -535,6 +543,11 @@ export class ChatService {
this.newReplyListener = listener;
}
/** Subscribe to inbound-media notifications (fires once per chat, on the first image). */
setMediaReceivedListener(listener: MediaReceivedListener | null): void {
this.mediaReceivedListener = listener;
}
private beginSync(kind: 'full' | 'delta'): void {
this.syncingKind = kind;
this.progress = {
@@ -1167,12 +1180,25 @@ export class ChatService {
);
const incomingMessages = conversationMessages.filter((message) => !message.isSelf);
const hasReply = incomingMessages.length > 0;
const hasReceivedImage = incomingMessages.some(
(message) => (message.media?.length ?? 0) > 0,
const incomingMediaCount = incomingMessages.reduce(
(total, message) => total + (message.media?.length ?? 0),
0,
);
const hasReceivedImage = incomingMediaCount > 0;
const firstAttributedReply = hasReply && !performanceMessage.gotResponse;
if (hasReceivedImage && !performanceMessage.gotImage) {
this.messagesRepo.markGotImage(performanceMessage.id);
// Guarded by the durable `gotImage` flag, so re-syncing a conversation
// cannot re-notify for media that was already reported.
try {
this.mediaReceivedListener?.({
conversationId: chat.roomId,
username: chat.username,
count: incomingMediaCount,
});
} catch (err) {
this.logger.warn('[chat] mediaReceivedListener threw:', err);
}
}
if (hasReply && this.successfulMessagesRepo && this.outcomesRepo) {
+217
View File
@@ -0,0 +1,217 @@
import type { Logger } from '../utils/logger.js';
import type { Config } from '../config/index.js';
import type { RealtimeEvent } from '../web/events/event-types.js';
export type PushNotificationSettings = Config['pushNotifications'];
export type PushNotificationEventKey = keyof PushNotificationSettings['events'];
export interface PushNotificationDraft {
event: PushNotificationEventKey;
title: string;
body: string;
/** ntfy emoji shortcodes rendered alongside the notification. */
tags: string[];
/** Overrides the configured priority when an event is more urgent. */
priority?: PushNotificationSettings['priority'];
}
export interface PushDeliveryResult {
delivered: boolean;
reason?: string;
}
/** Minimal surface of the realtime hub the notifier needs. */
export interface PushEventSource {
subscribe(listener: (event: RealtimeEvent) => void): { unsubscribe(): void };
}
type Fetcher = (input: string, init: RequestInit) => Promise<{ ok: boolean; status: number; statusText: string }>;
const REQUEST_TIMEOUT_MS = 10_000;
const MAX_BODY_LENGTH = 400;
/**
* Maps a realtime event onto the push notification an operator would want on
* their phone, or null when the event is not notification-worthy. Pure, so the
* copy and the event selection are testable without any transport.
*/
export function pushNotificationForEvent(event: RealtimeEvent): PushNotificationDraft | null {
switch (event.type) {
case 'media.received':
return {
event: 'imageReceived',
title: `Image from u/${event.payload.sender}`,
body: event.payload.count > 1
? `${event.payload.count} images are attached to the conversation.`
: 'An image is attached to the conversation.',
tags: ['camera'],
};
case 'reply.received':
return {
event: 'replyReceived',
title: `Reply from u/${event.payload.sender}`,
body: event.payload.preview?.trim() || 'A new message arrived in the conversation.',
tags: ['speech_balloon'],
};
case 'message.failed':
return {
event: 'messageFailed',
title: `Message to u/${event.payload.recipient} failed`,
body: event.payload.reason,
tags: ['warning'],
};
case 'bot.status.changed':
if (event.payload.status !== 'stopped') return null;
return {
event: 'botStopped',
title: 'Bot stopped',
body: event.payload.reason ?? 'The bot is no longer processing candidates.',
tags: ['octagonal_sign'],
};
case 'rate-limit.changed':
if (!event.payload.limited) return null;
return {
event: 'rateLimited',
title: 'Rate limit reached',
body: event.payload.reason ?? 'Sending is paused until the limit resets.',
tags: ['hourglass'],
};
case 'reddit.session.changed':
if (!['expired', 'logged-out', 'error'].includes(event.payload.status)) return null;
return {
event: 'sessionExpired',
title: 'Reddit account needs attention',
body: event.payload.reason
?? (event.payload.username
? `The session for u/${event.payload.username} is ${event.payload.status}.`
: `The Reddit session is ${event.payload.status}.`),
tags: ['rotating_light'],
priority: 'high',
};
case 'sync.failed':
return {
event: 'syncFailed',
title: `${event.payload.kind} sync failed`,
body: event.payload.reason,
tags: ['warning'],
};
default:
return null;
}
}
/**
* Publishes selected application events to a single ntfy topic so they reach
* the operator's phone. Delivery is best-effort: a failing or unreachable ntfy
* server is logged and never propagated into the calling path.
*/
export class PushNotifier {
private subscription: { unsubscribe(): void } | null = null;
private attachedAt = 0;
constructor(
private readonly getSettings: () => PushNotificationSettings,
private readonly logger: Logger,
private readonly fetcher: Fetcher = globalThis.fetch as unknown as Fetcher,
private readonly now: () => number = Date.now,
) {}
/**
* Subscribes to the realtime hub. Events buffered before this call are
* skipped: the hub replays its buffer to every new subscriber, and replaying
* a restart's worth of history to somebody's phone is not useful.
*/
attach(source: PushEventSource): void {
if (this.subscription) return;
this.attachedAt = this.now();
this.subscription = source.subscribe((event) => {
if (Date.parse(event.occurredAt) < this.attachedAt) return;
const draft = pushNotificationForEvent(event);
if (!draft) return;
void this.publish(draft);
});
}
detach(): void {
this.subscription?.unsubscribe();
this.subscription = null;
}
/** Sends a notification if the event is enabled and the topic is configured. */
async publish(draft: PushNotificationDraft): Promise<PushDeliveryResult> {
const settings = this.getSettings();
if (!settings.enabled) return { delivered: false, reason: 'Push notifications are disabled.' };
if (!settings.events[draft.event]) {
return { delivered: false, reason: `The ${draft.event} event is not selected.` };
}
return this.deliver(draft, settings);
}
/** Sends a fixed notification regardless of the per-event selection. */
sendTest(): Promise<PushDeliveryResult> {
const settings = this.getSettings();
if (!settings.enabled) return Promise.resolve({ delivered: false, reason: 'Push notifications are disabled.' });
return this.deliver({
event: 'imageReceived',
title: 'RedditBot test notification',
body: 'Push notifications are configured correctly.',
tags: ['white_check_mark'],
}, settings);
}
private async deliver(
draft: PushNotificationDraft,
settings: PushNotificationSettings,
): Promise<PushDeliveryResult> {
const topic = settings.topic.trim();
if (topic === '') return { delivered: false, reason: 'No ntfy topic is configured.' };
let endpoint: string;
try {
endpoint = new URL(topic, `${settings.serverUrl.trim().replace(/\/+$/, '')}/`).toString();
} catch {
return { delivered: false, reason: `Invalid ntfy server URL: ${settings.serverUrl}` };
}
const headers: Record<string, string> = {
'Content-Type': 'text/plain; charset=utf-8',
Title: headerSafe(draft.title),
Priority: draft.priority ?? settings.priority,
Tags: draft.tags.join(','),
};
if (settings.authToken) headers.Authorization = `Bearer ${settings.authToken}`;
try {
const response = await this.fetcher(endpoint, {
method: 'POST',
headers,
body: truncate(draft.body, MAX_BODY_LENGTH),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
const reason = `ntfy responded ${response.status} ${response.statusText}`.trim();
this.logger.warn(`[push] ${draft.event} notification rejected: ${reason}`);
return { delivered: false, reason };
}
return { delivered: true };
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
this.logger.warn(`[push] ${draft.event} notification failed: ${reason}`);
return { delivered: false, reason };
}
}
}
/**
* Titles travel as an HTTP header, so newlines (which would split the header)
* and non-ASCII bytes are removed. The body keeps its UTF-8 text.
*/
function headerSafe(value: string): string {
// eslint-disable-next-line no-control-regex
const ascii = value.replace(/[\r\n]+/g, ' ').replace(/[^\x20-\x7E]/g, '').trim();
return ascii.length <= 120 ? ascii : `${ascii.slice(0, 117)}...`;
}
function truncate(value: string, limit: number): string {
return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
}
+2
View File
@@ -27,6 +27,7 @@ import type { AiUsageRepository } from '../database/ai-usage-repository.js';
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
import type { MediaLibraryService } from '../services/media-library.js';
import type { AccountContentRepository } from '../database/account-repositories.js';
import type { PushNotifier } from '../services/push-notifier.js';
/** Bag of everything the route modules might need. */
export interface WebContext {
@@ -62,6 +63,7 @@ export interface WebContext {
accountRuntimes?: RedditAccountRuntimeManager;
candidateQueue?: CandidateQueueRepository;
mediaLibrary?: MediaLibraryService;
pushNotifier?: PushNotifier;
};
/** Bot pause/resume toggle read by the main loop. */
+3
View File
@@ -28,6 +28,9 @@ export function createDashboardEventAdapters(hub: DashboardEventPublisher) {
reply(input: { conversationId: string; id: string; username: string; body?: string }): Published {
return hub.publish('reply.received', { conversationId: input.conversationId, replyId: input.id, sender: input.username, preview: input.body });
},
mediaReceived(input: { conversationId: string; username: string; count: number }): Published {
return hub.publish('media.received', { conversationId: input.conversationId, sender: input.username, count: input.count });
},
sync(input: SyncInput): Published {
const kind = syncKind(input.kind);
switch (input.phase) {
+6
View File
@@ -46,6 +46,12 @@ export interface RealtimeEventMap {
sender: string;
preview?: string;
};
'media.received': {
conversationId: string;
sender: string;
/** Number of inbound media attachments in the conversation. */
count: number;
};
'sync.started': {
syncId: string;
kind: SyncKind;
+10
View File
@@ -4,6 +4,8 @@ import {
createDashboardEventAdapters,
createDashboardEventPublisher,
RealtimeEventHub,
type RealtimeEventListener,
type RealtimeEventSubscription,
} from './events/index.js';
import { createExpressSseHandler } from './events/express-sse-adapter.js';
import { toJsonSafe } from '../utils/json-safe.js';
@@ -28,6 +30,14 @@ export class LogBroadcaster {
this.dashboardEvents = createDashboardEventAdapters(createDashboardEventPublisher(this.eventHub));
}
/**
* Subscribe an in-process consumer (such as the push notifier) to the same
* typed event stream the dashboard's SSE clients receive.
*/
subscribe(listener: RealtimeEventListener): RealtimeEventSubscription {
return this.eventHub.subscribe(listener);
}
push(level: LogEntry['level'], message: string, data?: unknown): void {
const safeData = toJsonSafe(data);
const realtimeEvent = this.eventHub.closed
+123
View File
@@ -0,0 +1,123 @@
import { Router } from 'express';
import { z } from 'zod';
import type { WebContext } from '../context.js';
import type { PushNotificationSettings } from '../../services/push-notifier.js';
/** ntfy restricts topic names to this alphabet. */
const TOPIC_PATTERN = /^[-_A-Za-z0-9]{1,64}$/;
const ServerUrlSchema = z.string().trim().refine((value) => {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}, 'Server URL must be an http:// or https:// address.');
const TopicSchema = z.string().trim().refine(
(value) => value === '' || TOPIC_PATTERN.test(value),
'Topic may only contain letters, numbers, hyphens and underscores.',
);
const PushNotificationSettingsUpdateSchema = z.strictObject({
enabled: z.boolean().optional(),
serverUrl: ServerUrlSchema.optional(),
topic: TopicSchema.optional(),
priority: z.enum(['min', 'low', 'default', 'high', 'max']).optional(),
events: z.strictObject({
imageReceived: z.boolean(),
replyReceived: z.boolean(),
messageFailed: z.boolean(),
botStopped: z.boolean(),
rateLimited: z.boolean(),
sessionExpired: z.boolean(),
syncFailed: z.boolean(),
}).partial().optional(),
});
type PushNotificationSettingsResponse = Omit<PushNotificationSettings, 'authToken'> & {
hasAuthToken: boolean;
};
/** The stored token is environment-owned and is never returned to a client. */
function settingsResponse(ctx: WebContext): PushNotificationSettingsResponse {
const settings = ctx.config.pushNotifications;
return {
enabled: settings.enabled,
serverUrl: settings.serverUrl,
topic: settings.topic,
priority: settings.priority,
events: { ...settings.events },
hasAuthToken: Boolean(settings.authToken),
};
}
export function createPushNotificationsRouter(ctx: WebContext): Router {
const router = Router();
router.get('/push-notifications', (_req, res) => {
res.json(settingsResponse(ctx));
});
router.put('/push-notifications', (req, res) => {
const parsed = PushNotificationSettingsUpdateSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid push notification settings', detail: parsed.error.format() });
return;
}
const configStore = ctx.services.configStore;
if (!configStore) {
res.status(503).json({ error: 'Configuration persistence is unavailable' });
return;
}
const updates = parsed.data;
if (updates.enabled === true) {
const topic = (updates.topic ?? ctx.config.pushNotifications.topic).trim();
if (topic === '') {
res.status(400).json({ error: 'A topic is required before push notifications can be enabled' });
return;
}
}
// Assigned field by field, never replaced: the notifier holds a reference
// to this object and reads it on every event, so a save applies without a
// restart.
const settings = ctx.config.pushNotifications;
if (updates.enabled !== undefined) settings.enabled = updates.enabled;
if (updates.serverUrl !== undefined) settings.serverUrl = updates.serverUrl;
if (updates.topic !== undefined) settings.topic = updates.topic;
if (updates.priority !== undefined) settings.priority = updates.priority;
if (updates.events) Object.assign(settings.events, updates.events);
try {
configStore.save(ctx.config);
} catch (error) {
ctx.logger.error('Failed to persist push notification settings:', error);
res.status(500).json({ error: String(error) });
return;
}
ctx.activity.push('info', 'Push notification settings updated via dashboard');
ctx.broadcaster.push('info', 'Push notification settings updated', {
enabled: settings.enabled,
topic: settings.topic,
});
res.json(settingsResponse(ctx));
});
router.post('/push-notifications/test', async (_req, res) => {
const notifier = ctx.services.pushNotifier;
if (!notifier) {
res.status(503).json({ error: 'Push notifications are unavailable' });
return;
}
const result = await notifier.sendTest();
res.json({ delivered: result.delivered, ...(result.reason ? { reason: result.reason } : {}) });
});
return router;
}
+7
View File
@@ -18,6 +18,7 @@ import type { MediaSyncService } from '../services/media-sync.js';
import type { ResponseCheckerService } from '../services/response-checker.js';
import type { RateLimiter } from '../utils/rate-limiter.js';
import type { OpenRouterClient } from '../ai/openrouter.js';
import type { PushNotifier } from '../services/push-notifier.js';
import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js';
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
@@ -38,6 +39,7 @@ import { createDebugRouter } from './routes/debug.js';
import { createLogsRouter } from './routes/logs.js';
import { createAnalyticsRouter } from './routes/analytics.js';
import { createConfigRouter } from './routes/config.js';
import { createPushNotificationsRouter } from './routes/push-notifications.js';
import { createLoginRouter } from './routes/login.js';
import { createDashboardAuth, createDashboardSessionRouter } from './auth.js';
import { resolveDashboardAssets } from './dashboard-assets.js';
@@ -178,6 +180,7 @@ export class WebServer {
this.app.use('/api', createLogsRouter(ctx));
this.app.use('/api', createAnalyticsRouter(ctx));
this.app.use('/api', createConfigRouter(ctx));
this.app.use('/api', createPushNotificationsRouter(ctx));
this.app.use('/api', createLoginRouter(ctx));
this.app.use('/api', (_req, res) => {
@@ -260,6 +263,10 @@ export class WebServer {
this.services.configStore = configStore;
}
setPushNotifier(pushNotifier: PushNotifier): void {
this.services.pushNotifier = pushNotifier;
}
setRedditAccountsService(accounts: RedditAccountsService): void {
this.services.redditAccounts = accounts;
}
@@ -0,0 +1,140 @@
import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConversationOutcomesRepository, ConversationStateRepository } from '../src/database/repositories.js';
import { ChatCache } from '../src/services/chat/cache.js';
import { MatrixClient } from '../src/services/chat/matrix/client.js';
import type { MatrixSyncResponse } from '../src/services/chat/matrix/types.js';
import { ChatService } from '../src/services/chat/service.js';
import { createMockLogger } from './mockLogger.js';
import { authenticatedAccountAuth, createTestDb, createTestRepositories } from './testUtils.js';
const credentials = {
homeserver: 'https://matrix.example.test',
accessToken: 'token',
userId: '@me:example.test',
capturedAt: Date.now(),
};
const temporaryDirectories: string[] = [];
const openDatabases: Array<ReturnType<typeof createTestDb>> = [];
afterEach(async () => {
for (const database of openDatabases.splice(0)) database.close();
await Promise.all(temporaryDirectories.splice(0).map((directory) =>
fs.rm(directory, { recursive: true, force: true })));
});
describe('ChatService inbound media notifications', () => {
it('reports an inbound image exactly once, however often the chat re-syncs', async () => {
const fixture = await createFixture({ inboundMediaCount: 2 });
const received: unknown[] = [];
fixture.service.setMediaReceivedListener((info) => received.push(info));
await fixture.service.runStartupSync();
expect(received).toEqual([{ conversationId: fixture.roomId, username: 'alice', count: 2 }]);
expect(fixture.repos.messagesRepo.getByChatRoomId(fixture.roomId)).toMatchObject({ gotImage: true });
await fixture.service.runStartupSync();
expect(received).toHaveLength(1);
});
it('stays quiet for a text-only conversation', async () => {
const fixture = await createFixture({ inboundMediaCount: 0 });
const received: unknown[] = [];
fixture.service.setMediaReceivedListener((info) => received.push(info));
await fixture.service.runStartupSync();
expect(received).toEqual([]);
});
it('records the image even when the listener throws', async () => {
const fixture = await createFixture({ inboundMediaCount: 1 });
fixture.service.setMediaReceivedListener(() => { throw new Error('notifier exploded'); });
await fixture.service.runStartupSync();
expect(fixture.repos.messagesRepo.getByChatRoomId(fixture.roomId)).toMatchObject({ gotImage: true });
});
});
async function createFixture(options: { inboundMediaCount: number }) {
const logger = createMockLogger();
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'chat-media-notify-'));
temporaryDirectories.push(directory);
const cacheFile = path.join(directory, 'chat-cache.json');
const db = createTestDb();
openDatabases.push(db);
const repos = createTestRepositories(db);
const outcomes = new ConversationOutcomesRepository(db);
const conversationStates = new ConversationStateRepository(db);
const now = Date.now();
const roomId = '!alice:example.test';
repos.postsRepo.insert({
id: 'source-post', subreddit: 'example', author: 'alice', title: 'Source',
imageUrl: null, processedAt: now - 10_000,
});
repos.messagesRepo.insert({
postId: 'source-post', recipient: 'alice', subject: 'alice', message: 'Hello',
sentAt: now - 9_000, strategyId: 'direct', chatRoomId: roomId,
});
const media = Array.from({ length: options.inboundMediaCount }, (_unused, index) => ({
originalUrl: `mxc://example.test/image-${index}`,
localPath: `/data/media/image-${index}.jpg`,
fileName: `image-${index}.jpg`,
}));
await fs.writeFile(cacheFile, JSON.stringify({
version: 1,
savedAt: now - 60_000,
chats: [{
username: 'alice',
roomName: 'alice',
lastMessageTime: now - 2_000,
lastMessageSnippet: '[Image]',
isFromUs: false,
unread: false,
roomId,
messages: [
{ sender: 'Me', text: 'Hello', timestamp: now - 5_000, isSelf: true },
{
sender: 'alice',
text: media.length > 0 ? '[Image]' : 'Thanks!',
timestamp: now - 2_000,
isSelf: false,
...(media.length > 0 ? { media } : {}),
},
],
}],
nextBatch: 's1',
lastFullSyncAt: now - 120_000,
lastDeltaSyncAt: now - 60_000,
cooldown: null,
}), 'utf8');
const matrix = new MatrixClient(credentials, logger, { minRequestIntervalMs: 0 });
matrix.sync = vi.fn(async (): Promise<MatrixSyncResponse> => ({ next_batch: 's2' })) as typeof matrix.sync;
const cache = new ChatCache(logger, cacheFile);
const service = new ChatService(
matrix,
logger,
repos.successfulMessagesRepo,
undefined,
undefined,
undefined,
cache,
repos.messagesRepo,
undefined,
authenticatedAccountAuth,
outcomes,
conversationStates,
);
await service.loadCache();
return { repos, roomId, service };
}
+23
View File
@@ -44,6 +44,7 @@ describe('ConfigStore', () => {
config.web.authToken = 'also-secret';
config.web.agentReadToken = 'read-agent-secret';
config.web.agentControlToken = 'control-agent-secret';
config.pushNotifications.authToken = 'ntfy-secret';
store.save(config);
const row = db.prepare('SELECT config_json FROM app_config WHERE id = 1').get() as { config_json: string };
@@ -51,9 +52,31 @@ describe('ConfigStore', () => {
expect(row.config_json).not.toContain('also-secret');
expect(row.config_json).not.toContain('read-agent-secret');
expect(row.config_json).not.toContain('control-agent-secret');
expect(row.config_json).not.toContain('ntfy-secret');
expect(JSON.parse(row.config_json)).not.toHaveProperty('database');
});
it('takes the ntfy access token from the environment on every load', () => {
const config = getTestConfig();
config.pushNotifications.topic = 'redditbot-7f3a9c21';
config.pushNotifications.authToken = 'saved-token-must-be-ignored';
store.save(config);
const withToken = store.load({
OPENROUTER_API_KEY: 'environment-key',
WEB_DASHBOARD_TOKEN: 'environment-dashboard-token',
NTFY_AUTH_TOKEN: 'environment-ntfy-token',
});
const withoutToken = store.load({
OPENROUTER_API_KEY: 'environment-key',
WEB_DASHBOARD_TOKEN: 'environment-dashboard-token',
});
expect(withToken?.pushNotifications.topic).toBe('redditbot-7f3a9c21');
expect(withToken?.pushNotifications.authToken).toBe('environment-ntfy-token');
expect(withoutToken?.pushNotifications.authToken).toBeUndefined();
});
it('updates the singleton record instead of appending versions', () => {
store.save(getTestConfig());
store.save(getTestConfig({ browser: { sessionDir: './data/browser-session', dryRun: true } }));
@@ -0,0 +1,174 @@
// @vitest-environment jsdom
import '@testing-library/jest-dom/vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HttpResponse, http } from 'msw';
import { setupServer } from 'msw/node';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
import { SettingsRoute } from '../../apps/dashboard/src/features/settings/settings-route.js';
const config = {
rateLimit: { minDelayBetweenMessagesMs: 300_000, maxMessagesPerHour: 8, jitterMs: 60_000 },
browser: { dryRun: true },
reddit: { prioritizeRecentPosts: false, recentWindowMinutes: 60, maxReadyCandidates: 25 },
subreddits: [{ name: 'casualconversation', type: 'chat', weight: 2, tone: 'default' }],
tones: ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing'],
};
function pushSettings(overrides: Record<string, unknown> = {}) {
return {
enabled: false,
serverUrl: 'https://ntfy.sh',
topic: '',
priority: 'default',
events: {
imageReceived: true,
replyReceived: false,
messageFailed: false,
botStopped: false,
rateLimited: false,
sessionExpired: true,
syncFailed: false,
},
hasAuthToken: false,
...overrides,
};
}
let settings = pushSettings();
let saved: unknown[] = [];
const server = setupServer(
http.get('/api/config', () => HttpResponse.json(config)),
http.get('/api/accounts', () => HttpResponse.json([])),
http.get('/api/push-notifications', () => HttpResponse.json(settings)),
http.put('/api/push-notifications', async ({ request }) => {
const body = await request.json() as Record<string, unknown>;
saved.push(body);
settings = pushSettings({ ...body, events: { ...settings.events, ...(body.events as object) } });
return HttpResponse.json(settings);
}),
);
function renderSettings() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const router = createMemoryRouter([{ path: '/settings', element: <SettingsRoute /> }], {
initialEntries: ['/settings?section=notifications'],
});
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>,
);
}
function panel() {
return screen.getByRole('region', { name: 'Notifications' });
}
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
beforeEach(() => {
settings = pushSettings();
saved = [];
});
afterEach(() => {
cleanup();
server.resetHandlers();
vi.restoreAllMocks();
});
afterAll(() => server.close());
describe('push notification settings', () => {
it('shows the notifications section with every selectable event', async () => {
renderSettings();
const section = await screen.findByRole('region', { name: 'Notifications' });
expect(within(section).getByLabelText(/Server URL/)).toHaveValue('https://ntfy.sh');
expect(within(section).getByRole('group', { name: /Events to notify me about \(2 of 7\)/ })).toBeInTheDocument();
expect(within(section).getByRole('checkbox', { name: /Image received/ })).toBeChecked();
expect(within(section).getByRole('checkbox', { name: /Reply received/ })).not.toBeChecked();
expect(within(section).getByText(/No access token is configured/)).toBeInTheDocument();
});
it('saves the topic and event selection the operator picked', async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('checkbox', { name: /Send push notifications/ }));
await user.type(within(panel()).getByLabelText(/Topic/), 'redditbot-7f3a9c21');
await user.click(within(panel()).getByRole('checkbox', { name: /Reply received/ }));
await user.click(within(panel()).getByRole('button', { name: /Save notifications/ }));
await waitFor(() => expect(saved).toHaveLength(1));
expect(saved[0]).toMatchObject({
enabled: true,
topic: 'redditbot-7f3a9c21',
events: { imageReceived: true, replyReceived: true },
});
expect(await within(panel()).findByText('Notification settings saved.')).toBeInTheDocument();
});
it('refuses to save an enabled configuration with no topic', async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('checkbox', { name: /Send push notifications/ }));
await user.click(within(panel()).getByRole('button', { name: /Save notifications/ }));
expect(within(panel()).getByText(/A topic is required/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('rejects a topic containing characters ntfy will not accept', async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.type(within(panel()).getByLabelText(/Topic/), 'not a topic!');
await user.click(within(panel()).getByRole('button', { name: /Save notifications/ }));
expect(within(panel()).getByText(/only contain letters, numbers/)).toBeInTheDocument();
expect(saved).toHaveLength(0);
});
it('sends a test notification and reports the delivery outcome', async () => {
settings = pushSettings({ enabled: true, topic: 'redditbot-7f3a9c21' });
server.use(http.post('/api/push-notifications/test', () => HttpResponse.json({
delivered: false,
reason: 'ntfy responded 403 Forbidden',
})));
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('button', { name: /Send test notification/ }));
expect(await within(panel()).findByText(/ntfy responded 403 Forbidden/)).toBeInTheDocument();
});
it('will not send a test until unsaved changes are saved', async () => {
settings = pushSettings({ enabled: true, topic: 'redditbot-7f3a9c21' });
const user = userEvent.setup();
renderSettings();
await screen.findByRole('region', { name: 'Notifications' });
await user.click(within(panel()).getByRole('checkbox', { name: /Sync failed/ }));
expect(within(panel()).getByRole('button', { name: /Send test notification/ })).toBeDisabled();
});
it('reports that an access token is configured without revealing it', async () => {
settings = pushSettings({ hasAuthToken: true });
renderSettings();
const section = await screen.findByRole('region', { name: 'Notifications' });
expect(within(section).getByText(/An access token is configured/)).toBeInTheDocument();
});
});
+239
View File
@@ -0,0 +1,239 @@
import { describe, expect, it, beforeEach, vi } from 'vitest';
import {
PushNotifier,
pushNotificationForEvent,
type PushNotificationSettings,
} from '../src/services/push-notifier.js';
import type { RealtimeEvent, RealtimeEventMap, RealtimeEventType } from '../src/web/events/event-types.js';
import { createMockLogger, getLogsByLevel } from './mockLogger.js';
interface RecordedRequest {
url: string;
init: RequestInit;
}
function settings(overrides: Partial<PushNotificationSettings> = {}): PushNotificationSettings {
return {
enabled: true,
serverUrl: 'https://ntfy.example.com',
topic: 'redditbot-test',
priority: 'default',
events: {
imageReceived: true,
replyReceived: true,
messageFailed: true,
botStopped: true,
rateLimited: true,
sessionExpired: true,
syncFailed: true,
},
...overrides,
};
}
function event<TType extends RealtimeEventType>(
type: TType,
payload: RealtimeEventMap[TType],
occurredAt = '2026-08-10T12:00:00.000Z',
): RealtimeEvent {
return { id: `${type}-1`, occurredAt, type, payload } as RealtimeEvent;
}
class FakeHub {
listener: ((event: RealtimeEvent) => void) | null = null;
unsubscribed = false;
subscribe(listener: (event: RealtimeEvent) => void) {
this.listener = listener;
return { unsubscribe: () => { this.unsubscribed = true; this.listener = null; } };
}
emit(value: RealtimeEvent): void {
this.listener?.(value);
}
}
describe('pushNotificationForEvent', () => {
it('maps an inbound image onto the imageReceived notification', () => {
expect(pushNotificationForEvent(event('media.received', {
conversationId: 'room-1',
sender: 'alice',
count: 2,
}))).toMatchObject({
event: 'imageReceived',
title: 'Image from u/alice',
body: '2 images are attached to the conversation.',
});
});
it('only notifies for the actionable side of two-way status events', () => {
expect(pushNotificationForEvent(event('bot.status.changed', { status: 'running', dryRun: false }))).toBeNull();
expect(pushNotificationForEvent(event('bot.status.changed', { status: 'stopped', dryRun: false })))
.toMatchObject({ event: 'botStopped' });
expect(pushNotificationForEvent(event('rate-limit.changed', { limited: false }))).toBeNull();
expect(pushNotificationForEvent(event('rate-limit.changed', { limited: true })))
.toMatchObject({ event: 'rateLimited' });
expect(pushNotificationForEvent(event('reddit.session.changed', { status: 'authenticated' }))).toBeNull();
expect(pushNotificationForEvent(event('reddit.session.changed', { status: 'expired' })))
.toMatchObject({ event: 'sessionExpired', priority: 'high' });
});
it('ignores events that are not worth a phone notification', () => {
expect(pushNotificationForEvent(event('log.created', { level: 'error', message: 'boom' }))).toBeNull();
expect(pushNotificationForEvent(event('sync.progress', { syncId: 's1', kind: 'full', completed: 3 }))).toBeNull();
expect(pushNotificationForEvent(event('message.sent', { messageId: 'm1', recipient: 'alice' }))).toBeNull();
});
});
describe('PushNotifier', () => {
let requests: RecordedRequest[];
let response: { ok: boolean; status: number; statusText: string };
let fetcher: (url: string, init: RequestInit) => Promise<typeof response>;
beforeEach(() => {
requests = [];
response = { ok: true, status: 200, statusText: 'OK' };
fetcher = (url, init) => {
requests.push({ url, init });
return Promise.resolve(response);
};
});
it('posts the notification to the configured topic', async () => {
const notifier = new PushNotifier(() => settings(), createMockLogger(), fetcher);
const result = await notifier.publish({
event: 'imageReceived',
title: 'Image from u/alice',
body: 'An image is attached.',
tags: ['camera'],
});
expect(result).toEqual({ delivered: true });
expect(requests).toHaveLength(1);
expect(requests[0].url).toBe('https://ntfy.example.com/redditbot-test');
expect(requests[0].init.method).toBe('POST');
expect(requests[0].init.body).toBe('An image is attached.');
const headers = requests[0].init.headers as Record<string, string>;
expect(headers.Title).toBe('Image from u/alice');
expect(headers.Priority).toBe('default');
expect(headers.Tags).toBe('camera');
expect(headers.Authorization).toBeUndefined();
});
it('tolerates a trailing slash on the server URL and sends the access token when set', async () => {
const notifier = new PushNotifier(
() => settings({ serverUrl: 'https://ntfy.example.com/', authToken: 'tk_secret' }),
createMockLogger(),
fetcher,
);
await notifier.publish({ event: 'imageReceived', title: 'Hi', body: 'There', tags: [] });
expect(requests[0].url).toBe('https://ntfy.example.com/redditbot-test');
expect((requests[0].init.headers as Record<string, string>).Authorization).toBe('Bearer tk_secret');
});
it('sends nothing when disabled, unselected, or missing a topic', async () => {
const disabled = new PushNotifier(() => settings({ enabled: false }), createMockLogger(), fetcher);
const unselected = new PushNotifier(
() => settings({ events: { ...settings().events, imageReceived: false } }),
createMockLogger(),
fetcher,
);
const topicless = new PushNotifier(() => settings({ topic: ' ' }), createMockLogger(), fetcher);
const draft = { event: 'imageReceived', title: 'Hi', body: 'There', tags: [] } as const;
expect(await disabled.publish({ ...draft })).toMatchObject({ delivered: false });
expect(await unselected.publish({ ...draft })).toMatchObject({ delivered: false });
expect(await topicless.publish({ ...draft })).toMatchObject({ delivered: false });
expect(requests).toHaveLength(0);
});
it('reports a rejected or unreachable server without throwing', async () => {
const logger = createMockLogger();
response = { ok: false, status: 403, statusText: 'Forbidden' };
const rejecting = new PushNotifier(() => settings(), logger, fetcher);
expect(await rejecting.publish({ event: 'imageReceived', title: 'Hi', body: 'There', tags: [] }))
.toEqual({ delivered: false, reason: 'ntfy responded 403 Forbidden' });
const unreachable = new PushNotifier(() => settings(), logger, () => Promise.reject(new Error('ECONNREFUSED')));
expect(await unreachable.publish({ event: 'imageReceived', title: 'Hi', body: 'There', tags: [] }))
.toEqual({ delivered: false, reason: 'ECONNREFUSED' });
expect(getLogsByLevel(logger, 'warn')).toHaveLength(2);
});
it('strips newlines from the title so the header cannot be split', async () => {
const notifier = new PushNotifier(() => settings(), createMockLogger(), fetcher);
await notifier.publish({
event: 'replyReceived',
title: 'Reply from u/alice\r\nX-Injected: yes',
body: 'hello',
tags: [],
});
expect((requests[0].init.headers as Record<string, string>).Title)
.toBe('Reply from u/alice X-Injected: yes');
});
it('sends selected hub events and skips the ones the operator turned off', async () => {
const hub = new FakeHub();
const notifier = new PushNotifier(
() => settings({ events: { ...settings().events, replyReceived: false } }),
createMockLogger(),
fetcher,
() => Date.parse('2026-08-10T11:00:00.000Z'),
);
notifier.attach(hub);
hub.emit(event('media.received', { conversationId: 'room-1', sender: 'alice', count: 1 }));
hub.emit(event('reply.received', { conversationId: 'room-1', replyId: 'r1', sender: 'alice' }));
await vi.waitFor(() => expect(requests).toHaveLength(1));
expect((requests[0].init.headers as Record<string, string>).Title).toBe('Image from u/alice');
});
it('skips buffered events the hub replays from before it attached', async () => {
const hub = new FakeHub();
const notifier = new PushNotifier(
() => settings(),
createMockLogger(),
fetcher,
() => Date.parse('2026-08-10T12:00:00.000Z'),
);
notifier.attach(hub);
hub.emit(event('media.received', { conversationId: 'old', sender: 'alice', count: 1 }, '2026-08-10T11:59:59.000Z'));
hub.emit(event('media.received', { conversationId: 'new', sender: 'bob', count: 1 }, '2026-08-10T12:00:01.000Z'));
await vi.waitFor(() => expect(requests).toHaveLength(1));
expect((requests[0].init.headers as Record<string, string>).Title).toBe('Image from u/bob');
});
it('sends a test notification even when no event is selected, but not when disabled', async () => {
const noEvents = Object.fromEntries(
Object.keys(settings().events).map((key) => [key, false]),
) as PushNotificationSettings['events'];
const notifier = new PushNotifier(() => settings({ events: noEvents }), createMockLogger(), fetcher);
expect(await notifier.sendTest()).toEqual({ delivered: true });
expect(requests).toHaveLength(1);
const disabled = new PushNotifier(() => settings({ enabled: false }), createMockLogger(), fetcher);
expect(await disabled.sendTest()).toMatchObject({ delivered: false });
expect(requests).toHaveLength(1);
});
it('stops delivering once detached', async () => {
const hub = new FakeHub();
const notifier = new PushNotifier(() => settings(), createMockLogger(), fetcher, () => 0);
notifier.attach(hub);
notifier.detach();
expect(hub.unsubscribed).toBe(true);
hub.emit(event('media.received', { conversationId: 'room-1', sender: 'alice', count: 1 }));
await Promise.resolve();
expect(requests).toHaveLength(0);
});
});
+16
View File
@@ -93,6 +93,22 @@ export function getTestConfig(overrides?: Partial<Config>): Config {
variants: [],
...overrides?.chatStrategies,
},
pushNotifications: {
enabled: false,
serverUrl: 'https://ntfy.sh',
topic: '',
priority: 'default',
events: {
imageReceived: true,
replyReceived: false,
messageFailed: false,
botStopped: false,
rateLimited: false,
sessionExpired: true,
syncFailed: false,
},
...overrides?.pushNotifications,
},
web: {
enabled: true,
port: 3000,
+170
View File
@@ -0,0 +1,170 @@
import express from 'express';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { WebContext } from '../../src/web/context.js';
import { createPushNotificationsRouter } from '../../src/web/routes/push-notifications.js';
import { ConfigSchema, type Config } from '../../src/config/index.js';
import type { PushDeliveryResult } from '../../src/services/push-notifier.js';
import { getTestConfig } from '../testUtils.js';
const servers: Array<ReturnType<express.Express['listen']>> = [];
async function request(app: express.Express, path: string, init?: RequestInit): Promise<Response> {
const server = app.listen(0);
servers.push(server);
await new Promise<void>((resolve) => server.once('listening', resolve));
const { port } = server.address() as AddressInfo;
return fetch(`http://127.0.0.1:${port}${path}`, init);
}
afterEach(async () => {
await Promise.all(servers.splice(0).map((server) => new Promise<void>((resolve) => server.close(() => resolve()))));
});
interface HarnessOptions {
authToken?: string;
sendTest?: () => Promise<PushDeliveryResult>;
withNotifier?: boolean;
}
function harness(options: HarnessOptions = {}) {
// Deep-cloned because zod reuses one default object across parses, so two
// harnesses would otherwise share the same `pushNotifications` instance.
const config = structuredClone(ConfigSchema.parse(getTestConfig())) as Config;
if (options.authToken) config.pushNotifications.authToken = options.authToken;
const saved: Config[] = [];
const sendTest = vi.fn(options.sendTest ?? (() => Promise.resolve({ delivered: true })));
const ctx = {
config,
runtimeConfig: config,
services: {
configStore: { save: (next: Config) => saved.push(next) },
...(options.withNotifier === false ? {} : { pushNotifier: { sendTest } }),
},
activity: { push: vi.fn() },
broadcaster: { push: vi.fn() },
logger: { error: vi.fn(), info: vi.fn(), warn: vi.fn() },
} as unknown as WebContext;
const app = express();
app.use(express.json());
app.use('/api', createPushNotificationsRouter(ctx));
return { app, config, saved, sendTest };
}
function update(app: express.Express, body: unknown): Promise<Response> {
return request(app, '/api/push-notifications', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
}
describe('push notification settings route', () => {
it('returns the settings with the image event on by default', async () => {
const { app } = harness();
const body = await (await request(app, '/api/push-notifications')).json();
expect(body).toEqual({
enabled: false,
serverUrl: 'https://ntfy.sh',
topic: '',
priority: 'default',
events: {
imageReceived: true,
replyReceived: false,
messageFailed: false,
botStopped: false,
rateLimited: false,
sessionExpired: true,
syncFailed: false,
},
hasAuthToken: false,
});
});
it('never returns the access token, only whether one is configured', async () => {
const { app } = harness({ authToken: 'tk_secret' });
const response = await request(app, '/api/push-notifications');
const text = await response.text();
expect(text).not.toContain('tk_secret');
expect(JSON.parse(text).hasAuthToken).toBe(true);
});
it('persists an edited selection in place, so the running notifier sees it', async () => {
const { app, config, saved } = harness();
const settingsRef = config.pushNotifications;
const eventsRef = config.pushNotifications.events;
const response = await update(app, {
enabled: true,
serverUrl: 'https://ntfy.example.com',
topic: 'redditbot-7f3a9c21',
priority: 'high',
events: { imageReceived: true, replyReceived: true },
});
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
enabled: true,
serverUrl: 'https://ntfy.example.com',
topic: 'redditbot-7f3a9c21',
priority: 'high',
});
// Same object references: the notifier holds this config, not a copy.
expect(config.pushNotifications).toBe(settingsRef);
expect(config.pushNotifications.events).toBe(eventsRef);
expect(config.pushNotifications.events.replyReceived).toBe(true);
// A partial events payload leaves the unmentioned selections alone.
expect(config.pushNotifications.events.sessionExpired).toBe(true);
expect(saved).toHaveLength(1);
});
it('rejects an invalid server URL, topic, or unknown field', async () => {
const { app, saved } = harness();
expect((await update(app, { serverUrl: 'ftp://ntfy.example.com' })).status).toBe(400);
expect((await update(app, { topic: 'not a topic!' })).status).toBe(400);
expect((await update(app, { webhook: 'https://example.com' })).status).toBe(400);
expect(saved).toHaveLength(0);
});
it('refuses to enable notifications without a topic', async () => {
const { app, config, saved } = harness();
const response = await update(app, { enabled: true });
expect(response.status).toBe(400);
expect((await response.json()).error).toMatch(/topic is required/i);
expect(config.pushNotifications.enabled).toBe(false);
expect(saved).toHaveLength(0);
});
it('enables notifications when the topic arrives in the same save', async () => {
const { app, config } = harness();
const response = await update(app, { enabled: true, topic: 'redditbot-7f3a9c21' });
expect(response.status).toBe(200);
expect(config.pushNotifications.enabled).toBe(true);
});
it('reports the outcome of a test notification', async () => {
const { app, sendTest } = harness();
expect(await (await request(app, '/api/push-notifications/test', { method: 'POST' })).json())
.toEqual({ delivered: true });
expect(sendTest).toHaveBeenCalledTimes(1);
const failing = harness({ sendTest: () => Promise.resolve({ delivered: false, reason: 'ntfy responded 403 Forbidden' }) });
expect(await (await request(failing.app, '/api/push-notifications/test', { method: 'POST' })).json())
.toEqual({ delivered: false, reason: 'ntfy responded 403 Forbidden' });
});
it('reports unavailable rather than crashing when the notifier is missing', async () => {
const { app } = harness({ withNotifier: false });
expect((await request(app, '/api/push-notifications/test', { method: 'POST' })).status).toBe(503);
});
});