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:
1 parent
827eb3a1e0
commit
38d175e4a1
34 files changed
+1728
-8
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user