Extract dashboard room message route

This commit is contained in:
ejclaw
2026-04-28 19:20:39 +09:00
parent b1cbb360de
commit ee8be2f127
3 changed files with 358 additions and 127 deletions

View File

@@ -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<string, RegisteredGroup>;
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);
});
});

View File

@@ -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<string, RegisteredGroup>;
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<string>();
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<Response | null> {
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 });
}

View File

@@ -39,6 +39,10 @@ import type {
ScheduledTask, ScheduledTask,
} from './types.js'; } from './types.js';
import { handleOverviewRoute } from './web-dashboard-overview-routes.js'; import { handleOverviewRoute } from './web-dashboard-overview-routes.js';
import {
createRoomMessageIdCache,
handleRoomMessageRoute,
} from './web-dashboard-room-message-routes.js';
import { import {
handleRoomTimelineRoute, handleRoomTimelineRoute,
startRoomsTimelineCacheRefresh, startRoomsTimelineCacheRefresh,
@@ -53,7 +57,6 @@ import { handleSettingsRoute } from './web-dashboard-settings-routes.js';
import { handleScheduledTaskRoute } from './web-dashboard-task-routes.js'; import { handleScheduledTaskRoute } from './web-dashboard-task-routes.js';
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000; 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 STACK_RESTART_UNIT_NAME = 'ejclaw-stack-restart.service';
const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE = 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.'; '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( function parsePairedInboxTarget(
inboxId: string, inboxId: string,
): { taskId: string; status: PairedTask['status'] } | null { ): { 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 { function makeWebRunId(prefix: string): string {
return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
} }
@@ -528,23 +476,11 @@ export function createWebDashboardHandler(
startRoomsTimelineCacheRefresh(roomsTimelineDeps); startRoomsTimelineCacheRefresh(roomsTimelineDeps);
} }
const seenRoomMessageIds: string[] = []; const rememberRoomMessageId = createRoomMessageIdCache();
const seenRoomMessageIdSet = new Set<string>();
const dismissedInboxKeys = new Set<string>(); const dismissedInboxKeys = new Set<string>();
const recentServiceRestarts: ServiceRestartRecord[] = []; const recentServiceRestarts: ServiceRestartRecord[] = [];
const activeServiceRestartTargets = new Set<string>(); const activeServiceRestartTargets = new Set<string>();
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: { function isInboxItemDismissed(item: {
id: string; id: string;
lastOccurredAt: string; lastOccurredAt: string;
@@ -558,7 +494,6 @@ export function createWebDashboardHandler(
return async (request: Request): Promise<Response> => { return async (request: Request): Promise<Response> => {
const url = new URL(request.url); const url = new URL(request.url);
const actionInboxId = parseInboxActionPath(url.pathname); const actionInboxId = parseInboxActionPath(url.pathname);
const messageRoomJid = parseRoomMessagePath(url.pathname);
const scheduledTaskRoute = await handleScheduledTaskRoute({ const scheduledTaskRoute = await handleScheduledTaskRoute({
url, url,
@@ -725,63 +660,19 @@ export function createWebDashboardHandler(
}); });
} }
if (messageRoomJid) { const roomMessageRoute = await handleRoomMessageRoute({
if (request.method !== 'POST') { url,
return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); request,
} jsonResponse,
enqueueMessageCheck,
if (!loadRoomBindings || !enqueueMessageCheck) { loadRoomBindings,
return jsonResponse( messageExists,
{ error: 'Room message injection is not configured' }, rememberRoomMessageId,
{ status: 503 }, writeChatMetadata,
); writeMessage,
} now: opts.now,
});
const body = await readRoomMessageBody(request); if (roomMessageRoute) return roomMessageRoute;
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 roomTimelineRoute = handleRoomTimelineRoute({ const roomTimelineRoute = handleRoomTimelineRoute({
url, url,