From ee8be2f127ad9bc3c34317645a491e57f13b3308 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Tue, 28 Apr 2026 19:20:39 +0900 Subject: [PATCH] Extract dashboard room message route --- src/web-dashboard-room-message-routes.test.ts | 165 +++++++++++++++++ src/web-dashboard-room-message-routes.ts | 175 ++++++++++++++++++ src/web-dashboard-server.ts | 145 ++------------- 3 files changed, 358 insertions(+), 127 deletions(-) create mode 100644 src/web-dashboard-room-message-routes.test.ts create mode 100644 src/web-dashboard-room-message-routes.ts diff --git a/src/web-dashboard-room-message-routes.test.ts b/src/web-dashboard-room-message-routes.test.ts new file mode 100644 index 0000000..8bceb0c --- /dev/null +++ b/src/web-dashboard-room-message-routes.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest'; + +import type { NewMessage, RegisteredGroup } from './types.js'; +import { + createRoomMessageIdCache, + handleRoomMessageRoute, +} from './web-dashboard-room-message-routes.js'; + +function jsonResponse(value: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(value), { + ...init, + headers: { 'content-type': 'application/json', ...init?.headers }, + }); +} + +function roomMessageRequest( + roomJid: string, + body: unknown, + method = 'POST', +): Request { + return new Request( + `http://localhost/api/rooms/${encodeURIComponent(roomJid)}/messages`, + { + method, + headers: { 'content-type': 'application/json' }, + body: method === 'POST' ? JSON.stringify(body) : undefined, + }, + ); +} + +function makeDeps( + overrides: { + rooms?: Record; + existingMessage?: (chatJid: string, id: string) => boolean; + messages?: NewMessage[]; + queued?: Array<{ chatJid: string; groupFolder: string }>; + } = {}, +) { + const messages = overrides.messages ?? []; + const queued = overrides.queued ?? []; + return { + enqueueMessageCheck: (chatJid: string, groupFolder: string) => { + queued.push({ chatJid, groupFolder }); + }, + loadRoomBindings: () => + overrides.rooms ?? { + 'dc:ops': { + name: '#ops', + folder: 'ops-room', + added_at: '2026-04-26T05:00:00.000Z', + }, + }, + messageExists: overrides.existingMessage ?? (() => false), + rememberRoomMessageId: createRoomMessageIdCache(), + writeChatMetadata: ( + _chatJid: string, + _timestamp: string, + _name?: string, + _channel?: string, + _isGroup?: boolean, + ) => undefined, + writeMessage: (message: NewMessage) => { + messages.push(message); + }, + messages, + queued, + }; +} + +describe('web dashboard room message routes', () => { + it('injects messages and deduplicates repeated request ids', async () => { + const messages: NewMessage[] = []; + const queued: Array<{ chatJid: string; groupFolder: string }> = []; + const deps = makeDeps({ messages, queued }); + + const first = await handleRoomMessageRoute({ + ...deps, + jsonResponse, + now: () => '2026-04-26T05:10:00.000Z', + request: roomMessageRequest('dc:ops', { + nickname: ' 눈쟁이 ', + requestId: 'compose 1', + text: ' run a dashboard check ', + }), + url: new URL('http://localhost/api/rooms/dc%3Aops/messages'), + }); + const second = await handleRoomMessageRoute({ + ...deps, + jsonResponse, + request: roomMessageRequest('dc:ops', { + requestId: 'compose 1', + text: 'second submit', + }), + url: new URL('http://localhost/api/rooms/dc%3Aops/messages'), + }); + + expect(first?.status).toBe(200); + expect(second?.status).toBe(200); + await expect(first?.json()).resolves.toMatchObject({ + id: 'web-compose-1', + queued: true, + }); + await expect(second?.json()).resolves.toMatchObject({ + id: 'web-compose-1', + queued: false, + duplicate: true, + }); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + chat_jid: 'dc:ops', + content: 'run a dashboard check', + id: 'web-compose-1', + message_source_kind: 'ipc_injected_human', + sender: 'web-dashboard', + sender_name: '눈쟁이', + timestamp: '2026-04-26T05:10:00.000Z', + }); + expect(queued).toEqual([{ chatJid: 'dc:ops', groupFolder: 'ops-room' }]); + }); + + it('handles fall-through and invalid room message requests', async () => { + const deps = makeDeps({ rooms: {} }); + + const fallThrough = await handleRoomMessageRoute({ + ...deps, + jsonResponse, + request: new Request('http://localhost/api/overview'), + url: new URL('http://localhost/api/overview'), + }); + expect(fallThrough).toBeNull(); + + const notConfigured = await handleRoomMessageRoute({ + ...deps, + enqueueMessageCheck: undefined, + jsonResponse, + request: roomMessageRequest('dc:ops', { text: 'hello' }), + url: new URL('http://localhost/api/rooms/dc%3Aops/messages'), + }); + expect(notConfigured?.status).toBe(503); + + const empty = await handleRoomMessageRoute({ + ...deps, + jsonResponse, + request: roomMessageRequest('dc:ops', { text: ' ' }), + url: new URL('http://localhost/api/rooms/dc%3Aops/messages'), + }); + expect(empty?.status).toBe(400); + + const missing = await handleRoomMessageRoute({ + ...deps, + jsonResponse, + request: roomMessageRequest('dc:missing', { text: 'hello' }), + url: new URL('http://localhost/api/rooms/dc%3Amissing/messages'), + }); + expect(missing?.status).toBe(404); + + const wrongMethod = await handleRoomMessageRoute({ + ...deps, + jsonResponse, + request: roomMessageRequest('dc:ops', null, 'GET'), + url: new URL('http://localhost/api/rooms/dc%3Aops/messages'), + }); + expect(wrongMethod?.status).toBe(405); + }); +}); diff --git a/src/web-dashboard-room-message-routes.ts b/src/web-dashboard-room-message-routes.ts new file mode 100644 index 0000000..a693552 --- /dev/null +++ b/src/web-dashboard-room-message-routes.ts @@ -0,0 +1,175 @@ +import type { NewMessage, RegisteredGroup } from './types.js'; + +type JsonResponse = ( + value: unknown, + init?: ResponseInit, + request?: Request, +) => Response; + +export type RoomMessageIdRememberer = (id: string) => boolean; + +export interface RoomMessageRouteDependencies { + enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void; + loadRoomBindings?: () => Record; + messageExists: (chatJid: string, id: string) => boolean; + rememberRoomMessageId: RoomMessageIdRememberer; + writeChatMetadata: ( + chatJid: string, + timestamp: string, + name?: string, + channel?: string, + isGroup?: boolean, + ) => void; + writeMessage: (message: NewMessage) => void; +} + +interface RoomMessageRouteContext extends RoomMessageRouteDependencies { + jsonResponse: JsonResponse; + now?: () => string; + request: Request; + url: URL; +} + +const ROOM_MESSAGE_ID_CACHE_LIMIT = 500; + +export function createRoomMessageIdCache( + limit = ROOM_MESSAGE_ID_CACHE_LIMIT, +): RoomMessageIdRememberer { + const ids: string[] = []; + const idSet = new Set(); + + return (id: string): boolean => { + if (idSet.has(id)) return false; + idSet.add(id); + ids.push(id); + if (ids.length > limit) { + const oldest = ids.shift(); + if (oldest) idSet.delete(oldest); + } + return true; + }; +} + +function parseRoomMessagePath(pathname: string): string | null { + const match = pathname.match(/^\/api\/rooms\/([^/]+)\/messages$/); + if (!match) return null; + try { + return decodeURIComponent(match[1]); + } catch { + return null; + } +} + +function sanitizeRoomMessageRequestId(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + if (!trimmed) return null; + const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120); + return safe || null; +} + +async function readRoomMessageBody(request: Request): Promise<{ + text: string; + requestId: string | null; + nickname: string | null; +} | null> { + try { + const body = (await request.json()) as { + text?: unknown; + requestId?: unknown; + nickname?: unknown; + }; + if (typeof body.text !== 'string') return null; + const text = body.text.trim(); + if (!text) return null; + let nickname: string | null = null; + if (typeof body.nickname === 'string') { + const trimmed = body.nickname.trim().slice(0, 32); + if (trimmed) nickname = trimmed; + } + return { + text: text.length > 8000 ? text.slice(0, 8000) : text, + requestId: sanitizeRoomMessageRequestId(body.requestId), + nickname, + }; + } catch { + return null; + } +} + +function makeWebMessageId(): string { + return `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function makeWebMessageIdFromRequest(requestId: string | null): string { + return requestId ? `web-${requestId}` : makeWebMessageId(); +} + +export async function handleRoomMessageRoute({ + enqueueMessageCheck, + jsonResponse, + loadRoomBindings, + messageExists, + now, + rememberRoomMessageId, + request, + url, + writeChatMetadata, + writeMessage, +}: RoomMessageRouteContext): Promise { + const messageRoomJid = parseRoomMessagePath(url.pathname); + if (!messageRoomJid) return null; + + if (request.method !== 'POST') { + return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); + } + + if (!loadRoomBindings || !enqueueMessageCheck) { + return jsonResponse( + { error: 'Room message injection is not configured' }, + { status: 503 }, + ); + } + + const body = await readRoomMessageBody(request); + if (!body) { + return jsonResponse({ error: 'Message text is required' }, { status: 400 }); + } + + const binding = loadRoomBindings()[messageRoomJid]; + if (!binding) { + return jsonResponse({ error: 'Room not found' }, { status: 404 }); + } + + const timestamp = now?.() ?? new Date().toISOString(); + const id = makeWebMessageIdFromRequest(body.requestId); + if (body.requestId && messageExists(messageRoomJid, id)) { + return jsonResponse({ ok: true, id, queued: false, duplicate: true }); + } + + if (!rememberRoomMessageId(`${messageRoomJid}:${id}`)) { + return jsonResponse({ ok: true, id, queued: false, duplicate: true }); + } + + writeChatMetadata( + messageRoomJid, + timestamp, + binding.name, + 'web-dashboard', + true, + ); + writeMessage({ + id, + chat_jid: messageRoomJid, + sender: 'web-dashboard', + sender_name: body.nickname ?? 'Web Dashboard', + content: body.text, + timestamp, + is_from_me: false, + is_bot_message: false, + message_source_kind: 'ipc_injected_human', + }); + enqueueMessageCheck(messageRoomJid, binding.folder); + + return jsonResponse({ ok: true, id, queued: true }); +} diff --git a/src/web-dashboard-server.ts b/src/web-dashboard-server.ts index 112326e..31eaf8c 100644 --- a/src/web-dashboard-server.ts +++ b/src/web-dashboard-server.ts @@ -39,6 +39,10 @@ import type { ScheduledTask, } from './types.js'; import { handleOverviewRoute } from './web-dashboard-overview-routes.js'; +import { + createRoomMessageIdCache, + handleRoomMessageRoute, +} from './web-dashboard-room-message-routes.js'; import { handleRoomTimelineRoute, startRoomsTimelineCacheRefresh, @@ -53,7 +57,6 @@ import { handleSettingsRoute } from './web-dashboard-settings-routes.js'; import { handleScheduledTaskRoute } from './web-dashboard-task-routes.js'; const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; -const ROOM_MESSAGE_ID_CACHE_LIMIT = 500; const STACK_RESTART_UNIT_NAME = 'ejclaw-stack-restart.service'; const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE = 'Stack restart unit is not installed yet. Run setup service from an external shell before retrying from a managed EJClaw service.'; @@ -259,16 +262,6 @@ function parseInboxActionPath(pathname: string): string | null { } } -function parseRoomMessagePath(pathname: string): string | null { - const match = pathname.match(/^\/api\/rooms\/([^/]+)\/messages$/); - if (!match) return null; - try { - return decodeURIComponent(match[1]); - } catch { - return null; - } -} - function parsePairedInboxTarget( inboxId: string, ): { taskId: string; status: PairedTask['status'] } | null { @@ -323,51 +316,6 @@ async function readInboxAction( } } -function sanitizeRoomMessageRequestId(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - if (!trimmed) return null; - const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120); - return safe || null; -} - -async function readRoomMessageBody(request: Request): Promise<{ - text: string; - requestId: string | null; - nickname: string | null; -} | null> { - try { - const body = (await request.json()) as { - text?: unknown; - requestId?: unknown; - nickname?: unknown; - }; - if (typeof body.text !== 'string') return null; - const text = body.text.trim(); - if (!text) return null; - let nickname: string | null = null; - if (typeof body.nickname === 'string') { - const trimmed = body.nickname.trim().slice(0, 32); - if (trimmed) nickname = trimmed; - } - return { - text: text.length > 8000 ? text.slice(0, 8000) : text, - requestId: sanitizeRoomMessageRequestId(body.requestId), - nickname, - }; - } catch { - return null; - } -} - -function makeWebMessageId(): string { - return `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -} - -function makeWebMessageIdFromRequest(requestId: string | null): string { - return requestId ? `web-${requestId}` : makeWebMessageId(); -} - function makeWebRunId(prefix: string): string { return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } @@ -528,23 +476,11 @@ export function createWebDashboardHandler( startRoomsTimelineCacheRefresh(roomsTimelineDeps); } - const seenRoomMessageIds: string[] = []; - const seenRoomMessageIdSet = new Set(); + const rememberRoomMessageId = createRoomMessageIdCache(); const dismissedInboxKeys = new Set(); const recentServiceRestarts: ServiceRestartRecord[] = []; const activeServiceRestartTargets = new Set(); - function rememberRoomMessageId(id: string): boolean { - if (seenRoomMessageIdSet.has(id)) return false; - seenRoomMessageIdSet.add(id); - seenRoomMessageIds.push(id); - if (seenRoomMessageIds.length > ROOM_MESSAGE_ID_CACHE_LIMIT) { - const oldest = seenRoomMessageIds.shift(); - if (oldest) seenRoomMessageIdSet.delete(oldest); - } - return true; - } - function isInboxItemDismissed(item: { id: string; lastOccurredAt: string; @@ -558,7 +494,6 @@ export function createWebDashboardHandler( return async (request: Request): Promise => { const url = new URL(request.url); const actionInboxId = parseInboxActionPath(url.pathname); - const messageRoomJid = parseRoomMessagePath(url.pathname); const scheduledTaskRoute = await handleScheduledTaskRoute({ url, @@ -725,63 +660,19 @@ export function createWebDashboardHandler( }); } - if (messageRoomJid) { - if (request.method !== 'POST') { - return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); - } - - if (!loadRoomBindings || !enqueueMessageCheck) { - return jsonResponse( - { error: 'Room message injection is not configured' }, - { status: 503 }, - ); - } - - const body = await readRoomMessageBody(request); - if (!body) { - return jsonResponse( - { error: 'Message text is required' }, - { status: 400 }, - ); - } - - const binding = loadRoomBindings()[messageRoomJid]; - if (!binding) { - return jsonResponse({ error: 'Room not found' }, { status: 404 }); - } - - const timestamp = opts.now?.() ?? new Date().toISOString(); - const id = makeWebMessageIdFromRequest(body.requestId); - if (body.requestId && messageExists(messageRoomJid, id)) { - return jsonResponse({ ok: true, id, queued: false, duplicate: true }); - } - - if (!rememberRoomMessageId(`${messageRoomJid}:${id}`)) { - return jsonResponse({ ok: true, id, queued: false, duplicate: true }); - } - - writeChatMetadata( - messageRoomJid, - timestamp, - binding.name, - 'web-dashboard', - true, - ); - writeMessage({ - id, - chat_jid: messageRoomJid, - sender: 'web-dashboard', - sender_name: body.nickname ?? 'Web Dashboard', - content: body.text, - timestamp, - is_from_me: false, - is_bot_message: false, - message_source_kind: 'ipc_injected_human', - }); - enqueueMessageCheck(messageRoomJid, binding.folder); - - return jsonResponse({ ok: true, id, queued: true }); - } + const roomMessageRoute = await handleRoomMessageRoute({ + url, + request, + jsonResponse, + enqueueMessageCheck, + loadRoomBindings, + messageExists, + rememberRoomMessageId, + writeChatMetadata, + writeMessage, + now: opts.now, + }); + if (roomMessageRoute) return roomMessageRoute; const roomTimelineRoute = handleRoomTimelineRoute({ url,