feat(web): wire domain events into realtime stream
This commit is contained in:
1 parent
8c8ff11fd7
commit
d5f0175dc2
6 files changed
+155
-1
No files matched your search
+37
-1
@@ -33,6 +33,7 @@ import { MediaSyncService } from './services/media-sync.js';
|
|||||||
import { RateLimiter } from './utils/rate-limiter.js';
|
import { RateLimiter } from './utils/rate-limiter.js';
|
||||||
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
|
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
|
||||||
import { WebServer } from './web/server.js';
|
import { WebServer } from './web/server.js';
|
||||||
|
import { reportReply } from './web/event-reporting.js';
|
||||||
|
|
||||||
export interface AppContainer {
|
export interface AppContainer {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -126,6 +127,10 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
logger.info('Reddit login verified');
|
logger.info('Reddit login verified');
|
||||||
}
|
}
|
||||||
|
broadcaster.dashboardEvents.redditSession({
|
||||||
|
state: loggedIn ? 'authenticated' : 'login-required',
|
||||||
|
username: loggedIn ? config.reddit.username : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
// Reddit API client (shares browser context for auth)
|
// Reddit API client (shares browser context for auth)
|
||||||
const redditApi = new RedditApiClient(config, logger);
|
const redditApi = new RedditApiClient(config, logger);
|
||||||
@@ -273,16 +278,47 @@ export async function bootstrap(config: Config, logger: Logger): Promise<AppCont
|
|||||||
webServer.setChatService(chatService);
|
webServer.setChatService(chatService);
|
||||||
// Stream sync-progress frames over the dashboard's SSE channel so the
|
// Stream sync-progress frames over the dashboard's SSE channel so the
|
||||||
// full-sync UI can show live phase + per-room progress.
|
// full-sync UI can show live phase + per-room progress.
|
||||||
|
let lastSyncProgress: { kind: 'full' | 'delta'; current: number; startedAt: number } | null = null;
|
||||||
chatService.setProgressListener((progress) => {
|
chatService.setProgressListener((progress) => {
|
||||||
webServer!.broadcastSyncProgress(progress as Record<string, unknown> | null);
|
webServer!.broadcastSyncProgress(progress as Record<string, unknown> | null);
|
||||||
|
if (progress) {
|
||||||
|
const syncId = `chat-${progress.startedAt}`;
|
||||||
|
if (progress.phase === 'starting') {
|
||||||
|
broadcaster.dashboardEvents.sync({ phase: 'started', id: syncId, kind: progress.kind });
|
||||||
|
} else {
|
||||||
|
broadcaster.dashboardEvents.sync({
|
||||||
|
phase: 'progress',
|
||||||
|
id: syncId,
|
||||||
|
kind: progress.kind,
|
||||||
|
current: progress.current,
|
||||||
|
total: progress.total,
|
||||||
|
label: progress.label ?? progress.phase,
|
||||||
});
|
});
|
||||||
chatService.setNewReplyListener(({ username, replySnippet }) => {
|
}
|
||||||
|
lastSyncProgress = progress;
|
||||||
|
} else if (lastSyncProgress) {
|
||||||
|
broadcaster.dashboardEvents.sync({
|
||||||
|
phase: 'completed',
|
||||||
|
id: `chat-${lastSyncProgress.startedAt}`,
|
||||||
|
kind: lastSyncProgress.kind,
|
||||||
|
processed: lastSyncProgress.current,
|
||||||
|
});
|
||||||
|
lastSyncProgress = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
chatService.setNewReplyListener(({ conversationId, replyId, username, replySnippet }) => {
|
||||||
webServer!.logActivity(
|
webServer!.logActivity(
|
||||||
'response',
|
'response',
|
||||||
`Reply from u/${username}: ${replySnippet}`,
|
`Reply from u/${username}: ${replySnippet}`,
|
||||||
username,
|
username,
|
||||||
);
|
);
|
||||||
webServer!.pushLog('info', `💬 Reply from u/${username}`);
|
webServer!.pushLog('info', `💬 Reply from u/${username}`);
|
||||||
|
reportReply(broadcaster.dashboardEvents, {
|
||||||
|
conversationId,
|
||||||
|
replyId,
|
||||||
|
username,
|
||||||
|
body: replySnippet,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
webServer.setMediaService(mediaService);
|
webServer.setMediaService(mediaService);
|
||||||
webServer.setMediaSyncService(mediaSyncService);
|
webServer.setMediaSyncService(mediaSyncService);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { AppContainer } from './bootstrap.js';
|
import type { AppContainer } from './bootstrap.js';
|
||||||
import { withRetry } from './utils/retry.js';
|
import { withRetry } from './utils/retry.js';
|
||||||
import { isSkip } from './services/processor.js';
|
import { isSkip } from './services/processor.js';
|
||||||
|
import { reportDeliveryResult, reportInvitationCooldown } from './web/event-reporting.js';
|
||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
@@ -93,6 +94,7 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
|
|
||||||
let lastPausedLog = 0;
|
let lastPausedLog = 0;
|
||||||
let lastCooldownLog = 0;
|
let lastCooldownLog = 0;
|
||||||
|
let reportedCooldownUntil: number | null = null;
|
||||||
const COOLDOWN_LOG_INTERVAL_MS = 5 * 60_000;
|
const COOLDOWN_LOG_INTERVAL_MS = 5 * 60_000;
|
||||||
|
|
||||||
while (!isShuttingDown) {
|
while (!isShuttingDown) {
|
||||||
@@ -110,6 +112,10 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
// pull new posts — they'd just queue up and skip. Wait the cooldown out.
|
// pull new posts — they'd just queue up and skip. Wait the cooldown out.
|
||||||
const cooldown = chatService.getInvitationCooldown();
|
const cooldown = chatService.getInvitationCooldown();
|
||||||
if (cooldown) {
|
if (cooldown) {
|
||||||
|
if (webServer && reportedCooldownUntil !== cooldown.until) {
|
||||||
|
reportInvitationCooldown(webServer.dashboardEvents, cooldown);
|
||||||
|
reportedCooldownUntil = cooldown.until;
|
||||||
|
}
|
||||||
const minutesLeft = Math.ceil((cooldown.until - Date.now()) / 60_000);
|
const minutesLeft = Math.ceil((cooldown.until - Date.now()) / 60_000);
|
||||||
if (Date.now() - lastCooldownLog > COOLDOWN_LOG_INTERVAL_MS) {
|
if (Date.now() - lastCooldownLog > COOLDOWN_LOG_INTERVAL_MS) {
|
||||||
logger.warn(`Chat invitation cooldown active — ~${minutesLeft}m left. ${cooldown.reason}`);
|
logger.warn(`Chat invitation cooldown active — ~${minutesLeft}m left. ${cooldown.reason}`);
|
||||||
@@ -122,6 +128,10 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
await sleep(config.reddit.pollIntervalMs);
|
await sleep(config.reddit.pollIntervalMs);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (webServer && reportedCooldownUntil !== null) {
|
||||||
|
reportInvitationCooldown(webServer.dashboardEvents, null);
|
||||||
|
reportedCooldownUntil = null;
|
||||||
|
}
|
||||||
lastCooldownLog = 0;
|
lastCooldownLog = 0;
|
||||||
|
|
||||||
const posts = await monitorService.getUnprocessedPosts();
|
const posts = await monitorService.getUnprocessedPosts();
|
||||||
@@ -242,6 +252,14 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
);
|
);
|
||||||
webServer?.pushLog('info', `✅ Message sent to u/${post.author} (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`);
|
webServer?.pushLog('info', `✅ Message sent to u/${post.author} (${currentCount}/${config.rateLimit.maxMessagesPerHour} this hour)`);
|
||||||
webServer?.updateStats(currentCount);
|
webServer?.updateStats(currentCount);
|
||||||
|
if (webServer) {
|
||||||
|
reportDeliveryResult(webServer.dashboardEvents, {
|
||||||
|
success: true,
|
||||||
|
messageId: post.id,
|
||||||
|
username: post.author,
|
||||||
|
conversationId: chat.roomId,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
postsRepo.setError(post.id, 'Failed to send message');
|
postsRepo.setError(post.id, 'Failed to send message');
|
||||||
webServer?.logActivity(
|
webServer?.logActivity(
|
||||||
@@ -251,6 +269,14 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
|
|||||||
postImageUrl ?? undefined,
|
postImageUrl ?? undefined,
|
||||||
);
|
);
|
||||||
webServer?.pushLog('error', `❌ Failed to send message to u/${post.author}`);
|
webServer?.pushLog('error', `❌ Failed to send message to u/${post.author}`);
|
||||||
|
if (webServer) {
|
||||||
|
reportDeliveryResult(webServer.dashboardEvents, {
|
||||||
|
success: false,
|
||||||
|
messageId: post.id,
|
||||||
|
username: post.author,
|
||||||
|
error: 'Chat service did not create a conversation',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return success;
|
return success;
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export type SyncProgressListener = (progress: SyncProgress | null) => void;
|
|||||||
|
|
||||||
/** Fired when a chat we sent into first receives a reply from the other party. */
|
/** Fired when a chat we sent into first receives a reply from the other party. */
|
||||||
export type NewReplyListener = (info: {
|
export type NewReplyListener = (info: {
|
||||||
|
conversationId: string;
|
||||||
|
replyId: string;
|
||||||
username: string;
|
username: string;
|
||||||
replySnippet: string;
|
replySnippet: string;
|
||||||
}) => void;
|
}) => void;
|
||||||
@@ -806,6 +808,8 @@ export class ChatService {
|
|||||||
this.logger.info(`[chat] stored successful chat example from ${chat.username}`);
|
this.logger.info(`[chat] stored successful chat example from ${chat.username}`);
|
||||||
try {
|
try {
|
||||||
this.newReplyListener?.({
|
this.newReplyListener?.({
|
||||||
|
conversationId: chat.roomId,
|
||||||
|
replyId: `${chat.roomId}:${firstReply?.timestamp ?? Date.now()}`,
|
||||||
username: chat.username,
|
username: chat.username,
|
||||||
replySnippet: firstReply?.text.slice(0, 200) ?? '',
|
replySnippet: firstReply?.text.slice(0, 200) ?? '',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
interface DeliveryEvents {
|
||||||
|
messageSent(input: { id: string; username: string; conversationId?: string }): unknown;
|
||||||
|
messageFailed(input: { username: string; error: unknown; messageId?: string; conversationId?: string }): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversationEvents {
|
||||||
|
conversation(input: { id: string; state: 'replied'; unread?: number }): unknown;
|
||||||
|
reply(input: { conversationId: string; id: string; username: string; body?: string }): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RateLimitEvents {
|
||||||
|
rateLimit(input: { blocked: boolean; resetAt?: number; reason?: string }): unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeliveryResult {
|
||||||
|
success: boolean;
|
||||||
|
messageId: string;
|
||||||
|
username: string;
|
||||||
|
conversationId?: string;
|
||||||
|
error?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportDeliveryResult(events: DeliveryEvents, result: DeliveryResult): void {
|
||||||
|
if (result.success) {
|
||||||
|
events.messageSent({ id: result.messageId, username: result.username, conversationId: result.conversationId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.messageFailed({
|
||||||
|
messageId: result.messageId,
|
||||||
|
username: result.username,
|
||||||
|
conversationId: result.conversationId,
|
||||||
|
error: result.error ?? 'Message delivery failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportReply(
|
||||||
|
events: ConversationEvents,
|
||||||
|
reply: { conversationId: string; replyId: string; username: string; body?: string },
|
||||||
|
): void {
|
||||||
|
events.conversation({ id: reply.conversationId, state: 'replied', unread: 1 });
|
||||||
|
events.reply({ conversationId: reply.conversationId, id: reply.replyId, username: reply.username, body: reply.body });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportInvitationCooldown(
|
||||||
|
events: RateLimitEvents,
|
||||||
|
cooldown: { until: number; reason: string } | null,
|
||||||
|
): void {
|
||||||
|
if (cooldown) {
|
||||||
|
events.rateLimit({ blocked: true, resetAt: cooldown.until, reason: cooldown.reason });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.rateLimit({ blocked: false });
|
||||||
|
}
|
||||||
@@ -163,6 +163,10 @@ export class WebServer {
|
|||||||
return this.botRunning;
|
return this.botRunning;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get dashboardEvents(): LogBroadcaster['dashboardEvents'] {
|
||||||
|
return this.broadcaster.dashboardEvents;
|
||||||
|
}
|
||||||
|
|
||||||
setChatService(chatService: ChatService): void {
|
setChatService(chatService: ChatService): void {
|
||||||
this.services.chat = chatService;
|
this.services.chat = chatService;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
reportDeliveryResult,
|
||||||
|
reportInvitationCooldown,
|
||||||
|
reportReply,
|
||||||
|
} from '../../src/web/event-reporting.js';
|
||||||
|
|
||||||
|
describe('dashboard event reporting', () => {
|
||||||
|
it('reports authoritative delivery success and failure', () => {
|
||||||
|
const events = { messageSent: vi.fn(), messageFailed: vi.fn() };
|
||||||
|
|
||||||
|
reportDeliveryResult(events, { success: true, messageId: 'post-1', username: 'alice', conversationId: 'room-1' });
|
||||||
|
reportDeliveryResult(events, { success: false, messageId: 'post-2', username: 'bob', error: 'Matrix rejected the message' });
|
||||||
|
|
||||||
|
expect(events.messageSent).toHaveBeenCalledWith({ id: 'post-1', username: 'alice', conversationId: 'room-1' });
|
||||||
|
expect(events.messageFailed).toHaveBeenCalledWith({ messageId: 'post-2', username: 'bob', error: 'Matrix rejected the message' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports replies and invitation cooldown state', () => {
|
||||||
|
const events = { conversation: vi.fn(), reply: vi.fn(), rateLimit: vi.fn() };
|
||||||
|
|
||||||
|
reportReply(events, { conversationId: 'room-1', replyId: 'reply-1', username: 'alice', body: 'Hello there' });
|
||||||
|
reportInvitationCooldown(events, { until: 10_000, reason: 'Reddit asked us to slow down' });
|
||||||
|
reportInvitationCooldown(events, null);
|
||||||
|
|
||||||
|
expect(events.conversation).toHaveBeenCalledWith({ id: 'room-1', state: 'replied', unread: 1 });
|
||||||
|
expect(events.reply).toHaveBeenCalledWith({ conversationId: 'room-1', id: 'reply-1', username: 'alice', body: 'Hello there' });
|
||||||
|
expect(events.rateLimit).toHaveBeenNthCalledWith(1, { blocked: true, resetAt: 10_000, reason: 'Reddit asked us to slow down' });
|
||||||
|
expect(events.rateLimit).toHaveBeenNthCalledWith(2, { blocked: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user