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:
Jamie BruntonandClaude Opus 5 committed 2026-08-10 13:55:00 +01:00
1 parent 827eb3a1e0
commit 38d175e4a1
34 files changed
+1728 -8

No files matched your search

+14
View File
@@ -39,6 +39,7 @@ import { MediaSyncService } from './services/media-sync.js';
import { RateLimiter } from './utils/rate-limiter.js';
import { WebServer } from './web/server.js';
import { reportReply } from './web/event-reporting.js';
import { PushNotifier } from './services/push-notifier.js';
import { RedditSessionImporter } from './services/reddit-session-importer.js';
import { AccountAuthContext, authenticateCurrentAccount } from './services/account-auth-context.js';
import { ConfigStore } from './config/persistence.js';
@@ -88,6 +89,7 @@ export interface AppContainer {
mediaSyncService: MediaSyncService;
mediaLibraryService: MediaLibraryService;
rateLimiter: RateLimiter;
pushNotifier: PushNotifier;
webServer: WebServer | null;
}
@@ -117,6 +119,11 @@ export async function bootstrap(
attachLogSink(logger, broadcaster);
}
// Phone push notifications ride the same typed event stream the dashboard
// consumes, so every notifiable event has exactly one publisher.
const pushNotifier = new PushNotifier(() => config.pushNotifications, logger);
pushNotifier.attach(broadcaster);
// Database
const db = existingDatabase ?? createDatabase(config);
runMigrations(db);
@@ -423,6 +430,11 @@ export async function bootstrap(
lastSyncProgress = null;
}
});
chatService.setMediaReceivedListener(({ conversationId, username, count }) => {
webServer!.logActivity('response', `Image received from u/${username}`, username);
webServer!.pushLog('info', `🖼️ Image from u/${username}`);
broadcaster.dashboardEvents.mediaReceived({ conversationId, username, count });
});
chatService.setNewReplyListener(({ conversationId, replyId, username, replySnippet }) => {
webServer!.logActivity(
'response',
@@ -453,6 +465,7 @@ export async function bootstrap(
webServer.setConversationReadStateRepository(conversationReadStateRepo);
webServer.setCandidateQueueRepository(candidateQueueRepo);
webServer.setConfigStore(new ConfigStore(db));
webServer.setPushNotifier(pushNotifier);
webServer.setOnConfigChange(() => logger.info('Runtime config updated via dashboard'));
}
@@ -500,6 +513,7 @@ export async function bootstrap(
mediaSyncService,
mediaLibraryService,
rateLimiter,
pushNotifier,
webServer,
};
}
+49
View File
@@ -147,6 +147,50 @@ export const ConfigSchema = z.object({
killOnSellerKeyword: true,
aiReplyScreening: { enabled: false, killConfidence: 0.8, flagConfidence: 0.6 },
}),
pushNotifications: z.object({
enabled: z.boolean().default(false),
/** Base URL of the ntfy server, e.g. https://ntfy.example.com. */
serverUrl: z.string().default('https://ntfy.sh'),
/** Single ntfy topic every selected event is published to. */
topic: z.string().default(''),
/**
* Bearer token for a protected topic. Environment-owned (NTFY_AUTH_TOKEN)
* like the other secrets, so it is never written to the settings record.
*/
authToken: z.string().optional(),
priority: z.enum(['min', 'low', 'default', 'high', 'max']).default('default'),
events: z.object({
imageReceived: z.boolean().default(true),
replyReceived: z.boolean().default(false),
messageFailed: z.boolean().default(false),
botStopped: z.boolean().default(false),
rateLimited: z.boolean().default(false),
sessionExpired: z.boolean().default(true),
syncFailed: z.boolean().default(false),
}).default({
imageReceived: true,
replyReceived: false,
messageFailed: false,
botStopped: false,
rateLimited: false,
sessionExpired: true,
syncFailed: false,
}),
}).default({
enabled: false,
serverUrl: 'https://ntfy.sh',
topic: '',
priority: 'default',
events: {
imageReceived: true,
replyReceived: false,
messageFailed: false,
botStopped: false,
rateLimited: false,
sessionExpired: true,
syncFailed: false,
},
}),
openrouter: z.object({
apiKey: z.string(),
imageAnalysisModel: z.string().default('google/gemini-3-flash-preview'),
@@ -245,6 +289,11 @@ export function loadConfig(store?: ConfigPersistence): Config {
// its path is an environment-owned bootstrap value rather than app config.
rawConfig.database.path = process.env.DATABASE_PATH ?? './data/reddit-bot.db';
if (process.env.NTFY_AUTH_TOKEN?.trim()) {
if (!rawConfig.pushNotifications) rawConfig.pushNotifications = {};
rawConfig.pushNotifications.authToken = process.env.NTFY_AUTH_TOKEN;
}
if (process.env.WEB_DASHBOARD_TOKEN !== undefined) {
if (!rawConfig.web) rawConfig.web = {};
rawConfig.web.authToken = process.env.WEB_DASHBOARD_TOKEN;
+9 -2
View File
@@ -1,9 +1,10 @@
import type Database from 'better-sqlite3';
import { ConfigSchema, type Config } from './index.js';
type StoredConfig = Omit<Config, 'database' | 'openrouter' | 'web'> & {
type StoredConfig = Omit<Config, 'database' | 'openrouter' | 'web' | 'pushNotifications'> & {
openrouter: Omit<Config['openrouter'], 'apiKey'>;
web: Omit<Config['web'], 'authToken' | 'agentReadToken' | 'agentControlToken'>;
pushNotifications: Omit<Config['pushNotifications'], 'authToken'>;
};
/**
@@ -51,7 +52,8 @@ function toStoredConfig(config: Config): StoredConfig {
agentControlToken: _agentControlToken,
...web
} = applicationConfig.web;
return { ...applicationConfig, openrouter, web };
const { authToken: _ntfyToken, ...pushNotifications } = applicationConfig.pushNotifications;
return { ...applicationConfig, openrouter, web, pushNotifications };
}
function withBootstrapSettings(stored: unknown, environment: NodeJS.ProcessEnv): unknown {
@@ -59,8 +61,13 @@ function withBootstrapSettings(stored: unknown, environment: NodeJS.ProcessEnv):
const record = stored as Record<string, unknown>;
const openrouter = objectValue(record.openrouter);
const web = objectValue(record.web);
const pushNotifications = objectValue(record.pushNotifications);
return {
...record,
pushNotifications: {
...pushNotifications,
authToken: optionalSecret(environment.NTFY_AUTH_TOKEN),
},
database: { path: environment.DATABASE_PATH ?? './data/reddit-bot.db' },
openrouter: { ...openrouter, apiKey: environment.OPENROUTER_API_KEY },
web: {
+2
View File
@@ -7,6 +7,8 @@ export type {
SyncPhase,
SyncCompletion,
SyncProgressListener,
NewReplyListener,
MediaReceivedListener,
ConversationStartResult,
} from './service.js';
export type {
+28 -2
View File
@@ -135,6 +135,13 @@ export type NewReplyListener = (info: {
replySnippet: string;
}) => void;
/** Fired the first time a conversation we opened receives inbound media. */
export type MediaReceivedListener = (info: {
conversationId: string;
username: string;
count: number;
}) => void;
export class ChatService {
private cache: ChatCache;
private queue: Promise<unknown> = Promise.resolve();
@@ -147,6 +154,7 @@ export class ChatService {
private progress: SyncProgress | null = null;
private progressListener: SyncProgressListener | null = null;
private newReplyListener: NewReplyListener | null = null;
private mediaReceivedListener: MediaReceivedListener | null = null;
private invitationCooldown: InvitationCooldown | null = null;
constructor(
@@ -535,6 +543,11 @@ export class ChatService {
this.newReplyListener = listener;
}
/** Subscribe to inbound-media notifications (fires once per chat, on the first image). */
setMediaReceivedListener(listener: MediaReceivedListener | null): void {
this.mediaReceivedListener = listener;
}
private beginSync(kind: 'full' | 'delta'): void {
this.syncingKind = kind;
this.progress = {
@@ -1167,12 +1180,25 @@ export class ChatService {
);
const incomingMessages = conversationMessages.filter((message) => !message.isSelf);
const hasReply = incomingMessages.length > 0;
const hasReceivedImage = incomingMessages.some(
(message) => (message.media?.length ?? 0) > 0,
const incomingMediaCount = incomingMessages.reduce(
(total, message) => total + (message.media?.length ?? 0),
0,
);
const hasReceivedImage = incomingMediaCount > 0;
const firstAttributedReply = hasReply && !performanceMessage.gotResponse;
if (hasReceivedImage && !performanceMessage.gotImage) {
this.messagesRepo.markGotImage(performanceMessage.id);
// Guarded by the durable `gotImage` flag, so re-syncing a conversation
// cannot re-notify for media that was already reported.
try {
this.mediaReceivedListener?.({
conversationId: chat.roomId,
username: chat.username,
count: incomingMediaCount,
});
} catch (err) {
this.logger.warn('[chat] mediaReceivedListener threw:', err);
}
}
if (hasReply && this.successfulMessagesRepo && this.outcomesRepo) {
+217
View File
@@ -0,0 +1,217 @@
import type { Logger } from '../utils/logger.js';
import type { Config } from '../config/index.js';
import type { RealtimeEvent } from '../web/events/event-types.js';
export type PushNotificationSettings = Config['pushNotifications'];
export type PushNotificationEventKey = keyof PushNotificationSettings['events'];
export interface PushNotificationDraft {
event: PushNotificationEventKey;
title: string;
body: string;
/** ntfy emoji shortcodes rendered alongside the notification. */
tags: string[];
/** Overrides the configured priority when an event is more urgent. */
priority?: PushNotificationSettings['priority'];
}
export interface PushDeliveryResult {
delivered: boolean;
reason?: string;
}
/** Minimal surface of the realtime hub the notifier needs. */
export interface PushEventSource {
subscribe(listener: (event: RealtimeEvent) => void): { unsubscribe(): void };
}
type Fetcher = (input: string, init: RequestInit) => Promise<{ ok: boolean; status: number; statusText: string }>;
const REQUEST_TIMEOUT_MS = 10_000;
const MAX_BODY_LENGTH = 400;
/**
* Maps a realtime event onto the push notification an operator would want on
* their phone, or null when the event is not notification-worthy. Pure, so the
* copy and the event selection are testable without any transport.
*/
export function pushNotificationForEvent(event: RealtimeEvent): PushNotificationDraft | null {
switch (event.type) {
case 'media.received':
return {
event: 'imageReceived',
title: `Image from u/${event.payload.sender}`,
body: event.payload.count > 1
? `${event.payload.count} images are attached to the conversation.`
: 'An image is attached to the conversation.',
tags: ['camera'],
};
case 'reply.received':
return {
event: 'replyReceived',
title: `Reply from u/${event.payload.sender}`,
body: event.payload.preview?.trim() || 'A new message arrived in the conversation.',
tags: ['speech_balloon'],
};
case 'message.failed':
return {
event: 'messageFailed',
title: `Message to u/${event.payload.recipient} failed`,
body: event.payload.reason,
tags: ['warning'],
};
case 'bot.status.changed':
if (event.payload.status !== 'stopped') return null;
return {
event: 'botStopped',
title: 'Bot stopped',
body: event.payload.reason ?? 'The bot is no longer processing candidates.',
tags: ['octagonal_sign'],
};
case 'rate-limit.changed':
if (!event.payload.limited) return null;
return {
event: 'rateLimited',
title: 'Rate limit reached',
body: event.payload.reason ?? 'Sending is paused until the limit resets.',
tags: ['hourglass'],
};
case 'reddit.session.changed':
if (!['expired', 'logged-out', 'error'].includes(event.payload.status)) return null;
return {
event: 'sessionExpired',
title: 'Reddit account needs attention',
body: event.payload.reason
?? (event.payload.username
? `The session for u/${event.payload.username} is ${event.payload.status}.`
: `The Reddit session is ${event.payload.status}.`),
tags: ['rotating_light'],
priority: 'high',
};
case 'sync.failed':
return {
event: 'syncFailed',
title: `${event.payload.kind} sync failed`,
body: event.payload.reason,
tags: ['warning'],
};
default:
return null;
}
}
/**
* Publishes selected application events to a single ntfy topic so they reach
* the operator's phone. Delivery is best-effort: a failing or unreachable ntfy
* server is logged and never propagated into the calling path.
*/
export class PushNotifier {
private subscription: { unsubscribe(): void } | null = null;
private attachedAt = 0;
constructor(
private readonly getSettings: () => PushNotificationSettings,
private readonly logger: Logger,
private readonly fetcher: Fetcher = globalThis.fetch as unknown as Fetcher,
private readonly now: () => number = Date.now,
) {}
/**
* Subscribes to the realtime hub. Events buffered before this call are
* skipped: the hub replays its buffer to every new subscriber, and replaying
* a restart's worth of history to somebody's phone is not useful.
*/
attach(source: PushEventSource): void {
if (this.subscription) return;
this.attachedAt = this.now();
this.subscription = source.subscribe((event) => {
if (Date.parse(event.occurredAt) < this.attachedAt) return;
const draft = pushNotificationForEvent(event);
if (!draft) return;
void this.publish(draft);
});
}
detach(): void {
this.subscription?.unsubscribe();
this.subscription = null;
}
/** Sends a notification if the event is enabled and the topic is configured. */
async publish(draft: PushNotificationDraft): Promise<PushDeliveryResult> {
const settings = this.getSettings();
if (!settings.enabled) return { delivered: false, reason: 'Push notifications are disabled.' };
if (!settings.events[draft.event]) {
return { delivered: false, reason: `The ${draft.event} event is not selected.` };
}
return this.deliver(draft, settings);
}
/** Sends a fixed notification regardless of the per-event selection. */
sendTest(): Promise<PushDeliveryResult> {
const settings = this.getSettings();
if (!settings.enabled) return Promise.resolve({ delivered: false, reason: 'Push notifications are disabled.' });
return this.deliver({
event: 'imageReceived',
title: 'RedditBot test notification',
body: 'Push notifications are configured correctly.',
tags: ['white_check_mark'],
}, settings);
}
private async deliver(
draft: PushNotificationDraft,
settings: PushNotificationSettings,
): Promise<PushDeliveryResult> {
const topic = settings.topic.trim();
if (topic === '') return { delivered: false, reason: 'No ntfy topic is configured.' };
let endpoint: string;
try {
endpoint = new URL(topic, `${settings.serverUrl.trim().replace(/\/+$/, '')}/`).toString();
} catch {
return { delivered: false, reason: `Invalid ntfy server URL: ${settings.serverUrl}` };
}
const headers: Record<string, string> = {
'Content-Type': 'text/plain; charset=utf-8',
Title: headerSafe(draft.title),
Priority: draft.priority ?? settings.priority,
Tags: draft.tags.join(','),
};
if (settings.authToken) headers.Authorization = `Bearer ${settings.authToken}`;
try {
const response = await this.fetcher(endpoint, {
method: 'POST',
headers,
body: truncate(draft.body, MAX_BODY_LENGTH),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!response.ok) {
const reason = `ntfy responded ${response.status} ${response.statusText}`.trim();
this.logger.warn(`[push] ${draft.event} notification rejected: ${reason}`);
return { delivered: false, reason };
}
return { delivered: true };
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
this.logger.warn(`[push] ${draft.event} notification failed: ${reason}`);
return { delivered: false, reason };
}
}
}
/**
* Titles travel as an HTTP header, so newlines (which would split the header)
* and non-ASCII bytes are removed. The body keeps its UTF-8 text.
*/
function headerSafe(value: string): string {
// eslint-disable-next-line no-control-regex
const ascii = value.replace(/[\r\n]+/g, ' ').replace(/[^\x20-\x7E]/g, '').trim();
return ascii.length <= 120 ? ascii : `${ascii.slice(0, 117)}...`;
}
function truncate(value: string, limit: number): string {
return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
}
+2
View File
@@ -27,6 +27,7 @@ import type { AiUsageRepository } from '../database/ai-usage-repository.js';
import type { CandidateQueueRepository } from '../database/candidate-queue-repository.js';
import type { MediaLibraryService } from '../services/media-library.js';
import type { AccountContentRepository } from '../database/account-repositories.js';
import type { PushNotifier } from '../services/push-notifier.js';
/** Bag of everything the route modules might need. */
export interface WebContext {
@@ -62,6 +63,7 @@ export interface WebContext {
accountRuntimes?: RedditAccountRuntimeManager;
candidateQueue?: CandidateQueueRepository;
mediaLibrary?: MediaLibraryService;
pushNotifier?: PushNotifier;
};
/** Bot pause/resume toggle read by the main loop. */
+3
View File
@@ -28,6 +28,9 @@ export function createDashboardEventAdapters(hub: DashboardEventPublisher) {
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) {
+6
View File
@@ -46,6 +46,12 @@ export interface RealtimeEventMap {
sender: string;
preview?: string;
};
'media.received': {
conversationId: string;
sender: string;
/** Number of inbound media attachments in the conversation. */
count: number;
};
'sync.started': {
syncId: string;
kind: SyncKind;
+10
View File
@@ -4,6 +4,8 @@ import {
createDashboardEventAdapters,
createDashboardEventPublisher,
RealtimeEventHub,
type RealtimeEventListener,
type RealtimeEventSubscription,
} from './events/index.js';
import { createExpressSseHandler } from './events/express-sse-adapter.js';
import { toJsonSafe } from '../utils/json-safe.js';
@@ -28,6 +30,14 @@ export class LogBroadcaster {
this.dashboardEvents = createDashboardEventAdapters(createDashboardEventPublisher(this.eventHub));
}
/**
* Subscribe an in-process consumer (such as the push notifier) to the same
* typed event stream the dashboard's SSE clients receive.
*/
subscribe(listener: RealtimeEventListener): RealtimeEventSubscription {
return this.eventHub.subscribe(listener);
}
push(level: LogEntry['level'], message: string, data?: unknown): void {
const safeData = toJsonSafe(data);
const realtimeEvent = this.eventHub.closed
+123
View File
@@ -0,0 +1,123 @@
import { Router } from 'express';
import { z } from 'zod';
import type { WebContext } from '../context.js';
import type { PushNotificationSettings } from '../../services/push-notifier.js';
/** ntfy restricts topic names to this alphabet. */
const TOPIC_PATTERN = /^[-_A-Za-z0-9]{1,64}$/;
const ServerUrlSchema = 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.');
const TopicSchema = z.string().trim().refine(
(value) => value === '' || TOPIC_PATTERN.test(value),
'Topic may only contain letters, numbers, hyphens and underscores.',
);
const PushNotificationSettingsUpdateSchema = z.strictObject({
enabled: z.boolean().optional(),
serverUrl: ServerUrlSchema.optional(),
topic: TopicSchema.optional(),
priority: z.enum(['min', 'low', 'default', 'high', 'max']).optional(),
events: z.strictObject({
imageReceived: z.boolean(),
replyReceived: z.boolean(),
messageFailed: z.boolean(),
botStopped: z.boolean(),
rateLimited: z.boolean(),
sessionExpired: z.boolean(),
syncFailed: z.boolean(),
}).partial().optional(),
});
type PushNotificationSettingsResponse = Omit<PushNotificationSettings, 'authToken'> & {
hasAuthToken: boolean;
};
/** The stored token is environment-owned and is never returned to a client. */
function settingsResponse(ctx: WebContext): PushNotificationSettingsResponse {
const settings = ctx.config.pushNotifications;
return {
enabled: settings.enabled,
serverUrl: settings.serverUrl,
topic: settings.topic,
priority: settings.priority,
events: { ...settings.events },
hasAuthToken: Boolean(settings.authToken),
};
}
export function createPushNotificationsRouter(ctx: WebContext): Router {
const router = Router();
router.get('/push-notifications', (_req, res) => {
res.json(settingsResponse(ctx));
});
router.put('/push-notifications', (req, res) => {
const parsed = PushNotificationSettingsUpdateSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid push notification settings', detail: parsed.error.format() });
return;
}
const configStore = ctx.services.configStore;
if (!configStore) {
res.status(503).json({ error: 'Configuration persistence is unavailable' });
return;
}
const updates = parsed.data;
if (updates.enabled === true) {
const topic = (updates.topic ?? ctx.config.pushNotifications.topic).trim();
if (topic === '') {
res.status(400).json({ error: 'A topic is required before push notifications can be enabled' });
return;
}
}
// Assigned field by field, never replaced: the notifier holds a reference
// to this object and reads it on every event, so a save applies without a
// restart.
const settings = ctx.config.pushNotifications;
if (updates.enabled !== undefined) settings.enabled = updates.enabled;
if (updates.serverUrl !== undefined) settings.serverUrl = updates.serverUrl;
if (updates.topic !== undefined) settings.topic = updates.topic;
if (updates.priority !== undefined) settings.priority = updates.priority;
if (updates.events) Object.assign(settings.events, updates.events);
try {
configStore.save(ctx.config);
} catch (error) {
ctx.logger.error('Failed to persist push notification settings:', error);
res.status(500).json({ error: String(error) });
return;
}
ctx.activity.push('info', 'Push notification settings updated via dashboard');
ctx.broadcaster.push('info', 'Push notification settings updated', {
enabled: settings.enabled,
topic: settings.topic,
});
res.json(settingsResponse(ctx));
});
router.post('/push-notifications/test', async (_req, res) => {
const notifier = ctx.services.pushNotifier;
if (!notifier) {
res.status(503).json({ error: 'Push notifications are unavailable' });
return;
}
const result = await notifier.sendTest();
res.json({ delivered: result.delivered, ...(result.reason ? { reason: result.reason } : {}) });
});
return router;
}
+7
View File
@@ -18,6 +18,7 @@ import type { MediaSyncService } from '../services/media-sync.js';
import type { ResponseCheckerService } from '../services/response-checker.js';
import type { RateLimiter } from '../utils/rate-limiter.js';
import type { OpenRouterClient } from '../ai/openrouter.js';
import type { PushNotifier } from '../services/push-notifier.js';
import type { StrategySelector } from '../services/strategy-selector.js';
import type { BrowserClient } from '../browser/client.js';
import type { RedditSessionImporter } from '../services/reddit-session-importer.js';
@@ -38,6 +39,7 @@ import { createDebugRouter } from './routes/debug.js';
import { createLogsRouter } from './routes/logs.js';
import { createAnalyticsRouter } from './routes/analytics.js';
import { createConfigRouter } from './routes/config.js';
import { createPushNotificationsRouter } from './routes/push-notifications.js';
import { createLoginRouter } from './routes/login.js';
import { createDashboardAuth, createDashboardSessionRouter } from './auth.js';
import { resolveDashboardAssets } from './dashboard-assets.js';
@@ -178,6 +180,7 @@ export class WebServer {
this.app.use('/api', createLogsRouter(ctx));
this.app.use('/api', createAnalyticsRouter(ctx));
this.app.use('/api', createConfigRouter(ctx));
this.app.use('/api', createPushNotificationsRouter(ctx));
this.app.use('/api', createLoginRouter(ctx));
this.app.use('/api', (_req, res) => {
@@ -260,6 +263,10 @@ export class WebServer {
this.services.configStore = configStore;
}
setPushNotifier(pushNotifier: PushNotifier): void {
this.services.pushNotifier = pushNotifier;
}
setRedditAccountsService(accounts: RedditAccountsService): void {
this.services.redditAccounts = accounts;
}