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
@@ -83,6 +83,15 @@ export const DashboardEventSchema = z.discriminatedUnion('type', [
|
||||
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'),
|
||||
|
||||
@@ -7,5 +7,6 @@ export * from './events.js';
|
||||
export * from './media.js';
|
||||
export * from './messages.js';
|
||||
export * from './outreach.js';
|
||||
export * from './push-notifications.js';
|
||||
export * from './session.js';
|
||||
export * from './stats.js';
|
||||
@@ -0,0 +1,93 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Events an operator can subscribe to for phone push notifications. Keys are
|
||||
* shared verbatim between the config schema, the API and the dashboard so a
|
||||
* new event only has to be added in one place.
|
||||
*/
|
||||
export const PUSH_NOTIFICATION_EVENT_KEYS = [
|
||||
'imageReceived',
|
||||
'replyReceived',
|
||||
'messageFailed',
|
||||
'botStopped',
|
||||
'rateLimited',
|
||||
'sessionExpired',
|
||||
'syncFailed',
|
||||
] as const;
|
||||
|
||||
export type PushNotificationEventKey = (typeof PUSH_NOTIFICATION_EVENT_KEYS)[number];
|
||||
|
||||
export const PushNotificationPrioritySchema = z.union([
|
||||
z.literal('min'),
|
||||
z.literal('low'),
|
||||
z.literal('default'),
|
||||
z.literal('high'),
|
||||
z.literal('max'),
|
||||
]);
|
||||
|
||||
export const PushNotificationEventsSchema = z.strictObject({
|
||||
imageReceived: z.boolean(),
|
||||
replyReceived: z.boolean(),
|
||||
messageFailed: z.boolean(),
|
||||
botStopped: z.boolean(),
|
||||
rateLimited: z.boolean(),
|
||||
sessionExpired: z.boolean(),
|
||||
syncFailed: z.boolean(),
|
||||
});
|
||||
|
||||
/** ntfy restricts topic names to this alphabet. */
|
||||
export const NTFY_TOPIC_PATTERN = /^[-_A-Za-z0-9]{1,64}$/;
|
||||
|
||||
export const PushNotificationServerUrlSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((value) => {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'Server URL must be an http:// or https:// address.');
|
||||
|
||||
export const PushNotificationTopicSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) => value === '' || NTFY_TOPIC_PATTERN.test(value),
|
||||
'Topic may only contain letters, numbers, hyphens and underscores.',
|
||||
);
|
||||
|
||||
export const PushNotificationSettingsSchema = z.strictObject({
|
||||
enabled: z.boolean(),
|
||||
serverUrl: PushNotificationServerUrlSchema,
|
||||
topic: PushNotificationTopicSchema,
|
||||
priority: PushNotificationPrioritySchema,
|
||||
events: PushNotificationEventsSchema,
|
||||
});
|
||||
|
||||
export const PushNotificationSettingsResponseSchema = PushNotificationSettingsSchema.extend({
|
||||
/**
|
||||
* Whether an access token is configured. The token itself is
|
||||
* environment-owned and is never returned over the API.
|
||||
*/
|
||||
hasAuthToken: z.boolean(),
|
||||
});
|
||||
|
||||
export const PushNotificationSettingsUpdateSchema = z.strictObject({
|
||||
enabled: z.boolean().optional(),
|
||||
serverUrl: PushNotificationServerUrlSchema.optional(),
|
||||
topic: PushNotificationTopicSchema.optional(),
|
||||
priority: PushNotificationPrioritySchema.optional(),
|
||||
events: PushNotificationEventsSchema.partial().optional(),
|
||||
});
|
||||
|
||||
export const PushNotificationTestResponseSchema = z.strictObject({
|
||||
delivered: z.boolean(),
|
||||
reason: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PushNotificationSettings = z.infer<typeof PushNotificationSettingsSchema>;
|
||||
export type PushNotificationSettingsResponse = z.infer<typeof PushNotificationSettingsResponseSchema>;
|
||||
export type PushNotificationSettingsUpdate = z.infer<typeof PushNotificationSettingsUpdateSchema>;
|
||||
export type PushNotificationTestResponse = z.infer<typeof PushNotificationTestResponseSchema>;
|
||||
Reference in new issue
Block a user