feat(dashboard): send room messages from web (#33)
This commit is contained in:
@@ -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}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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: '使用量スナップショットなし。収集器を確認。',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export {
|
|||||||
getOpenWorkItem,
|
getOpenWorkItem,
|
||||||
getOpenWorkItemForChat,
|
getOpenWorkItemForChat,
|
||||||
getRecentChatMessages,
|
getRecentChatMessages,
|
||||||
|
hasMessage,
|
||||||
hasRecentRestartAnnouncement,
|
hasRecentRestartAnnouncement,
|
||||||
markWorkItemDelivered,
|
markWorkItemDelivered,
|
||||||
markWorkItemDeliveryRetry,
|
markWorkItemDeliveryRetry,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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-'),
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user