Files
RedditBot/src/web/events/event-adapters.ts
T
Jamie BruntonandClaude Opus 5 38d175e4a1 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>
2026-08-10 13:55:00 +01:00

55 lines
4.0 KiB
TypeScript

import type { RealtimeEvent } from './event-types.js';
import type { DashboardEventPublisher } from './event-publisher.js';
type Published = RealtimeEvent;
type SyncInput =
| { phase: 'started'; id: string; kind: 'delta' | 'full' | 'media' }
| { phase: 'progress'; id: string; kind: 'delta' | 'full' | 'media'; current: number; total?: number; label?: string }
| { phase: 'completed'; id: string; kind: 'delta' | 'full' | 'media'; processed: number }
| { phase: 'failed'; id: string; kind: 'delta' | 'full' | 'media'; error: string; code?: string };
const syncKind = (kind: SyncInput['kind']) => kind === 'delta' ? 'conversations' as const : kind;
const errorReason = (error: unknown) => error instanceof Error ? error.message : String(error);
export function createDashboardEventAdapters(hub: DashboardEventPublisher) {
return {
bot(input: { running: boolean; dryRun: boolean; transitioning?: 'starting' | 'stopping'; reason?: string }): Published {
return hub.publish('bot.status.changed', { status: input.transitioning ?? (input.running ? 'running' : 'stopped'), dryRun: input.dryRun, reason: input.reason });
},
messageSent(input: { id: string; username: string; conversationId?: string }): Published {
return hub.publish('message.sent', { messageId: input.id, recipient: input.username, conversationId: input.conversationId });
},
messageFailed(input: { username: string; error: unknown; messageId?: string; conversationId?: string; code?: string; retryable?: boolean }): Published {
return hub.publish('message.failed', { recipient: input.username, reason: errorReason(input.error), messageId: input.messageId, conversationId: input.conversationId, code: input.code, retryable: input.retryable });
},
conversation(input: { id: string; state: 'new' | 'awaiting_reply' | 'replied' | 'closed'; unread?: number }): Published {
return hub.publish('conversation.updated', { conversationId: input.id, status: input.state === 'awaiting_reply' ? 'awaiting-reply' : input.state, unreadCount: input.unread });
},
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) {
case 'started': return hub.publish('sync.started', { syncId: input.id, kind });
case 'progress': return hub.publish('sync.progress', { syncId: input.id, kind, completed: input.current, total: input.total, message: input.label });
case 'completed': return hub.publish('sync.completed', { syncId: input.id, kind, processed: input.processed });
case 'failed': return hub.publish('sync.failed', { syncId: input.id, kind, reason: input.error, code: input.code });
}
},
rateLimit(input: { blocked: boolean; remaining?: number; resetAt?: number; reason?: string }): Published {
return hub.publish('rate-limit.changed', { limited: input.blocked, remaining: input.remaining, resetsAt: input.resetAt === undefined ? undefined : new Date(input.resetAt).toISOString(), reason: input.reason });
},
redditSession(input: { state: 'unknown' | 'authenticating' | 'authenticated' | 'expired' | 'login-required' | 'unreachable'; username?: string; reason?: string }): Published {
const status = input.state === 'login-required' ? 'logged-out' : input.state === 'unreachable' ? 'error' : input.state;
return hub.publish('reddit.session.changed', { status, username: input.username, reason: input.reason });
},
log(input: { level: 'debug' | 'info' | 'warn' | 'error'; message: string; data?: unknown }): Published {
return hub.publish('log.created', input);
},
};
}