Extract dashboard room message route (#76)

* review: harden readonly git checks and lint verification

* test: satisfy readonly reviewer sandbox typing

* test: fix readonly reviewer sandbox assertions

* test: remove impossible readonly sandbox guards

* test: relax readonly sandbox assertions

* test: cast readonly sandbox expectation shape

* test: cast readonly sandbox expectation through unknown

* Phase 0 — STEP_DONE/TASK_DONE split + owner-follow-up integration smoke

* Add STEP_DONE guards, verdict storage, and stale delivery suppression

* style: format paired stepdone telemetry files

* Route STEP_DONE through reviewer

* Add structured Discord attachments

* style: format structured attachment test

* Fix room thread bot output parity

* Remove duplicate work item attachment migration from owner branch

* Remove legacy dashboard rooms renderer

* Extract dashboard parsed body renderer

* Extract dashboard redaction helpers

* Extract dashboard RoomCardV2 component

* Include RoomCardV2 test in vitest suite

* Extract dashboard RoomBoardV2 component

* Extract dashboard EmptyState component

* Extract dashboard InboxPanel component

* Split dashboard InboxPanel card renderer

* Extract dashboard TaskPanel component

* Extract dashboard UsagePanel component

* Fix dashboard room thread chunk rendering

* Extract dashboard ServicePanel component

* Render dashboard live progress markdown

* Extract dashboard SettingsPanel component

* Fix structured attachment rendering

* Extract dashboard simple route table

* Extract dashboard settings routes

* Extract dashboard service routes

* Extract dashboard room timeline routes

* Extract dashboard task routes

* Extract dashboard overview route

* Extract dashboard room message route
This commit is contained in:
Eyejoker
2026-04-28 19:25:49 +09:00
committed by GitHub
parent 87d0ef8c05
commit 49382713ca
3 changed files with 358 additions and 127 deletions

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 });
}