feat(dashboard): send room messages from web (#33)

This commit is contained in:
Eyejoker
2026-04-27 02:23:32 +09:00
committed by GitHub
parent a352b9af5b
commit 1676f53f4d
10 changed files with 612 additions and 6 deletions

View File

@@ -7,6 +7,7 @@ import {
type StatusSnapshot, type StatusSnapshot,
fetchDashboardData, fetchDashboardData,
runScheduledTaskAction, runScheduledTaskAction,
sendRoomMessage,
} from './api'; } from './api';
import { import {
LOCALES, LOCALES,
@@ -44,6 +45,10 @@ const DEFAULT_VIEW: DashboardView = 'inbox';
const HEALTH_STALE_MS = 5 * 60_000; const HEALTH_STALE_MS = 5 * 60_000;
const HEALTH_DOWN_MS = 15 * 60_000; const HEALTH_DOWN_MS = 15 * 60_000;
function makeClientRequestId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function isDashboardView( function isDashboardView(
value: string | null | undefined, value: string | null | undefined,
): value is DashboardView { ): value is DashboardView {
@@ -865,13 +870,58 @@ function HealthPanel({
); );
} }
function RoomMessageForm({
busy,
onChange,
onSubmit,
t,
value,
}: {
busy: boolean;
onChange: (value: string) => void;
onSubmit: () => void;
t: Messages;
value: string;
}) {
return (
<form
className="room-compose"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<textarea
aria-label={t.rooms.message}
maxLength={8000}
onChange={(event) => onChange(event.target.value)}
placeholder={t.rooms.messagePlaceholder}
rows={2}
value={value}
/>
<button disabled={busy || !value.trim()} type="submit">
{busy ? t.rooms.sending : t.rooms.send}
</button>
</form>
);
}
function RoomPanel({ function RoomPanel({
onSendRoomMessage,
roomMessageKey,
snapshots, snapshots,
t, t,
}: { }: {
onSendRoomMessage: (
roomJid: string,
text: string,
requestId: string,
) => Promise<boolean>;
roomMessageKey: string | null;
snapshots: StatusSnapshot[]; snapshots: StatusSnapshot[];
t: Messages; t: Messages;
}) { }) {
const [drafts, setDrafts] = useState<Record<string, string>>({});
const entries = snapshots.flatMap((snapshot) => const entries = snapshots.flatMap((snapshot) =>
snapshot.entries.map((entry) => ({ snapshot.entries.map((entry) => ({
...entry, ...entry,
@@ -883,6 +933,19 @@ function RoomPanel({
return <EmptyState>{t.rooms.empty}</EmptyState>; return <EmptyState>{t.rooms.empty}</EmptyState>;
} }
function setDraft(jid: string, value: string) {
setDrafts((previous) => ({ ...previous, [jid]: value }));
}
async function submitRoomMessage(jid: string) {
const text = drafts[jid]?.trim();
if (!text) return;
const success = await onSendRoomMessage(jid, text, makeClientRequestId());
if (success) {
setDraft(jid, '');
}
}
return ( return (
<> <>
<div className="table-wrap desktop-table"> <div className="table-wrap desktop-table">
@@ -893,6 +956,7 @@ function RoomPanel({
<th>{t.rooms.status}</th> <th>{t.rooms.status}</th>
<th>{t.rooms.queue}</th> <th>{t.rooms.queue}</th>
<th>{t.rooms.elapsed}</th> <th>{t.rooms.elapsed}</th>
<th>{t.rooms.message}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -917,6 +981,15 @@ function RoomPanel({
{queueLabel(entry.pendingTasks, entry.pendingMessages, t)} {queueLabel(entry.pendingTasks, entry.pendingMessages, t)}
</td> </td>
<td>{formatDuration(entry.elapsedMs, t)}</td> <td>{formatDuration(entry.elapsedMs, t)}</td>
<td>
<RoomMessageForm
busy={roomMessageKey === entry.jid}
onChange={(value) => setDraft(entry.jid, value)}
onSubmit={() => void submitRoomMessage(entry.jid)}
t={t}
value={drafts[entry.jid] ?? ''}
/>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -950,6 +1023,13 @@ function RoomPanel({
<strong>{formatDuration(entry.elapsedMs, t)}</strong> <strong>{formatDuration(entry.elapsedMs, t)}</strong>
</span> </span>
</div> </div>
<RoomMessageForm
busy={roomMessageKey === entry.jid}
onChange={(value) => setDraft(entry.jid, value)}
onSubmit={() => void submitRoomMessage(entry.jid)}
t={t}
value={drafts[entry.jid] ?? ''}
/>
<details className="record-details"> <details className="record-details">
<summary>{t.rooms.details}</summary> <summary>{t.rooms.details}</summary>
<p className="record-id"> <p className="record-id">
@@ -1349,6 +1429,7 @@ function App() {
const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>( const [taskActionKey, setTaskActionKey] = useState<TaskActionKey | null>(
null, null,
); );
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
const t = messages[locale]; const t = messages[locale];
function setDashboardLocale(nextLocale: Locale) { function setDashboardLocale(nextLocale: Locale) {
@@ -1397,6 +1478,24 @@ function App() {
} }
} }
async function handleRoomMessage(
roomJid: string,
text: string,
requestId: string,
) {
setRoomMessageKey(roomJid);
try {
await sendRoomMessage(roomJid, text, requestId);
await refresh(false);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
} finally {
setRoomMessageKey(null);
}
}
useEffect(() => { useEffect(() => {
document.documentElement.lang = localeTags[locale]; document.documentElement.lang = localeTags[locale];
}, [locale]); }, [locale]);
@@ -1520,7 +1619,12 @@ function App() {
<h2>{t.panels.rooms}</h2> <h2>{t.panels.rooms}</h2>
<span>{t.panels.queue}</span> <span>{t.panels.queue}</span>
</div> </div>
<RoomPanel snapshots={data.snapshots} t={t} /> <RoomPanel
onSendRoomMessage={handleRoomMessage}
roomMessageKey={roomMessageKey}
snapshots={data.snapshots}
t={t}
/>
</section> </section>
) : null} ) : null}

View File

@@ -161,3 +161,14 @@ export async function runScheduledTaskAction(
action, action,
}); });
} }
export async function sendRoomMessage(
roomJid: string,
text: string,
requestId: string,
): Promise<{ ok: true; id: string; queued: boolean }> {
return postJson(`/api/rooms/${encodeURIComponent(roomJid)}/messages`, {
requestId,
text,
});
}

View File

@@ -115,6 +115,10 @@ export interface Messages {
queue: string; queue: string;
elapsed: string; elapsed: string;
details: string; details: string;
message: string;
messagePlaceholder: string;
send: string;
sending: string;
}; };
usage: { usage: {
empty: string; empty: string;
@@ -331,6 +335,10 @@ export const messages = {
queue: '큐', queue: '큐',
elapsed: '경과', elapsed: '경과',
details: '세부', details: '세부',
message: 'Message',
messagePlaceholder: 'Type request...',
send: 'Send',
sending: 'Sending',
}, },
usage: { usage: {
empty: '사용량 스냅샷 없음. 수집기 확인.', empty: '사용량 스냅샷 없음. 수집기 확인.',
@@ -531,6 +539,10 @@ export const messages = {
queue: 'queue', queue: 'queue',
elapsed: 'elapsed', elapsed: 'elapsed',
details: 'details', details: 'details',
message: 'message',
messagePlaceholder: 'Type request...',
send: 'Send',
sending: 'Sending',
}, },
usage: { usage: {
empty: 'No usage snapshot. Check collector.', empty: 'No usage snapshot. Check collector.',
@@ -731,6 +743,10 @@ export const messages = {
queue: '队列', queue: '队列',
elapsed: '耗时', elapsed: '耗时',
details: '详情', details: '详情',
message: 'Message',
messagePlaceholder: 'Type request...',
send: 'Send',
sending: 'Sending',
}, },
usage: { usage: {
empty: '暂无用量快照。检查采集器。', empty: '暂无用量快照。检查采集器。',
@@ -931,6 +947,10 @@ export const messages = {
queue: 'キュー', queue: 'キュー',
elapsed: '経過', elapsed: '経過',
details: '詳細', details: '詳細',
message: 'Message',
messagePlaceholder: 'Type request...',
send: 'Send',
sending: 'Sending',
}, },
usage: { usage: {
empty: '使用量スナップショットなし。収集器を確認。', empty: '使用量スナップショットなし。収集器を確認。',

View File

@@ -513,7 +513,7 @@ dd,
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
min-width: 720px; min-width: 860px;
} }
th, th,
@@ -1095,6 +1095,44 @@ progress::-moz-progress-bar {
display: none; display: none;
} }
.room-compose {
display: grid;
min-width: min(240px, 100%);
gap: 8px;
}
.room-compose textarea {
width: 100%;
min-height: 54px;
resize: vertical;
border: 1px solid rgba(43, 55, 38, 0.14);
border-radius: 14px;
padding: 10px 11px;
color: var(--ink);
background: rgba(255, 250, 240, 0.68);
font: inherit;
line-height: 1.3;
}
.room-compose textarea:focus-visible {
outline: 3px solid rgba(191, 95, 44, 0.22);
}
.room-compose button {
min-height: 38px;
border: 0;
border-radius: 999px;
color: #fffaf0;
background: var(--ink);
font-weight: 900;
cursor: pointer;
}
.room-compose button:disabled {
cursor: not-allowed;
opacity: 0.44;
}
.task-board { .task-board {
display: grid; display: grid;
gap: 12px; gap: 12px;

View File

@@ -22,6 +22,7 @@ export {
getOpenWorkItem, getOpenWorkItem,
getOpenWorkItemForChat, getOpenWorkItemForChat,
getRecentChatMessages, getRecentChatMessages,
hasMessage,
hasRecentRestartAnnouncement, hasRecentRestartAnnouncement,
markWorkItemDelivered, markWorkItemDelivered,
markWorkItemDeliveryRetry, markWorkItemDeliveryRetry,

View File

@@ -95,6 +95,17 @@ export function getAllChatsFromDatabase(database: Database): ChatInfo[] {
.all() as ChatInfo[]; .all() as ChatInfo[];
} }
export function hasMessageInDatabase(
database: Database,
chatJid: string,
id: string,
): boolean {
const row = database
.prepare('SELECT 1 FROM messages WHERE chat_jid = ? AND id = ? LIMIT 1')
.get(chatJid, id);
return !!row;
}
export function storeMessageInDatabase( export function storeMessageInDatabase(
database: Database, database: Database,
msg: NewMessage, msg: NewMessage,

View File

@@ -26,6 +26,7 @@ import {
getNewMessagesBySeqFromDatabase, getNewMessagesBySeqFromDatabase,
getNewMessagesFromDatabase, getNewMessagesFromDatabase,
getRecentChatMessagesFromDatabase, getRecentChatMessagesFromDatabase,
hasMessageInDatabase,
hasRecentRestartAnnouncementInDatabase, hasRecentRestartAnnouncementInDatabase,
storeChatMetadataInDatabase, storeChatMetadataInDatabase,
storeMessageInDatabase, storeMessageInDatabase,
@@ -141,6 +142,10 @@ export function storeMessage(msg: NewMessage): void {
storeMessageInDatabase(requireDatabase(), msg); storeMessageInDatabase(requireDatabase(), msg);
} }
export function hasMessage(chatJid: string, id: string): boolean {
return hasMessageInDatabase(requireDatabase(), chatJid, id);
}
export function getNewMessages( export function getNewMessages(
jids: string[], jids: string[],
lastTimestamp: string, lastTimestamp: string,

View File

@@ -521,7 +521,12 @@ async function main(): Promise<void> {
}, },
purgeOnStart: true, purgeOnStart: true,
}); });
webDashboardServer = startWebDashboardServer(WEB_DASHBOARD); webDashboardServer = startWebDashboardServer({
...WEB_DASHBOARD,
getRoomBindings: runtimeState.getRoomBindings,
enqueueMessageCheck: (chatJid, groupFolder) =>
queue.enqueueMessageCheck(chatJid, resolveGroupIpcPath(groupFolder)),
});
leaseRecoveryTimer = setInterval(() => { leaseRecoveryTimer = setInterval(() => {
const failover = getGlobalFailoverInfo(); const failover = getGlobalFailoverInfo();

View File

@@ -5,7 +5,12 @@ import path from 'path';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
import type { StatusSnapshot } from './status-dashboard.js'; import type { StatusSnapshot } from './status-dashboard.js';
import type { PairedTask, ScheduledTask } from './types.js'; import type {
NewMessage,
PairedTask,
RegisteredGroup,
ScheduledTask,
} from './types.js';
import { createWebDashboardHandler } from './web-dashboard-server.js'; import { createWebDashboardHandler } from './web-dashboard-server.js';
const tempDirs: string[] = []; const tempDirs: string[] = [];
@@ -344,6 +349,263 @@ describe('web dashboard server handler', () => {
expect(wrongMethod.status).toBe(405); expect(wrongMethod.status).toBe(405);
}); });
it('injects room messages and queues room work from the web dashboard', async () => {
const messages: NewMessage[] = [];
const metadata: Array<{
chatJid: string;
timestamp: string;
name?: string;
channel?: string;
isGroup?: boolean;
}> = [];
const queued: Array<{ chatJid: string; groupFolder: string }> = [];
const rooms: Record<string, RegisteredGroup> = {
'dc:ops': {
name: '#ops',
folder: 'ops-room',
added_at: '2026-04-26T05:00:00.000Z',
},
};
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [],
getRoomBindings: () => rooms,
storeChatMetadata: (chatJid, timestamp, name, channel, isGroup) => {
metadata.push({ chatJid, timestamp, name, channel, isGroup });
},
storeMessage: (message) => {
messages.push(message);
},
hasMessage: () => false,
enqueueMessageCheck: (chatJid, groupFolder) => {
queued.push({ chatJid, groupFolder });
},
now: () => '2026-04-26T05:10:00.000Z',
});
const response = await handler(
new Request(
`http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/messages`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
requestId: 'room-compose-1',
text: ' run a dashboard check ',
}),
},
),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
ok: true,
queued: true,
});
expect(metadata).toEqual([
{
chatJid: 'dc:ops',
timestamp: '2026-04-26T05:10:00.000Z',
name: '#ops',
channel: 'web-dashboard',
isGroup: true,
},
]);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
chat_jid: 'dc:ops',
sender: 'web-dashboard',
sender_name: 'Web Dashboard',
content: 'run a dashboard check',
timestamp: '2026-04-26T05:10:00.000Z',
is_from_me: false,
is_bot_message: false,
message_source_kind: 'ipc_injected_human',
});
expect(messages[0]?.id).toBe('web-room-compose-1');
expect(queued).toEqual([{ chatJid: 'dc:ops', groupFolder: 'ops-room' }]);
});
it('deduplicates repeated room message request ids', async () => {
const messages: NewMessage[] = [];
const queued: Array<{ chatJid: string; groupFolder: string }> = [];
const existing = new Set<string>();
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [],
getRoomBindings: () => ({
'dc:ops': {
name: '#ops',
folder: 'ops-room',
added_at: '2026-04-26T05:00:00.000Z',
},
}),
storeChatMetadata: () => undefined,
storeMessage: (message) => {
messages.push(message);
existing.add(`${message.chat_jid}:${message.id}`);
},
hasMessage: (chatJid, id) => existing.has(`${chatJid}:${id}`),
enqueueMessageCheck: (chatJid, groupFolder) => {
queued.push({ chatJid, groupFolder });
},
});
const request = () =>
new Request(
`http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/messages`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
requestId: 'same-submit',
text: 'repeat me',
}),
},
);
const first = await handler(request());
const second = await handler(request());
expect(first.status).toBe(200);
expect(second.status).toBe(200);
await expect(first.json()).resolves.toMatchObject({
id: 'web-same-submit',
queued: true,
});
await expect(second.json()).resolves.toMatchObject({
id: 'web-same-submit',
queued: false,
duplicate: true,
});
expect(messages).toHaveLength(1);
expect(queued).toHaveLength(1);
});
it('deduplicates existing room message request ids after restart', async () => {
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [],
getRoomBindings: () => ({
'dc:ops': {
name: '#ops',
folder: 'ops-room',
added_at: '2026-04-26T05:00:00.000Z',
},
}),
storeChatMetadata: () => {
throw new Error('storeChatMetadata should not be called');
},
storeMessage: () => {
throw new Error('storeMessage should not be called');
},
hasMessage: (chatJid, id) =>
chatJid === 'dc:ops' && id === 'web-previous-submit',
enqueueMessageCheck: () => {
throw new Error('enqueueMessageCheck should not be called');
},
});
const response = await handler(
new Request(
`http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/messages`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
requestId: 'previous-submit',
text: 'repeat after restart',
}),
},
),
);
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
id: 'web-previous-submit',
queued: false,
duplicate: true,
});
});
it('rejects invalid room message requests', async () => {
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [],
getRoomBindings: () => ({
'dc:ops': {
name: '#ops',
folder: 'ops-room',
added_at: '2026-04-26T05:00:00.000Z',
},
}),
storeMessage: () => {
throw new Error('storeMessage should not be called');
},
enqueueMessageCheck: () => {
throw new Error('enqueueMessageCheck should not be called');
},
});
const empty = await handler(
new Request(
`http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/messages`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: ' ' }),
},
),
);
expect(empty.status).toBe(400);
const missing = await handler(
new Request(
`http://localhost/api/rooms/${encodeURIComponent('dc:missing')}/messages`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: 'hello' }),
},
),
);
expect(missing.status).toBe(404);
const wrongMethod = await handler(
new Request(
`http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/messages`,
{
method: 'GET',
},
),
);
expect(wrongMethod.status).toBe(405);
});
it('returns 503 when room message injection is not wired', async () => {
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [],
});
const response = await handler(
new Request(
`http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/messages`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: 'hello' }),
},
),
);
expect(response.status).toBe(503);
});
it('serves Vite static assets and falls back to index for SPA routes', async () => { it('serves Vite static assets and falls back to index for SPA routes', async () => {
const staticDir = fs.mkdtempSync( const staticDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'ejclaw-dashboard-'), path.join(os.tmpdir(), 'ejclaw-dashboard-'),

View File

@@ -7,6 +7,9 @@ import {
getAllOpenPairedTasks, getAllOpenPairedTasks,
getAllTasks, getAllTasks,
getTaskById, getTaskById,
hasMessage,
storeChatMetadata,
storeMessage,
updateTask, updateTask,
} from './db.js'; } from './db.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -14,13 +17,19 @@ import {
readStatusSnapshots, readStatusSnapshots,
type StatusSnapshot, type StatusSnapshot,
} from './status-dashboard.js'; } from './status-dashboard.js';
import type { PairedTask, ScheduledTask } from './types.js'; import type {
NewMessage,
PairedTask,
RegisteredGroup,
ScheduledTask,
} from './types.js';
import { import {
buildWebDashboardOverview, buildWebDashboardOverview,
sanitizeScheduledTask, sanitizeScheduledTask,
} from './web-dashboard-data.js'; } from './web-dashboard-data.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;
export interface WebDashboardHandlerOptions { export interface WebDashboardHandlerOptions {
staticDir?: string; staticDir?: string;
@@ -34,6 +43,17 @@ export interface WebDashboardHandlerOptions {
) => void; ) => void;
deleteTask?: (id: string) => void; deleteTask?: (id: string) => void;
getPairedTasks?: () => PairedTask[]; getPairedTasks?: () => PairedTask[];
getRoomBindings?: () => Record<string, RegisteredGroup>;
storeChatMetadata?: (
chatJid: string,
timestamp: string,
name?: string,
channel?: string,
isGroup?: boolean,
) => void;
storeMessage?: (message: NewMessage) => void;
hasMessage?: (chatJid: string, id: string) => boolean;
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
now?: () => string; now?: () => string;
} }
@@ -125,6 +145,16 @@ function parseTaskActionPath(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 isTaskAction(value: unknown): value is TaskAction { function isTaskAction(value: unknown): value is TaskAction {
return value === 'pause' || value === 'resume' || value === 'cancel'; return value === 'pause' || value === 'resume' || value === 'cancel';
} }
@@ -138,6 +168,42 @@ async function readTaskAction(request: Request): Promise<TaskAction | 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 } | null> {
try {
const body = (await request.json()) as {
text?: unknown;
requestId?: unknown;
};
if (typeof body.text !== 'string') return null;
const text = body.text.trim();
if (!text) return null;
return {
text: text.length > 8000 ? text.slice(0, 8000) : text,
requestId: sanitizeRoomMessageRequestId(body.requestId),
};
} 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 function createWebDashboardHandler( export function createWebDashboardHandler(
opts: WebDashboardHandlerOptions = {}, opts: WebDashboardHandlerOptions = {},
): (request: Request) => Response | Promise<Response> { ): (request: Request) => Response | Promise<Response> {
@@ -147,11 +213,30 @@ export function createWebDashboardHandler(
const mutateTask = opts.updateTask ?? updateTask; const mutateTask = opts.updateTask ?? updateTask;
const removeTask = opts.deleteTask ?? deleteTask; const removeTask = opts.deleteTask ?? deleteTask;
const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks; const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks;
const loadRoomBindings = opts.getRoomBindings;
const writeChatMetadata = opts.storeChatMetadata ?? storeChatMetadata;
const writeMessage = opts.storeMessage ?? storeMessage;
const messageExists = opts.hasMessage ?? hasMessage;
const enqueueMessageCheck = opts.enqueueMessageCheck;
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS; const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
const seenRoomMessageIds: string[] = [];
const seenRoomMessageIdSet = 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;
}
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 actionTaskId = parseTaskActionPath(url.pathname); const actionTaskId = parseTaskActionPath(url.pathname);
const messageRoomJid = parseRoomMessagePath(url.pathname);
if (actionTaskId) { if (actionTaskId) {
if (request.method !== 'POST') { if (request.method !== 'POST') {
@@ -192,6 +277,64 @@ 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: '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 });
}
if (request.method !== 'GET' && request.method !== 'HEAD') { if (request.method !== 'GET' && request.method !== 'HEAD') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 }); return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
} }
@@ -242,6 +385,8 @@ export function startWebDashboardServer(
host?: string; host?: string;
port?: number; port?: number;
staticDir?: string; staticDir?: string;
getRoomBindings?: () => Record<string, RegisteredGroup>;
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
} = {}, } = {},
): StartedWebDashboardServer | null { ): StartedWebDashboardServer | null {
const enabled = opts.enabled ?? WEB_DASHBOARD.enabled; const enabled = opts.enabled ?? WEB_DASHBOARD.enabled;
@@ -253,7 +398,11 @@ export function startWebDashboardServer(
const server = Bun.serve({ const server = Bun.serve({
hostname: host, hostname: host,
port, port,
fetch: createWebDashboardHandler({ staticDir }), fetch: createWebDashboardHandler({
staticDir,
getRoomBindings: opts.getRoomBindings,
enqueueMessageCheck: opts.enqueueMessageCheck,
}),
}); });
const url = `http://${host}:${server.port}`; const url = `http://${host}:${server.port}`;