Files
RedditBot/packages/api-contracts/src/events.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

184 lines
4.8 KiB
TypeScript

import { z } from 'zod';
import { SyncProgressSchema } from './chats.js';
const EventBase = {
id: z.string(),
occurredAt: z.string().datetime({ offset: true }),
};
export const BotRuntimeStatusSchema = z.union([
z.literal('starting'),
z.literal('running'),
z.literal('stopping'),
z.literal('stopped'),
]);
export const ConversationStatusSchema = z.union([
z.literal('new'),
z.literal('awaiting-reply'),
z.literal('replied'),
z.literal('closed'),
]);
export const RealtimeSyncKindSchema = z.union([
z.literal('media'),
z.literal('messages'),
z.literal('conversations'),
z.literal('full'),
]);
export const RealtimeRedditSessionStatusSchema = z.union([
z.literal('unknown'),
z.literal('authenticating'),
z.literal('authenticated'),
z.literal('expired'),
z.literal('logged-out'),
z.literal('error'),
]);
export const DashboardEventSchema = z.discriminatedUnion('type', [
z.strictObject({
...EventBase,
type: z.literal('bot.status.changed'),
payload: z.strictObject({
status: BotRuntimeStatusSchema,
dryRun: z.boolean(),
reason: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('message.sent'),
payload: z.strictObject({
messageId: z.string(),
recipient: z.string(),
conversationId: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('message.failed'),
payload: z.strictObject({
recipient: z.string(),
reason: z.string(),
messageId: z.string().optional(),
conversationId: z.string().optional(),
code: z.string().optional(),
retryable: z.boolean().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('conversation.updated'),
payload: z.strictObject({
conversationId: z.string(),
status: ConversationStatusSchema,
unreadCount: z.number().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('reply.received'),
payload: z.strictObject({
conversationId: z.string(),
replyId: z.string(),
sender: z.string(),
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'),
payload: z.strictObject({
syncId: z.string(),
kind: RealtimeSyncKindSchema,
}),
}),
z.strictObject({
...EventBase,
type: z.literal('sync.progress'),
payload: z.strictObject({
syncId: z.string(),
kind: RealtimeSyncKindSchema,
completed: z.number(),
total: z.number().optional(),
message: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('sync.completed'),
payload: z.strictObject({
syncId: z.string(),
kind: RealtimeSyncKindSchema,
processed: z.number(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('sync.failed'),
payload: z.strictObject({
syncId: z.string(),
kind: RealtimeSyncKindSchema,
reason: z.string(),
code: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('rate-limit.changed'),
payload: z.strictObject({
limited: z.boolean(),
remaining: z.number().optional(),
resetsAt: z.string().optional(),
reason: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('reddit.session.changed'),
payload: z.strictObject({
status: RealtimeRedditSessionStatusSchema,
username: z.string().optional(),
reason: z.string().optional(),
}),
}),
z.strictObject({
...EventBase,
type: z.literal('log.created'),
payload: z.strictObject({
level: z.union([z.literal('debug'), z.literal('info'), z.literal('warn'), z.literal('error')]),
message: z.string(),
data: z.unknown().optional(),
}),
}),
]);
/** Existing `/api/logs/stream` frames, retained for an incremental migration. */
export const LegacyDashboardStreamEventSchema = z.discriminatedUnion('type', [
z.strictObject({
type: z.literal('connected'),
timestamp: z.number(),
}),
z.strictObject({
type: z.literal('log'),
timestamp: z.number(),
level: z.union([z.literal('info'), z.literal('warn'), z.literal('error'), z.literal('debug')]),
message: z.string(),
data: z.unknown().optional(),
}),
z.strictObject({
type: z.literal('sync-progress'),
progress: SyncProgressSchema.nullable(),
}),
]);
export type DashboardEvent = z.infer<typeof DashboardEventSchema>;
export type LegacyDashboardStreamEvent = z.infer<typeof LegacyDashboardStreamEventSchema>;