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;