('codex');
useEffect(() => {
let cancelled = false;
@@ -180,91 +308,27 @@ export function RuntimeInventorySettings({ t }: { t: Messages }) {
) : (
-
-
-
- {selectedRoom && selectedRoom.agents.length > 1 ? (
-
-
- {t.settings.runtime.selectAgentLabel}
-
-
- {selectedRoom.agents.map((agent) => (
-
- ))}
-
-
- ) : null}
-
+
{!selectedAgent || selectedAgent.availableSkillIds.length === 0 ? (
{t.settings.runtime.emptySkills}
) : (
-
- {selectedAgent.availableSkillIds.map((skillId) => {
- const skill = catalogById.get(skillId);
- const key = `${selectedRoomJid}:${selectedAgent.agentType}:${skillId}`;
- const enabled =
- selectedAgent.effectiveEnabledSkillIds.includes(skillId);
- return (
-
- );
- })}
-
+ void handleToggle(input)}
+ savingKey={savingKey}
+ selectedAgent={selectedAgent}
+ selectedRoomJid={selectedRoomJid}
+ t={t}
+ />
)}
)}
diff --git a/apps/dashboard/src/SettingsModelFields.tsx b/apps/dashboard/src/SettingsModelFields.tsx
index 3e44e80..0db9531 100644
--- a/apps/dashboard/src/SettingsModelFields.tsx
+++ b/apps/dashboard/src/SettingsModelFields.tsx
@@ -53,14 +53,14 @@ function effortOptionsForRole(
draft: ModelConfigSnapshot,
role: ModelRole,
): readonly EffortValue[] {
- const agentType = draft.agentTypes[role];
+ const agentType = draft.agentTypes?.[role];
if (agentType) return effortValuesForAgent(agentType);
return effortValuesForAgent('codex');
}
export function hasUnsupportedModelEffort(draft: ModelConfigSnapshot): boolean {
for (const role of MODEL_ROLES) {
- const agentType = draft.agentTypes[role];
+ const agentType = draft.agentTypes?.[role];
if (!agentType) continue;
if (!isEffortSupported(agentType, draft[role].effort)) return true;
}
@@ -80,7 +80,7 @@ export function ModelRoleFields({
{MODEL_ROLES.map((role) => {
const roleConfig = draft[role];
- const agentType = draft.agentTypes[role];
+ const agentType = draft.agentTypes?.[role] ?? null;
const effortOptions = effortOptionsForRole(draft, role);
const effortInvalid =
agentType !== null &&
diff --git a/quality/code-quality-budgets.json b/quality/code-quality-budgets.json
index 9b7f93d..b40aeaf 100644
--- a/quality/code-quality-budgets.json
+++ b/quality/code-quality-budgets.json
@@ -20,7 +20,7 @@
"owner": "dashboard shell hotspot"
},
"apps/dashboard/src/i18n.ts": {
- "maxLines": 1330,
+ "maxLines": 2156,
"owner": "dashboard i18n hotspot"
},
"apps/dashboard/src/styles.css": {
@@ -40,6 +40,10 @@
"maxFunctionLines": 279,
"owner": "runner shared test hotspot"
},
+ "scripts/dashboard-ux.ts": {
+ "maxLines": 1034,
+ "owner": "dashboard ux smoke hotspot"
+ },
"setup/migrate-room-registrations.test.ts": {
"maxFunctionLines": 393,
"owner": "setup test hotspot"
@@ -64,10 +68,6 @@
"maxFunctionLines": 548,
"owner": "agent runner test hotspot"
},
- "src/channels/discord.test.ts": {
- "maxFunctionLines": 816,
- "owner": "discord test hotspot"
- },
"src/codex-warmup.test.ts": {
"maxFunctionLines": 277,
"owner": "codex warmup test hotspot"
diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts
index 5f41e29..7c9c4b8 100644
--- a/scripts/dashboard-ux.ts
+++ b/scripts/dashboard-ux.ts
@@ -24,282 +24,8 @@ async function main() {
try {
const baseUrl = server.resolvedUrls?.local[0] ?? 'http://127.0.0.1:5175/';
-
- await runScenario(
- 'settings nav keeps hash route stable',
- browser,
- baseUrl,
- async (page, state) => {
- await openSettings(page, baseUrl);
-
- const originalUrl = page.url();
- assert.equal(originalUrl.endsWith('/#/settings'), true);
- assert.equal(
- await page.locator('.settings-nav a[href^="#settings-"]').count(),
- 0,
- );
-
- await page
- .locator(
- '.settings-nav button[data-settings-target="settings-codex"]',
- )
- .click();
- await page.waitForTimeout(150);
- assert.equal(page.url(), originalUrl);
- await assertVisible(page.locator('.settings-panel'));
- await assertVisible(page.locator('#settings-codex'));
-
- await openSettingsSection(page, 'settings-runtime');
- assert.equal(page.url(), originalUrl);
- await assertVisible(page.locator('#settings-runtime'));
- await page.getByRole('heading', { name: '방 선택' }).waitFor();
- const skillToggle = page
- .locator(
- '#settings-runtime .settings-toggle-row input[type="checkbox"]',
- )
- .first();
- assert.equal(await skillToggle.isChecked(), true);
- await skillToggle.click();
- assert.equal(await skillToggle.isChecked(), false);
- assert.equal(state.roomSkillUpdates, 1);
-
- await page
- .locator(
- '.settings-nav button[data-settings-target="settings-accounts"]',
- )
- .click();
- await page.waitForTimeout(150);
- assert.equal(page.url(), originalUrl);
- await assertVisible(page.locator('.settings-panel'));
- await assertVisible(page.locator('#settings-accounts'));
-
- const sidebar = page.locator('.settings-sidebar');
- const navBoxBefore = await sidebar.boundingBox();
- await openSettingsSection(page, 'settings-general');
- await page.waitForTimeout(150);
- const navBoxAfter = await sidebar.boundingBox();
- assert.ok(navBoxBefore && navBoxAfter);
- assert.equal(navBoxBefore.y, navBoxAfter.y);
-
- await assertNoSeriousA11yViolations(page);
- },
- );
-
- await runScenario(
- 'codex goal toggle persists and explains restart',
- browser,
- baseUrl,
- async (page, state) => {
- await openSettings(page, baseUrl);
- await openSettingsSection(page, 'settings-codex');
-
- const goalToggle = page.getByRole('checkbox', { name: /\/goal/ });
- await assertVisible(goalToggle);
- assert.equal(await goalToggle.isChecked(), false);
-
- await goalToggle.click();
- await page
- .getByText('저장됨. 적용하려면 사이드바의 스택 재시작을 눌러 주세요.')
- .waitFor();
-
- assert.equal(await goalToggle.isChecked(), true);
- assert.equal(state.codexFeatures.goals, true);
- assert.equal(state.codexFeatureUpdates, 1);
-
- await assertNoSeriousA11yViolations(page);
- },
- );
-
- await runScenario(
- 'restart action is singular and guarded',
- browser,
- baseUrl,
- async (page, state) => {
- const dialogMessages: string[] = [];
- page.on('dialog', async (dialog) => {
- dialogMessages.push(dialog.message());
- await dialog.accept();
- });
-
- await openSettings(page, baseUrl);
-
- const restartButtons = page.getByRole('button', {
- name: '스택 재시작',
- });
- assert.equal(await restartButtons.count(), 1);
-
- await restartButtons.first().click();
- await page.waitForTimeout(250);
-
- assert.equal(dialogMessages.length, 1);
- assert.equal(state.restartRequests, 1);
-
- await assertNoSeriousA11yViolations(page);
- },
- );
-
- await runScenario(
- 'rooms surface user action badges without inbox navigation',
- browser,
- baseUrl,
- async (page, state) => {
- state.approvalAction = true;
- state.ciWatcherFailures = 2;
-
- await page.goto(new URL('/#/inbox', baseUrl).toString(), {
- waitUntil: 'networkidle',
- });
-
- await page.waitForURL(/#\/rooms$/);
- await assertVisible(page.locator('#rooms .rooms-v2'));
- assert.equal(await page.locator('a[href="#/inbox"]').count(), 0);
- assert.equal(await page.locator('a[href="#/health"]').count(), 0);
- await assertVisible(page.getByText(/승인|Approval/).first());
- await assertVisible(page.locator('.system-status-strip'));
- assert.equal(
- await page.getByRole('button', { name: 'Dismiss' }).count(),
- 0,
- );
-
- // This scenario protects the Inbox information architecture. The
- // accessibility scan stays scoped to Settings, where this UX suite
- // currently has stable interactive coverage.
- },
- );
-
- await runScheduledBoardScenario(browser, baseUrl);
-
- await runScenario(
- 'health route redirects to rooms and degraded state is conditional',
- browser,
- baseUrl,
- async (page, state) => {
- state.approvalAction = true;
-
- await page.goto(new URL('/#/rooms', baseUrl).toString(), {
- waitUntil: 'networkidle',
- });
- await assertVisible(page.locator('#rooms .rooms-v2'));
- assert.equal(await page.locator('.system-status-strip').count(), 0);
-
- state.ciWatcherFailures = 2;
- await page.goto(new URL('/?degraded=1#/health', baseUrl).toString(), {
- waitUntil: 'networkidle',
- });
-
- await page.waitForURL(/#\/rooms$/);
- await assertVisible(page.locator('#rooms .rooms-v2'));
- await assertVisible(page.locator('.system-status-strip'));
- assert.equal(await page.locator('#health').count(), 0);
- assert.equal(await page.locator('a[href="#/health"]').count(), 0);
- await assertVisible(page.getByText(/CI 실패|CI failure/).first());
- },
- );
-
- await runMobileScenario(
- 'mobile settings keeps nav and content aligned without horizontal scroll',
- browser,
- baseUrl,
- async (page) => {
- await openSettings(page, baseUrl);
- await assertNoHorizontalOverflow(page);
-
- const nav = page.locator('.settings-nav-scroll');
- const section = page.locator('.settings-section').first();
- const navBox = await nav.boundingBox();
- const sectionBox = await section.boundingBox();
- assert.ok(navBox && sectionBox);
- assert.equal(Math.round(navBox.x), Math.round(sectionBox.x));
- assert.equal(Math.round(navBox.width), Math.round(sectionBox.width));
-
- const selectedTab = page.locator(
- '.settings-nav button[aria-selected="true"]',
- );
- const tabBox = await selectedTab.boundingBox();
- const subtitleBox = await selectedTab.locator('small').boundingBox();
- assert.ok(tabBox && subtitleBox);
- assert.ok(
- subtitleBox.y + subtitleBox.height <= tabBox.y + tabBox.height + 1,
- 'tab subtitle should not be clipped',
- );
-
- await openSettingsSection(page, 'settings-models');
- await assertNoHorizontalOverflow(page);
- await assertNoSeriousA11yViolations(page);
- },
- );
-
- await runMobileScenario(
- 'mobile accounts use compact rows for multiple entries',
- browser,
- baseUrl,
- async (page) => {
- await openSettings(page, baseUrl);
- await openSettingsSection(page, 'settings-accounts');
- await assertVisible(page.locator('#settings-accounts'));
-
- assert.equal(await page.locator('.settings-account-list').count(), 2);
- assert.equal(await page.locator('.settings-account-row').count(), 6);
- assert.equal(await page.locator('.settings-account-card').count(), 0);
-
- const listBox = await page
- .locator('.settings-account-list')
- .first()
- .boundingBox();
- const rowBox = await page
- .locator('.settings-account-row')
- .first()
- .boundingBox();
- assert.ok(listBox && rowBox);
- assert.ok(rowBox.height <= 72, 'account row should stay compact');
-
- await assertNoHorizontalOverflow(page);
- },
- );
-
- await runMobileScenario(
- 'mobile primary views render without layout overflow',
- browser,
- baseUrl,
- async (page) => {
- for (const hash of [
- '#/rooms',
- '#/scheduled',
- '#/usage',
- '#/settings',
- ]) {
- await page.goto(new URL(hash, baseUrl).toString(), {
- waitUntil: 'networkidle',
- });
- await assertNoHorizontalOverflow(page);
- }
-
- await page.goto(new URL('/#/scheduled', baseUrl).toString(), {
- waitUntil: 'networkidle',
- });
- await assertVisible(page.locator('#scheduled .task-summary-bar'));
- await assertVisible(page.locator('#scheduled .task-filter-tabs'));
- },
- );
-
- await runMobileScenario(
- 'mobile scheduled board uses compact rows and filter tabs',
- browser,
- baseUrl,
- async (page) => {
- await page.goto(new URL('/#/scheduled', baseUrl).toString(), {
- waitUntil: 'networkidle',
- });
- await assertVisible(page.locator('#scheduled .task-summary-bar'));
- await assertVisible(page.locator('#scheduled .task-filter-tabs'));
- assert.equal(await page.locator('#scheduled .task-row').count(), 3);
-
- await page.getByRole('tab', { name: /예약|Scheduled/i }).click();
- assert.equal(await page.locator('#scheduled .task-row').count(), 1);
- await assertNoHorizontalOverflow(page);
- },
- );
-
+ await runDesktopDashboardScenarios(browser, baseUrl);
+ await runMobileDashboardScenarios(browser, baseUrl);
console.log('dashboard:ux passed');
} finally {
await browser.close();
@@ -307,6 +33,278 @@ async function main() {
}
}
+async function runDesktopDashboardScenarios(browser: Browser, baseUrl: string) {
+ await runScenario(
+ 'settings nav keeps hash route stable',
+ browser,
+ baseUrl,
+ async (page, state) => {
+ await openSettings(page, baseUrl);
+
+ const originalUrl = page.url();
+ assert.equal(originalUrl.endsWith('/#/settings'), true);
+ assert.equal(
+ await page.locator('.settings-nav a[href^="#settings-"]').count(),
+ 0,
+ );
+
+ await page
+ .locator('.settings-nav button[data-settings-target="settings-codex"]')
+ .click();
+ await page.waitForTimeout(150);
+ assert.equal(page.url(), originalUrl);
+ await assertVisible(page.locator('.settings-panel'));
+ await assertVisible(page.locator('#settings-codex'));
+
+ await openSettingsSection(page, 'settings-runtime');
+ assert.equal(page.url(), originalUrl);
+ await assertVisible(page.locator('#settings-runtime'));
+ await page.getByRole('heading', { name: '방 선택' }).waitFor();
+ const skillToggle = page
+ .locator(
+ '#settings-runtime .settings-toggle-row input[type="checkbox"]',
+ )
+ .first();
+ assert.equal(await skillToggle.isChecked(), true);
+ await skillToggle.click();
+ assert.equal(await skillToggle.isChecked(), false);
+ assert.equal(state.roomSkillUpdates, 1);
+
+ await page
+ .locator(
+ '.settings-nav button[data-settings-target="settings-accounts"]',
+ )
+ .click();
+ await page.waitForTimeout(150);
+ assert.equal(page.url(), originalUrl);
+ await assertVisible(page.locator('.settings-panel'));
+ await assertVisible(page.locator('#settings-accounts'));
+
+ const sidebar = page.locator('.settings-sidebar');
+ const navBoxBefore = await sidebar.boundingBox();
+ await openSettingsSection(page, 'settings-general');
+ await page.waitForTimeout(150);
+ const navBoxAfter = await sidebar.boundingBox();
+ assert.ok(navBoxBefore && navBoxAfter);
+ assert.equal(navBoxBefore.y, navBoxAfter.y);
+
+ await assertNoSeriousA11yViolations(page);
+ },
+ );
+
+ await runScenario(
+ 'codex goal toggle persists and explains next-run apply',
+ browser,
+ baseUrl,
+ async (page, state) => {
+ await openSettings(page, baseUrl);
+ await openSettingsSection(page, 'settings-codex');
+
+ const goalToggle = page.getByRole('checkbox', { name: /\/goal/ });
+ await assertVisible(goalToggle);
+ assert.equal(await goalToggle.isChecked(), false);
+
+ await goalToggle.click();
+ await page
+ .getByText('스택 재시작 없이 다음 Codex 작업부터 적용됩니다.')
+ .waitFor();
+
+ assert.equal(await goalToggle.isChecked(), true);
+ assert.equal(state.codexFeatures.goals, true);
+ assert.equal(state.codexFeatureUpdates, 1);
+
+ await assertNoSeriousA11yViolations(page);
+ },
+ );
+
+ await runScenario(
+ 'restart action is singular and guarded',
+ browser,
+ baseUrl,
+ async (page, state) => {
+ const dialogMessages: string[] = [];
+ page.on('dialog', async (dialog) => {
+ dialogMessages.push(dialog.message());
+ await dialog.accept();
+ });
+
+ await openSettings(page, baseUrl);
+
+ const restartButtons = page.getByRole('button', {
+ name: '스택 재시작',
+ });
+ assert.equal(await restartButtons.count(), 1);
+
+ await restartButtons.first().click();
+ await page.waitForTimeout(250);
+
+ assert.equal(dialogMessages.length, 1);
+ assert.equal(state.restartRequests, 1);
+
+ await assertNoSeriousA11yViolations(page);
+ },
+ );
+
+ await runScenario(
+ 'rooms surface user action badges without inbox navigation',
+ browser,
+ baseUrl,
+ async (page, state) => {
+ state.approvalAction = true;
+ state.ciWatcherFailures = 2;
+
+ await page.goto(new URL('/#/inbox', baseUrl).toString(), {
+ waitUntil: 'networkidle',
+ });
+
+ await page.waitForURL(/#\/rooms$/);
+ await assertVisible(page.locator('#rooms .rooms-v2'));
+ assert.equal(await page.locator('a[href="#/inbox"]').count(), 0);
+ assert.equal(await page.locator('a[href="#/health"]').count(), 0);
+ await assertVisible(page.getByText(/승인|Approval/).first());
+ await assertVisible(page.locator('.system-status-strip'));
+ assert.equal(
+ await page.getByRole('button', { name: 'Dismiss' }).count(),
+ 0,
+ );
+
+ // This scenario protects the Inbox information architecture. The
+ // accessibility scan stays scoped to Settings, where this UX suite
+ // currently has stable interactive coverage.
+ },
+ );
+
+ await runScheduledBoardScenario(browser, baseUrl);
+
+ await runScenario(
+ 'health route redirects to rooms and degraded state is conditional',
+ browser,
+ baseUrl,
+ async (page, state) => {
+ state.approvalAction = true;
+
+ await page.goto(new URL('/#/rooms', baseUrl).toString(), {
+ waitUntil: 'networkidle',
+ });
+ await assertVisible(page.locator('#rooms .rooms-v2'));
+ assert.equal(await page.locator('.system-status-strip').count(), 0);
+
+ state.ciWatcherFailures = 2;
+ await page.goto(new URL('/?degraded=1#/health', baseUrl).toString(), {
+ waitUntil: 'networkidle',
+ });
+
+ await page.waitForURL(/#\/rooms$/);
+ await assertVisible(page.locator('#rooms .rooms-v2'));
+ await assertVisible(page.locator('.system-status-strip'));
+ assert.equal(await page.locator('#health').count(), 0);
+ assert.equal(await page.locator('a[href="#/health"]').count(), 0);
+ await assertVisible(page.getByText(/CI 실패|CI failure/).first());
+ },
+ );
+}
+
+async function runMobileDashboardScenarios(browser: Browser, baseUrl: string) {
+ await runMobileScenario(
+ 'mobile settings keeps nav and content aligned without horizontal scroll',
+ browser,
+ baseUrl,
+ async (page) => {
+ await openSettings(page, baseUrl);
+ await assertNoHorizontalOverflow(page);
+
+ const nav = page.locator('.settings-nav-scroll');
+ const section = page.locator('.settings-section').first();
+ const navBox = await nav.boundingBox();
+ const sectionBox = await section.boundingBox();
+ assert.ok(navBox && sectionBox);
+ assert.equal(Math.round(navBox.x), Math.round(sectionBox.x));
+ assert.equal(Math.round(navBox.width), Math.round(sectionBox.width));
+
+ const selectedTab = page.locator(
+ '.settings-nav button[aria-selected="true"]',
+ );
+ const tabBox = await selectedTab.boundingBox();
+ const subtitleBox = await selectedTab.locator('small').boundingBox();
+ assert.ok(tabBox && subtitleBox);
+ assert.ok(
+ subtitleBox.y + subtitleBox.height <= tabBox.y + tabBox.height + 1,
+ 'tab subtitle should not be clipped',
+ );
+
+ await openSettingsSection(page, 'settings-models');
+ await assertNoHorizontalOverflow(page);
+ await assertNoSeriousA11yViolations(page);
+ },
+ );
+
+ await runMobileScenario(
+ 'mobile accounts use compact rows for multiple entries',
+ browser,
+ baseUrl,
+ async (page) => {
+ await openSettings(page, baseUrl);
+ await openSettingsSection(page, 'settings-accounts');
+ await assertVisible(page.locator('#settings-accounts'));
+
+ assert.equal(await page.locator('.settings-account-list').count(), 2);
+ assert.equal(await page.locator('.settings-account-row').count(), 6);
+ assert.equal(await page.locator('.settings-account-card').count(), 0);
+
+ const listBox = await page
+ .locator('.settings-account-list')
+ .first()
+ .boundingBox();
+ const rowBox = await page
+ .locator('.settings-account-row')
+ .first()
+ .boundingBox();
+ assert.ok(listBox && rowBox);
+ assert.ok(rowBox.height <= 72, 'account row should stay compact');
+
+ await assertNoHorizontalOverflow(page);
+ },
+ );
+
+ await runMobileScenario(
+ 'mobile primary views render without layout overflow',
+ browser,
+ baseUrl,
+ async (page) => {
+ for (const hash of ['#/rooms', '#/scheduled', '#/usage', '#/settings']) {
+ await page.goto(new URL(hash, baseUrl).toString(), {
+ waitUntil: 'networkidle',
+ });
+ await assertNoHorizontalOverflow(page);
+ }
+
+ await page.goto(new URL('/#/scheduled', baseUrl).toString(), {
+ waitUntil: 'networkidle',
+ });
+ await assertVisible(page.locator('#scheduled .task-summary-bar'));
+ await assertVisible(page.locator('#scheduled .task-filter-tabs'));
+ },
+ );
+
+ await runMobileScenario(
+ 'mobile scheduled board uses compact rows and filter tabs',
+ browser,
+ baseUrl,
+ async (page) => {
+ await page.goto(new URL('/#/scheduled', baseUrl).toString(), {
+ waitUntil: 'networkidle',
+ });
+ await assertVisible(page.locator('#scheduled .task-summary-bar'));
+ await assertVisible(page.locator('#scheduled .task-filter-tabs'));
+ assert.equal(await page.locator('#scheduled .task-row').count(), 3);
+
+ await page.getByRole('tab', { name: /예약|Scheduled/i }).click();
+ assert.equal(await page.locator('#scheduled .task-row').count(), 1);
+ await assertNoHorizontalOverflow(page);
+ },
+ );
+}
+
async function runScheduledBoardScenario(browser: Browser, baseUrl: string) {
await runScenario(
'scheduled board surfaces next task without empty lanes',
@@ -386,9 +384,9 @@ async function runScenarioWithViewport(
isMobile: viewport.width <= 640,
hasTouch: viewport.width <= 640,
});
- await context.addInitScript(() => {
- window.localStorage.setItem('ejclaw.dashboard.locale.v2', 'ko');
- });
+ await context.addInitScript(
+ "localStorage.setItem('ejclaw.dashboard.locale.v2', 'ko')",
+ );
const state = createMockApiState();
await context.route('**/api/**', (route) => handleMockApi(route, state));
const page = await context.newPage();
@@ -458,14 +456,17 @@ async function assertNoSeriousA11yViolations(page: Page) {
}
async function assertNoHorizontalOverflow(page: Page) {
- const overflow = await page.evaluate(() => {
+ const overflow = await page.evaluate<{
+ body: boolean;
+ document: boolean;
+ }>(`(() => {
const doc = document.documentElement;
const tolerance = 1;
return {
document: doc.scrollWidth - doc.clientWidth > tolerance,
body: document.body.scrollWidth - document.body.clientWidth > tolerance,
};
- });
+ })()`);
assert.equal(
overflow.document,
false,
@@ -503,11 +504,7 @@ async function handleMockApi(route: Route, state: MockApiState) {
}
if (method === 'GET' && url.pathname === '/api/settings/models') {
- await fulfillJson(route, {
- owner: { model: 'codex', effort: 'high' },
- reviewer: { model: 'claude', effort: 'medium' },
- arbiter: { model: 'claude', effort: 'medium' },
- });
+ await fulfillJson(route, mockModelSettings());
return;
}
@@ -542,130 +539,18 @@ async function handleMockApi(route: Route, state: MockApiState) {
}
if (method === 'GET' && url.pathname === '/api/settings/moa') {
- await fulfillJson(route, {
- enabled: true,
- referenceModels: ['kimi', 'glm'],
- models: [
- {
- name: 'kimi',
- enabled: true,
- model: 'kimi-k2',
- baseUrl: 'https://api.moonshot.ai',
- apiFormat: 'anthropic',
- apiKeyConfigured: true,
- lastStatus: null,
- },
- {
- name: 'glm',
- enabled: true,
- model: 'glm-4.6',
- baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
- apiFormat: 'anthropic',
- apiKeyConfigured: true,
- lastStatus: null,
- },
- ],
- });
+ await fulfillJson(route, mockMoaSettings());
return;
}
if (method === 'GET' && url.pathname === '/api/settings/accounts') {
- await fulfillJson(route, {
- claude: [
- {
- index: 0,
- expiresAt: null,
- scopes: [],
- subscriptionType: 'max',
- rateLimitTier: 'default',
- exists: true,
- },
- {
- index: 1,
- expiresAt: null,
- scopes: [],
- subscriptionType: 'pro',
- rateLimitTier: 'default',
- exists: true,
- },
- {
- index: 2,
- expiresAt: null,
- scopes: [],
- subscriptionType: 'team',
- rateLimitTier: 'default',
- exists: true,
- },
- ],
- codex: [
- {
- index: 0,
- accountId: 'acct_test',
- email: 'codex@example.com',
- planType: 'plus',
- subscriptionUntil: null,
- subscriptionLastChecked: '2026-01-01T00:00:00.000Z',
- subscriptionSource: null,
- liveStatus: {
- checkedAt: '2026-01-01T00:00:00.000Z',
- source: 'wham/usage',
- planType: 'plus',
- email: 'codex@example.com',
- rateLimit: {
- allowed: true,
- limitReached: false,
- primaryWindow: {
- limitWindowSeconds: 18000,
- resetAfterSeconds: 3600,
- resetAt: '2026-01-01T01:00:00.000Z',
- usedPercent: 8,
- },
- secondaryWindow: null,
- },
- rateLimitReachedType: null,
- additionalRateLimits: [],
- credits: null,
- spendControl: null,
- },
- exists: true,
- },
- {
- index: 1,
- accountId: 'acct_backup',
- email: 'backup@example.com',
- planType: 'plus',
- subscriptionUntil: '2026-06-01T00:00:00.000Z',
- subscriptionLastChecked: '2026-01-01T00:00:00.000Z',
- exists: true,
- },
- {
- index: 2,
- accountId: 'acct_sandbox',
- email: 'sandbox@example.com',
- planType: 'free',
- subscriptionUntil: null,
- subscriptionLastChecked: '2026-01-01T00:00:00.000Z',
- exists: true,
- },
- ],
- codexCurrentIndex: 0,
- });
+ await fulfillJson(route, mockAccounts());
return;
}
if (method === 'POST' && url.pathname === '/api/services/stack/actions') {
state.restartRequests += 1;
- await fulfillJson(route, {
- ok: true,
- restart: {
- id: 'restart-test',
- target: 'stack',
- requestedAt: new Date(0).toISOString(),
- completedAt: null,
- status: 'running',
- services: ['ejclaw'],
- },
- });
+ await fulfillJson(route, mockStackRestart());
return;
}
@@ -676,6 +561,147 @@ async function handleMockApi(route: Route, state: MockApiState) {
);
}
+function mockModelSettings() {
+ return {
+ owner: { model: 'codex', effort: 'high' },
+ reviewer: { model: 'claude', effort: 'medium' },
+ arbiter: { model: 'claude', effort: 'medium' },
+ agentTypes: {
+ owner: 'codex',
+ reviewer: 'claude-code',
+ arbiter: null,
+ },
+ };
+}
+
+function mockMoaSettings() {
+ return {
+ enabled: true,
+ referenceModels: ['kimi', 'glm'],
+ models: [
+ {
+ name: 'kimi',
+ enabled: true,
+ model: 'kimi-k2',
+ baseUrl: 'https://api.moonshot.ai',
+ apiFormat: 'anthropic',
+ apiKeyConfigured: true,
+ lastStatus: null,
+ },
+ {
+ name: 'glm',
+ enabled: true,
+ model: 'glm-4.6',
+ baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
+ apiFormat: 'anthropic',
+ apiKeyConfigured: true,
+ lastStatus: null,
+ },
+ ],
+ };
+}
+
+function mockAccounts() {
+ return {
+ claude: [
+ {
+ index: 0,
+ expiresAt: null,
+ scopes: [],
+ subscriptionType: 'max',
+ rateLimitTier: 'default',
+ exists: true,
+ },
+ {
+ index: 1,
+ expiresAt: null,
+ scopes: [],
+ subscriptionType: 'pro',
+ rateLimitTier: 'default',
+ exists: true,
+ },
+ {
+ index: 2,
+ expiresAt: null,
+ scopes: [],
+ subscriptionType: 'team',
+ rateLimitTier: 'default',
+ exists: true,
+ },
+ ],
+ codex: [
+ {
+ index: 0,
+ accountId: 'acct_test',
+ email: 'codex@example.com',
+ planType: 'plus',
+ subscriptionUntil: null,
+ subscriptionLastChecked: '2026-01-01T00:00:00.000Z',
+ subscriptionSource: null,
+ liveStatus: mockCodexLiveStatus(),
+ exists: true,
+ },
+ {
+ index: 1,
+ accountId: 'acct_backup',
+ email: 'backup@example.com',
+ planType: 'plus',
+ subscriptionUntil: '2026-06-01T00:00:00.000Z',
+ subscriptionLastChecked: '2026-01-01T00:00:00.000Z',
+ exists: true,
+ },
+ {
+ index: 2,
+ accountId: 'acct_sandbox',
+ email: 'sandbox@example.com',
+ planType: 'free',
+ subscriptionUntil: null,
+ subscriptionLastChecked: '2026-01-01T00:00:00.000Z',
+ exists: true,
+ },
+ ],
+ codexCurrentIndex: 0,
+ };
+}
+
+function mockCodexLiveStatus() {
+ return {
+ checkedAt: '2026-01-01T00:00:00.000Z',
+ source: 'wham/usage',
+ planType: 'plus',
+ email: 'codex@example.com',
+ rateLimit: {
+ allowed: true,
+ limitReached: false,
+ primaryWindow: {
+ limitWindowSeconds: 18000,
+ resetAfterSeconds: 3600,
+ resetAt: '2026-01-01T01:00:00.000Z',
+ usedPercent: 8,
+ },
+ secondaryWindow: null,
+ },
+ rateLimitReachedType: null,
+ additionalRateLimits: [],
+ credits: null,
+ spendControl: null,
+ };
+}
+
+function mockStackRestart() {
+ return {
+ ok: true,
+ restart: {
+ id: 'restart-test',
+ target: 'stack',
+ requestedAt: new Date(0).toISOString(),
+ completedAt: null,
+ status: 'running',
+ services: ['ejclaw'],
+ },
+ };
+}
+
async function handleMockRoomSkillRoute(
route: Route,
state: MockApiState,
diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts
index 246ca57..0912fe8 100644
--- a/src/channels/discord.test.ts
+++ b/src/channels/discord.test.ts
@@ -233,830 +233,820 @@ async function triggerMessage(message: any) {
// --- Tests ---
-describe('DiscordChannel', () => {
+beforeEach(() => {
+ vi.clearAllMocks();
+ hasReviewerLeaseMock.mockReturnValue(false);
+ loginShouldRejectRef.value = false;
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('channel registration', () => {
+ it('warns when the canonical owner token is not configured', () => {
+ const ownerFactory = registeredChannelFactories.get('discord');
+
+ expect(ownerFactory).toBeTypeOf('function');
+ expect(ownerFactory?.(createTestOpts() as any)).toBeNull();
+ expect(logger.warn).toHaveBeenCalledWith(
+ 'Discord: DISCORD_OWNER_BOT_TOKEN not set',
+ );
+ });
+});
+
+// --- Connection lifecycle ---
+
+describe('connection lifecycle', () => {
+ it('resolves connect() when client is ready', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+
+ await channel.connect();
+
+ expect(channel.isConnected()).toBe(true);
+ });
+
+ it('rejects connect() when login fails', async () => {
+ loginShouldRejectRef.value = true;
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('bad-token', opts);
+
+ await expect(channel.connect()).rejects.toThrow(
+ 'An invalid token was provided.',
+ );
+ expect(channel.isConnected()).toBe(false);
+ });
+
+ it('registers message handlers on connect', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+
+ await channel.connect();
+
+ expect(currentClient().eventHandlers.has('messageCreate')).toBe(true);
+ expect(currentClient().eventHandlers.has('error')).toBe(true);
+ expect(currentClient().eventHandlers.has('ready')).toBe(true);
+ });
+
+ it('disconnects cleanly', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+
+ await channel.connect();
+ expect(channel.isConnected()).toBe(true);
+
+ await channel.disconnect();
+ expect(channel.isConnected()).toBe(false);
+ });
+
+ it('isConnected() returns false before connect', () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+
+ expect(channel.isConnected()).toBe(false);
+ });
+});
+
+// --- Text message handling ---
+
+describe('text message handling', () => {
+ it('delivers message for registered channel', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'Hello everyone',
+ guildName: 'Test Server',
+ channelName: 'general',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.any(String),
+ 'Test Server #general',
+ 'discord',
+ true,
+ );
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ id: 'msg_001',
+ chat_jid: 'dc:1234567890123456',
+ sender: '55512345',
+ sender_name: 'Alice',
+ content: 'Hello everyone',
+ is_from_me: false,
+ }),
+ );
+ });
+
+ it('only emits metadata for unregistered channels', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ channelId: '9999999999999999',
+ content: 'Unknown channel',
+ guildName: 'Other Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'dc:9999999999999999',
+ expect.any(String),
+ expect.any(String),
+ 'discord',
+ true,
+ );
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('ignores its own bot messages', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ authorId: '999888777',
+ isBot: true,
+ content: 'I am the connected bot',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('ignores other bot messages in normal rooms', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ authorId: '111222333',
+ isBot: true,
+ content: 'I am another bot',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).not.toHaveBeenCalled();
+ });
+
+ it('delivers other bot messages in paired rooms', async () => {
+ hasReviewerLeaseMock.mockReturnValue(true);
+
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ authorId: '111222333',
+ isBot: true,
+ content: 'I am another bot',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: 'I am another bot',
+ is_bot_message: true,
+ }),
+ );
+ });
+
+ it('uses member displayName when available (server nickname)', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'Hi',
+ memberDisplayName: 'Alice Nickname',
+ authorDisplayName: 'Alice Global',
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({ sender_name: 'Alice Nickname' }),
+ );
+ });
+
+ it('falls back to author displayName when no member', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'Hi',
+ memberDisplayName: undefined,
+ authorDisplayName: 'Alice Global',
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({ sender_name: 'Alice Global' }),
+ );
+ });
+
+ it('uses sender name for DM chats (no guild)', async () => {
+ const opts = createTestOpts({
+ roomBindings: vi.fn(() => ({
+ 'dc:1234567890123456': {
+ name: 'DM',
+ folder: 'dm',
+ trigger: '@Andy',
+ added_at: '2024-01-01T00:00:00.000Z',
+ },
+ })),
+ });
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'Hello',
+ guildName: undefined,
+ authorDisplayName: 'Alice',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.any(String),
+ 'Alice',
+ 'discord',
+ false,
+ );
+ });
+
+ it('uses guild name + channel name for server messages', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'Hello',
+ guildName: 'My Server',
+ channelName: 'bot-chat',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onChatMetadata).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.any(String),
+ 'My Server #bot-chat',
+ 'discord',
+ true,
+ );
+ });
+});
+
+// --- bot mention handling ---
+
+describe('bot mention handling', () => {
+ it('passes through <@botId> mentions without rewriting them', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: '<@999888777> what time is it?',
+ mentionsBotId: true,
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '<@999888777> what time is it?',
+ }),
+ );
+ });
+
+ it('leaves mixed text and mentions untouched', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: '@Andy hello <@999888777>',
+ mentionsBotId: true,
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ // Should NOT prepend @Andy — already starts with trigger
+ // But the <@botId> should still be stripped
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '@Andy hello <@999888777>',
+ }),
+ );
+ });
+
+ it('does not translate when bot is not mentioned', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'hello everyone',
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: 'hello everyone',
+ }),
+ );
+ });
+
+ it('passes through <@!botId> nickname mentions without rewriting them', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: '<@!999888777> check this',
+ mentionsBotId: true,
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '<@!999888777> check this',
+ }),
+ );
+ });
+});
+
+// --- Attachments ---
+
+describe('attachments', () => {
+ let originalFetch: typeof globalThis.fetch;
+
beforeEach(() => {
- vi.clearAllMocks();
- hasReviewerLeaseMock.mockReturnValue(false);
- loginShouldRejectRef.value = false;
+ originalFetch = globalThis.fetch;
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok: true,
+ arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
+ text: () => Promise.resolve('Hello from text file'),
+ }),
+ );
});
afterEach(() => {
- vi.restoreAllMocks();
+ globalThis.fetch = originalFetch;
});
- describe('channel registration', () => {
- it('warns when the canonical owner token is not configured', () => {
- const ownerFactory = registeredChannelFactories.get('discord');
-
- expect(ownerFactory).toBeTypeOf('function');
- expect(ownerFactory?.(createTestOpts() as any)).toBeNull();
- expect(logger.warn).toHaveBeenCalledWith(
- 'Discord: DISCORD_OWNER_BOT_TOKEN not set',
- );
- });
- });
-
- // --- Connection lifecycle ---
-
- describe('connection lifecycle', () => {
- it('resolves connect() when client is ready', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
-
- await channel.connect();
-
- expect(channel.isConnected()).toBe(true);
- });
-
- it('rejects connect() when login fails', async () => {
- loginShouldRejectRef.value = true;
- const opts = createTestOpts();
- const channel = new DiscordChannel('bad-token', opts);
-
- await expect(channel.connect()).rejects.toThrow(
- 'An invalid token was provided.',
- );
- expect(channel.isConnected()).toBe(false);
- });
-
- it('registers message handlers on connect', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
-
- await channel.connect();
-
- expect(currentClient().eventHandlers.has('messageCreate')).toBe(true);
- expect(currentClient().eventHandlers.has('error')).toBe(true);
- expect(currentClient().eventHandlers.has('ready')).toBe(true);
- });
-
- it('disconnects cleanly', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
-
- await channel.connect();
- expect(channel.isConnected()).toBe(true);
-
- await channel.disconnect();
- expect(channel.isConnected()).toBe(false);
- });
-
- it('isConnected() returns false before connect', () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
-
- expect(channel.isConnected()).toBe(false);
- });
- });
-
- // --- Text message handling ---
-
- describe('text message handling', () => {
- it('delivers message for registered channel', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- content: 'Hello everyone',
- guildName: 'Test Server',
- channelName: 'general',
- });
- await triggerMessage(msg);
-
- expect(opts.onChatMetadata).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.any(String),
- 'Test Server #general',
- 'discord',
- true,
- );
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({
- id: 'msg_001',
- chat_jid: 'dc:1234567890123456',
- sender: '55512345',
- sender_name: 'Alice',
- content: 'Hello everyone',
- is_from_me: false,
- }),
- );
- });
-
- it('only emits metadata for unregistered channels', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- channelId: '9999999999999999',
- content: 'Unknown channel',
- guildName: 'Other Server',
- });
- await triggerMessage(msg);
-
- expect(opts.onChatMetadata).toHaveBeenCalledWith(
- 'dc:9999999999999999',
- expect.any(String),
- expect.any(String),
- 'discord',
- true,
- );
- expect(opts.onMessage).not.toHaveBeenCalled();
- });
-
- it('ignores its own bot messages', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- authorId: '999888777',
- isBot: true,
- content: 'I am the connected bot',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).not.toHaveBeenCalled();
- });
-
- it('ignores other bot messages in normal rooms', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- authorId: '111222333',
- isBot: true,
- content: 'I am another bot',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).not.toHaveBeenCalled();
- });
-
- it('delivers other bot messages in paired rooms', async () => {
- hasReviewerLeaseMock.mockReturnValue(true);
-
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- authorId: '111222333',
- isBot: true,
- content: 'I am another bot',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({
- content: 'I am another bot',
- is_bot_message: true,
- }),
- );
- });
-
- it('uses member displayName when available (server nickname)', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- content: 'Hi',
- memberDisplayName: 'Alice Nickname',
- authorDisplayName: 'Alice Global',
- guildName: 'Server',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({ sender_name: 'Alice Nickname' }),
- );
- });
-
- it('falls back to author displayName when no member', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- content: 'Hi',
- memberDisplayName: undefined,
- authorDisplayName: 'Alice Global',
- guildName: 'Server',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({ sender_name: 'Alice Global' }),
- );
- });
-
- it('uses sender name for DM chats (no guild)', async () => {
- const opts = createTestOpts({
- roomBindings: vi.fn(() => ({
- 'dc:1234567890123456': {
- name: 'DM',
- folder: 'dm',
- trigger: '@Andy',
- added_at: '2024-01-01T00:00:00.000Z',
- },
- })),
- });
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- content: 'Hello',
- guildName: undefined,
- authorDisplayName: 'Alice',
- });
- await triggerMessage(msg);
-
- expect(opts.onChatMetadata).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.any(String),
- 'Alice',
- 'discord',
- false,
- );
- });
-
- it('uses guild name + channel name for server messages', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- content: 'Hello',
- guildName: 'My Server',
- channelName: 'bot-chat',
- });
- await triggerMessage(msg);
-
- expect(opts.onChatMetadata).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.any(String),
- 'My Server #bot-chat',
- 'discord',
- true,
- );
- });
- });
-
- // --- bot mention handling ---
-
- describe('bot mention handling', () => {
- it('passes through <@botId> mentions without rewriting them', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- content: '<@999888777> what time is it?',
- mentionsBotId: true,
- guildName: 'Server',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({
- content: '<@999888777> what time is it?',
- }),
- );
- });
-
- it('leaves mixed text and mentions untouched', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- content: '@Andy hello <@999888777>',
- mentionsBotId: true,
- guildName: 'Server',
- });
- await triggerMessage(msg);
-
- // Should NOT prepend @Andy — already starts with trigger
- // But the <@botId> should still be stripped
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({
- content: '@Andy hello <@999888777>',
- }),
- );
- });
-
- it('does not translate when bot is not mentioned', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- content: 'hello everyone',
- guildName: 'Server',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({
- content: 'hello everyone',
- }),
- );
- });
-
- it('passes through <@!botId> nickname mentions without rewriting them', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- content: '<@!999888777> check this',
- mentionsBotId: true,
- guildName: 'Server',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({
- content: '<@!999888777> check this',
- }),
- );
- });
- });
-
- // --- Attachments ---
-
- describe('attachments', () => {
- let originalFetch: typeof globalThis.fetch;
-
- beforeEach(() => {
- originalFetch = globalThis.fetch;
- vi.stubGlobal(
- 'fetch',
- vi.fn().mockResolvedValue({
- ok: true,
- arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
- text: () => Promise.resolve('Hello from text file'),
- }),
- );
- });
-
- afterEach(() => {
- globalThis.fetch = originalFetch;
- });
-
- it('stores image attachment with placeholder', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const attachments = new Map([
- [
- 'att1',
- {
- id: 'att1',
- name: 'photo.png',
- contentType: 'image/png',
- url: 'https://cdn.example.com/photo.png',
- },
- ],
- ]);
- const msg = createMessage({
- content: '',
- attachments,
- guildName: 'Server',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({
- content: expect.stringMatching(/^\[Image: .+\.png\]$/),
- }),
- );
- });
-
- it('stores video attachment with placeholder', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const attachments = new Map([
- ['att1', { name: 'clip.mp4', contentType: 'video/mp4' }],
- ]);
- const msg = createMessage({
- content: '',
- attachments,
- guildName: 'Server',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({
- content: '[Video: clip.mp4]',
- }),
- );
- });
-
- it('stores file attachment with placeholder', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const attachments = new Map([
- ['att1', { name: 'report.pdf', contentType: 'application/pdf' }],
- ]);
- const msg = createMessage({
- content: '',
- attachments,
- guildName: 'Server',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({
- content: '[File: report.pdf]',
- }),
- );
- });
-
- it('includes text content with attachments', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const attachments = new Map([
- [
- 'att1',
- {
- id: 'att1',
- name: 'photo.jpg',
- contentType: 'image/jpeg',
- url: 'https://cdn.example.com/photo.jpg',
- },
- ],
- ]);
- const msg = createMessage({
- content: 'Check this out',
- attachments,
- guildName: 'Server',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({
- content: expect.stringMatching(
- /^Check this out\n\[Image: .+\.jpg\]$/,
- ),
- }),
- );
- });
-
- it('handles multiple attachments', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const attachments = new Map([
- [
- 'att1',
- {
- id: 'att1',
- name: 'a.png',
- contentType: 'image/png',
- url: 'https://cdn.example.com/a.png',
- },
- ],
- [
- 'att2',
- {
- id: 'att2',
- name: 'b.txt',
- contentType: 'text/plain',
- url: 'https://cdn.example.com/b.txt',
- },
- ],
- ]);
- const msg = createMessage({
- content: '',
- attachments,
- guildName: 'Server',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({
- content: expect.stringMatching(
- /^\[Image: .+\.png\]\n\[File: b\.txt\]\nHello from text file$/,
- ),
- }),
- );
- });
- });
-
- // --- Reply context ---
-
- describe('reply context', () => {
- it('includes reply author in content', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const msg = createMessage({
- content: 'I agree with that',
- reference: { messageId: 'original_msg_id' },
- guildName: 'Server',
- });
- await triggerMessage(msg);
-
- expect(opts.onMessage).toHaveBeenCalledWith(
- 'dc:1234567890123456',
- expect.objectContaining({
- content: '[Reply to Bob] I agree with that',
- }),
- );
- });
- });
-
- // --- sendMessage ---
-
- describe('sendMessage', () => {
- it('sends message via channel', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- await channel.sendMessage('dc:1234567890123456', 'Hello');
-
- await currentClient().channels.fetch('1234567890123456');
- expect(currentClient().channels.fetch).toHaveBeenCalledWith(
- '1234567890123456',
- );
- });
-
- it('strips dc: prefix from JID', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- await channel.sendMessage('dc:9876543210', 'Test');
-
- expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210');
- });
-
- it('propagates send failure to the caller', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- currentClient().channels.fetch.mockRejectedValueOnce(
- new Error('Channel not found'),
- );
-
- await expect(
- channel.sendMessage('dc:1234567890123456', 'Will fail'),
- ).rejects.toThrow('Channel not found');
- });
-
- it('rejects when client is not initialized', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
-
- // Don't connect — client is null
- await expect(
- channel.sendMessage('dc:1234567890123456', 'No client'),
- ).rejects.toThrow('Discord client not initialized');
- });
-
- it('splits messages exceeding 2000 characters', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const mockChannel = {
- send: vi.fn().mockResolvedValue(undefined),
- sendTyping: vi.fn(),
- };
- currentClient().channels.fetch.mockResolvedValue(mockChannel);
-
- const longText = 'x'.repeat(3000);
- await channel.sendMessage('dc:1234567890123456', longText);
-
- expect(mockChannel.send).toHaveBeenCalledTimes(2);
- expect(mockChannel.send).toHaveBeenNthCalledWith(1, {
- content: 'x'.repeat(2000),
- files: undefined,
- flags: 1 << 2,
- });
- expect(mockChannel.send).toHaveBeenNthCalledWith(2, {
- content: 'x'.repeat(1000),
- files: undefined,
- flags: 1 << 2,
- });
- });
-
- it('sends structured attachments as Discord files', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
- const { dir, filePath } = createTempPng('structured.png');
- const mockChannel = {
- send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
- sendTyping: vi.fn(),
- };
- currentClient().channels.fetch.mockResolvedValue(mockChannel);
-
- await channel.sendMessage(
- 'dc:1234567890123456',
- '이미지를 생성했습니다.',
+ it('stores image attachment with placeholder', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const attachments = new Map([
+ [
+ 'att1',
{
- attachments: [
- { path: filePath, name: 'result.png', mime: 'image/png' },
- ],
+ id: 'att1',
+ name: 'photo.png',
+ contentType: 'image/png',
+ url: 'https://cdn.example.com/photo.png',
},
- );
-
- expect(mockChannel.send).toHaveBeenCalledWith({
- content: '이미지를 생성했습니다.',
- files: [{ attachment: filePath, name: 'result.png' }],
- flags: 1 << 2,
- });
- fs.rmSync(dir, { recursive: true, force: true });
+ ],
+ ]);
+ const msg = createMessage({
+ content: '',
+ attachments,
+ guildName: 'Server',
});
+ await triggerMessage(msg);
- it('uses legacy image tags as Discord attachment fallback', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
- const { dir, filePath } = createTempPng('screenshot.png');
- const mockChannel = {
- send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
- sendTyping: vi.fn(),
- };
- currentClient().channels.fetch.mockResolvedValue(mockChannel);
-
- await channel.sendMessage(
- 'dc:1234567890123456',
- `스크린샷입니다.\n[Image: screenshot.png → ${filePath}]`,
- );
-
- expect(mockChannel.send).toHaveBeenCalledWith({
- content: '스크린샷입니다.',
- files: [{ attachment: filePath, name: 'screenshot.png' }],
- flags: 1 << 2,
- });
- fs.rmSync(dir, { recursive: true, force: true });
- });
-
- it('logs channel name and Discord message ids after sending', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const mockChannel = {
- send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
- sendTyping: vi.fn(),
- };
- currentClient().channels.fetch.mockResolvedValue(mockChannel);
-
- await channel.sendMessage('dc:1234567890123456', 'Hello');
-
- expect(logger.info).toHaveBeenCalledWith(
- expect.objectContaining({
- jid: 'dc:1234567890123456',
- channelName: 'discord',
- deliveryMode: 'send',
- chunkCount: 1,
- messageId: 'discord-message-1',
- messageIds: ['discord-message-1'],
- }),
- 'Discord message sent',
- );
- });
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: expect.stringMatching(/^\[Image: .+\.png\]$/),
+ }),
+ );
});
- // --- ownsJid ---
+ it('stores video attachment with placeholder', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
- describe('ownsJid', () => {
- it('owns dc: JIDs', () => {
- const channel = new DiscordChannel('test-token', createTestOpts());
- expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
+ const attachments = new Map([
+ ['att1', { name: 'clip.mp4', contentType: 'video/mp4' }],
+ ]);
+ const msg = createMessage({
+ content: '',
+ attachments,
+ guildName: 'Server',
});
+ await triggerMessage(msg);
- it('can be configured as an outbound-only role bot', () => {
- const channel = new DiscordChannel(
- 'test-token',
- createTestOpts(),
- undefined,
- 'discord-review',
- false,
- false,
- );
- expect(channel.ownsJid('dc:1234567890123456')).toBe(false);
- });
-
- it('does not own WhatsApp group JIDs', () => {
- const channel = new DiscordChannel('test-token', createTestOpts());
- expect(channel.ownsJid('12345@g.us')).toBe(false);
- });
-
- it('does not own Telegram JIDs', () => {
- const channel = new DiscordChannel('test-token', createTestOpts());
- expect(channel.ownsJid('tg:123456789')).toBe(false);
- });
-
- it('does not own unknown JID formats', () => {
- const channel = new DiscordChannel('test-token', createTestOpts());
- expect(channel.ownsJid('random-string')).toBe(false);
- });
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '[Video: clip.mp4]',
+ }),
+ );
});
- // --- setTyping ---
+ it('stores file attachment with placeholder', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
- describe('setTyping', () => {
- it('sends typing indicator when isTyping is true', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- const mockChannel = {
- send: vi.fn(),
- sendTyping: vi.fn().mockResolvedValue(undefined),
- };
- currentClient().channels.fetch.mockResolvedValue(mockChannel);
-
- await channel.setTyping('dc:1234567890123456', true);
-
- expect(mockChannel.sendTyping).toHaveBeenCalled();
+ const attachments = new Map([
+ ['att1', { name: 'report.pdf', contentType: 'application/pdf' }],
+ ]);
+ const msg = createMessage({
+ content: '',
+ attachments,
+ guildName: 'Server',
});
+ await triggerMessage(msg);
- it('does nothing when isTyping is false', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- await channel.setTyping('dc:1234567890123456', false);
-
- // channels.fetch should NOT be called
- expect(currentClient().channels.fetch).not.toHaveBeenCalled();
- });
-
- it('does not send stale typing after typing was disabled mid-flight', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
- await channel.connect();
-
- let resolveFetch!: (value: unknown) => void;
- const fetchPromise = new Promise((resolve) => {
- resolveFetch = resolve;
- });
- const mockChannel = {
- send: vi.fn(),
- sendTyping: vi.fn().mockResolvedValue(undefined),
- };
-
- currentClient().channels.fetch.mockImplementationOnce(() => fetchPromise);
-
- const typingPromise = channel.setTyping('dc:1234567890123456', true);
- await Promise.resolve();
- await channel.setTyping('dc:1234567890123456', false);
- resolveFetch(mockChannel);
- await typingPromise;
-
- expect(mockChannel.sendTyping).not.toHaveBeenCalled();
- });
-
- it('does nothing when client is not initialized', async () => {
- const opts = createTestOpts();
- const channel = new DiscordChannel('test-token', opts);
-
- // Don't connect
- await channel.setTyping('dc:1234567890123456', true);
-
- // No error
- });
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '[File: report.pdf]',
+ }),
+ );
});
- // --- Channel properties ---
+ it('includes text content with attachments', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
- describe('channel properties', () => {
- it('has name "discord"', () => {
- const channel = new DiscordChannel('test-token', createTestOpts());
- expect(channel.name).toBe('discord');
+ const attachments = new Map([
+ [
+ 'att1',
+ {
+ id: 'att1',
+ name: 'photo.jpg',
+ contentType: 'image/jpeg',
+ url: 'https://cdn.example.com/photo.jpg',
+ },
+ ],
+ ]);
+ const msg = createMessage({
+ content: 'Check this out',
+ attachments,
+ guildName: 'Server',
});
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: expect.stringMatching(/^Check this out\n\[Image: .+\.jpg\]$/),
+ }),
+ );
+ });
+
+ it('handles multiple attachments', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const attachments = new Map([
+ [
+ 'att1',
+ {
+ id: 'att1',
+ name: 'a.png',
+ contentType: 'image/png',
+ url: 'https://cdn.example.com/a.png',
+ },
+ ],
+ [
+ 'att2',
+ {
+ id: 'att2',
+ name: 'b.txt',
+ contentType: 'text/plain',
+ url: 'https://cdn.example.com/b.txt',
+ },
+ ],
+ ]);
+ const msg = createMessage({
+ content: '',
+ attachments,
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: expect.stringMatching(
+ /^\[Image: .+\.png\]\n\[File: b\.txt\]\nHello from text file$/,
+ ),
+ }),
+ );
+ });
+});
+
+// --- Reply context ---
+
+describe('reply context', () => {
+ it('includes reply author in content', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const msg = createMessage({
+ content: 'I agree with that',
+ reference: { messageId: 'original_msg_id' },
+ guildName: 'Server',
+ });
+ await triggerMessage(msg);
+
+ expect(opts.onMessage).toHaveBeenCalledWith(
+ 'dc:1234567890123456',
+ expect.objectContaining({
+ content: '[Reply to Bob] I agree with that',
+ }),
+ );
+ });
+});
+
+// --- sendMessage ---
+
+describe('sendMessage', () => {
+ it('sends message via channel', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ await channel.sendMessage('dc:1234567890123456', 'Hello');
+
+ await currentClient().channels.fetch('1234567890123456');
+ expect(currentClient().channels.fetch).toHaveBeenCalledWith(
+ '1234567890123456',
+ );
+ });
+
+ it('strips dc: prefix from JID', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ await channel.sendMessage('dc:9876543210', 'Test');
+
+ expect(currentClient().channels.fetch).toHaveBeenCalledWith('9876543210');
+ });
+
+ it('propagates send failure to the caller', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ currentClient().channels.fetch.mockRejectedValueOnce(
+ new Error('Channel not found'),
+ );
+
+ await expect(
+ channel.sendMessage('dc:1234567890123456', 'Will fail'),
+ ).rejects.toThrow('Channel not found');
+ });
+
+ it('rejects when client is not initialized', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+
+ // Don't connect — client is null
+ await expect(
+ channel.sendMessage('dc:1234567890123456', 'No client'),
+ ).rejects.toThrow('Discord client not initialized');
+ });
+
+ it('splits messages exceeding 2000 characters', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const mockChannel = {
+ send: vi.fn().mockResolvedValue(undefined),
+ sendTyping: vi.fn(),
+ };
+ currentClient().channels.fetch.mockResolvedValue(mockChannel);
+
+ const longText = 'x'.repeat(3000);
+ await channel.sendMessage('dc:1234567890123456', longText);
+
+ expect(mockChannel.send).toHaveBeenCalledTimes(2);
+ expect(mockChannel.send).toHaveBeenNthCalledWith(1, {
+ content: 'x'.repeat(2000),
+ files: undefined,
+ flags: 1 << 2,
+ });
+ expect(mockChannel.send).toHaveBeenNthCalledWith(2, {
+ content: 'x'.repeat(1000),
+ files: undefined,
+ flags: 1 << 2,
+ });
+ });
+
+ it('sends structured attachments as Discord files', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+ const { dir, filePath } = createTempPng('structured.png');
+ const mockChannel = {
+ send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
+ sendTyping: vi.fn(),
+ };
+ currentClient().channels.fetch.mockResolvedValue(mockChannel);
+
+ await channel.sendMessage('dc:1234567890123456', '이미지를 생성했습니다.', {
+ attachments: [{ path: filePath, name: 'result.png', mime: 'image/png' }],
+ });
+
+ expect(mockChannel.send).toHaveBeenCalledWith({
+ content: '이미지를 생성했습니다.',
+ files: [{ attachment: filePath, name: 'result.png' }],
+ flags: 1 << 2,
+ });
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ it('uses legacy image tags as Discord attachment fallback', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+ const { dir, filePath } = createTempPng('screenshot.png');
+ const mockChannel = {
+ send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
+ sendTyping: vi.fn(),
+ };
+ currentClient().channels.fetch.mockResolvedValue(mockChannel);
+
+ await channel.sendMessage(
+ 'dc:1234567890123456',
+ `스크린샷입니다.\n[Image: screenshot.png → ${filePath}]`,
+ );
+
+ expect(mockChannel.send).toHaveBeenCalledWith({
+ content: '스크린샷입니다.',
+ files: [{ attachment: filePath, name: 'screenshot.png' }],
+ flags: 1 << 2,
+ });
+ fs.rmSync(dir, { recursive: true, force: true });
+ });
+
+ it('logs channel name and Discord message ids after sending', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const mockChannel = {
+ send: vi.fn().mockResolvedValue({ id: 'discord-message-1' }),
+ sendTyping: vi.fn(),
+ };
+ currentClient().channels.fetch.mockResolvedValue(mockChannel);
+
+ await channel.sendMessage('dc:1234567890123456', 'Hello');
+
+ expect(logger.info).toHaveBeenCalledWith(
+ expect.objectContaining({
+ jid: 'dc:1234567890123456',
+ channelName: 'discord',
+ deliveryMode: 'send',
+ chunkCount: 1,
+ messageId: 'discord-message-1',
+ messageIds: ['discord-message-1'],
+ }),
+ 'Discord message sent',
+ );
+ });
+});
+
+// --- ownsJid ---
+
+describe('ownsJid', () => {
+ it('owns dc: JIDs', () => {
+ const channel = new DiscordChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('dc:1234567890123456')).toBe(true);
+ });
+
+ it('can be configured as an outbound-only role bot', () => {
+ const channel = new DiscordChannel(
+ 'test-token',
+ createTestOpts(),
+ undefined,
+ 'discord-review',
+ false,
+ false,
+ );
+ expect(channel.ownsJid('dc:1234567890123456')).toBe(false);
+ });
+
+ it('does not own WhatsApp group JIDs', () => {
+ const channel = new DiscordChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('12345@g.us')).toBe(false);
+ });
+
+ it('does not own Telegram JIDs', () => {
+ const channel = new DiscordChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('tg:123456789')).toBe(false);
+ });
+
+ it('does not own unknown JID formats', () => {
+ const channel = new DiscordChannel('test-token', createTestOpts());
+ expect(channel.ownsJid('random-string')).toBe(false);
+ });
+});
+
+// --- setTyping ---
+
+describe('setTyping', () => {
+ it('sends typing indicator when isTyping is true', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ const mockChannel = {
+ send: vi.fn(),
+ sendTyping: vi.fn().mockResolvedValue(undefined),
+ };
+ currentClient().channels.fetch.mockResolvedValue(mockChannel);
+
+ await channel.setTyping('dc:1234567890123456', true);
+
+ expect(mockChannel.sendTyping).toHaveBeenCalled();
+ });
+
+ it('does nothing when isTyping is false', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ await channel.setTyping('dc:1234567890123456', false);
+
+ // channels.fetch should NOT be called
+ expect(currentClient().channels.fetch).not.toHaveBeenCalled();
+ });
+
+ it('does not send stale typing after typing was disabled mid-flight', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+ await channel.connect();
+
+ let resolveFetch!: (value: unknown) => void;
+ const fetchPromise = new Promise((resolve) => {
+ resolveFetch = resolve;
+ });
+ const mockChannel = {
+ send: vi.fn(),
+ sendTyping: vi.fn().mockResolvedValue(undefined),
+ };
+
+ currentClient().channels.fetch.mockImplementationOnce(() => fetchPromise);
+
+ const typingPromise = channel.setTyping('dc:1234567890123456', true);
+ await Promise.resolve();
+ await channel.setTyping('dc:1234567890123456', false);
+ resolveFetch(mockChannel);
+ await typingPromise;
+
+ expect(mockChannel.sendTyping).not.toHaveBeenCalled();
+ });
+
+ it('does nothing when client is not initialized', async () => {
+ const opts = createTestOpts();
+ const channel = new DiscordChannel('test-token', opts);
+
+ // Don't connect
+ await channel.setTyping('dc:1234567890123456', true);
+
+ // No error
+ });
+});
+
+// --- Channel properties ---
+
+describe('channel properties', () => {
+ it('has name "discord"', () => {
+ const channel = new DiscordChannel('test-token', createTestOpts());
+ expect(channel.name).toBe('discord');
});
});