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>
184 lines
4.8 KiB
TypeScript
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>;
|