Internalize all IM channel implementations

This commit is contained in:
xmanrui 2026-08-15 15:40:53 +08:00
parent 8996476693
commit bd469f58f8
95 changed files with 25012 additions and 100 deletions

View file

@ -0,0 +1,118 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DINGTALK_ENDPOINTS,
DINGTALK_RPC_CHANNEL,
formatRemaining,
normalizeProvisioning,
normalizeSnapshot,
presentError,
safeQrSource,
unwrapRpcResult,
} from '../../../plugin-src/client/channels/dingtalk/api.js';
test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
assert.equal(DINGTALK_RPC_CHANNEL, '/dingtalk');
assert.deepEqual(DINGTALK_ENDPOINTS, {
status: 'connection.status',
beginProvisioning: 'provision.begin',
pollProvisioning: 'provision.poll',
cancelProvisioning: 'provision.cancel',
reconnectBot: 'bot.reconnect',
deleteBot: 'bot.delete',
});
});
test('RPC envelopes are required and sensitive error details are replaced', () => {
assert.equal(unwrapRpcResult({ ok: true, value: { ready: true } }).ready, true);
assert.throws(
() => unwrapRpcResult({ value: {} }),
/无法识别/,
);
assert.throws(
() => unwrapRpcResult({
ok: false,
error: {
code: 'clientSecret=should-not-escape',
message: 'clientSecret=super-secret-value',
},
}),
(error) => error.code === 'DINGTALK_RPC_ERROR'
&& error.message === '钉钉操作失败'
&& !error.message.includes('super-secret-value'),
);
});
test('QR images accept only bounded base64 PNG or WebP data URLs', () => {
assert.equal(safeQrSource('data:image/png;base64,AAAA'), 'data:image/png;base64,AAAA');
assert.equal(safeQrSource('data:image/webp;base64,AAAA'), 'data:image/webp;base64,AAAA');
assert.equal(safeQrSource('data:image/svg+xml;base64,AAAA'), undefined);
assert.equal(safeQrSource('data:image/png;base64,AAAA\n<script>'), undefined);
assert.equal(safeQrSource('javascript:alert(1)'), undefined);
});
test('provisioning keeps only the browser-safe attempt projection', () => {
const now = 1_000;
const value = normalizeProvisioning({
attemptId: 'attempt-safe',
status: 'pending',
expiresIn: 90,
pollIntervalMs: 10,
qrCodeDataUrl: 'data:image/png;base64,AAAA',
verificationUrl: 'https://login.dingtalk.com/device',
deviceCode: 'raw-device-code',
clientSecret: 'raw-client-secret',
secretRef: 'credential-ref',
}, now);
assert.deepEqual(value, {
attemptId: 'attempt-safe',
status: 'pending',
expiresAt: 91_000,
pollIntervalMs: 1_000,
qrCodeDataUrl: 'data:image/png;base64,AAAA',
});
assert.equal(normalizeProvisioning({ attemptId: 'safe', status: 'unknown' }, now).status, 'failed');
assert.doesNotMatch(JSON.stringify(value), /raw-device|raw-client|credential-ref/);
});
test('snapshot derives totals and exposes only browser-safe bot state', () => {
const snapshot = normalizeSnapshot({
schemaVersion: 1,
revision: 7,
totals: { configured: 99, connected: 99 },
bots: [{
botId: 'bot-safe',
connected: true,
state: 'offline',
bot: {
name: '研发助手',
clientIdMasked: 'ding••••6f2a',
clientId: 'raw-client-id',
clientSecret: 'raw-client-secret',
},
secretRef: 'credential-ref',
deviceCode: 'device-code',
health: { status: 'healthy', summary: '连接正常', lastCheckedAt: 123 },
stats: { messagesReceived: 8, messagesReplied: 6 },
senders: { pending: [{ senderId: 'raw-staff-id' }] },
}],
});
assert.deepEqual(snapshot.totals, { configured: 1, connected: 1 });
assert.equal(snapshot.bots[0].state, 'connected');
assert.equal('senders' in snapshot.bots[0], false);
assert.doesNotMatch(
JSON.stringify(snapshot),
/raw-client-id|raw-client-secret|credential-ref|device-code|raw-staff-id/,
);
});
test('presentation helpers redact sensitive messages and format countdowns', () => {
assert.deepEqual(
presentError({ code: 'UPSTREAM_FAILED', message: 'accessToken: visible-value' }),
{ code: 'UPSTREAM_FAILED', message: '钉钉操作失败,请稍后重试' },
);
assert.equal(formatRemaining(61_000), '01:01');
assert.equal(formatRemaining(-1), '00:00');
});

View file

@ -0,0 +1,272 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as React from 'react';
import TestRenderer from 'react-test-renderer';
import {
DINGTALK_ENDPOINTS,
} from '../../../plugin-src/client/channels/dingtalk/api.js';
import { DingtalkSettingsTab } from '../../../plugin-src/client/channels/dingtalk/index.js';
const { act, create } = TestRenderer;
function deferred() {
let resolve;
let reject;
const promise = new Promise((onResolve, onReject) => {
resolve = onResolve;
reject = onReject;
});
return { promise, resolve, reject };
}
function ok(value) {
return { ok: true, value };
}
function provisioning(attemptId, overrides = {}) {
return {
attemptId,
status: 'pending',
expiresAt: Date.now() + 60_000,
pollIntervalMs: 1_000,
qrCodeDataUrl: `data:image/png;base64,${attemptId === 'attempt-old' ? 'QUFBQQ==' : 'QkJCQg=='}`,
...overrides,
};
}
function snapshot(overrides = {}) {
return {
schemaVersion: 1,
revision: 1,
state: 'disconnected',
bots: [],
provisioning: null,
...overrides,
};
}
function createBrowserClock() {
let nextId = 1;
const timeouts = new Map();
const intervals = new Map();
const frames = new Map();
const cancelledFrames = [];
const previousWindow = globalThis.window;
globalThis.window = {
setTimeout(callback, delay) {
const id = nextId++;
timeouts.set(id, { callback, delay });
return id;
},
clearTimeout(id) {
timeouts.delete(id);
},
setInterval(callback, delay) {
const id = nextId++;
intervals.set(id, { callback, delay });
return id;
},
clearInterval(id) {
intervals.delete(id);
},
requestAnimationFrame(callback) {
const id = nextId++;
frames.set(id, callback);
return id;
},
cancelAnimationFrame(id) {
cancelledFrames.push(id);
frames.delete(id);
},
};
return {
cancelledFrames,
frames,
intervals,
timeouts,
runInterval(delay) {
const entry = [...intervals.values()].find((candidate) => candidate.delay === delay);
assert.ok(entry, `missing ${delay}ms interval`);
return entry.callback();
},
runTimeout(delay) {
const match = [...timeouts.entries()].find(([, candidate]) => candidate.delay === delay);
assert.ok(match, `missing ${delay}ms timeout`);
const [id, entry] = match;
timeouts.delete(id);
return entry.callback();
},
restore() {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
},
};
}
async function flushMicrotasks() {
for (let index = 0; index < 6; index += 1) await Promise.resolve();
}
function nodeText(node) {
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (!node) return '';
const children = Array.isArray(node) ? node : node.children;
return Array.isArray(children) ? children.map(nodeText).join('') : nodeText(children);
}
function findButton(renderer, label) {
const button = renderer.root.findAllByType('button')
.find((candidate) => nodeText(candidate) === label);
assert.ok(button, `missing button: ${label}`);
return button;
}
test('a late poll response cannot issue status RPCs or schedule work after effect cleanup', async (t) => {
const clock = createBrowserClock();
t.after(() => clock.restore());
const oldPoll = deferred();
let beginCount = 0;
let statusCalls = 0;
const rpcCall = async (endpoint, payload) => {
if (endpoint === DINGTALK_ENDPOINTS.status) {
statusCalls += 1;
return ok(snapshot());
}
if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) {
beginCount += 1;
return ok(provisioning(beginCount === 1 ? 'attempt-old' : 'attempt-new'));
}
if (endpoint === DINGTALK_ENDPOINTS.cancelProvisioning) return ok({ cancelled: true });
if (endpoint === DINGTALK_ENDPOINTS.pollProvisioning) {
assert.equal(payload.attemptId, 'attempt-old');
return oldPoll.promise;
}
throw new Error(`unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
await flushMicrotasks();
});
await act(async () => {
findButton(renderer, '生成钉钉二维码').props.onClick();
await flushMicrotasks();
});
await act(async () => {
clock.runTimeout(1_000);
await flushMicrotasks();
});
await act(async () => {
findButton(renderer, '换一个二维码').props.onClick();
await flushMicrotasks();
});
assert.equal(clock.timeouts.size, 1, 'the replacement attempt owns one poll timer');
assert.equal(statusCalls, 1, 'only the initial status request has run');
await act(async () => {
oldPoll.resolve(ok(provisioning('attempt-old', {
status: 'connected',
botId: 'bot-old',
})));
await flushMicrotasks();
});
assert.equal(statusCalls, 1, 'the disposed poll cannot start a connected-status refresh');
assert.equal(clock.timeouts.size, 1, 'the disposed poll cannot add another timer');
assert.equal(renderer.root.findByType('img').props.src, 'data:image/png;base64,QkJCQg==');
act(() => renderer.unmount());
});
test('a stale periodic status response cannot restore cancelled provisioning', async (t) => {
const clock = createBrowserClock();
t.after(() => clock.restore());
const staleStatus = deferred();
let statusCalls = 0;
const rpcCall = async (endpoint) => {
if (endpoint === DINGTALK_ENDPOINTS.status) {
statusCalls += 1;
return statusCalls === 1 ? ok(snapshot()) : staleStatus.promise;
}
if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) {
return ok(provisioning('attempt-old'));
}
if (endpoint === DINGTALK_ENDPOINTS.cancelProvisioning) return ok({ cancelled: true });
throw new Error(`unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
await flushMicrotasks();
});
await act(async () => {
findButton(renderer, '生成钉钉二维码').props.onClick();
await flushMicrotasks();
});
const firstAnnouncement = [...clock.frames.keys()][0];
assert.ok(firstAnnouncement, 'starting provisioning schedules an announcement');
await act(async () => {
void clock.runInterval(15_000);
await flushMicrotasks();
});
await act(async () => {
findButton(renderer, '取消').props.onClick();
await flushMicrotasks();
});
assert.ok(clock.cancelledFrames.includes(firstAnnouncement), 'a new announcement cancels the old frame');
await act(async () => {
staleStatus.resolve(ok(snapshot({ provisioning: provisioning('attempt-old') })));
await flushMicrotasks();
});
assert.equal(renderer.root.findAllByType('img').length, 0);
findButton(renderer, '生成钉钉二维码');
assert.ok(clock.frames.size > 0, 'cancel leaves its announcement or focus frame pending');
act(() => renderer.unmount());
assert.equal(clock.frames.size, 0, 'unmount cancels every pending animation frame');
});
test('unmount does not cancel a Host provisioning task that already started', async (t) => {
const clock = createBrowserClock();
t.after(() => clock.restore());
const begin = deferred();
const calls = [];
const rpcCall = async (endpoint, payload, signal) => {
calls.push({ endpoint, payload, signal });
if (endpoint === DINGTALK_ENDPOINTS.status) return ok(snapshot());
if (endpoint === DINGTALK_ENDPOINTS.beginProvisioning) return begin.promise;
throw new Error(`unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
await flushMicrotasks();
});
await act(async () => {
findButton(renderer, '生成钉钉二维码').props.onClick();
await flushMicrotasks();
});
const beginCall = calls.find((call) => call.endpoint === DINGTALK_ENDPOINTS.beginProvisioning);
assert.ok(beginCall);
assert.equal(beginCall.signal, undefined, 'component teardown must not abort Host provisioning');
act(() => renderer.unmount());
begin.resolve(ok(provisioning('attempt-old')));
await flushMicrotasks();
assert.equal(
calls.filter((call) => call.endpoint === DINGTALK_ENDPOINTS.cancelProvisioning).length,
0,
);
assert.equal(clock.frames.size, 0);
});

View file

@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
const CLIENT_URL = new URL('../../../plugin-src/client/channels/dingtalk/index.js', import.meta.url);
const STYLES_URL = new URL('../../../plugin-src/client/channels/dingtalk/styles.js', import.meta.url);
test('standalone client exports a reusable settings component and registration', async () => {
const source = await readFile(CLIENT_URL, 'utf8');
assert.match(source, /export const name = 'dingtalk-settings'/);
assert.match(source, /export const inject = \['slots', 'connection'\]/);
assert.match(source, /export function DingtalkSettingsTab\(\{ rpcCall \}\)/);
assert.match(source, /export function apply\(ctx\)/);
assert.match(source, /ctx\.connection\.rpc\.call\(DINGTALK_RPC_CHANNEL/);
assert.match(source, /id: 'dingtalk'/);
assert.match(source, /label: '钉钉'/);
});
test('QR guidance describes the complete official DingTalk authorization flow', async () => {
const source = await readFile(CLIENT_URL, 'utf8');
assert.match(source, /使用已加入企业\/组织的钉钉账号扫描左侧二维码/);
assert.match(source, /如果钉钉提示尚未加入组织/);
assert.match(source, /在授权页点击“一键创建新机器人”/);
assert.match(source, /保持本页打开,等待机器人自动连接/);
assert.match(source, /官方授权页目前可能显示 OpenClaw 品牌/);
assert.match(source, /safeQrSource\(provision\.qrCodeDataUrl\)/);
assert.doesNotMatch(source, /verificationUrl|打开备用链接/);
assert.doesNotMatch(source, /dangerouslySetInnerHTML|window\.open\(/);
});
test('the settings page has no local sender approval workflow', async () => {
const source = await readFile(CLIENT_URL, 'utf8');
assert.match(source, /payload: \{ botId: account\.botId, confirm: true \}/);
assert.doesNotMatch(source, /SenderAccess|approveSender|revokeSender|待批准|已批准|批准使用/);
});
test('the client uses an isolated compact and accessible DingTalk style namespace', async () => {
const [source, styles] = await Promise.all([
readFile(CLIENT_URL, 'utf8'),
readFile(STYLES_URL, 'utf8'),
]);
assert.doesNotMatch(source, /\bdxw-|\bbxf-/);
assert.doesNotMatch(styles, /\bdxw-|\bbxf-/);
assert.match(styles, /\.ddt-page \{/);
assert.match(styles, /--ddt-accent: #1677ff/);
assert.match(styles, /@media \(max-width: 720px\)/);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)/);
assert.match(source, /aria-live': 'polite'/);
assert.match(source, /role: 'alertdialog'/);
});
test('the QR card responds to its plugin panel width instead of the browser viewport', async () => {
const styles = await readFile(STYLES_URL, 'utf8');
assert.match(styles, /container-type: inline-size/);
assert.match(
styles,
/@container \(max-width: 680px\)[\s\S]*\.ddt-qrLayout \{ grid-template-columns: minmax\(0, 1fr\); justify-items: center;/,
);
assert.match(styles, /\.ddt-qrFrame \{[^\n]*width: min\(270px, 100%\)/);
assert.match(styles, /\.ddt-countdown \{ width: min\(270px, 100%\)/);
assert.match(styles, /\.ddt-qrLayout \{[^\n]*align-items: start;/);
assert.match(styles, /\.ddt-qrColumn \{ width: 100%; min-width: 0; \}/);
assert.match(styles, /\.ddt-qrCopy \{ min-width: 0; overflow-wrap: anywhere; \}/);
});
test('bot metrics stay in one compact three-column row at narrow widths', async () => {
const styles = await readFile(STYLES_URL, 'utf8');
assert.match(
styles,
/\.ddt-metrics \{[^\n]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\)/,
);
assert.doesNotMatch(
styles,
/\.ddt-metrics \{ grid-template-columns: minmax\(0, 1fr\); \}/,
);
});
test('the narrow-panel toolbar keeps all three controls on one row', async () => {
const styles = await readFile(STYLES_URL, 'utf8');
assert.match(
styles,
/@container \(max-width: 680px\)[\s\S]*\.ddt-tools \{ width: 100%; flex-wrap: nowrap; gap: 6px; \}/,
);
assert.match(styles, /\.ddt-tools \.ddt-badge \{ min-height: 34px;/);
assert.match(styles, /\.ddt-tools \.ddt-button \{[^\n]*white-space: nowrap;/);
});

View file

@ -0,0 +1,127 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import test from 'node:test';
import {
DingtalkConfigStore,
deriveDingtalkBotIdentity,
deriveDingtalkSenderKey,
} from '../../../src/channels/dingtalk/config-store.mjs';
async function temporaryConfig(t) {
const directory = await mkdtemp(join(tmpdir(), 'dsh-dingtalk-config-'));
t.after(() => rm(directory, { recursive: true, force: true }));
return join(directory, 'nested', 'bots.json');
}
test('bot identity is deterministic while sender keys are random opaque values', () => {
const clientId = 'ding-client-one';
const clientDigest = createHash('sha256').update(clientId).digest('hex').slice(0, 24);
assert.deepEqual(deriveDingtalkBotIdentity(clientId), {
botId: `dt_${clientDigest}`,
secretRef: `DSH_DINGTALK_BOT_SECRET_${clientDigest.toUpperCase()}`,
});
const firstSenderKey = deriveDingtalkSenderKey();
const secondSenderKey = deriveDingtalkSenderKey();
assert.match(firstSenderKey, /^dt_sender_[a-f0-9]{32}$/);
assert.notEqual(firstSenderKey, secondSenderKey);
});
test('config persists only clientId, derived secretRef, and approvedSenders with mode 0600', async (t) => {
const path = await temporaryConfig(t);
const store = await new DingtalkConfigStore(path).load();
const identity = deriveDingtalkBotIdentity('ding-client-one');
await store.save({
...identity,
clientId: 'ding-client-one',
approvedSenders: [{
senderKey: 'dt_sender_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
staffId: 'staff-private-one',
displayName: '测试用户',
approvedAt: '2026-08-15T00:00:00.000Z',
}],
ignoredMetadata: 'not-persisted',
});
const document = JSON.parse(await readFile(path, 'utf8'));
assert.deepEqual(Object.keys(document.bots[0]).sort(), [
'approvedSenders',
'clientId',
'secretRef',
]);
assert.equal('botId' in document.bots[0], false);
assert.equal('clientSecret' in document.bots[0], false);
assert.equal((await stat(path)).mode & 0o777, 0o600);
const reloaded = await new DingtalkConfigStore(path).load();
assert.deepEqual(reloaded.get(identity.botId), {
botId: identity.botId,
clientId: 'ding-client-one',
secretRef: identity.secretRef,
approvedSenders: [{
senderKey: 'dt_sender_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
staffId: 'staff-private-one',
displayName: '测试用户',
approvedAt: '2026-08-15T00:00:00.000Z',
}],
});
});
test('config rejects a persisted secret or a non-derived secret reference', async (t) => {
const path = await temporaryConfig(t);
const identity = deriveDingtalkBotIdentity('ding-client-one');
await mkdir(dirname(path), { recursive: true });
await writeFile(path, JSON.stringify({
version: 1,
bots: [{
clientId: 'ding-client-one',
secretRef: identity.secretRef,
clientSecret: 'must-not-be-here',
approvedSenders: [],
}],
}));
await assert.rejects(
new DingtalkConfigStore(path).load(),
/invalid bot data/,
);
const store = await new DingtalkConfigStore(`${path}.other`).load();
await assert.rejects(
store.save({
...identity,
clientId: 'ding-client-one',
clientSecret: 'must-not-be-here',
approvedSenders: [],
}),
/invalid dsh-dingtalk bot data/,
);
await assert.rejects(
store.save({
botId: identity.botId,
clientId: 'ding-client-one',
secretRef: 'DSH_DINGTALK_BOT_SECRET_000000000000000000000000',
approvedSenders: [],
}),
/invalid dsh-dingtalk bot data/,
);
});
test('queued concurrent saves retain every bot and remove by derived bot ID', async (t) => {
const path = await temporaryConfig(t);
const store = await new DingtalkConfigStore(path).load();
const first = deriveDingtalkBotIdentity('ding-client-one');
const second = deriveDingtalkBotIdentity('ding-client-two');
await Promise.all([
store.save({ ...first, clientId: 'ding-client-one', approvedSenders: [] }),
store.save({ ...second, clientId: 'ding-client-two', approvedSenders: [] }),
]);
assert.equal(store.list().length, 2);
assert.equal(store.getByClientId('ding-client-two').botId, second.botId);
assert.equal((await store.remove(first.botId)).clientId, 'ding-client-one');
assert.deepEqual(store.list().map((bot) => bot.botId), [second.botId]);
});

View file

@ -0,0 +1,73 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ConnectionSupervisor } from '../../../plugin-src/host/channels/dingtalk/connection-supervisor.mjs';
function scheduler() {
const tasks = [];
return {
tasks,
setTimeout(callback, delay) {
const task = { callback, delay, unref() {} };
tasks.push(task);
return task;
},
clearTimeout(task) {
const index = tasks.indexOf(task);
if (index >= 0) tasks.splice(index, 1);
},
async runNext() {
const task = tasks.shift();
assert.ok(task);
task.callback();
await new Promise((resolve) => setImmediate(resolve));
},
};
}
test('supervisor starts configured DingTalk runtimes after Harness is healthy', async () => {
const timers = scheduler();
let healthChecks = 0;
let initializes = 0;
const supervisor = new ConnectionSupervisor({
harness: { async ensureRunning() { healthChecks += 1; } },
controller: {
async initialize() {
initializes += 1;
return { totals: { configured: 1, connected: 1 } };
},
status() {},
},
setTimeoutImpl: timers.setTimeout,
clearTimeoutImpl: timers.clearTimeout,
healthyIntervalMs: 9_000,
}).start();
await timers.runNext();
assert.equal(healthChecks, 1);
assert.equal(initializes, 1);
assert.equal((await supervisor.ready).totals.connected, 1);
assert.equal(timers.tasks[0].delay, 9_000);
await supervisor.close();
});
test('supervisor retries an offline DingTalk runtime without blocking startup', async () => {
const timers = scheduler();
const warnings = [];
const supervisor = new ConnectionSupervisor({
harness: { async ensureRunning() {} },
controller: {
async initialize() { return { totals: { configured: 2, connected: 1 } }; },
status() {},
},
logger: { warn: (...args) => warnings.push(args) },
retryDelaysMs: [7, 11],
setTimeoutImpl: timers.setTimeout,
clearTimeoutImpl: timers.clearTimeout,
}).start();
await timers.runNext();
assert.equal(timers.tasks[0].delay, 7);
assert.match(warnings[0][0], /1\/2 bots connected/);
await supervisor.close();
});

View file

@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DingtalkDeviceAuth,
DingtalkDeviceAuthError,
} from '../../../src/channels/dingtalk/device-auth.mjs';
function fetchFixture(responses) {
const calls = [];
return {
calls,
fetch: async (url, init) => {
calls.push({ url, init });
return {
ok: true,
json: async () => responses.shift(),
};
},
};
}
test('device auth performs init then begin with the fixed DingTalk registration source', async () => {
const fixture = fetchFixture([
{ errcode: 0, nonce: 'nonce-private' },
{
errcode: 0,
device_code: 'device-private',
user_code: 'user-code',
verification_uri: 'https://oapi.dingtalk.com/verify',
verification_uri_complete: 'https://oapi.dingtalk.com/verify?user_code=user-code',
expires_in: 120,
interval: 3,
},
]);
const auth = new DingtalkDeviceAuth({ fetch: fixture.fetch, clock: () => 10_000 });
const result = await auth.start();
assert.deepEqual(fixture.calls.map((call) => call.url), [
'https://oapi.dingtalk.com/app/registration/init',
'https://oapi.dingtalk.com/app/registration/begin',
]);
assert.deepEqual(JSON.parse(fixture.calls[0].init.body), { source: 'DING_DWS_CLAW' });
assert.deepEqual(JSON.parse(fixture.calls[1].init.body), { nonce: 'nonce-private' });
assert.equal(fixture.calls[0].init.redirect, 'error');
assert.equal(result.deviceCode, 'device-private');
assert.equal(result.verificationUrl, 'https://oapi.dingtalk.com/verify?user_code=user-code');
assert.equal(result.expiresAt, 130_000);
assert.equal(result.pollIntervalMs, 3_000);
});
test('device auth polls with a host-only device code and normalizes credentials', async () => {
const fixture = fetchFixture([{
errcode: 0,
status: 'success',
client_id: 'ding-client',
client_secret: 'private-secret',
}]);
const auth = new DingtalkDeviceAuth({ fetch: fixture.fetch });
const result = await auth.poll({ deviceCode: 'device-private' });
assert.equal(fixture.calls[0].url, 'https://oapi.dingtalk.com/app/registration/poll');
assert.deepEqual(JSON.parse(fixture.calls[0].init.body), { device_code: 'device-private' });
assert.deepEqual(result, {
status: 'SUCCESS',
clientId: 'ding-client',
clientSecret: 'private-secret',
failReason: null,
});
});
test('device auth accepts only default-port HTTPS DingTalk registration hosts', () => {
const fetch = async () => assert.fail('fetch must not run');
for (const baseUrl of [
'http://oapi.dingtalk.com',
'https://oapi.dingtalk.com:8443',
'https://example.com',
'https://dingtalk.com@example.com',
]) {
assert.throws(
() => new DingtalkDeviceAuth({ fetch, baseUrl }),
/valid HTTPS URL/,
);
}
assert.doesNotThrow(
() => new DingtalkDeviceAuth({ fetch, baseUrl: 'https://staging.dingtalk.com/root/' }),
);
});
test('device auth API failures do not echo remote response details', async () => {
const fixture = fetchFixture([{
errcode: 400,
errmsg: 'do not leak device-private or client-secret-private',
}]);
const auth = new DingtalkDeviceAuth({ fetch: fixture.fetch });
await assert.rejects(
auth.start(),
(error) => {
assert.ok(error instanceof DingtalkDeviceAuthError);
assert.equal(error.code, 'api-error');
assert.doesNotMatch(error.message, /device-private|client-secret-private/);
return true;
},
);
});
test('device auth bounds a stalled registration request with a local timeout', async () => {
const fetch = async (_url, { signal }) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
const auth = new DingtalkDeviceAuth({ fetch, timeoutMs: 5 });
await assert.rejects(
auth.start(),
(error) => error instanceof DingtalkDeviceAuthError && error.code === 'timeout',
);
});

View file

@ -0,0 +1,334 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createDingtalkApi,
DINGTALK_AI_CARD_TEMPLATE_ID,
DINGTALK_API_BASE_URL,
normalizeDingtalkCardMarkdown,
normalizeDingtalkSessionWebhook,
splitDingtalkText,
} from '../../../src/channels/dingtalk/dingtalk-api.mjs';
function jsonResponse(value, { status = 200 } = {}) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => value,
};
}
function deferred() {
let resolve;
const promise = new Promise((resolvePromise) => { resolve = resolvePromise; });
return { promise, resolve };
}
test('registration API performs init, begin, and poll with normalized results', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url: url.toString(), options });
if (url.pathname.endsWith('/init')) return jsonResponse({ errcode: 0, nonce: ' nonce-1 ' });
if (url.pathname.endsWith('/begin')) {
return jsonResponse({
errcode: 0,
device_code: ' device-1 ',
user_code: 'user-1',
verification_uri: 'https://h5.dingtalk.com/verify',
verification_uri_complete: 'https://h5.dingtalk.com/verify?code=one',
expires_in: 300,
interval: 3,
});
}
return jsonResponse({
errcode: 0,
status: 'success',
client_id: 'ding-client',
client_secret: 'host-only-secret',
});
};
const api = createDingtalkApi({ fetchImpl, cardMinIntervalMs: 0, cardBackoffMs: 0 });
const begun = await api.beginRegistration();
const polled = await api.pollRegistration({ deviceCode: begun.deviceCode });
assert.deepEqual(begun, {
deviceCode: 'device-1',
userCode: 'user-1',
verificationUri: 'https://h5.dingtalk.com/verify',
verificationUriComplete: 'https://h5.dingtalk.com/verify?code=one',
expiresInSeconds: 300,
intervalSeconds: 3,
});
assert.deepEqual(polled, {
status: 'SUCCESS',
failReason: undefined,
clientId: 'ding-client',
clientSecret: 'host-only-secret',
});
assert.deepEqual(calls.map(({ url, options }) => ({
path: new URL(url).pathname,
body: JSON.parse(options.body),
redirect: options.redirect,
})), [
{ path: '/app/registration/init', body: { source: 'DING_DWS_CLAW' }, redirect: 'error' },
{ path: '/app/registration/begin', body: { nonce: 'nonce-1' }, redirect: 'error' },
{ path: '/app/registration/poll', body: { device_code: 'device-1' }, redirect: 'error' },
]);
});
test('session replies use the fixed token endpoint, reject redirects, and cache access tokens', async () => {
const calls = [];
let now = 1_000;
const fetchImpl = async (url, options) => {
calls.push({ url: url.toString(), options });
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
}
return jsonResponse({ errcode: 0 });
};
const api = createDingtalkApi({ fetchImpl, now: () => now });
const request = {
clientId: 'ding-client',
clientSecret: 'host-only-secret',
sessionWebhook: 'https://oapi.dingtalk.com/robot/sendBySession?session=opaque',
text: '回答',
};
await api.sendText(request);
now += 10_000;
await api.sendText({ ...request, text: '继续' });
assert.equal(calls.filter(({ url }) => url.includes('/oauth2/accessToken')).length, 1);
assert.equal(calls.length, 3);
for (const { options } of calls) assert.equal(options.redirect, 'error');
assert.deepEqual(JSON.parse(calls[0].options.body), {
appKey: 'ding-client',
appSecret: 'host-only-secret',
});
assert.equal(calls[1].options.headers['x-acs-dingtalk-access-token'], 'access-token');
assert.deepEqual(JSON.parse(calls[2].options.body), {
msgtype: 'text',
text: { content: '继续' },
});
});
test('session webhook validation accepts only HTTPS DingTalk hosts on the default port', () => {
assert.equal(
normalizeDingtalkSessionWebhook('https://dingtalk.com/reply?ticket=one'),
'https://dingtalk.com/reply?ticket=one',
);
assert.equal(
normalizeDingtalkSessionWebhook('https://oapi.dingtalk.com:443/reply?ticket=one'),
'https://oapi.dingtalk.com/reply?ticket=one',
);
for (const value of [
'http://oapi.dingtalk.com/reply',
'https://oapi.dingtalk.com.evil.example/reply',
'https://user@oapi.dingtalk.com/reply',
'https://oapi.dingtalk.com:8443/reply',
]) {
assert.throws(() => normalizeDingtalkSessionWebhook(value), /不受信任/);
}
});
test('AI Card replies create, deliver, stream full snapshots, and finalize on fixed endpoints', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url: url.toString(), options });
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
}
return jsonResponse({});
};
const api = createDingtalkApi({ fetchImpl, cardMinIntervalMs: 0, cardBackoffMs: 0 });
const credentials = { clientId: 'ding-client', clientSecret: 'host-only-secret' };
const card = await api.createAiCard({
...credentials,
target: { type: 'user', userId: 'staff-one' },
initialText: '正在处理…',
});
await api.updateAiCard({ ...credentials, ...card, text: '第一行\n第二行' });
await api.finishAiCard({ ...credentials, ...card, text: '最终回答' });
const cardCalls = calls.slice(1).map(({ url, options }) => ({
method: options.method,
path: new URL(url).pathname,
body: JSON.parse(options.body),
token: options.headers['x-acs-dingtalk-access-token'],
redirect: options.redirect,
}));
assert.deepEqual(cardCalls.map(({ method, path }) => ({ method, path })), [
{ method: 'POST', path: '/v1.0/card/instances' },
{ method: 'POST', path: '/v1.0/card/instances/deliver' },
{ method: 'PUT', path: '/v1.0/card/instances' },
{ method: 'PUT', path: '/v1.0/card/streaming' },
{ method: 'PUT', path: '/v1.0/card/streaming' },
{ method: 'PUT', path: '/v1.0/card/streaming' },
{ method: 'PUT', path: '/v1.0/card/instances' },
]);
assert.ok(cardCalls.every(({ token, redirect }) => token === 'access-token' && redirect === 'error'));
assert.equal(cardCalls[0].body.cardTemplateId, DINGTALK_AI_CARD_TEMPLATE_ID);
assert.equal(cardCalls[0].body.outTrackId, card.cardInstanceId);
assert.equal(cardCalls[1].body.openSpaceId, 'dtv1.card//IM_ROBOT.staff-one');
assert.equal(cardCalls[1].body.imRobotOpenDeliverModel.robotCode, 'ding-client');
assert.equal(cardCalls[2].body.cardData.cardParamMap.flowStatus, '2');
assert.equal(cardCalls[3].body.content, '正在处理…');
assert.equal(cardCalls[4].body.content, '第一行<br>第二行');
assert.equal(cardCalls[4].body.isFull, true);
assert.equal(cardCalls[4].body.isFinalize, false);
assert.equal(cardCalls[5].body.content, '最终回答');
assert.equal(cardCalls[5].body.isFinalize, true);
assert.equal(cardCalls[6].body.cardData.cardParamMap.flowStatus, '3');
});
test('AI Card markdown preserves fenced code while rendering ordinary line breaks', () => {
assert.equal(normalizeDingtalkCardMarkdown('一\n二'), '一<br>二');
assert.equal(
normalizeDingtalkCardMarkdown('```js\nconst answer = 42\n```\n完成'),
'```js\nconst answer = 42\n```\n完成',
);
});
test('AI Card start slots preserve 20 QPS without blocking cleanup behind slow HTTP', async () => {
const firstUpdate = deferred();
const bodies = [];
const waits = [];
let now = 0;
const fetchImpl = async (url, options) => {
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
}
const body = JSON.parse(options.body);
bodies.push(body);
if (new URL(url).pathname === '/v1.0/card/streaming' && body.isError === false) {
await firstUpdate.promise;
}
return jsonResponse({});
};
const api = createDingtalkApi({
fetchImpl,
now: () => now,
cardMinIntervalMs: 50,
delay: async (ms) => {
waits.push(ms);
now += ms;
},
});
const request = {
clientId: 'ding-client',
clientSecret: 'host-only-secret',
cardInstanceId: 'card-one',
};
const slowUpdate = api.updateAiCard({ ...request, text: '仍在请求中' });
await new Promise((resolve) => setImmediate(resolve));
await api.failAiCard({ ...request, text: '及时收口' });
assert.equal(bodies.some((body) => body.isError === true), true);
assert.equal(bodies.some((body) => body.cardData?.cardParamMap?.flowStatus === '5'), true);
assert.deepEqual(waits, [50, 50]);
firstUpdate.resolve();
await slowUpdate;
});
test('AI Card retries one QPS rejection after the configured backoff', async () => {
let attempts = 0;
const waits = [];
const fetchImpl = async (url) => {
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
}
attempts += 1;
return attempts === 1 ? jsonResponse({}, { status: 403 }) : jsonResponse({});
};
const api = createDingtalkApi({
fetchImpl,
cardMinIntervalMs: 0,
cardBackoffMs: 1_000,
delay: async (ms) => waits.push(ms),
});
await api.updateAiCard({
clientId: 'ding-client',
clientSecret: 'host-only-secret',
cardInstanceId: 'card-one',
text: '重试内容',
});
assert.equal(attempts, 2);
assert.deepEqual(waits, [1_000]);
});
test('AI Card cleanup marks failure while a completed final frame never falls back twice', async () => {
const calls = [];
let rejectCompletedStatus = true;
const fetchImpl = async (url, options) => {
calls.push({ url: url.toString(), options });
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
}
const body = JSON.parse(options.body);
if (rejectCompletedStatus
&& new URL(url).pathname === '/v1.0/card/instances'
&& body.cardData?.cardParamMap?.flowStatus === '3') {
return jsonResponse({}, { status: 500 });
}
return jsonResponse({});
};
const api = createDingtalkApi({ fetchImpl, cardMinIntervalMs: 0, cardBackoffMs: 0 });
const request = {
clientId: 'ding-client',
clientSecret: 'host-only-secret',
cardInstanceId: 'card-one',
};
assert.deepEqual(await api.finishAiCard({ ...request, text: '最终答案' }), {
delivered: true,
completed: false,
});
rejectCompletedStatus = false;
await api.failAiCard({ ...request, text: '处理失败' });
const bodies = calls
.filter(({ url }) => new URL(url).pathname.startsWith('/v1.0/card/'))
.map(({ options }) => JSON.parse(options.body));
assert.equal(bodies.some((body) => body.isFinalize === true && body.isError === false), true);
assert.equal(bodies.some((body) => body.isError === true), true);
assert.equal(bodies.some((body) => body.cardData?.cardParamMap?.flowStatus === '5'), true);
});
test('AI Card creation closes a delivered card with an independent signal after abort', async () => {
const controller = new AbortController();
const bodies = [];
const fetchImpl = async (url, options) => {
if (url.toString() === `${DINGTALK_API_BASE_URL}v1.0/oauth2/accessToken`) {
return jsonResponse({ accessToken: 'access-token', expireIn: 7_200 });
}
const body = JSON.parse(options.body);
bodies.push(body);
if (options.method === 'PUT' && body.cardData?.cardParamMap?.flowStatus === '2') {
controller.abort(new DOMException('stopped', 'AbortError'));
throw controller.signal.reason;
}
return jsonResponse({});
};
const api = createDingtalkApi({ fetchImpl, cardMinIntervalMs: 0, cardBackoffMs: 0 });
await assert.rejects(api.createAiCard({
clientId: 'ding-client',
clientSecret: 'host-only-secret',
target: { type: 'user', userId: 'staff-one' },
initialText: '正在处理',
signal: controller.signal,
}), { name: 'AbortError' });
assert.equal(bodies.some((body) => body.isError === true), true);
assert.equal(bodies.some((body) => body.cardData?.cardParamMap?.flowStatus === '5'), true);
});
test('text splitting prefers line boundaries and never produces oversized chunks', () => {
const chunks = splitDingtalkText('第一段\n第二段很长\n第三段', 8);
assert.equal(chunks.join('').replaceAll('\n', ''), '第一段第二段很长第三段');
assert.ok(chunks.every((chunk) => chunk.length <= 8));
});

View file

@ -0,0 +1,271 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createDingtalkBridgeStatus,
DingtalkHarnessBridge,
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
function message(id, text, overrides = {}) {
return {
msgId: id,
msgtype: 'text',
text: { content: text },
conversationType: '1',
conversationId: `conversation-${id}`,
senderStaffId: 'staff-approved',
senderNick: '钉钉用户',
sessionWebhook: `https://oapi.dingtalk.com/robot/reply?ticket=${id}`,
...overrides,
};
}
function stateFixture() {
const sessions = new Map();
const seen = new Set();
const pending = new Map();
return {
sessions,
seen,
pending,
state: {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: (key) => sessions.get(key) ?? null,
setSession: async (key, sessionId) => sessions.set(key, sessionId),
clearSession: async (key) => sessions.delete(key),
pendingSenders: () => [...pending.values()].map((entry) => structuredClone(entry)),
recordPendingSender: async ({ staffId, displayName, lastSeenAt }) => {
const existing = [...pending.values()].find((entry) => entry.staffId === staffId);
const entry = {
requestId: existing?.requestId ?? `request-${staffId}`,
staffId,
displayName,
requestedAt: existing?.requestedAt ?? lastSeenAt,
lastSeenAt,
};
pending.set(entry.requestId, entry);
return structuredClone(entry);
},
removePendingSenderByStaffId: async (staffId) => {
const entry = [...pending.values()].find((value) => value.staffId === staffId);
if (!entry) return false;
pending.delete(entry.requestId);
return true;
},
},
};
}
test('bridge maps a DingTalk direct conversation to one persistent Harness session', async () => {
const fixture = stateFixture();
const sent = [];
const asked = [];
const status = createDingtalkBridgeStatus();
const bridge = new DingtalkHarnessBridge({
api: { sendText: async (request) => sent.push(request) },
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async (sessionId) => sessionId === 'session-one',
createSession: async () => 'session-one',
ask: async (sessionId, text) => {
asked.push({ sessionId, text });
return 'Harness 回答';
},
},
state: fixture.state,
status,
});
await Promise.all([
bridge.accept(message('one', '你好')),
bridge.accept(message('one', '重复消息')),
]);
await bridge.accept(message('two', '继续'));
assert.equal(fixture.sessions.get('p2p:staff-approved'), 'session-one');
assert.deepEqual(asked, [
{ sessionId: 'session-one', text: '你好' },
{ sessionId: 'session-one', text: '继续' },
]);
assert.deepEqual(sent.map(({ text, sessionWebhook }) => ({ text, sessionWebhook })), [
{ text: 'Harness 回答', sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=one' },
{ text: 'Harness 回答', sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=two' },
]);
assert.equal(status.messagesReceived, 2);
assert.equal(status.messagesReplied, 2);
assert.equal(status.stats.messagesReplied, 2);
});
test('senders in the bot visibility scope enter Harness without local approval', async () => {
const fixture = stateFixture();
const sent = [];
const asked = [];
const status = createDingtalkBridgeStatus();
const bridge = new DingtalkHarnessBridge({
api: { sendText: async (request) => sent.push(request) },
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => false,
createSession: async () => 'session-visible-sender',
ask: async (sessionId, text) => {
asked.push({ sessionId, text });
return '直接回答';
},
},
state: fixture.state,
status,
});
await bridge.accept(message('visible', '可见范围内的问题', {
senderStaffId: 'raw-staff-id',
senderNick: '可见范围用户',
}));
assert.deepEqual(asked, [{
sessionId: 'session-visible-sender',
text: '可见范围内的问题',
}]);
assert.equal(sent.length, 1);
assert.equal(sent[0].text, '直接回答');
assert.equal(fixture.pending.size, 0);
assert.deepEqual(status.pendingSenders, []);
assert.equal(status.messagesRejected, 0);
assert.equal(status.messagesReplied, 1);
});
test('group messages require an explicit bot mention before Harness work', async () => {
const fixture = stateFixture();
const sent = [];
const asked = [];
const bridge = new DingtalkHarnessBridge({
api: { sendText: async (request) => sent.push(request) },
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => true,
createSession: async () => 'session-group',
ask: async (_sessionId, text) => {
asked.push(text);
return '群聊回答';
},
},
state: fixture.state,
});
const group = {
conversationType: '2',
conversationId: 'group-one',
};
await bridge.accept(message('not-mentioned', '群聊噪音', { ...group, isInAtList: false }));
await bridge.accept(message('mentioned', '明确问题', { ...group, isInAtList: true }));
assert.deepEqual(asked, ['明确问题']);
assert.deepEqual(sent.map(({ text }) => text), ['群聊回答']);
assert.equal(bridge.status.messagesIgnored, 1);
assert.equal(fixture.sessions.get('group:group-one'), 'session-group');
});
test('bridge streams Harness snapshots into one DingTalk AI Card and finalizes it', async () => {
const fixture = stateFixture();
const calls = { create: [], update: [], finish: [], text: [] };
const bridge = new DingtalkHarnessBridge({
api: {
sendText: async (request) => calls.text.push(request),
createAiCard: async (request) => {
calls.create.push(request);
return { cardInstanceId: 'card-one' };
},
updateAiCard: async (request) => calls.update.push(request),
finishAiCard: async (request) => {
calls.finish.push(request);
return { delivered: true, completed: false };
},
},
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => false,
createSession: async () => 'session-stream',
ask: async (_sessionId, _text, options) => {
options.onUpdate({ type: 'text', text: '生成中的完整快照' });
await new Promise((resolve) => setTimeout(resolve, 510));
return '最终完整回答';
},
},
state: fixture.state,
});
await bridge.accept(message('stream', '请流式回答'));
assert.equal(calls.create.length, 1);
assert.deepEqual(calls.create[0].target, { type: 'user', userId: 'staff-approved' });
assert.equal(calls.update.at(-1).text, '生成中的完整快照');
assert.equal(calls.finish.length, 1);
assert.equal(calls.finish[0].text, '最终完整回答');
assert.equal(calls.text.length, 0);
assert.equal(bridge.status.messagesReplied, 1);
});
test('bridge asks Harness once and falls back to final text when AI Card creation fails', async () => {
const fixture = stateFixture();
const sent = [];
let asks = 0;
const bridge = new DingtalkHarnessBridge({
api: {
sendText: async (request) => sent.push(request.text),
createAiCard: async () => { throw new Error('card unavailable'); },
updateAiCard: async () => undefined,
finishAiCard: async () => undefined,
},
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
sessionExists: async () => false,
createSession: async () => 'session-fallback',
ask: async () => {
asks += 1;
return '文本降级回答';
},
},
state: fixture.state,
logger: { error() {} },
});
await bridge.accept(message('fallback', '卡片失败也要回答'));
assert.equal(asks, 1);
assert.deepEqual(sent, ['文本降级回答']);
assert.equal(bridge.status.messagesReplied, 1);
});
test('commands stay local and unsafe session webhooks are rejected before Harness', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-approved', 'old-session');
const sent = [];
let asked = 0;
const bridge = new DingtalkHarnessBridge({
api: { sendText: async (request) => sent.push(request.text) },
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
ask: async () => { asked += 1; },
},
state: fixture.state,
logger: { warn() {}, error() {} },
});
await bridge.accept(message('new', '/new'));
assert.equal(fixture.sessions.has('p2p:staff-approved'), false);
await bridge.accept(message('unsafe', '不应执行', {
sessionWebhook: 'https://oapi.dingtalk.com.attacker.example/reply?private=one',
}));
assert.equal(asked, 0);
assert.equal(sent[0], '已开启新会话。请发送你的问题。');
assert.equal(sent.length, 1);
assert.equal(bridge.status.lastError, '钉钉消息没有安全的回复地址。');
});

View file

@ -0,0 +1,199 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createDingTalkCardStream } from '../../../src/channels/dingtalk/dingtalk-card-stream.mjs';
function deferred() {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function scheduler() {
let now = 0;
const tasks = [];
return {
clock: () => now,
timer: {
setTimeout(callback, delay) {
const task = { callback, at: now + delay };
tasks.push(task);
return task;
},
clearTimeout(task) {
const index = tasks.indexOf(task);
if (index >= 0) tasks.splice(index, 1);
},
},
tasks,
async advance(milliseconds) {
now += milliseconds;
while (true) {
tasks.sort((left, right) => left.at - right.at);
const task = tasks[0];
if (!task || task.at > now) break;
tasks.shift();
task.callback();
await new Promise((resolve) => setImmediate(resolve));
}
},
};
}
function fixture(overrides = {}) {
const calls = { create: [], update: [], finish: [], fail: [] };
return {
calls,
api: {
async createAiCard(request) {
calls.create.push(request);
return { cardInstanceId: 'card-one' };
},
async updateAiCard(request) {
calls.update.push(request);
},
async finishAiCard(request) {
calls.finish.push(request);
},
async failAiCard(request) {
calls.fail.push(request);
},
...overrides,
},
};
}
function createStream(api, options = {}) {
return createDingTalkCardStream({
api,
clientId: 'ding-client',
clientSecret: 'host-secret',
target: { openConversationId: 'conversation-one' },
updateIntervalMs: 0,
...options,
});
}
test('finish waits for an in-flight update and discards stale pending progress', async () => {
const activeUpdate = deferred();
const fixtureValue = fixture({
async updateAiCard(request) {
fixtureValue.calls.update.push(request);
await activeUpdate.promise;
},
});
const stream = createStream(fixtureValue.api, { logger: { error() {} } });
assert.equal(await stream.start('正在处理'), true);
stream.push('第一段');
stream.push('应丢弃的旧进度');
const finishing = stream.finish('最终答案');
assert.equal(fixtureValue.calls.finish.length, 0);
activeUpdate.resolve();
assert.equal(await finishing, true);
assert.deepEqual(fixtureValue.calls.create, [{
clientId: 'ding-client',
clientSecret: 'host-secret',
target: { openConversationId: 'conversation-one' },
initialText: '正在处理',
signal: undefined,
}]);
assert.deepEqual(fixtureValue.calls.update.map(({ text, finished }) => ({ text, finished })), [
{ text: '第一段', finished: false },
]);
assert.deepEqual(fixtureValue.calls.finish.map(({ text }) => text), ['最终答案']);
});
test('progress buffering sends only the latest text after the throttle delay', async () => {
const timers = scheduler();
const fixtureValue = fixture();
const stream = createStream(fixtureValue.api, {
updateIntervalMs: 800,
clock: timers.clock,
timer: timers.timer,
});
await stream.start('开始');
stream.push('草稿一');
stream.push('草稿二');
stream.push('草稿三');
assert.equal(timers.tasks.length, 1);
await timers.advance(799);
assert.equal(fixtureValue.calls.update.length, 0);
await timers.advance(1);
assert.deepEqual(fixtureValue.calls.update.map(({ text }) => text), ['草稿三']);
assert.equal(await stream.finish('完成'), true);
});
test('an API failure permanently closes the stream without exposing request data', async () => {
const logged = [];
const fixtureValue = fixture({
async updateAiCard(request) {
fixtureValue.calls.update.push(request);
throw new Error(`remote rejected ${request.clientSecret}`);
},
});
const stream = createStream(fixtureValue.api, {
logger: { error: (...args) => logged.push(args) },
});
await stream.start('开始');
stream.push('失败的更新');
assert.equal(await stream.finish('不会发送'), false);
stream.push('失败后忽略');
assert.equal(await stream.finish('仍然不会发送'), false);
assert.equal(fixtureValue.calls.update.length, 1);
assert.equal(fixtureValue.calls.finish.length, 0);
assert.equal(fixtureValue.calls.fail.length, 1);
assert.deepEqual(logged, [['[dsh-dingtalk] AI Card update failed']]);
assert.doesNotMatch(JSON.stringify(logged), /host-secret|conversation-one/);
});
test('abort cancels a scheduled update and prevents finalization', async () => {
const timers = scheduler();
const controller = new AbortController();
const fixtureValue = fixture();
const stream = createStream(fixtureValue.api, {
signal: controller.signal,
updateIntervalMs: 800,
clock: timers.clock,
timer: timers.timer,
});
assert.equal(await stream.start('开始'), true);
stream.push('等待发送');
assert.equal(timers.tasks.length, 1);
controller.abort();
assert.equal(timers.tasks.length, 0);
await timers.advance(800);
assert.equal(fixtureValue.calls.update.length, 0);
assert.equal(await stream.finish('不会完成'), false);
assert.equal(fixtureValue.calls.finish.length, 0);
assert.equal(fixtureValue.calls.fail.length, 1);
});
test('a failed final frame closes the delivered card once and requests text fallback', async () => {
const fixtureValue = fixture({
async finishAiCard(request) {
fixtureValue.calls.finish.push(request);
throw new Error('final frame rejected');
},
});
const stream = createStream(fixtureValue.api, { logger: { error() {} } });
assert.equal(await stream.start('开始'), true);
assert.equal(await stream.finish('最终答案'), false);
assert.equal(fixtureValue.calls.finish.length, 1);
assert.equal(fixtureValue.calls.fail.length, 1);
assert.equal(fixtureValue.calls.fail[0].text, '消息处理失败,请稍后重试。');
assert.notEqual(fixtureValue.calls.fail[0].signal, fixtureValue.calls.finish[0].signal);
});

View file

@ -0,0 +1,428 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
deriveDingtalkBotIdentity,
} from '../../../src/channels/dingtalk/config-store.mjs';
import { DingtalkController } from '../../../src/channels/dingtalk/dingtalk-controller.mjs';
const flush = () => new Promise((resolve) => setImmediate(resolve));
function credentialsFixture(events = []) {
const values = new Map();
return {
values,
provider: {
resolve: async (ref) => values.has(ref)
? { configured: true, source: 'settings', value: values.get(ref) }
: { configured: false },
set: async (ref, value) => {
events.push(['set', ref]);
values.set(ref, value);
},
unset: async (ref) => {
events.push(['unset', ref]);
values.delete(ref);
},
},
};
}
function configFixture(initial = [], events = []) {
const bots = new Map(initial.map((bot) => [bot.botId, structuredClone(bot)]));
return {
bots,
store: {
list: () => [...bots.values()].map((bot) => structuredClone(bot)),
get: (botId) => bots.has(botId) ? structuredClone(bots.get(botId)) : null,
getByClientId: (clientId) => {
const found = [...bots.values()].find((bot) => bot.clientId === clientId);
return found ? structuredClone(found) : null;
},
save: async (bot) => {
events.push(['save', bot.botId]);
bots.set(bot.botId, structuredClone(bot));
return structuredClone(bot);
},
remove: async (botId) => {
events.push(['remove', botId]);
const value = bots.get(botId) ?? null;
bots.delete(botId);
return value;
},
},
};
}
function runtimeFactory({ events = [], pendingByClient = new Map(), failStart = false } = {}) {
const runtimes = [];
return {
runtimes,
createRuntime: async ({ botId, config, clientSecret }) => {
events.push(['create', botId]);
let ready = false;
const approvedIds = new Set(config.approvedSenders.map((sender) => sender.staffId));
const pending = (pendingByClient.get(config.clientId) ?? [])
.filter((sender) => !approvedIds.has(sender.staffId));
const runtime = {
botId,
config,
clientSecret,
get status() {
return {
ready,
state: ready ? 'connected' : 'offline',
pendingSenders: pending,
messagesReceived: 2,
messagesReplied: 1,
};
},
pendingSender(requestId) {
return pending.find((sender) => sender.requestId === requestId) ?? null;
},
async start() {
events.push(['start', botId]);
if (typeof failStart === 'function' ? failStart() : failStart) {
throw new Error('runtime failure containing private-secret');
}
ready = true;
},
async stop() {
events.push(['stop', botId]);
ready = false;
},
};
runtimes.push(runtime);
return runtime;
},
};
}
function successfulDeviceAuth(clientId = 'ding-client-private', clientSecret = 'client-secret-private') {
return {
start: async () => ({
deviceCode: 'device-code-private',
verificationUrl: 'https://oapi.dingtalk.com/verify?code=public-qr-code',
expiresAt: 100_000,
pollIntervalMs: 1_000,
}),
poll: async ({ deviceCode }) => {
assert.equal(deviceCode, 'device-code-private');
return { status: 'SUCCESS', clientId, clientSecret };
},
};
}
test('successful QR poll stores secret then config then starts runtime without public leakage', async () => {
const events = [];
const credentials = credentialsFixture(events);
const configs = configFixture([], events);
const runtimes = runtimeFactory({ events });
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
clock: () => 1_000,
});
const begun = await controller.startProvisioning();
assert.equal(begun.status, 'pending');
assert.doesNotMatch(JSON.stringify(begun), /device-code-private|client-secret-private|secretRef/);
const completed = await controller.registrationStatus(begun.attemptId);
assert.equal(completed.status, 'connected');
assert.match(completed.botId, /^dt_[a-f0-9]{24}$/);
assert.deepEqual(events.slice(0, 4).map(([event]) => event), ['set', 'save', 'create', 'start']);
assert.equal(credentials.values.size, 1);
assert.equal([...credentials.values.values()][0], 'client-secret-private');
assert.equal(configs.bots.size, 1);
assert.equal(runtimes.runtimes[0].clientSecret, 'client-secret-private');
const publicJson = JSON.stringify(controller.status());
assert.doesNotMatch(
publicJson,
/device-code-private|client-secret-private|secretRef|ding-client-private/,
);
assert.equal(controller.status().totals.connected, 1);
await controller.close();
});
test('scanning the same client ID is idempotent and replaces its one runtime', async () => {
const events = [];
const credentials = credentialsFixture(events);
const configs = configFixture([], events);
const runtimes = runtimeFactory({ events });
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
clock: () => 1_000,
});
const first = await controller.startProvisioning();
await controller.registrationStatus(first.attemptId);
const second = await controller.startProvisioning();
const completed = await controller.registrationStatus(second.attemptId);
assert.equal(completed.alreadyConnected, true);
assert.equal(configs.bots.size, 1);
assert.equal(credentials.values.size, 1);
assert.equal(controller.status().bots.length, 1);
assert.equal(runtimes.runtimes.length, 2);
assert.equal(runtimes.runtimes[0].status.ready, false);
assert.equal(runtimes.runtimes[1].status.ready, true);
await controller.close();
});
test('sender approval uses opaque request and sender keys while raw staff IDs stay host-only', async () => {
const events = [];
const identity = deriveDingtalkBotIdentity('ding-client-one');
const config = {
...identity,
clientId: 'ding-client-one',
approvedSenders: [],
};
const credentials = credentialsFixture(events);
credentials.values.set(identity.secretRef, 'client-secret-private');
const configs = configFixture([config], events);
const pendingByClient = new Map([[
'ding-client-one',
[{
staffId: 'staff-private-one',
senderNick: '待审批用户',
requestedAt: '2026-08-15T00:00:00.000Z',
requestId: 'ding_sender_abc123',
}],
]]);
const runtimes = runtimeFactory({ events, pendingByClient });
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
clock: () => Date.parse('2026-08-15T01:00:00.000Z'),
});
await controller.initialize();
const pending = controller.status().bots[0].senders.pending[0];
assert.equal(pending.requestId, 'ding_sender_abc123');
assert.equal(pending.displayName, '待审批用户');
assert.doesNotMatch(JSON.stringify(controller.status()), /staff-private-one|secretRef/);
await controller.approveSender(identity.botId, pending.requestId);
const approvedStatus = controller.status().bots[0].senders;
assert.equal(approvedStatus.pending.length, 0);
assert.equal(approvedStatus.approved.length, 1);
assert.match(approvedStatus.approved[0].senderKey, /^dt_sender_[a-f0-9]{32}$/);
assert.equal(approvedStatus.approved[0].approvedAt, '2026-08-15T01:00:00.000Z');
assert.equal(configs.bots.get(identity.botId).approvedSenders[0].staffId, 'staff-private-one');
assert.doesNotMatch(JSON.stringify(controller.status()), /staff-private-one|client-secret-private/);
await controller.revokeSender(identity.botId, approvedStatus.approved[0].senderKey);
assert.equal(configs.bots.get(identity.botId).approvedSenders.length, 0);
assert.equal(controller.status().bots[0].senders.approved.length, 0);
await controller.close();
});
test('runtime activation failure retains the authorized bot for reconnect without exposing detail', async () => {
let startCount = 0;
const events = [];
const credentials = credentialsFixture(events);
const configs = configFixture([], events);
const runtimes = runtimeFactory({ events, failStart: () => startCount++ === 0 });
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
logger: { error() {}, warn() {} },
clock: () => 1_000,
});
const begun = await controller.startProvisioning();
const completed = await controller.registrationStatus(begun.attemptId);
assert.equal(completed.status, 'connected');
assert.equal(credentials.values.size, 1);
assert.equal(configs.bots.size, 1);
const status = controller.status();
assert.deepEqual(status.totals, { configured: 1, connected: 0 });
assert.equal(status.bots[0].state, 'error');
assert.equal(status.bots[0].error.code, 'connection-failed');
assert.equal(events.some(([event]) => event === 'unset' || event === 'remove'), false);
assert.doesNotMatch(
JSON.stringify({ completed, status }),
/private-secret|client-secret-private|device-code-private|secretRef/,
);
const reconnected = await controller.reconnectBot(completed.botId);
assert.deepEqual(reconnected.totals, { configured: 1, connected: 1 });
assert.equal(reconnected.bots[0].state, 'connected');
assert.equal(reconnected.bots[0].error, null);
assert.equal(credentials.values.size, 1);
assert.equal(configs.bots.size, 1);
await controller.close();
});
test('config persistence failure rolls back the newly stored credential', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: {
...configs.store,
save: async () => {
throw new Error('config failure containing private-secret');
},
},
createRuntime: runtimeFactory().createRuntime,
logger: { error() {}, warn() {} },
clock: () => 1_000,
});
const begun = await controller.startProvisioning();
const failed = await controller.registrationStatus(begun.attemptId);
assert.equal(failed.status, 'failed');
assert.equal(failed.error.code, 'activation-failed');
assert.equal(credentials.values.size, 0);
assert.equal(configs.bots.size, 0);
assert.doesNotMatch(JSON.stringify(failed), /private-secret|client-secret-private|device-code-private/);
await controller.close();
});
test('cancelling while the persisted bot is starting rolls its binding back', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
let enteredStart;
let releaseStart;
const startEntered = new Promise((resolve) => { enteredStart = resolve; });
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: async () => ({
status: { ready: false },
start: async () => new Promise((resolve) => {
releaseStart = resolve;
enteredStart();
}),
stop: async () => {},
}),
logger: { error() {}, warn() {} },
clock: () => 1_000,
});
const begun = await controller.startProvisioning();
const polling = controller.registrationStatus(begun.attemptId);
await startEntered;
assert.equal(credentials.values.size, 1);
assert.equal(configs.bots.size, 1);
const cancelling = controller.cancelProvisioning(begun.attemptId);
releaseStart();
const [cancelled, polled] = await Promise.all([cancelling, polling]);
assert.equal(cancelled.status, 'cancelled');
assert.equal(polled.status, 'cancelled');
assert.equal(credentials.values.size, 0);
assert.equal(configs.bots.size, 0);
await controller.close();
});
test('cancelling an in-flight poll aborts it and writes no credential', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const controller = new DingtalkController({
deviceAuth: {
start: successfulDeviceAuth().start,
poll: async ({ signal }) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimeFactory().createRuntime,
clock: () => 1_000,
});
const begun = await controller.startProvisioning();
const polling = controller.registrationStatus(begun.attemptId);
await flush();
const cancelled = await controller.cancelProvisioning(begun.attemptId);
assert.equal(cancelled.status, 'cancelled');
assert.equal((await polling).status, 'cancelled');
assert.equal(credentials.values.size, 0);
assert.equal(configs.bots.size, 0);
await controller.close();
});
test('initialize manages multiple configured bot runtimes independently', async () => {
const firstIdentity = deriveDingtalkBotIdentity('ding-client-one');
const secondIdentity = deriveDingtalkBotIdentity('ding-client-two');
const configs = configFixture([
{ ...firstIdentity, clientId: 'ding-client-one', approvedSenders: [] },
{ ...secondIdentity, clientId: 'ding-client-two', approvedSenders: [] },
]);
const credentials = credentialsFixture();
credentials.values.set(firstIdentity.secretRef, 'secret-one');
credentials.values.set(secondIdentity.secretRef, 'secret-two');
const runtimes = runtimeFactory();
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
});
const status = await controller.initialize();
assert.deepEqual(status.totals, { configured: 2, connected: 2 });
assert.equal(runtimes.runtimes.length, 2);
await controller.close();
});
test('close waits for an in-flight transition and leaves no connected runtime behind', async () => {
const identity = deriveDingtalkBotIdentity('ding-client-close');
const configs = configFixture([{
...identity,
clientId: 'ding-client-close',
approvedSenders: [],
}]);
const credentials = credentialsFixture();
credentials.values.set(identity.secretRef, 'secret-close');
let rejectStart;
let stops = 0;
const controller = new DingtalkController({
deviceAuth: successfulDeviceAuth(),
credentials: credentials.provider,
configStore: configs.store,
createRuntime: async () => ({
status: { ready: false },
start: async () => new Promise((_, reject) => { rejectStart = reject; }),
stop: async () => {
stops += 1;
rejectStart?.(new DOMException('Runtime stopped', 'AbortError'));
},
}),
logger: { warn() {}, error() {} },
});
const initializing = controller.initialize();
await flush();
const closing = controller.close();
await Promise.race([
closing,
new Promise((_, reject) => setTimeout(
() => reject(new Error('controller close did not stop the provisional runtime')),
100,
)),
]);
await Promise.allSettled([initializing, closing]);
assert.ok(stops >= 1);
assert.equal(controller.status().totals.connected, 0);
await assert.rejects(
controller.reconnectBot(identity.botId),
/controller is closed/,
);
});

View file

@ -0,0 +1,368 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DingtalkRuntime } from '../../../src/channels/dingtalk/dingtalk-runtime.mjs';
async function eventually(predicate, timeoutMs = 1_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.fail('condition did not become true');
}
function stateFixture() {
const sessions = new Map();
const seen = new Set();
const pending = new Map();
return {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: (key) => sessions.get(key) ?? null,
setSession: async (key, value) => sessions.set(key, value),
clearSession: async (key) => sessions.delete(key),
pendingSenders: () => [...pending.values()],
pendingSender: (requestId) => pending.get(requestId) ?? null,
recordPendingSender: async ({ staffId, displayName, lastSeenAt }) => {
const entry = {
requestId: `request-${staffId}`,
staffId,
displayName,
requestedAt: lastSeenAt,
lastSeenAt,
};
pending.set(entry.requestId, entry);
return entry;
},
removePendingSenderByStaffId: async (staffId) => {
const entry = [...pending.values()].find((value) => value.staffId === staffId);
if (!entry) return false;
pending.delete(entry.requestId);
return true;
},
};
}
test('runtime owns one DWClient, waits for socket OPEN, acknowledges first, and disconnects on stop', async () => {
const order = [];
const state = stateFixture();
await state.recordPendingSender({
staffId: 'staff-approved',
displayName: '已批准用户',
lastSeenAt: '2026-08-15T01:00:00.000Z',
});
let callback;
const client = {
connected: false,
socket: { readyState: 0 },
registerCallbackListener(topic, listener) {
order.push(['register', topic]);
callback = listener;
},
async connect() {
order.push(['connect']);
setTimeout(() => {
this.connected = true;
this.socket.readyState = 1;
}, 10);
},
socketCallBackResponse(messageId, body) {
order.push(['ack', messageId, body]);
},
disconnect() {
order.push(['disconnect']);
this.connected = false;
this.socket.readyState = 3;
},
};
const runtime = new DingtalkRuntime({
config: {
clientId: 'ding-client',
approvedSenders: [{ staffId: 'staff-approved' }],
},
clientSecret: 'host-secret',
harness: {
ensureRunning: async () => order.push(['harness-ready']),
sessionExists: async () => true,
createSession: async () => 'session-one',
ask: async () => {
order.push(['ask']);
return 'Harness 回答';
},
},
state,
api: {
sendText: async ({ text }) => order.push(['send', text]),
},
streamFactory: async ({ clientId, clientSecret }) => {
assert.equal(clientId, 'ding-client');
assert.equal(clientSecret, 'host-secret');
return { client, topic: 'robot-topic' };
},
connectPollIntervalMs: 2,
});
const started = await runtime.start();
assert.equal(started.ready, true);
assert.equal(started.dingtalkStreamState, 'connected');
assert.deepEqual(started.pendingSenders, []);
assert.deepEqual(order.slice(0, 3), [
['harness-ready'],
['register', 'robot-topic'],
['connect'],
]);
callback({
headers: { messageId: 'callback-one' },
data: JSON.stringify({
msgId: 'business-one',
msgtype: 'text',
text: { content: '问题' },
conversationType: '1',
senderStaffId: 'staff-approved',
senderNick: '用户',
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=one',
}),
});
assert.deepEqual(order.at(-1), ['ack', 'callback-one', { success: true }]);
await eventually(() => runtime.status.messagesReplied === 1);
assert.ok(order.findIndex(([action]) => action === 'ack') < order.findIndex(([action]) => action === 'ask'));
assert.ok(order.findIndex(([action]) => action === 'ack') < order.findIndex(([action]) => action === 'send'));
await runtime.stop();
assert.deepEqual(order.at(-1), ['disconnect']);
assert.equal(runtime.status.ready, false);
assert.equal(runtime.status.dingtalkStreamState, 'idle');
});
test('runtime sends visible-scope messages to Harness without local sender approval', async () => {
const state = stateFixture();
const asked = [];
const sent = [];
let callback;
const client = {
connected: true,
socket: { readyState: 1 },
registerCallbackListener(_topic, listener) { callback = listener; },
async connect() {},
socketCallBackResponse() {},
disconnect() {},
};
const runtime = new DingtalkRuntime({
config: { clientId: 'ding-client', approvedSenders: [] },
clientSecret: 'host-secret',
harness: {
ensureRunning: async () => true,
sessionExists: async () => false,
createSession: async () => 'session-visible-sender',
ask: async (sessionId, text) => {
asked.push({ sessionId, text });
return '直接回答';
},
},
state,
api: { sendText: async ({ text }) => sent.push(text) },
streamFactory: async () => ({ client, topic: 'robot-topic' }),
});
await runtime.start();
callback({
headers: { messageId: 'callback-pending' },
data: JSON.stringify({
msgId: 'business-pending',
msgtype: 'text',
text: { content: '请求使用' },
conversationType: '1',
senderStaffId: 'raw-staff-id',
senderNick: '可见范围用户',
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=pending',
}),
});
await eventually(() => runtime.status.messagesReplied === 1);
assert.deepEqual(asked, [{
sessionId: 'session-visible-sender',
text: '请求使用',
}]);
assert.deepEqual(sent, ['直接回答']);
assert.deepEqual(runtime.pendingSenders(), []);
await runtime.stop();
});
test('runtime accepts an OPEN DingTalk socket when the SDK registered flag remains false', async () => {
const client = {
connected: true,
registered: false,
socket: { readyState: 1 },
registerCallbackListener() {},
async connect() {},
socketCallBackResponse() {},
disconnect() {},
};
const runtime = new DingtalkRuntime({
config: { clientId: 'ding-client', approvedSenders: [] },
clientSecret: 'host-secret',
harness: { ensureRunning: async () => true },
state: stateFixture(),
api: { sendText: async () => true },
streamFactory: async () => ({ client, topic: 'robot-topic' }),
});
assert.equal((await runtime.start()).ready, true);
assert.equal(client.registered, false);
await runtime.stop();
});
test('runtime never reports ready when connect resolves before the socket opens permanently', async () => {
let disconnects = 0;
const client = {
connected: false,
socket: { readyState: 0 },
registerCallbackListener() {},
async connect() {},
socketCallBackResponse() {},
disconnect() { disconnects += 1; },
};
const runtime = new DingtalkRuntime({
config: { clientId: 'ding-client', approvedSenders: [] },
clientSecret: 'host-secret',
harness: { ensureRunning: async () => true },
state: stateFixture(),
api: { sendText: async () => true },
streamFactory: async () => ({ client, topic: 'robot-topic' }),
connectTimeoutMs: 15,
connectPollIntervalMs: 2,
logger: { warn() {}, error() {} },
});
await assert.rejects(runtime.start(), /handshake timed out/);
assert.equal(disconnects, 1);
assert.equal(runtime.status.ready, false);
assert.equal(runtime.status.dingtalkStreamState, 'failed');
});
test('runtime bounds a stalled SDK gateway lookup and disconnects a late connection', async () => {
let finishConnect;
let disconnects = 0;
const client = {
connected: false,
socket: { readyState: 0 },
registerCallbackListener() {},
connect: async () => new Promise((resolve) => { finishConnect = resolve; }),
socketCallBackResponse() {},
disconnect() { disconnects += 1; },
};
const runtime = new DingtalkRuntime({
config: { clientId: 'ding-client', approvedSenders: [] },
clientSecret: 'host-secret',
harness: { ensureRunning: async () => true },
state: stateFixture(),
api: { sendText: async () => true },
streamFactory: async () => ({ client, topic: 'robot-topic' }),
connectTimeoutMs: 10,
connectPollIntervalMs: 2,
logger: { warn() {}, error() {} },
});
await assert.rejects(runtime.start(), /handshake timed out after 10ms/);
assert.equal(disconnects, 1);
finishConnect();
await eventually(() => disconnects === 2);
assert.equal(runtime.status.ready, false);
assert.equal(runtime.status.dingtalkStreamState, 'failed');
});
test('a callback from a stopped stream is not acknowledged or processed', async () => {
const events = [];
let callback;
const client = {
connected: true,
socket: { readyState: 1 },
registerCallbackListener(_topic, listener) { callback = listener; },
async connect() {},
socketCallBackResponse() { events.push('ack'); },
disconnect() { this.connected = false; this.socket.readyState = 3; },
};
const runtime = new DingtalkRuntime({
config: { clientId: 'ding-client', approvedSenders: [{ staffId: 'approved' }] },
clientSecret: 'host-secret',
harness: { ensureRunning: async () => true, ask: async () => events.push('ask') },
state: stateFixture(),
api: { sendText: async () => events.push('send') },
streamFactory: async () => ({ client, topic: 'robot-topic' }),
});
await runtime.start();
await runtime.stop();
callback({
headers: { messageId: 'late-callback' },
data: JSON.stringify({
msgId: 'late-message',
msgtype: 'text',
text: { content: '不应处理' },
conversationType: '1',
senderStaffId: 'approved',
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=late',
}),
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(events, []);
});
test('stop aborts in-flight Harness work without waiting for the reply timeout', async () => {
let callback;
let askStarted;
const askStartedPromise = new Promise((resolve) => { askStarted = resolve; });
const client = {
connected: true,
socket: { readyState: 1 },
registerCallbackListener(_topic, listener) { callback = listener; },
async connect() {},
socketCallBackResponse() {},
disconnect() { this.connected = false; this.socket.readyState = 3; },
};
const runtime = new DingtalkRuntime({
config: { clientId: 'ding-client', approvedSenders: [{ staffId: 'approved' }] },
clientSecret: 'host-secret',
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
createSession: async () => 'session-one',
ask: async (_sessionId, _text, { signal }) => {
askStarted();
await new Promise((resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
},
},
state: stateFixture(),
api: { sendText: async () => true },
streamFactory: async () => ({ client, topic: 'robot-topic' }),
logger: { warn() {}, error() {} },
});
await runtime.start();
callback({
headers: { messageId: 'callback-hanging' },
data: JSON.stringify({
msgId: 'business-hanging',
msgtype: 'text',
text: { content: '长时间问题' },
conversationType: '1',
senderStaffId: 'approved',
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=hanging',
}),
});
await askStartedPromise;
await Promise.race([
runtime.stop(),
new Promise((_, reject) => setTimeout(
() => reject(new Error('runtime stop did not abort Harness work')),
100,
)),
]);
assert.equal(runtime.status.ready, false);
assert.equal(runtime.status.dingtalkStreamState, 'idle');
});

View file

@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { HarnessClient, HarnessReplyTracker, HarnessRpcError } from '../../../src/channels/dingtalk/harness-client.mjs';
test('reply tracker associates only the Harness turn created by the DingTalk prompt RPC', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'dingtalk-prompt', afterSeq: 2 });
const update = tracker.consume([
{ event: { seq: 3, type: 'turn/start', data: { turn: 9 } } },
{ event: {
seq: 4,
type: 'user/message',
data: { turn: 9, source: { rpcId: 'dingtalk-prompt' } },
} },
{ event: {
seq: 5,
type: 'assistant/chunk',
data: { turn: 9, step: 0, chunk: { type: 'text-delta', index: 0, text: '钉钉' } },
} },
]);
assert.deepEqual(update, { type: 'text', text: '钉钉' });
tracker.consume([
{ event: {
seq: 6,
type: 'assistant/message',
data: { turn: 9, message: { content: [{ type: 'text', text: '钉钉回复完成' }] } },
} },
{ event: { seq: 7, type: 'turn/end', data: { turn: 9, reason: 'completed' } } },
]);
assert.equal(tracker.finished, true);
assert.equal(tracker.answer, '钉钉回复完成');
});
test('reply tracker ignores interleaved turns and events at or before the baseline', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'target', afterSeq: 10 });
tracker.consume([
{ event: { seq: 9, type: 'turn/start', data: { turn: 1 } } },
{ event: { seq: 11, type: 'turn/start', data: { turn: 2 } } },
{ event: { seq: 12, type: 'user/message', data: { turn: 2, source: { rpcId: 'other' } } } },
{ event: {
seq: 13,
type: 'assistant/message',
data: { turn: 2, message: { content: [{ type: 'text', text: 'wrong' }] } },
} },
]);
assert.equal(tracker.answer, '');
assert.equal(tracker.finished, false);
});
test('Harness client validates the RPC envelope and preserves server error codes', async () => {
let request;
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/workspace',
fetchImpl: async (url, options) => {
request = { url: url.toString(), body: JSON.parse(options.body) };
return {
ok: true,
json: async () => ({
type: 'server-response',
rpcId: request.body.rpcId,
result: { ok: false, error: { code: 'session-not-found', message: 'missing' } },
}),
};
},
});
await assert.rejects(
client.rpc('session.history', { sessionId: 'one' }),
(error) => error instanceof HarnessRpcError && error.code === 'session-not-found',
);
assert.equal(request.url, 'http://127.0.0.1:3080/api/session.history');
assert.match(request.body.rpcId, /^dingtalk-/);
});

View file

@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { apply, createDingtalkHostPlugin, inject, name } from '../../../plugin-src/host/channels/dingtalk/index.mjs';
function controller() {
return {
status() { return { bots: [] }; },
startProvisioning() {},
registrationStatus() {},
cancelProvisioning() {},
reconnectBot() {},
deleteBot() {},
approveSender() {},
revokeSender() {},
};
}
test('Host exports the DingTalk plugin identity and required services', () => {
const plugin = createDingtalkHostPlugin({ controller: controller() });
assert.equal(name, 'dsh-dingtalk-host');
assert.deepEqual(inject, ['connection', 'credentials', 'webServer']);
assert.equal(plugin.name, name);
assert.deepEqual(plugin.inject, inject);
});
test('Host installs loopback RPC for an injected controller', async () => {
const calls = [];
const dispose = () => {};
const ctx = {
connection: {
rpc: {
handle: (...args) => {
calls.push(args);
return dispose;
},
},
},
};
assert.equal(await apply(ctx, { controller: controller() }), dispose);
assert.equal(calls[0][0], '/dingtalk');
assert.deepEqual(calls[0][2], { authority: 'loopback' });
});

View file

@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { createProductionController } from '../../../plugin-src/host/channels/dingtalk/production.mjs';
test('production assembly keeps secrets in credentials and creates per-bot runtimes', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-dingtalk-production-'));
const seen = {};
class ConfigStore {
constructor(path) { seen.configPath = path; }
async load() { return this; }
}
class DeviceAuth {
constructor(options) { seen.deviceAuthOptions = options; }
}
class StateStore {
constructor(path) { seen.statePath = path; }
async load() { return this; }
}
class Harness {
constructor(options) { seen.harnessOptions = options; }
stopManagedProcess() { seen.harnessStopped = true; }
}
class Runtime {
constructor(options) { seen.runtimeOptions = options; }
}
class Controller {
constructor(options) {
seen.controllerOptions = options;
this.status = () => ({ totals: { configured: 0, connected: 0 } });
this.initialize = async () => this.status();
}
async close() { seen.controllerClosed = true; }
}
const supervisor = {
ready: Promise.resolve(null),
start() { return this; },
async close() { seen.supervisorClosed = true; },
};
const credentials = {};
const production = await createProductionController({
credentials,
webServer: { port: 3080 },
logger: () => console,
}, { dataDir: directory }, {
ConfigStore,
DeviceAuth,
StateStore,
HarnessClient: Harness,
Controller,
Runtime,
createConnectionSupervisor: () => supervisor,
});
assert.equal(seen.controllerOptions.credentials, credentials);
assert.equal(seen.harnessOptions.baseUrl.href, 'http://127.0.0.1:3080/');
assert.equal(seen.harnessOptions.autostart, false);
const runtime = await seen.controllerOptions.createRuntime({
botId: 'dt_abc',
config: { botId: 'dt_abc', clientId: 'dingabc' },
clientSecret: 'host-only-secret',
});
assert.ok(runtime instanceof Runtime);
assert.equal(seen.runtimeOptions.clientSecret, 'host-only-secret');
assert.match(seen.statePath, /dt_abc\/state\.json$/);
await production.close();
assert.equal(seen.supervisorClosed, true);
assert.equal(seen.controllerClosed, true);
assert.equal(seen.harnessStopped, true);
});

View file

@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DINGTALK_ENDPOINTS,
createDingtalkRpcHandler,
installDingtalkRpc,
} from '../../../plugin-src/host/channels/dingtalk/rpc.mjs';
function controller(overrides = {}) {
return {
status: async () => ({ bots: [] }),
startProvisioning: async () => ({
attemptId: 'attempt_1',
status: 'pending',
verificationUrl: 'https://open-dev.dingtalk.com/registration',
deviceCode: 'must-not-leak',
secretRef: 'must-not-leak',
}),
registrationStatus: async () => ({ attemptId: 'attempt_1', status: 'pending' }),
cancelProvisioning: async () => ({ attemptId: 'attempt_1', status: 'cancelled' }),
reconnectBot: async () => ({ bots: [] }),
deleteBot: async () => ({ bots: [] }),
approveSender: async () => ({ bots: [] }),
revokeSender: async () => ({ bots: [] }),
...overrides,
};
}
test('RPC encodes QR on the Host and strips all credential material', async () => {
const handler = createDingtalkRpcHandler(controller(), {
encodeQr: async (url) => `data:image/png;base64,${Buffer.from(url).toString('base64')}`,
});
const result = await handler(DINGTALK_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' });
assert.equal(result.ok, true);
assert.match(result.value.qrCodeDataUrl, /^data:image\/png;base64,/);
assert.equal('verificationUrl' in result.value, false);
assert.equal('deviceCode' in result.value, false);
assert.equal('secretRef' in result.value, false);
});
test('status re-encodes an active QR without exposing its authorization URL', async () => {
const handler = createDingtalkRpcHandler(controller({
status: async () => ({
bots: [],
provisioning: {
attemptId: 'attempt_1',
status: 'pending',
verificationUrl: 'https://open-dev.dingtalk.com/registration',
},
}),
}), {
encodeQr: async () => 'data:image/png;base64,AAAA',
});
const result = await handler(DINGTALK_ENDPOINTS.status, {});
assert.equal(result.ok, true);
assert.equal(result.value.provisioning.qrCodeDataUrl, 'data:image/png;base64,AAAA');
assert.equal('verificationUrl' in result.value.provisioning, false);
});
test('RPC validates mutating requests before invoking the controller', async () => {
let calls = 0;
const handler = createDingtalkRpcHandler(controller({
approveSender: async () => { calls += 1; },
}));
const rejected = await handler(DINGTALK_ENDPOINTS.approveSender, {
botId: 'dt_abc', requestId: 'request_1', confirm: false,
});
assert.equal(rejected.ok, false);
assert.equal(rejected.error.code, 'bad-request');
assert.equal(calls, 0);
});
test('RPC is registered for loopback clients only', () => {
const registrations = [];
const dispose = () => {};
const ctx = {
connection: {
rpc: {
handle: (...args) => {
registrations.push(args);
return dispose;
},
},
},
};
assert.equal(installDingtalkRpc(ctx, controller()), dispose);
assert.equal(registrations[0][0], '/dingtalk');
assert.deepEqual(registrations[0][2], { authority: 'loopback' });
});

View file

@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { DingtalkStateStore } from '../../../src/channels/dingtalk/state-store.mjs';
test('state store persists sessions, dedupe IDs, and host-only pending sender records atomically', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-dingtalk-state-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const path = join(directory, 'bot.json');
let currentTime = '2026-08-15T01:00:00.000Z';
const store = await new DingtalkStateStore(path, {
idFactory: () => 'fixed-id',
now: () => currentTime,
}).load();
await store.setSession('p2p:staff-one', 'session-one');
await store.markSeen('message-one');
const first = await store.recordPendingSender({
staffId: 'staff-one',
displayName: '小明',
sessionWebhook: 'https://oapi.dingtalk.com/reply?secret=must-not-persist',
});
currentTime = '2026-08-15T01:05:00.000Z';
const updated = await store.recordPendingSender({ staffId: 'staff-one', displayName: '小明新名字' });
assert.equal(store.sessionFor('p2p:staff-one'), 'session-one');
assert.equal(store.hasSeen('message-one'), true);
assert.equal(first.requestId, 'ding_sender_fixed-id');
assert.equal(updated.requestId, first.requestId);
assert.equal(updated.requestedAt, '2026-08-15T01:00:00.000Z');
assert.equal(updated.lastSeenAt, '2026-08-15T01:05:00.000Z');
assert.deepEqual(store.pendingSender(first.requestId), updated);
const storedText = await readFile(path, 'utf8');
assert.doesNotMatch(storedText, /sessionWebhook|must-not-persist/);
assert.equal((await stat(path)).mode & 0o777, 0o600);
const reloaded = await new DingtalkStateStore(path).load();
assert.deepEqual(reloaded.pendingSenders(), [updated]);
assert.equal(await reloaded.removePendingSenderByStaffId('staff-one'), true);
assert.deepEqual(reloaded.pendingSenders(), []);
});
test('state store keeps only normalized fields from an existing pending-sender document', async (t) => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-dingtalk-state-normalize-'));
t.after(() => rm(directory, { recursive: true, force: true }));
const path = join(directory, 'bot.json');
const { writeFile } = await import('node:fs/promises');
await writeFile(path, JSON.stringify({
version: 1,
sessions: { valid: 'session', invalid: 3 },
seenMessageIds: ['one', 'one', null],
pendingSenders: {
request: {
requestId: 'request',
staffId: 'staff',
nick: '昵称',
requestedAt: '2026-08-15T01:00:00.000Z',
lastSeenAt: '2026-08-15T01:02:00.000Z',
sessionWebhook: 'https://oapi.dingtalk.com/reply?secret=discard',
},
},
}));
const store = await new DingtalkStateStore(path).load();
assert.deepEqual(store.snapshot(), {
version: 1,
sessions: { valid: 'session' },
seenMessageIds: ['one'],
pendingSenders: {
request: {
requestId: 'request',
staffId: 'staff',
displayName: '昵称',
requestedAt: '2026-08-15T01:00:00.000Z',
lastSeenAt: '2026-08-15T01:02:00.000Z',
},
},
});
});

View file

@ -0,0 +1,240 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
function event(messageId, text) {
return {
sender: { sender_type: 'user', sender_id: { open_id: 'ou_user' } },
message: {
message_id: messageId,
message_type: 'text',
chat_type: 'p2p',
chat_id: 'oc_chat',
content: JSON.stringify({ text }),
},
};
}
test('bridge maps a Feishu conversation to a persistent Harness session and replies', async () => {
const sent = [];
const reactions = [];
const removedReactions = [];
const streamed = [];
const sessions = new Map();
const seen = new Set();
const asked = [];
const client = {
im: { v1: { message: { create: async (request) => {
sent.push(request);
return { code: 0 };
} } } },
};
const channel = {
addReaction: async (messageId, emojiType) => {
reactions.push({ messageId, emojiType });
return `reaction-${emojiType}`;
},
removeReaction: async (messageId, reactionId) => {
removedReactions.push({ messageId, reactionId });
},
stream: async (chatId, input, options) => {
const updates = [];
await input.markdown({
setContent: async (content) => updates.push(content),
});
streamed.push({ chatId, options, updates });
return { messageId: 'om_reply' };
},
};
const harness = {
ensureRunning: async () => true,
sessionExists: async (sessionId) => sessionId === 'session-test',
createSession: async () => 'session-test',
ask: async (sessionId, text, options) => {
asked.push({ sessionId, text });
await options.onUpdate({ type: 'text', text: 'Harness' });
return 'Harness reply';
},
};
const state = {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: (key) => sessions.get(key) ?? null,
setSession: async (key, sessionId) => sessions.set(key, sessionId),
clearSession: async (key) => sessions.delete(key),
};
const status = {
messagesReceived: 0,
messagesReplied: 0,
messagesRejected: 0,
lastMessageAt: null,
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
};
const bridge = new FeishuHarnessBridge({
client,
channel,
harness,
state,
status,
allowedSenderOpenIds: new Set(['ou_user']),
});
bridge.accept(event('om_1', '你好'));
await bridge.waitForIdle();
assert.equal(sessions.get('p2p:ou_user'), 'session-test');
assert.deepEqual(asked, [{ sessionId: 'session-test', text: '你好' }]);
assert.deepEqual(streamed, [{
chatId: 'oc_chat',
options: { replyTo: 'om_1' },
updates: ['Harness', 'Harness reply'],
}]);
assert.deepEqual(reactions, [
{ messageId: 'om_1', emojiType: 'OnIt' },
{ messageId: 'om_1', emojiType: 'DONE' },
]);
assert.deepEqual(removedReactions, [
{ messageId: 'om_1', reactionId: 'reaction-OnIt' },
]);
assert.equal(sent.length, 0);
assert.equal(status.messagesReceived, 1);
assert.equal(status.messagesReplied, 1);
assert.equal(status.streamResponses, 1);
bridge.accept(event('om_1', '重复消息'));
await bridge.waitForIdle();
assert.equal(asked.length, 1);
bridge.accept({
...event('om_2', '越权消息'),
sender: { sender_type: 'user', sender_id: { open_id: 'ou_other' } },
});
await bridge.waitForIdle();
assert.equal(asked.length, 1);
assert.equal(status.messagesRejected, 1);
});
test('reaction failures do not block streaming replies', async () => {
const seen = new Set();
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
const bridge = new FeishuHarnessBridge({
client: {},
channel: {
addReaction: async () => { throw new Error('reaction unavailable'); },
stream: async (_chatId, input) => input.markdown({ setContent: async () => undefined }),
},
harness: {
sessionExists: async () => true,
ask: async (_sessionId, _text, options) => {
await options.onUpdate({ type: 'tool', name: 'web_search' });
return '天气结果';
},
},
state: {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: () => 'session-existing',
},
status,
allowedSenderOpenIds: new Set(['ou_user']),
});
bridge.accept(event('om_reaction_failure', '深圳天气'));
await bridge.waitForIdle();
assert.equal(status.messagesReplied, 1);
assert.equal(status.reactionErrors, 2);
assert.equal(status.streamResponses, 1);
});
test('a stream finalization failure falls back to text without repeating the prompt', async () => {
const seen = new Set();
const sent = [];
const finalReactions = [];
let askCount = 0;
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
const bridge = new FeishuHarnessBridge({
client: {
im: { v1: { message: { create: async (request) => {
sent.push(JSON.parse(request.data.content).text);
return { code: 0 };
} } } },
},
channel: {
addReaction: async (_messageId, emojiType) => {
finalReactions.push(emojiType);
return `reaction-${emojiType}`;
},
removeReaction: async () => undefined,
stream: async (_chatId, input) => {
await input.markdown({ setContent: async () => undefined });
throw new Error('card finalization failed');
},
},
harness: {
sessionExists: async () => true,
ask: async () => {
askCount += 1;
return '已经生成的最终回答';
},
},
state: {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: () => 'session-existing',
},
status,
allowedSenderOpenIds: new Set(['ou_user']),
});
bridge.accept(event('om_stream_finalize_failure', '不要重复提交'));
await bridge.waitForIdle();
assert.equal(askCount, 1);
assert.deepEqual(sent, ['已经生成的最终回答']);
assert.deepEqual(finalReactions, ['OnIt', 'DONE']);
assert.equal(status.messagesReplied, 1);
assert.equal(status.streamFallbacks, 1);
assert.equal(status.streamErrors, 1);
});
test('bridge does not expose internal error details in a Feishu failure reply', async () => {
const sent = [];
const seen = new Set();
const status = { messagesReceived: 0, messagesReplied: 0, messagesRejected: 0 };
const bridge = new FeishuHarnessBridge({
client: {
im: { v1: { message: { create: async (request) => {
sent.push(JSON.parse(request.data.content).text);
return { code: 0 };
} } } },
},
channel: {
addReaction: async (_messageId, emojiType) => `reaction-${emojiType}`,
removeReaction: async () => undefined,
},
harness: {
sessionExists: async () => true,
ask: async () => {
throw new Error('secret-shaped-internal-detail /private/path');
},
},
state: {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: () => 'session-existing',
},
status,
allowedSenderOpenIds: new Set(['ou_user']),
});
bridge.accept(event('om_internal_failure', '触发错误'));
await bridge.waitForIdle();
assert.equal(sent.length, 1);
assert.match(sent[0], /处理失败,请稍后重试/);
assert.doesNotMatch(sent[0], /secret-shaped-internal-detail|private\/path/);
assert.equal(status.lastError, 'secret-shaped-internal-detail /private/path');
});

View file

@ -0,0 +1,194 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
FEISHU_ENDPOINTS,
normalizeBotsSnapshot,
normalizeConnectionSnapshot,
normalizePollResult,
normalizeProvisioning,
presentError,
reconnectBot,
retryConnection,
screenFromSnapshot,
unwrapRpcResult,
} from '../../../plugin-src/client/channels/feishu/api.js';
test('multi-bot endpoints are bot-scoped and keep legacy operations separate', () => {
assert.equal(FEISHU_ENDPOINTS.reconnectBot, 'bot.reconnect');
assert.equal(FEISHU_ENDPOINTS.disconnectBot, 'bot.disconnect');
assert.equal(FEISHU_ENDPOINTS.deleteBot, 'bot.delete');
assert.equal(FEISHU_ENDPOINTS.testConnection, 'connection.test');
});
test('client normalizes multiple independent bots and derives authoritative totals', () => {
const snapshot = normalizeBotsSnapshot({
schemaVersion: 2,
revision: 9,
totals: { configured: 99, connected: 99 },
bots: [
{
botId: 'bot-a',
state: 'connected',
connected: true,
configured: true,
bot: {
name: '销售助手',
appIdMasked: 'cli_aaaa••••1111',
domain: 'feishu',
clientSecret: 'must-not-leak',
},
health: { status: 'healthy', summary: '长连接运行正常' },
},
{
botId: 'bot-b',
// A stale state label must not make an offline bot appear connected.
state: 'connected',
connected: false,
configured: true,
bot: { name: '研发助手', domain: 'lark' },
health: { status: 'offline', summary: '等待重连' },
error: { code: 'connection_failed', message: '连接失败' },
},
],
});
assert.equal(snapshot.schemaVersion, 2);
assert.equal(snapshot.revision, 9);
assert.deepEqual(snapshot.totals, { configured: 2, connected: 1 });
assert.equal(snapshot.bots[0].state, 'connected');
assert.equal(snapshot.bots[1].state, 'connecting');
assert.equal(snapshot.bots[1].bot.domain, 'lark');
assert.equal(snapshot.bots[1].error.message, '连接失败');
assert.equal('clientSecret' in snapshot.bots[0].bot, false);
});
test('multi-bot snapshot rejects entries without an opaque botId', () => {
assert.throws(
() => normalizeBotsSnapshot({ schemaVersion: 2, bots: [{ connected: true }] }),
/botId/,
);
});
test('provision polling preserves the newly connected botId', () => {
assert.deepEqual(normalizePollResult({
status: 'connected',
botId: 'bot-new',
}), {
status: 'connected',
botId: 'bot-new',
message: undefined,
connection: undefined,
provisioning: undefined,
});
});
test('reconnect helper scopes the mutation to one bot before refreshing the list', async () => {
const calls = [];
const status = { schemaVersion: 2, bots: [] };
const value = await reconnectBot(async (endpoint, payload) => {
calls.push({ endpoint, payload });
return endpoint === 'connection.status' ? status : { accepted: true };
}, 'bot-a');
assert.deepEqual(calls, [
{ endpoint: 'bot.reconnect', payload: { botId: 'bot-a' } },
{ endpoint: 'connection.status', payload: {} },
]);
assert.equal(value, status);
});
test('client only shows connected when the Host confirms connected=true', () => {
assert.equal(normalizeConnectionSnapshot({
state: 'connected',
connected: false,
}).state, 'connecting');
assert.equal(normalizeConnectionSnapshot({
state: 'connecting',
connected: true,
}).state, 'connected');
});
test('client accepts a Host-rendered QR code without exposing credentials', () => {
const provisioning = normalizeProvisioning({
attemptId: '7',
verificationUrl: 'https://accounts.feishu.cn/device',
qrCodeDataUrl: 'data:image/png;base64,AAAA',
expiresAt: 100_000,
pollIntervalMs: 1_800,
}, 1_000);
assert.equal(provisioning.attemptId, '7');
assert.equal(provisioning.qrCodeDataUrl, 'data:image/png;base64,AAAA');
assert.equal('clientSecret' in provisioning, false);
});
test('client unwraps RpcResult and redacts credential-shaped error text', () => {
assert.deepEqual(unwrapRpcResult({ ok: true, value: { connected: true } }), {
connected: true,
});
assert.throws(
() => unwrapRpcResult({
ok: false,
error: { code: 'internal', message: 'failed' },
}),
/failed/,
);
assert.doesNotMatch(
presentError(new Error('app_secret=do-not-show token=also-hidden')).message,
/do-not-show|also-hidden/,
);
});
test('configured offline and startup errors never become the create screen', () => {
const offline = normalizeConnectionSnapshot({
state: 'disconnected',
connected: false,
configured: true,
bot: { name: '北汇星河助手' },
health: { status: 'offline', summary: '长连接已断开' },
});
assert.equal(offline.configured, true);
assert.equal(screenFromSnapshot(offline).phase, 'offline');
const failed = screenFromSnapshot(normalizeConnectionSnapshot({
state: 'error',
connected: false,
configured: true,
error: { message: '启动失败' },
}));
assert.equal(failed.phase, 'error');
assert.equal(failed.retry, 'test');
assert.equal(failed.configured, true);
const failedBeforeConfiguration = screenFromSnapshot(normalizeConnectionSnapshot({
state: 'error',
connected: false,
configured: false,
error: { message: '二维码已过期' },
}));
assert.equal(failedBeforeConfiguration.phase, 'error');
assert.equal(failedBeforeConfiguration.retry, 'begin');
const fresh = screenFromSnapshot(normalizeConnectionSnapshot({
state: 'disconnected',
connected: false,
configured: false,
}));
assert.deepEqual(fresh, { phase: 'disconnected', configured: false });
});
test('retry connection calls connection.test before authoritative connection.status', async () => {
const calls = [];
const status = { state: 'connected', connected: true, configured: true };
const value = await retryConnection(async (endpoint, payload) => {
calls.push({ endpoint, payload });
return endpoint === 'connection.status' ? status : { accepted: true };
});
assert.deepEqual(calls, [
{ endpoint: 'connection.test', payload: {} },
{ endpoint: 'connection.status', payload: {} },
]);
assert.equal(value, status);
});

View file

@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { loadConfig } from '../../../src/channels/feishu/config.mjs';
const KEYS = [
'FEISHU_APP_ID',
'FEISHU_APP_SECRET',
'FEISHU_SECRET_SERVICE',
'FEISHU_ALLOWED_OPEN_IDS',
'HARNESS_WORKSPACE',
'HARNESS_AGENT_PRESET',
];
function withEnvironment(values, callback) {
const previous = Object.fromEntries(KEYS.map((key) => [key, process.env[key]]));
try {
for (const key of KEYS) delete process.env[key];
Object.assign(process.env, values);
return callback();
} finally {
for (const key of KEYS) {
if (previous[key] === undefined) delete process.env[key];
else process.env[key] = previous[key];
}
}
}
test('loadConfig fails closed when the sender allowlist is empty', () => {
withEnvironment({
FEISHU_APP_ID: 'cli_test',
FEISHU_APP_SECRET: 'test-secret',
HARNESS_WORKSPACE: '/tmp/test-workspace',
FEISHU_ALLOWED_OPEN_IDS: ' , ',
}, () => {
assert.throws(() => loadConfig(), /FEISHU_ALLOWED_OPEN_IDS/);
});
});
test('loadConfig parses a deduplicated sender allowlist and defaults to standard', () => {
withEnvironment({
FEISHU_APP_ID: 'cli_test',
FEISHU_APP_SECRET: 'test-secret',
HARNESS_WORKSPACE: '/tmp/test-workspace',
FEISHU_ALLOWED_OPEN_IDS: 'ou_one, ou_two,ou_one',
}, () => {
const config = loadConfig();
assert.equal(config.harnessAgentPreset, 'standard');
assert.deepEqual([...config.allowedSenderOpenIds], ['ou_one', 'ou_two']);
});
});

View file

@ -0,0 +1,98 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ConnectionSupervisor } from '../../../plugin-src/host/channels/feishu/connection-supervisor.mjs';
const flush = () => new Promise((resolve) => setImmediate(resolve));
function scheduler() {
const pending = [];
return {
pending,
setTimeoutImpl(callback, delay) {
const handle = { callback, delay, cancelled: false, unref() {} };
pending.push(handle);
return handle;
},
clearTimeoutImpl(handle) {
handle.cancelled = true;
},
async runNext() {
const handle = pending.shift();
assert.ok(handle, 'expected a scheduled supervisor pass');
assert.equal(handle.cancelled, false);
handle.callback();
await flush();
await flush();
return handle.delay;
},
};
}
test('supervisor waits for the in-process Harness Host before initializing bots', async () => {
const timers = scheduler();
const warnings = [];
let healthChecks = 0;
let initializations = 0;
const controller = {
async initialize() { initializations += 1; },
status() { return { totals: { configured: 1, connected: 1 } }; },
};
const supervisor = new ConnectionSupervisor({
controller,
harness: {
async ensureRunning() {
healthChecks += 1;
if (healthChecks < 3) throw new Error('Host is still starting');
},
},
logger: { warn: (...args) => warnings.push(args) },
retryDelaysMs: [5, 10],
healthyIntervalMs: 100,
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
}).start();
assert.equal(await timers.runNext(), 0);
assert.equal(initializations, 0);
assert.equal(timers.pending[0].delay, 5);
await timers.runNext();
assert.equal(initializations, 0);
assert.equal(timers.pending[0].delay, 10);
await timers.runNext();
assert.equal(await supervisor.ready.then((status) => status.totals.connected), 1);
assert.equal(initializations, 1);
assert.equal(timers.pending[0].delay, 100);
assert.equal(warnings.length, 2);
await supervisor.close();
assert.equal(timers.pending[0].cancelled, true);
});
test('supervisor retries an offline bot and leaves the recovered connection on the health interval', async () => {
const timers = scheduler();
let initializations = 0;
const controller = {
async initialize() { initializations += 1; },
status() {
return { totals: { configured: 1, connected: initializations >= 2 ? 1 : 0 } };
},
};
const supervisor = new ConnectionSupervisor({
controller,
harness: { async ensureRunning() {} },
logger: { warn() {} },
retryDelaysMs: [7],
healthyIntervalMs: 101,
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
}).start();
await timers.runNext();
assert.equal(initializations, 1);
assert.equal(timers.pending[0].delay, 7);
await timers.runNext();
assert.equal(initializations, 2);
assert.equal(timers.pending[0].delay, 101);
await supervisor.close();
});

View file

@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { verifyFeishuApp } from '../../../src/channels/feishu/feishu-app.mjs';
function response(body, ok = true, status = 200) {
return { ok, status, async json() { return body; } };
}
test('verifyFeishuApp validates credentials and returns a safe bot identity', async () => {
const requests = [];
const result = await verifyFeishuApp({
appId: 'cli_test',
appSecret: 'never-return-this',
fetchImpl: async (url, options) => {
requests.push({ url: String(url), options });
if (requests.length === 1) {
return response({ code: 0, tenant_access_token: 'tenant-token' });
}
return response({
code: 0,
bot: { app_name: '北汇星河助手', open_id: 'ou_bot', activate_status: 1 },
});
},
});
assert.deepEqual(result, {
appId: 'cli_test',
name: '北汇星河助手',
openId: 'ou_bot',
activated: 1,
});
assert.equal('appSecret' in result, false);
assert.match(requests[0].options.body, /never-return-this/);
assert.equal(requests[1].options.headers.authorization, 'Bearer tenant-token');
});
test('verifyFeishuApp rejects invalid credentials before reading bot info', async () => {
let calls = 0;
await assert.rejects(verifyFeishuApp({
appId: 'cli_bad',
appSecret: 'bad',
fetchImpl: async () => {
calls += 1;
return response({ code: 10003, msg: 'invalid app secret' });
},
}), /invalid app secret/);
assert.equal(calls, 1);
});

View file

@ -0,0 +1,131 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { VerifiedFeishuChannel } from '../../../src/channels/feishu/feishu-channel.mjs';
function fakeClient(overrides = {}) {
const calls = {
replies: [],
updates: [],
settings: [],
recalls: [],
reactionsAdded: [],
reactionsRemoved: [],
};
const client = {
cardkit: { v1: {
card: {
create: async () => ({ code: 0, data: { card_id: 'card-test' } }),
settings: async (request) => {
calls.settings.push(request);
return { code: 0 };
},
},
cardElement: {
content: async (request) => {
calls.updates.push(request);
return { code: 0 };
},
},
} },
im: { v1: {
message: {
reply: async (request) => {
calls.replies.push(request);
return { code: 0, data: { message_id: 'om-stream' } };
},
create: async () => ({ code: 0, data: { message_id: 'om-stream' } }),
delete: async (request) => {
calls.recalls.push(request);
return { code: 0 };
},
},
messageReaction: {
create: async (request) => {
calls.reactionsAdded.push(request);
return { code: 0, data: { reaction_id: 'reaction-test' } };
},
delete: async (request) => {
calls.reactionsRemoved.push(request);
return { code: 0 };
},
},
} },
};
if (overrides.updateContent) client.cardkit.v1.cardElement.content = overrides.updateContent;
if (overrides.finishCard) client.cardkit.v1.card.settings = overrides.finishCard;
return { client, calls };
}
test('VerifiedFeishuChannel streams content and verifies terminal settings', async () => {
const { client, calls } = fakeClient();
const channel = new VerifiedFeishuChannel({ client, initialText: '正在思考…' });
const result = await channel.stream('oc_chat', {
markdown: async (controller) => {
await controller.setContent('第一段');
await controller.setContent('第一段和第二段');
},
}, { replyTo: 'om_user' });
assert.deepEqual(result, { messageId: 'om-stream' });
assert.equal(calls.replies[0].path.message_id, 'om_user');
assert.deepEqual(calls.updates.map((call) => ({
content: call.data.content,
sequence: call.data.sequence,
})), [
{ content: '第一段', sequence: 1 },
{ content: '第一段和第二段', sequence: 2 },
]);
assert.equal(calls.settings[0].data.sequence, 3);
assert.deepEqual(JSON.parse(calls.settings[0].data.settings), {
config: {
streaming_mode: false,
summary: { content: '第一段和第二段' },
},
});
assert.equal(calls.recalls.length, 0);
});
test('VerifiedFeishuChannel rejects failed updates and recalls the partial card', async () => {
const { client, calls } = fakeClient({
updateContent: async () => ({ code: 230099, msg: 'element update failed' }),
});
const channel = new VerifiedFeishuChannel({ client });
await assert.rejects(channel.stream('oc_chat', {
markdown: async (controller) => controller.setContent('最终回答'),
}, { replyTo: 'om_user' }), /cardElement\.content failed/);
assert.deepEqual(calls.recalls, [{ path: { message_id: 'om-stream' } }]);
assert.equal(calls.settings.length, 0);
});
test('VerifiedFeishuChannel rejects failed finalization and recalls the card', async () => {
const { client, calls } = fakeClient({
finishCard: async (request) => {
calls.settings.push(request);
return { code: 230099, msg: 'card finalization failed' };
},
});
const channel = new VerifiedFeishuChannel({ client });
await assert.rejects(channel.stream('oc_chat', {
markdown: async (controller) => controller.setContent('已经生成的回答'),
}, { replyTo: 'om_user' }), /card\.settings failed/);
assert.deepEqual(calls.recalls, [{ path: { message_id: 'om-stream' } }]);
assert.equal(calls.settings.length, 1);
});
test('VerifiedFeishuChannel checks reaction API results', async () => {
const { client, calls } = fakeClient();
const channel = new VerifiedFeishuChannel({ client });
const reactionId = await channel.addReaction('om_user', 'OnIt');
await channel.removeReaction('om_user', reactionId);
assert.equal(reactionId, 'reaction-test');
assert.equal(calls.reactionsAdded[0].data.reaction_type.emoji_type, 'OnIt');
assert.equal(calls.reactionsRemoved[0].path.reaction_id, 'reaction-test');
});

View file

@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { FeishuRuntime } from '../../../src/channels/feishu/feishu-runtime.mjs';
class FakeClient {}
class FakeDispatcher {
register(handlers) {
this.handlers = handlers;
return this;
}
}
class FakeWSClient {
static instances = [];
constructor(options) {
this.options = options;
this.state = 'idle';
FakeWSClient.instances.push(this);
}
async start() {
this.state = 'connecting';
}
becomeReady() {
this.state = 'connected';
this.options.onReady();
}
getConnectionStatus() {
return { state: this.state };
}
close() {
this.state = 'closed';
}
}
function fakeLark() {
FakeWSClient.instances.length = 0;
return {
Domain: { Feishu: 'feishu-domain', Lark: 'lark-domain' },
LoggerLevel: { info: 'info' },
Client: FakeClient,
EventDispatcher: FakeDispatcher,
WSClient: FakeWSClient,
};
}
test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connected', async () => {
let harnessChecks = 0;
const runtime = new FeishuRuntime({
lark: fakeLark(),
appId: 'cli_test',
appSecret: 'secret',
ownerOpenId: 'ou_owner',
harness: {
async ensureRunning() { harnessChecks += 1; },
},
state: { hasSeen: () => false },
});
assert.equal(runtime.status.ready, false);
let settled = false;
const starting = runtime.start().then((value) => {
settled = true;
return value;
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(settled, false);
assert.equal(runtime.status.feishuLongConnectionState, 'connecting');
FakeWSClient.instances[0].becomeReady();
const status = await starting;
assert.equal(harnessChecks, 1);
assert.equal(status.ready, true);
assert.equal(status.feishuLongConnectionState, 'connected');
assert.equal(status.harnessReachable, true);
const stopped = await runtime.stop();
assert.equal(stopped.ready, false);
assert.equal(stopped.feishuLongConnectionState, 'idle');
assert.equal(FakeWSClient.instances[0].state, 'closed');
});
test('FeishuRuntime fails closed when the initial WebSocket handshake times out', async () => {
const runtime = new FeishuRuntime({
lark: fakeLark(),
appId: 'cli_test',
appSecret: 'secret',
ownerOpenId: 'ou_owner',
harness: { async ensureRunning() {} },
state: { hasSeen: () => false },
connectTimeoutMs: 10,
});
await assert.rejects(runtime.start(), /handshake timed out/);
assert.equal(runtime.status.ready, false);
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
assert.equal(FakeWSClient.instances[0].state, 'closed');
});
test('FeishuRuntime fails closed when Harness is unavailable', async () => {
const runtime = new FeishuRuntime({
lark: fakeLark(),
appId: 'cli_test',
appSecret: 'secret',
ownerOpenId: 'ou_owner',
harness: {
async ensureRunning() { throw new Error('Harness unavailable'); },
},
state: { hasSeen: () => false },
});
await assert.rejects(runtime.start(), /Harness unavailable/);
assert.equal(runtime.status.ready, false);
assert.equal(runtime.status.feishuLongConnectionState, 'failed');
assert.equal(runtime.status.lastError, 'Harness unavailable');
});

View file

@ -0,0 +1,132 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
HarnessClient,
HarnessReplyTracker,
} from '../../../src/channels/feishu/harness-client.mjs';
test('HarnessClient reads the nested workspace.create response used by DSH rc.6', async (t) => {
const methods = [];
t.mock.method(globalThis, 'fetch', async (_url, options) => {
const request = JSON.parse(options.body);
methods.push(request.method);
const value = request.method === 'workspace.list'
? { items: [], archivedSessionIds: [] }
: {
workspace: {
workspaceId: 'workspace-new',
path: '/tmp/dsh-feishu-workspace',
},
created: true,
};
return {
ok: true,
async json() {
return {
type: 'server-response',
rpcId: request.rpcId,
result: { ok: true, value },
};
},
};
});
const client = new HarnessClient({
baseUrl: 'http://127.0.0.1:3080',
workspace: '/tmp/dsh-feishu-workspace',
agentPreset: 'standard',
autostart: false,
dshBin: 'dsh',
});
assert.equal(await client.workspaceId(), 'workspace-new');
assert.deepEqual(methods, ['workspace.list', 'workspace.create']);
});
test('HarnessReplyTracker correlates the prompt and emits only answer text', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-1', afterSeq: 10 });
assert.equal(tracker.consume([
{ event: { type: 'turn/start', seq: 11, data: { turn: 4 } } },
{ event: {
type: 'user/message',
seq: 12,
data: { source: { rpcId: 'someone-else' } },
} },
{ event: {
type: 'assistant/chunk',
seq: 13,
data: { turn: 4, step: 1, chunk: { type: 'text-delta', index: 0, text: '忽略' } },
} },
{ event: { type: 'turn/end', seq: 14, data: { turn: 4, reason: { kind: 'completed' } } } },
]), null);
assert.equal(tracker.finished, false);
const first = tracker.consume([
{ event: { type: 'turn/start', seq: 15, data: { turn: 5 } } },
{ event: {
type: 'user/message',
seq: 16,
data: { source: { rpcId: 'prompt-1' } },
} },
{ event: {
type: 'assistant/chunk',
seq: 17,
data: { turn: 5, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: '不能泄露' } },
} },
{ event: {
type: 'assistant/chunk',
seq: 18,
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '深圳' } },
} },
]);
assert.deepEqual(first, { type: 'text', text: '深圳' });
const second = tracker.consume([
{ event: {
type: 'assistant/chunk',
seq: 18,
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '重复' } },
} },
{ event: {
type: 'assistant/chunk',
seq: 19,
data: { turn: 5, step: 1, chunk: { type: 'text-delta', index: 1, text: '明天有雨' } },
} },
]);
assert.deepEqual(second, { type: 'text', text: '深圳明天有雨' });
const final = tracker.consume([
{ event: {
type: 'assistant/message',
seq: 20,
data: {
turn: 5,
step: 1,
message: { content: [
{ type: 'reasoning', text: '仍然不能泄露' },
{ type: 'text', text: '深圳明天有阵雨。' },
] },
},
} },
{ event: { type: 'turn/end', seq: 21, data: { turn: 5, reason: { kind: 'completed' } } } },
]);
assert.deepEqual(final, { type: 'text', text: '深圳明天有阵雨。' });
assert.equal(tracker.finished, true);
assert.equal(tracker.answer, '深圳明天有阵雨。');
assert.deepEqual(tracker.reason, { kind: 'completed' });
});
test('HarnessReplyTracker emits tool progress without exposing tool results', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'prompt-tool' });
const update = tracker.consume([
{ type: 'turn/start', seq: 1, data: { turn: 1 } },
{ type: 'user/message', seq: 2, data: { source: { rpcId: 'prompt-tool' } } },
{ type: 'tool/call', seq: 3, data: { turn: 1, step: 1, name: 'web_search' } },
]);
assert.deepEqual(update, { type: 'tool', name: 'web_search' });
assert.deepEqual(tracker.consume([
{ type: 'tool/result', seq: 4, data: { turn: 1, step: 1, secret: 'not rendered' } },
]), { type: 'status', text: '正在整理结果…' });
});

View file

@ -0,0 +1,50 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
conversationKey,
extractText,
isAllowedSender,
isBotSender,
splitText,
} from '../../../src/channels/feishu/message-utils.mjs';
test('extractText removes bot mentions', () => {
const event = {
message: {
message_type: 'text',
content: JSON.stringify({ text: '@_user_1 你好' }),
mentions: [{ key: '@_user_1' }],
},
};
assert.equal(extractText(event), '你好');
});
test('conversationKey isolates p2p users and groups', () => {
assert.equal(conversationKey({
sender: { sender_id: { open_id: 'ou_test' } },
message: { chat_type: 'p2p', chat_id: 'oc_private' },
}), 'p2p:ou_test');
assert.equal(conversationKey({
sender: { sender_id: { open_id: 'ou_test' } },
message: { chat_type: 'group', chat_id: 'oc_group' },
}), 'group:oc_group');
});
test('splitText preserves all text', () => {
const input = `${'a'.repeat(12)}\n${'b'.repeat(12)}`;
const chunks = splitText(input, 15);
assert.equal(chunks.join('\n'), input);
assert.ok(chunks.every((chunk) => chunk.length <= 15));
});
test('isBotSender rejects bot loops', () => {
assert.equal(isBotSender({ sender: { sender_type: 'bot' } }), true);
assert.equal(isBotSender({ sender: { sender_type: 'user' } }), false);
});
test('isAllowedSender enforces an open-id allowlist', () => {
const event = { sender: { sender_id: { open_id: 'ou_allowed' } } };
assert.equal(isAllowedSender(event, new Set()), false);
assert.equal(isAllowedSender(event, new Set(['ou_allowed'])), true);
assert.equal(isAllowedSender(event, new Set(['ou_other'])), false);
});

View file

@ -0,0 +1,485 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { MultiBotDshFeishuController } from '../../../src/channels/feishu/multi-bot-controller.mjs';
const flush = () => new Promise((resolve) => setImmediate(resolve));
async function waitFor(predicate, timeoutMs = 1000) {
const deadline = Date.now() + timeoutMs;
while (!predicate()) {
if (Date.now() >= deadline) throw new Error('condition timed out');
await flush();
}
}
class MemoryConfigStore {
constructor(bots = []) {
this.bots = structuredClone(bots);
}
list() { return structuredClone(this.bots); }
getBot(id) {
const bot = this.bots.find((candidate) => candidate.id === id);
return bot ? structuredClone(bot) : null;
}
async saveBot(bot) {
const index = this.bots.findIndex((candidate) => candidate.id === bot.id);
if (index === -1) this.bots.push(structuredClone(bot));
else this.bots[index] = structuredClone(bot);
return structuredClone(bot);
}
async removeBot(id) {
const index = this.bots.findIndex((candidate) => candidate.id === id);
return index === -1 ? null : this.bots.splice(index, 1)[0];
}
}
function bot(id, suffix = id) {
return {
id,
appId: `cli_${suffix}`,
secretRef: `DSH_FEISHU_APP_SECRET_${suffix.toUpperCase()}`,
ownerOpenIds: [`ou_${suffix}`],
domain: 'feishu',
botName: `机器人 ${suffix}`,
botOpenId: `ou_bot_${suffix}`,
activated: 1,
};
}
function fixture({
bots = [],
secrets = {},
createBotIds = [],
failResolveRefs = new Set(),
failUnsetRefs = new Set(),
runtimeStart,
deleteState,
} = {}) {
const configStore = new MemoryConfigStore(bots);
const values = new Map(Object.entries(secrets));
const unsetCalls = [];
const registrationRuns = [];
const runtimes = new Map();
let registrationSequence = 0;
let botSequence = 0;
const controller = new MultiBotDshFeishuController({
registerApp(options) {
let resolve;
let reject;
const promise = new Promise((res, rej) => { resolve = res; reject = rej; });
registrationRuns.push({ options, resolve, reject });
return promise;
},
verifyApp: async ({ appId }) => ({
name: `已验证 ${appId}`,
openId: `ou_bot_${appId}`,
activated: 1,
}),
credentials: {
async resolve(ref) {
if (failResolveRefs.has(ref)) throw new Error('credential provider unavailable');
return values.has(ref) ? { value: values.get(ref), source: 'file' } : undefined;
},
async set(ref, value) { values.set(ref, value); },
async unset(ref) {
unsetCalls.push(ref);
if (failUnsetRefs.has(ref)) throw new Error('credential provider is read-only');
values.delete(ref);
},
},
configStore,
createRuntime: async ({ botId, config, appSecret }) => {
const status = {
ready: false,
feishuLongConnectionState: 'idle',
harnessReachable: false,
};
const runtime = {
botId,
config: structuredClone(config),
appSecret,
starts: 0,
stops: 0,
get status() { return structuredClone(status); },
async start() {
runtime.starts += 1;
if (runtimeStart) await runtimeStart({ botId, runtime });
status.ready = true;
status.feishuLongConnectionState = 'connected';
status.harnessReachable = true;
},
async stop() {
runtime.stops += 1;
status.ready = false;
status.feishuLongConnectionState = 'idle';
},
};
const history = runtimes.get(botId) ?? [];
history.push(runtime);
runtimes.set(botId, history);
return runtime;
},
deleteState: deleteState ?? (async () => {}),
createBotId() {
const id = createBotIds[botSequence] ?? `bot_generated_${++botSequence}`;
botSequence += createBotIds.length > 0 ? 1 : 0;
return id;
},
createRegistrationId: () => `reg_${++registrationSequence}`,
});
return { controller, configStore, values, unsetCalls, registrationRuns, runtimes };
}
async function completeScan(fx, result) {
const started = fx.controller.startRegistration();
const attemptId = started.registration.attempt;
await waitFor(() => fx.registrationRuns.length > 0);
const run = fx.registrationRuns.shift();
run.options.onQRCodeReady({ url: `https://accounts.feishu.cn/${attemptId}`, expireIn: 60 });
run.resolve(result);
await waitFor(() => ['succeeded', 'error'].includes(
fx.controller.registrationStatus(attemptId).registration.state,
));
return fx.controller.registrationStatus(attemptId);
}
test('initialization isolates failures and starts every bot with available credentials', async () => {
const missing = bot('bot_missing', 'missing');
const healthy = bot('bot_healthy', 'healthy');
const fx = fixture({
bots: [missing, healthy],
secrets: { [healthy.secretRef]: 'healthy-secret' },
failResolveRefs: new Set([missing.secretRef]),
});
await fx.controller.initialize();
const status = fx.controller.status();
assert.equal(status.totals.configured, 2);
assert.equal(status.totals.connected, 1);
assert.equal(status.bots.find((entry) => entry.botId === missing.id).phase, 'error');
assert.equal(status.bots.find((entry) => entry.botId === healthy.id).connected, true);
assert.equal(fx.runtimes.has(missing.id), false);
assert.equal(fx.runtimes.get(healthy.id).length, 1);
assert.doesNotMatch(JSON.stringify(status), /healthy-secret|DSH_FEISHU_APP_SECRET|ou_healthy/);
});
test('repeated initialization never restarts an already healthy bot', async () => {
const healthy = bot('bot_healthy', 'healthy');
const fx = fixture({
bots: [healthy],
secrets: { [healthy.secretRef]: 'healthy-secret' },
});
await fx.controller.initialize();
await fx.controller.initialize();
assert.equal(fx.controller.status().totals.connected, 1);
assert.equal(fx.runtimes.get(healthy.id).length, 1);
assert.equal(fx.runtimes.get(healthy.id)[0].starts, 1);
assert.equal(fx.runtimes.get(healthy.id)[0].stops, 0);
});
test('multiple scans create independent bots, credential refs and runtimes', async () => {
const fx = fixture({ createBotIds: ['bot_alpha', 'bot_beta'] });
const alpha = completeScan(fx, {
client_id: 'cli_alpha', client_secret: 'secret-alpha',
user_info: { open_id: 'ou_alpha', tenant_brand: 'feishu' },
});
const beta = completeScan(fx, {
client_id: 'cli_beta', client_secret: 'secret-beta',
user_info: { open_id: 'ou_beta', tenant_brand: 'feishu' },
});
const [alphaStatus, betaStatus] = await Promise.all([alpha, beta]);
assert.equal(alphaStatus.registration.state, 'succeeded');
assert.equal(betaStatus.registration.state, 'succeeded');
assert.equal(fx.configStore.list().length, 2);
const [first, second] = fx.configStore.list();
assert.notEqual(first.id, second.id);
assert.notEqual(first.secretRef, second.secretRef);
assert.match(first.secretRef, /^[A-Za-z_][A-Za-z0-9_]*$/);
assert.equal(fx.runtimes.get(first.id).length, 1);
assert.equal(fx.runtimes.get(second.id).length, 1);
assert.equal(fx.controller.status().totals.connected, 2);
});
test('scanning an existing app is idempotent and reuses its bot and secret ref', async () => {
const existing = bot('bot_existing', 'existing');
const fx = fixture({
bots: [existing],
secrets: { [existing.secretRef]: 'old-secret' },
});
await fx.controller.initialize();
const completed = await completeScan(fx, {
client_id: existing.appId,
client_secret: 'rotated-secret',
user_info: { open_id: 'ou_second_owner', tenant_brand: 'feishu' },
});
assert.equal(completed.registration.state, 'succeeded');
assert.equal(completed.registration.botId, existing.id);
assert.equal(fx.configStore.list().length, 1);
assert.equal(fx.configStore.list()[0].secretRef, existing.secretRef);
assert.deepEqual(fx.configStore.list()[0].ownerOpenIds.sort(), ['ou_existing', 'ou_second_owner']);
assert.equal(fx.values.get(existing.secretRef), 'rotated-secret');
assert.equal(fx.runtimes.get(existing.id).length, 2);
assert.equal(fx.runtimes.get(existing.id)[0].stops, 1);
});
test('deleting one bot clears only its secret and leaves the other runtime online', async () => {
const alpha = bot('bot_alpha', 'alpha');
const beta = bot('bot_beta', 'beta');
const fx = fixture({
bots: [alpha, beta],
secrets: { [alpha.secretRef]: 'secret-a', [beta.secretRef]: 'secret-b' },
});
await fx.controller.initialize();
const alphaRuntime = fx.runtimes.get(alpha.id)[0];
const betaRuntime = fx.runtimes.get(beta.id)[0];
const status = await fx.controller.deleteBot(alpha.id);
assert.deepEqual(fx.unsetCalls, [alpha.secretRef]);
assert.equal(fx.values.has(alpha.secretRef), false);
assert.equal(fx.values.get(beta.secretRef), 'secret-b');
assert.equal(alphaRuntime.stops, 1);
assert.equal(betaRuntime.stops, 0);
assert.equal(status.totals.configured, 1);
assert.equal(status.totals.connected, 1);
assert.equal(status.bots[0].botId, beta.id);
});
test('cancelling a terminal attempt is a no-op and cannot roll back a newer same-app scan', async () => {
const existing = bot('bot_existing', 'existing');
const fx = fixture({
bots: [existing],
secrets: { [existing.secretRef]: 'original-secret' },
});
await fx.controller.initialize();
const first = await completeScan(fx, {
client_id: existing.appId, client_secret: 'first-secret',
user_info: { open_id: 'ou_first', tenant_brand: 'feishu' },
});
const oldAttemptId = first.registration.attempt;
const second = await completeScan(fx, {
client_id: existing.appId, client_secret: 'newest-secret',
user_info: { open_id: 'ou_second', tenant_brand: 'feishu' },
});
const cancelled = await fx.controller.cancelRegistration(oldAttemptId);
assert.equal(cancelled.registration.state, 'succeeded');
assert.equal(second.registration.botId, existing.id);
assert.equal(fx.configStore.list().length, 1);
assert.equal(fx.values.get(existing.secretRef), 'newest-secret');
assert.equal(fx.controller.status().bots[0].connected, true);
});
test('credential removal failure is retriable and cannot affect another bot', async () => {
const alpha = bot('bot_alpha', 'alpha');
const beta = bot('bot_beta', 'beta');
const fx = fixture({
bots: [alpha, beta],
secrets: { [alpha.secretRef]: 'secret-a', [beta.secretRef]: 'secret-b' },
failUnsetRefs: new Set([alpha.secretRef]),
});
await fx.controller.initialize();
const betaRuntime = fx.runtimes.get(beta.id)[0];
await assert.rejects(fx.controller.deleteBot(alpha.id), /remove the Feishu credential/);
assert.equal(fx.configStore.list().length, 2);
assert.equal(fx.values.get(alpha.secretRef), 'secret-a');
assert.equal(fx.values.get(beta.secretRef), 'secret-b');
assert.equal(betaRuntime.stops, 0);
assert.equal(fx.controller.status().bots.find((entry) => entry.botId === alpha.id).error.code,
'credential_removal_failed');
// Deletion intent is durable. Even though the immutable secret still
// exists, a fresh controller must not resurrect this bot on restart.
const restarted = fixture({
bots: fx.configStore.list(),
secrets: Object.fromEntries(fx.values),
});
await restarted.controller.initialize();
assert.equal(restarted.runtimes.has(alpha.id), false);
assert.equal(restarted.controller.status().bots.find((entry) => entry.botId === alpha.id).error.code,
'deletion_pending');
assert.equal(restarted.controller.status().bots.find((entry) => entry.botId === beta.id).connected, true);
});
test('one bot activation failure does not stop a healthy existing bot', async () => {
const healthy = bot('bot_healthy', 'healthy');
const fx = fixture({
bots: [healthy],
secrets: { [healthy.secretRef]: 'healthy-secret' },
createBotIds: ['bot_failure'],
runtimeStart: async ({ botId }) => {
if (botId === 'bot_failure') throw new Error('new bot handshake failed');
},
});
await fx.controller.initialize();
const healthyRuntime = fx.runtimes.get(healthy.id)[0];
const result = await completeScan(fx, {
client_id: 'cli_failure', client_secret: 'failure-secret',
user_info: { open_id: 'ou_failure', tenant_brand: 'feishu' },
});
assert.equal(result.registration.state, 'error');
assert.equal(healthyRuntime.stops, 0);
assert.equal(fx.controller.status().bots.find((entry) => entry.botId === healthy.id).connected, true);
assert.equal(fx.controller.status().bots.find((entry) => entry.botId === 'bot_failure').phase, 'error');
});
test('close during credential activation cannot leave a late runtime or bot behind', async () => {
let releaseStart;
const startGate = new Promise((resolve) => { releaseStart = resolve; });
const fx = fixture({
createBotIds: ['bot_closing'],
runtimeStart: async ({ botId }) => {
if (botId === 'bot_closing') await startGate;
},
});
const started = fx.controller.startRegistration();
const attemptId = started.registration.attempt;
await waitFor(() => fx.registrationRuns.length === 1);
const run = fx.registrationRuns.shift();
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/closing', expireIn: 60 });
run.resolve({
client_id: 'cli_closing', client_secret: 'closing-secret',
user_info: { open_id: 'ou_closing', tenant_brand: 'feishu' },
});
await waitFor(() => fx.controller.registrationStatus(attemptId).registration.state === 'saving');
const closing = fx.controller.close();
releaseStart();
await closing;
assert.equal(fx.configStore.list().length, 0);
assert.equal(fx.values.size, 0);
assert.equal(fx.controller.status().totals.connected, 0);
assert.equal(fx.runtimes.get('bot_closing').at(-1).stops > 0, true);
assert.throws(() => fx.controller.startRegistration(), /closed/);
});
test('a failed repeat-scan restores the previously healthy config, secret and runtime', async () => {
const existing = bot('bot_existing', 'existing');
const fx = fixture({
bots: [existing],
secrets: { [existing.secretRef]: 'stable-secret' },
runtimeStart: async ({ runtime }) => {
if (runtime.appSecret === 'bad-rotated-secret') throw new Error('new handshake failed');
},
});
await fx.controller.initialize();
const result = await completeScan(fx, {
client_id: existing.appId,
client_secret: 'bad-rotated-secret',
user_info: { open_id: 'ou_new_owner', tenant_brand: 'feishu' },
});
assert.equal(result.registration.state, 'error');
assert.deepEqual(fx.configStore.list()[0], existing);
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
assert.equal(fx.controller.status().bots[0].connected, true);
assert.equal(fx.runtimes.get(existing.id).length, 3);
assert.equal(fx.runtimes.get(existing.id).at(-1).appSecret, 'stable-secret');
});
test('state cleanup failure keeps the bot visible and retryable with no live credential', async () => {
const existing = bot('bot_existing', 'existing');
const fx = fixture({
bots: [existing],
secrets: { [existing.secretRef]: 'stable-secret' },
deleteState: async () => { throw new Error('state file is busy'); },
});
await fx.controller.initialize();
await assert.rejects(fx.controller.deleteBot(existing.id), /session state/);
assert.equal(fx.configStore.list().length, 1);
assert.equal(fx.values.has(existing.secretRef), false);
const visible = fx.controller.status().bots[0];
assert.equal(visible.botId, existing.id);
assert.equal(visible.error.code, 'state_cleanup_failed');
assert.equal(visible.connected, false);
});
test('cancelling after a successful replacement start restores the old runtime exactly once', async () => {
const existing = bot('bot_existing', 'existing');
let releaseReplacement;
const replacementGate = new Promise((resolve) => { releaseReplacement = resolve; });
const fx = fixture({
bots: [existing],
secrets: { [existing.secretRef]: 'stable-secret' },
runtimeStart: async ({ runtime }) => {
if (runtime.appSecret === 'replacement-secret') await replacementGate;
},
});
await fx.controller.initialize();
const started = fx.controller.startRegistration();
const attemptId = started.registration.attempt;
await waitFor(() => fx.registrationRuns.length === 1);
const run = fx.registrationRuns.shift();
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/replacement', expireIn: 60 });
run.resolve({
client_id: existing.appId,
client_secret: 'replacement-secret',
user_info: { open_id: 'ou_replacement', tenant_brand: 'feishu' },
});
await waitFor(() => fx.runtimes.get(existing.id)?.length === 2);
const cancelling = fx.controller.cancelRegistration(attemptId);
releaseReplacement();
await cancelling;
const history = fx.runtimes.get(existing.id);
assert.equal(history.length, 3);
assert.equal(history[1].appSecret, 'replacement-secret');
assert.equal(history[1].stops, 1);
assert.equal(history[2].appSecret, 'stable-secret');
assert.equal(history[2].starts, 1);
assert.deepEqual(fx.configStore.list()[0], existing);
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
});
test('a cancelled replacement whose start rejects still restores the old runtime', async () => {
const existing = bot('bot_existing', 'existing');
let releaseReplacement;
const replacementGate = new Promise((resolve) => { releaseReplacement = resolve; });
const fx = fixture({
bots: [existing],
secrets: { [existing.secretRef]: 'stable-secret' },
runtimeStart: async ({ runtime }) => {
if (runtime.appSecret === 'replacement-secret') {
await replacementGate;
throw new Error('replacement handshake rejected');
}
},
});
await fx.controller.initialize();
const started = fx.controller.startRegistration();
const attemptId = started.registration.attempt;
await waitFor(() => fx.registrationRuns.length === 1);
const run = fx.registrationRuns.shift();
run.options.onQRCodeReady({ url: 'https://accounts.feishu.cn/replacement-reject', expireIn: 60 });
run.resolve({
client_id: existing.appId,
client_secret: 'replacement-secret',
user_info: { open_id: 'ou_replacement', tenant_brand: 'feishu' },
});
await waitFor(() => fx.runtimes.get(existing.id)?.length === 2);
const cancelling = fx.controller.cancelRegistration(attemptId);
releaseReplacement();
await cancelling;
const history = fx.runtimes.get(existing.id);
assert.equal(history.length, 3);
assert.equal(history.at(-1).appSecret, 'stable-secret');
assert.equal(history.at(-1).starts, 1);
assert.deepEqual(fx.configStore.list()[0], existing);
assert.equal(fx.values.get(existing.secretRef), 'stable-secret');
assert.equal(fx.controller.status().bots[0].connected, true);
});

View file

@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, readFile, stat, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { PluginConfigStore } from '../../../src/channels/feishu/plugin-config-store.mjs';
test('PluginConfigStore persists non-secret onboarding facts', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-config-'));
const path = join(dir, 'nested', 'config.json');
const store = await new PluginConfigStore(path).load();
assert.equal(store.get(), null);
await store.save({
appId: 'cli_test',
ownerOpenId: 'ou_owner',
domain: 'feishu',
botName: '北汇星河助手',
appSecret: 'must-not-be-written',
});
const raw = await readFile(path, 'utf8');
assert.doesNotMatch(raw, /must-not-be-written/);
assert.equal((await stat(path)).mode & 0o777, 0o600);
assert.equal((await new PluginConfigStore(path).load()).get().appId, 'cli_test');
await store.clear();
assert.equal(store.get(), null);
});
test('PluginConfigStore stays unconfigured after a failed write and can retry', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-config-retry-'));
const blockedParent = join(dir, 'blocked');
const path = join(blockedParent, 'config.json');
const store = await new PluginConfigStore(path).load();
await writeFile(blockedParent, 'not a directory');
const value = { appId: 'cli_retry', ownerOpenId: 'ou_retry', domain: 'feishu' };
await assert.rejects(store.save(value));
assert.equal(store.get(), null);
await unlink(blockedParent);
await mkdir(blockedParent);
await store.save(value);
assert.equal(store.get().appId, 'cli_retry');
});
test('PluginConfigStore migrates a v1 bot atomically without moving its credential', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-config-migrate-'));
const path = join(dir, 'config.json');
await writeFile(path, JSON.stringify({
version: 1,
appId: 'cli_legacy',
ownerOpenId: 'ou_legacy',
domain: 'feishu',
botName: '旧机器人',
}));
const store = await new PluginConfigStore(path).load();
const migrated = JSON.parse(await readFile(path, 'utf8'));
assert.equal(migrated.version, 2);
assert.equal(migrated.bots.length, 1);
assert.match(migrated.bots[0].id, /^bot_[a-f0-9]{24}$/);
assert.equal(migrated.bots[0].secretRef, 'DSH_FEISHU_APP_SECRET');
assert.deepEqual(migrated.bots[0].ownerOpenIds, ['ou_legacy']);
assert.equal(store.get().ownerOpenId, 'ou_legacy');
assert.equal(store.list()[0].appId, 'cli_legacy');
});
test('PluginConfigStore rejects an invalid or duplicate v2 document without dropping entries', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-config-invalid-v2-'));
const invalidPath = join(dir, 'invalid.json');
await writeFile(invalidPath, JSON.stringify({
version: 2,
bots: [
{
id: 'bot_good', appId: 'cli_good', secretRef: 'DSH_FEISHU_APP_SECRET_GOOD',
ownerOpenIds: ['ou_good'], domain: 'feishu',
},
{ id: '../bad', appId: 'cli_bad', secretRef: 'not-a-credential-ref', ownerOpenIds: ['ou_bad'] },
],
}));
await assert.rejects(new PluginConfigStore(invalidPath).load(), /invalid bot entry/);
const duplicatePath = join(dir, 'duplicate.json');
await writeFile(duplicatePath, JSON.stringify({
version: 2,
bots: [
{
id: 'bot_one', appId: 'cli_same', secretRef: 'DSH_FEISHU_APP_SECRET_ONE',
ownerOpenIds: ['ou_one'], domain: 'feishu',
},
{
id: 'bot_two', appId: 'cli_same', secretRef: 'DSH_FEISHU_APP_SECRET_TWO',
ownerOpenIds: ['ou_two'], domain: 'feishu',
},
],
}));
await assert.rejects(new PluginConfigStore(duplicatePath).load(), /duplicate bot identities/);
});

View file

@ -0,0 +1,139 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
DshFeishuController,
FEISHU_SECRET_REF,
} from '../../../src/channels/feishu/plugin-controller.mjs';
const flush = () => new Promise((resolve) => setImmediate(resolve));
function fixture({ initialConfig = null, failConfigSave = false } = {}) {
let sdkOptions;
let config = initialConfig;
let storedSecret = null;
const runtimes = [];
const controller = new DshFeishuController({
registerApp: (options) => {
sdkOptions = options;
return new Promise((resolve) => { fixture.resolveRegistration = resolve; });
},
verifyApp: async () => ({ name: '北汇星河助手', openId: 'ou_bot', activated: 1 }),
credentials: {
async resolve(ref) {
assert.equal(ref, FEISHU_SECRET_REF);
return storedSecret ? { value: storedSecret, source: 'file' } : undefined;
},
async set(ref, value) {
assert.equal(ref, FEISHU_SECRET_REF);
storedSecret = value;
},
async unset(ref) {
assert.equal(ref, FEISHU_SECRET_REF);
storedSecret = null;
},
},
configStore: {
get: () => config ? structuredClone(config) : null,
async save(next) {
if (failConfigSave) throw new Error('config write failed');
config = structuredClone(next);
return structuredClone(config);
},
async clear() { config = null; },
},
createRuntime: async () => {
const status = {
ready: false,
feishuLongConnectionState: 'idle',
harnessReachable: false,
};
const runtime = {
get status() { return structuredClone(status); },
async start() {
status.ready = true;
status.feishuLongConnectionState = 'connected';
status.harnessReachable = true;
},
async stop() {
status.ready = false;
status.feishuLongConnectionState = 'idle';
},
};
runtimes.push(runtime);
return runtime;
},
});
return {
controller,
getSdkOptions: () => sdkOptions,
getConfig: () => config,
getSecret: () => storedSecret,
runtimes,
};
}
test('QR success stores the secret off-config and becomes immediately chat-ready', async () => {
const fx = fixture();
const start = fx.controller.startRegistration();
assert.equal(start.phase, 'registering');
await flush();
assert.equal(fx.getSdkOptions().createOnly, true);
assert.equal(fx.getSdkOptions().addons.preset, false);
assert.deepEqual(fx.getSdkOptions().addons.events.items.tenant, ['im.message.receive_v1']);
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message.p2p_msg:readonly'));
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('im:message:send_as_bot'));
assert.ok(fx.getSdkOptions().addons.scopes.tenant.includes('cardkit:card:write'));
fx.getSdkOptions().onQRCodeReady({ url: 'https://accounts.feishu.cn/qr', expireIn: 600 });
fixture.resolveRegistration({
client_id: 'cli_created',
client_secret: 'new-secret',
user_info: { open_id: 'ou_owner', tenant_brand: 'feishu' },
});
await flush();
await flush();
await flush();
const status = fx.controller.status();
assert.equal(status.phase, 'connected');
assert.equal(status.connected, true);
assert.equal(status.bot.name, '北汇星河助手');
assert.equal(fx.getSecret(), 'new-secret');
assert.equal(fx.getConfig().appSecret, undefined);
assert.doesNotMatch(JSON.stringify(status), /new-secret|client_secret/);
});
test('disconnect removes configuration and stops automatic reuse', async () => {
const fx = fixture();
fx.controller.startRegistration();
await flush();
fixture.resolveRegistration({
client_id: 'cli_created',
client_secret: 'new-secret',
user_info: { open_id: 'ou_owner', tenant_brand: 'feishu' },
});
await flush();
await flush();
await flush();
await fx.controller.disconnect();
assert.equal(fx.controller.status().phase, 'unconfigured');
assert.equal(fx.getConfig(), null);
assert.equal(fx.getSecret(), null);
});
test('credential is removed when non-secret configuration cannot be persisted', async () => {
const fx = fixture({ failConfigSave: true });
fx.controller.startRegistration();
await flush();
fixture.resolveRegistration({
client_id: 'cli_created',
client_secret: 'new-secret',
user_info: { open_id: 'ou_owner', tenant_brand: 'feishu' },
});
await flush();
await flush();
assert.equal(fx.getSecret(), null);
assert.equal(fx.getConfig(), null);
assert.equal(fx.controller.status().phase, 'error');
});

View file

@ -0,0 +1,716 @@
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
FEISHU_APP_ID_REF,
FEISHU_APP_SECRET_REF,
FEISHU_ENDPOINTS,
FEISHU_MULTI_ENDPOINTS,
apply,
createDshCredentialStore,
createProductionController,
createProvisioningBackedController,
} from '../../../plugin-src/host/channels/feishu/index.mjs';
const signal = () => new AbortController().signal;
function status(overrides = {}) {
return {
phase: 'unconfigured',
connected: false,
configured: false,
bot: null,
registration: { state: 'idle', attempt: 0, updatedAt: 100 },
connection: {
ready: false,
feishuLongConnectionState: 'idle',
harnessReachable: false,
},
error: null,
...overrides,
};
}
async function rpcFixture(controller) {
let registration;
let disposed = false;
const ctx = {
connection: {
rpc: {
handle(channel, handler, options) {
registration = { channel, handler, options };
return async () => { disposed = true; };
},
},
},
};
const dispose = await apply(ctx, { controller });
return {
dispose,
get registration() { return registration; },
get disposed() { return disposed; },
};
}
test('Host plugin registers the real rc.6 Connection RPC shape as loopback-only', async () => {
const controller = {
status: async () => status(),
startRegistration: async () => status(),
cancelRegistration: async () => status(),
disconnect: async () => status(),
};
const fx = await rpcFixture(controller);
assert.equal(fx.registration.channel, '/feishu');
assert.deepEqual(fx.registration.options, { authority: 'loopback' });
const result = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
assert.equal(result.ok, true);
assert.equal(result.value.state, 'disconnected');
assert.equal(result.value.connected, false);
assert.equal(result.value.configured, false);
assert.equal(result.value.bot.name, '飞书机器人');
assert.equal(result.value.health.status, 'offline');
assert.equal('registration' in result.value, false);
await fx.dispose();
assert.equal(fx.disposed, true);
});
test('Host exposes configured offline as a redacted capability fact', async () => {
const secret = 'offline-secret-must-stay-host-only';
const controller = {
status: async () => status({
phase: 'disconnected',
configured: true,
bot: { name: '北汇星河助手', appSecret: secret },
credentials: { client_secret: secret },
connection: {
ready: false,
feishuLongConnectionState: 'failed',
harnessReachable: true,
token: secret,
},
}),
startRegistration: async () => status(),
cancelRegistration: async () => status(),
reconnect: async () => status({ configured: true }),
disconnect: async () => status(),
};
const fx = await rpcFixture(controller);
const result = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
assert.equal(result.ok, true);
assert.equal(result.value.state, 'disconnected');
assert.equal(result.value.connected, false);
assert.equal(result.value.configured, true);
assert.equal(result.value.health.status, 'offline');
assert.doesNotMatch(JSON.stringify(result), new RegExp(secret));
assert.equal('credentials' in result.value, false);
});
test('RPC dispatch matches every endpoint in client/api.js', async () => {
const calls = [];
let current = status({
phase: 'registering',
registration: {
state: 'qr_ready',
attempt: 7,
updatedAt: 100,
qrCodeUrl: 'https://accounts.feishu.cn/device',
expiresAt: Date.now() + 60_000,
},
});
const controller = {
status: async () => current,
startRegistration: async () => { calls.push('begin'); return current; },
cancelRegistration: async () => {
calls.push('cancel');
current = status({ registration: { state: 'cancelled', attempt: 7, updatedAt: 200 } });
return current;
},
reconnect: async () => { calls.push('test'); return current; },
disconnect: async () => { calls.push('disconnect'); return status(); },
};
const fx = await rpcFixture(controller);
const begun = await fx.registration.handler(
FEISHU_ENDPOINTS.beginProvisioning,
{ locale: 'zh-CN' },
signal(),
);
assert.equal(begun.ok, true);
assert.equal(begun.value.attemptId, '7');
assert.match(begun.value.qrCodeDataUrl, /^data:image\/png;base64,/);
const polled = await fx.registration.handler(
FEISHU_ENDPOINTS.pollProvisioning,
{ attemptId: '7' },
signal(),
);
assert.equal(polled.ok, true);
assert.equal(polled.value.status, 'pending');
const tested = await fx.registration.handler(FEISHU_ENDPOINTS.testConnection, {}, signal());
assert.equal(tested.ok, true);
const cancelled = await fx.registration.handler(
FEISHU_ENDPOINTS.cancelProvisioning,
{ attemptId: '7' },
signal(),
);
assert.equal(cancelled.ok, true);
assert.equal(cancelled.value.status, 'failed');
const disconnected = await fx.registration.handler(
FEISHU_ENDPOINTS.disconnect,
{ removeCredentials: true },
signal(),
);
assert.equal(disconnected.ok, true);
assert.deepEqual(calls, ['begin', 'test', 'cancel', 'disconnect']);
const attemptedSecret = await fx.registration.handler(
FEISHU_ENDPOINTS.beginProvisioning,
{ appSecret: 'must-not-cross-browser-boundary' },
signal(),
);
assert.equal(attemptedSecret.ok, false);
assert.equal(attemptedSecret.error.code, 'bad-request');
assert.doesNotMatch(JSON.stringify(attemptedSecret), /must-not-cross-browser-boundary/);
});
test('connection.test does not restart an already healthy long connection', async () => {
let reconnects = 0;
const healthy = status({
phase: 'connected',
connected: true,
configured: true,
connection: {
ready: true,
feishuLongConnectionState: 'connected',
harnessReachable: true,
},
});
const controller = {
status: async () => healthy,
startRegistration: async () => healthy,
cancelRegistration: async () => healthy,
reconnect: async () => { reconnects += 1; return healthy; },
disconnect: async () => status(),
};
const fx = await rpcFixture(controller);
const result = await fx.registration.handler(FEISHU_ENDPOINTS.testConnection, {}, signal());
assert.equal(result.ok, true);
assert.equal(result.value.connected, true);
assert.equal(reconnects, 0);
});
test('provision.begin waits through starting until onQRCodeReady is observable', async () => {
let current = status({
phase: 'registering',
registration: { state: 'starting', attempt: 3, updatedAt: 100 },
});
const controller = {
status: async () => current,
startRegistration: async () => {
setTimeout(() => {
current = status({
phase: 'registering',
registration: {
state: 'qr_ready',
attempt: 3,
updatedAt: 110,
qrCodeUrl: 'https://accounts.feishu.cn/async-qr',
expiresAt: Date.now() + 60_000,
},
});
}, 5);
return current;
},
cancelRegistration: async () => status(),
disconnect: async () => status(),
};
const fx = await rpcFixture(controller);
const begun = await fx.registration.handler(
FEISHU_ENDPOINTS.beginProvisioning,
{ locale: 'zh-CN' },
signal(),
);
assert.equal(begun.ok, true);
assert.equal(begun.value.attemptId, '3');
assert.equal(begun.value.verificationUrl, 'https://accounts.feishu.cn/async-qr');
assert.match(begun.value.qrCodeDataUrl, /^data:image\/png;base64,/);
});
test('cancelling during credential activation tears down the eventual runtime', async () => {
const calls = [];
const current = status({
phase: 'connecting',
configured: true,
registration: { state: 'saving', attempt: 11, updatedAt: 100 },
});
const controller = {
status: async () => current,
startRegistration: async () => current,
cancelRegistration: async () => { calls.push('cancel-only'); return current; },
disconnect: async () => { calls.push('disconnect'); return status(); },
};
const fx = await rpcFixture(controller);
const result = await fx.registration.handler(
FEISHU_ENDPOINTS.cancelProvisioning,
{ attemptId: '11' },
signal(),
);
assert.equal(result.ok, true);
assert.deepEqual(calls, ['disconnect']);
});
test('status returns qrCodeDataUrl while dropping all credential-shaped fields', async () => {
const secret = 'sdk-super-secret-value';
const controller = {
status: async () => status({
phase: 'registering',
appSecret: secret,
credentials: { client_secret: secret },
registration: {
state: 'qr_ready',
attempt: 1,
qrCodeUrl: 'https://accounts.feishu.cn/device',
expiresAt: Date.now() + 60_000,
client_secret: secret,
},
connection: {
ready: true,
connected: false,
state: 'connecting',
token: secret,
},
}),
startRegistration: async () => status(),
cancelRegistration: async () => status(),
disconnect: async () => status(),
};
const fx = await rpcFixture(controller);
const result = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
assert.equal(result.ok, true);
assert.equal(result.value.state, 'provisioning');
assert.equal(result.value.provisioning.verificationUrl, 'https://accounts.feishu.cn/device');
assert.match(result.value.provisioning.qrCodeDataUrl, /^data:image\/png;base64,/);
assert.doesNotMatch(JSON.stringify(result), /sdk-super-secret-value|client_secret|credentials|token/);
});
test('polling a new QR stays pending when another bot is already connected', async () => {
const current = status({
schemaVersion: 2,
phase: 'registering',
connected: false,
configured: true,
registration: {
state: 'qr_ready',
attempt: 'reg_new_bot',
qrCodeUrl: 'https://accounts.feishu.cn/new-bot',
expiresAt: Date.now() + 60_000,
},
bots: [{
botId: 'bot_existing',
phase: 'connected',
connected: true,
configured: true,
bot: { name: '现有机器人', appIdMasked: 'cli_old••••1234' },
connection: {
ready: true,
feishuLongConnectionState: 'connected',
harnessReachable: true,
},
}],
});
const controller = {
status: async () => current,
registrationStatus: async () => current,
startRegistration: async () => current,
cancelRegistration: async () => current,
disconnect: async () => status(),
};
const fx = await rpcFixture(controller);
const result = await fx.registration.handler(
FEISHU_ENDPOINTS.pollProvisioning,
{ attemptId: 'reg_new_bot' },
signal(),
);
assert.equal(result.ok, true);
assert.equal(result.value.status, 'pending');
assert.equal('botId' in result.value, false);
assert.equal('connection' in result.value, false);
});
test('multi-bot RPC operations require a botId and never expose host-only fields', async () => {
const calls = [];
const multi = status({
schemaVersion: 2,
revision: 9,
configured: true,
bots: [{
botId: 'bot_safe',
phase: 'connected',
connected: true,
configured: true,
secretRef: 'DSH_FEISHU_APP_SECRET_PRIVATE',
ownerOpenIds: ['ou_private'],
bot: {
name: '安全机器人',
appId: 'cli_raw_private',
appIdMasked: 'cli_safe••••1234',
domain: 'feishu',
},
connection: {
ready: true,
feishuLongConnectionState: 'connected',
harnessReachable: true,
token: 'private-token',
},
}],
});
const controller = {
status: async () => multi,
startRegistration: async () => multi,
cancelRegistration: async () => multi,
disconnect: async () => status(),
reconnectBot: async (botId) => { calls.push(['reconnect', botId]); return multi; },
disconnectBot: async (botId) => { calls.push(['disconnect', botId]); return multi; },
deleteBot: async (botId) => { calls.push(['delete', botId]); return status({ schemaVersion: 2, bots: [] }); },
};
const fx = await rpcFixture(controller);
for (const [endpoint, payload] of [
[FEISHU_MULTI_ENDPOINTS.reconnectBot, { botId: 'bot_safe' }],
[FEISHU_MULTI_ENDPOINTS.disconnectBot, { botId: 'bot_safe' }],
[FEISHU_MULTI_ENDPOINTS.deleteBot, { botId: 'bot_safe', confirm: true }],
]) {
const result = await fx.registration.handler(endpoint, payload, signal());
assert.equal(result.ok, true);
assert.doesNotMatch(JSON.stringify(result), /DSH_FEISHU_APP_SECRET|ou_private|cli_raw_private|private-token/);
}
assert.deepEqual(calls, [
['reconnect', 'bot_safe'],
['disconnect', 'bot_safe'],
['delete', 'bot_safe'],
]);
const invalid = await fx.registration.handler(
FEISHU_MULTI_ENDPOINTS.deleteBot,
{ botId: '../private', confirm: true },
signal(),
);
assert.equal(invalid.ok, false);
assert.equal(invalid.error.code, 'bad-request');
assert.doesNotMatch(JSON.stringify(invalid), /\.\.\/private/);
});
test('dependency failures and AbortSignal use valid RpcResult error branches', async () => {
const secret = 'should-never-be-reflected';
const controller = {
status: async () => { throw new Error(`SDK failed with ${secret}`); },
startRegistration: async () => status(),
cancelRegistration: async () => status(),
disconnect: async () => status(),
};
const fx = await rpcFixture(controller);
const failure = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
assert.deepEqual(failure, {
ok: false,
error: {
code: 'internal',
message: 'The Feishu integration operation failed.',
details: {},
},
});
assert.doesNotMatch(JSON.stringify(failure), new RegExp(secret));
const abort = new AbortController();
abort.abort();
const cancelled = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, abort.signal);
assert.equal(cancelled.ok, false);
assert.equal(cancelled.error.code, 'cancelled');
const unknown = await fx.registration.handler('credentials.read', {}, signal());
assert.equal(unknown.ok, false);
assert.equal(unknown.error.code, 'bad-request');
});
test('provisioning callback stores credentials and connects before connected is visible', async () => {
const secret = 'host-only-app-secret';
let onCredentials;
let registrationState = 'idle';
let configured = false;
let connectionStatus = {
ready: false,
feishuLongConnectionState: 'idle',
harnessReachable: false,
};
const saved = [];
const connectedWith = [];
const controller = createProvisioningBackedController({
createProvisioningManager(callbacks) {
onCredentials = callbacks.onCredentials;
return {
start() { registrationState = 'qr_ready'; return this.status(); },
status() {
return {
state: registrationState,
attempt: 1,
updatedAt: 100,
...(registrationState === 'qr_ready'
? { qrCodeUrl: 'https://accounts.feishu.cn/qr', expiresAt: Date.now() + 60_000 }
: {}),
};
},
cancel() { registrationState = 'cancelled'; return this.status(); },
};
},
credentialStore: {
async save(credentials) { saved.push(credentials); configured = true; },
async clear() { configured = false; },
async configured() { return configured; },
},
connectionManager: {
async connect(credentials) {
connectedWith.push(credentials);
connectionStatus = {
ready: true,
feishuLongConnectionState: 'connected',
harnessReachable: true,
};
},
async disconnect() {
connectionStatus = {
ready: false,
feishuLongConnectionState: 'idle',
harnessReachable: false,
};
},
status: () => connectionStatus,
},
});
const fx = await rpcFixture(controller);
const begun = await fx.registration.handler(
FEISHU_ENDPOINTS.beginProvisioning,
{ locale: 'zh-CN' },
signal(),
);
assert.equal(begun.ok, true);
await onCredentials({
client_id: 'cli_created',
client_secret: secret,
user_info: { open_id: 'ou_owner' },
});
registrationState = 'succeeded';
const ready = await fx.registration.handler(FEISHU_ENDPOINTS.status, {}, signal());
assert.equal(ready.ok, true);
assert.equal(ready.value.state, 'connected');
assert.equal(ready.value.connected, true);
assert.equal(saved[0].appSecret, secret);
assert.equal(connectedWith[0].appSecret, secret);
assert.doesNotMatch(JSON.stringify(ready), new RegExp(secret));
const disconnected = await fx.registration.handler(
FEISHU_ENDPOINTS.disconnect,
{ removeCredentials: true },
signal(),
);
assert.equal(disconnected.ok, true);
assert.equal(disconnected.value.state, 'disconnected');
assert.equal(configured, false);
});
test('DSH credential adapter stores refs off the browser plane and clears them', async () => {
const values = new Map();
const provider = {
async resolve(ref) {
return values.has(ref) ? { value: values.get(ref), source: 'file' } : undefined;
},
async describe(ref) {
return { configured: values.has(ref), source: values.has(ref) ? 'file' : undefined, writable: true };
},
async set(ref, value) { values.set(ref, value); },
async unset(ref) { values.delete(ref); },
};
const store = createDshCredentialStore(provider);
await store.save({ appId: 'cli_created', appSecret: 'stored-secret' });
assert.equal(values.get(FEISHU_APP_ID_REF), 'cli_created');
assert.equal(values.get(FEISHU_APP_SECRET_REF), 'stored-secret');
assert.equal(await store.configured(), true);
await store.clear();
assert.equal(values.size, 0);
assert.equal(await store.configured(), false);
});
test('production assembly needs only ctx credentials and the active DSH webServer', async () => {
const constructed = {};
class FakeConfigStore {
constructor(path) { constructed.configPath = path; }
async load() { return this; }
}
class FakeStateStore {
constructor(path) {
constructed.statePath = path;
(constructed.statePaths ??= []).push(path);
}
async load() { return this; }
}
class FakeHarness {
constructor(options) { constructed.harness = options; }
async ensureRunning() { constructed.harnessReadyChecks = (constructed.harnessReadyChecks ?? 0) + 1; }
stopManagedProcess() { constructed.harnessStopped = true; }
}
class FakeRuntime {
constructor(options) { constructed.runtime = options; }
}
class FakeController {
constructor(options) { constructed.controller = options; }
async initialize() { constructed.initialized = true; }
status() { return { totals: { configured: 0, connected: 0 } }; }
async close() { constructed.closed = true; }
}
const credentials = {};
const production = await createProductionController({
credentials,
webServer: { port: 43123, host: '127.0.0.1' },
logger: console,
}, {
dshHome: '/tmp/dsh-feishu-host-test',
workspace: '/tmp/dsh-feishu-workspace',
}, {
lark: { registerApp: async () => ({}) },
Controller: FakeController,
ConfigStore: FakeConfigStore,
StateStore: FakeStateStore,
HarnessClient: FakeHarness,
FeishuRuntime: FakeRuntime,
verifyFeishuApp: async () => ({}),
});
assert.equal(constructed.initialized, undefined);
await production.ready;
assert.equal(constructed.initialized, true);
assert.equal(constructed.harnessReadyChecks, 1);
assert.equal(constructed.controller.credentials, credentials);
assert.equal(String(constructed.harness.baseUrl), 'http://127.0.0.1:43123/');
assert.equal(constructed.harness.autostart, false);
assert.match(constructed.configPath, /integrations\/dsh-feishu\/config\.json$/);
await constructed.controller.createRuntime({
config: {
appId: 'cli_created',
domain: 'feishu',
ownerOpenId: 'ou_owner',
},
appSecret: 'host-only',
});
assert.match(constructed.statePath, /integrations\/dsh-feishu\/state\.json$/);
assert.equal(constructed.runtime.appSecret, 'host-only');
await constructed.controller.createRuntime({
botId: 'bot_alpha',
config: {
id: 'bot_alpha',
appId: 'cli_alpha',
secretRef: 'DSH_FEISHU_APP_SECRET_ALPHA',
domain: 'feishu',
ownerOpenIds: ['ou_alpha'],
},
appSecret: 'alpha-secret',
});
const alphaState = constructed.runtime.state;
await constructed.controller.createRuntime({
botId: 'bot_beta',
config: {
id: 'bot_beta',
appId: 'cli_beta',
secretRef: 'DSH_FEISHU_APP_SECRET_BETA',
domain: 'feishu',
ownerOpenIds: ['ou_beta'],
},
appSecret: 'beta-secret',
});
const betaState = constructed.runtime.state;
assert.notEqual(alphaState, betaState);
assert.ok(constructed.statePaths.some((path) => /bots\/bot_alpha\/state\.json$/.test(path)));
assert.ok(constructed.statePaths.some((path) => /bots\/bot_beta\/state\.json$/.test(path)));
await production.close();
assert.equal(constructed.closed, true);
assert.equal(constructed.harnessStopped, true);
});
test('a corrupt legacy state file cannot prevent a healthy v2 bot from starting', async () => {
const dataDir = await mkdtemp(join(tmpdir(), 'dsh-feishu-production-state-isolation-'));
await mkdir(dataDir, { recursive: true });
await writeFile(join(dataDir, 'state.json'), '{corrupt legacy state');
const legacy = {
id: 'bot_legacy',
appId: 'cli_legacy',
secretRef: 'DSH_FEISHU_APP_SECRET',
ownerOpenIds: ['ou_legacy'],
domain: 'feishu',
botName: '旧机器人',
};
const healthy = {
id: 'bot_healthy',
appId: 'cli_healthy',
secretRef: 'DSH_FEISHU_APP_SECRET_HEALTHY',
ownerOpenIds: ['ou_healthy'],
domain: 'feishu',
botName: '健康机器人',
};
await writeFile(join(dataDir, 'config.json'), JSON.stringify({ version: 2, bots: [legacy, healthy] }));
const secrets = new Map([
[legacy.secretRef, 'legacy-secret'],
[healthy.secretRef, 'healthy-secret'],
]);
class FakeRuntime {
#status = { ready: false, feishuLongConnectionState: 'idle', harnessReachable: false };
get status() { return structuredClone(this.#status); }
async start() {
this.#status = { ready: true, feishuLongConnectionState: 'connected', harnessReachable: true };
}
async stop() {
this.#status = { ready: false, feishuLongConnectionState: 'idle', harnessReachable: false };
}
}
class FakeHarness {
async ensureRunning() {}
stopManagedProcess() {}
}
const production = await createProductionController({
credentials: {
async resolve(ref) { return secrets.has(ref) ? { value: secrets.get(ref) } : undefined; },
async set(ref, value) { secrets.set(ref, value); },
async unset(ref) { secrets.delete(ref); },
},
webServer: { port: 43124 },
logger: console,
}, { dataDir, workspace: dataDir }, {
lark: { registerApp: async () => ({}) },
HarnessClient: FakeHarness,
FeishuRuntime: FakeRuntime,
verifyFeishuApp: async () => ({}),
});
await production.ready;
const statusValue = production.controller.status();
assert.equal(statusValue.bots.find((entry) => entry.botId === 'bot_legacy').phase, 'error');
assert.equal(statusValue.bots.find((entry) => entry.botId === 'bot_healthy').connected, true);
assert.equal(statusValue.totals.connected, 1);
await production.close();
});

View file

@ -0,0 +1,280 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { RegistrationManager } from '../../../src/channels/feishu/registration-manager.mjs';
function deferred() {
let resolve;
let reject;
const promise = new Promise((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function fakeClock() {
let time = 0;
let sequence = 0;
const timers = new Map();
return {
now: () => time,
setTimeout(fn, delay) {
const id = ++sequence;
timers.set(id, { at: time + delay, fn });
return id;
},
clearTimeout(id) {
timers.delete(id);
},
advance(milliseconds) {
const target = time + milliseconds;
for (;;) {
const due = [...timers.entries()]
.filter(([, timer]) => timer.at <= target)
.sort((left, right) => left[1].at - right[1].at)[0];
if (!due) break;
const [id, timer] = due;
timers.delete(id);
time = timer.at;
timer.fn();
}
time = target;
},
};
}
async function flushPromises() {
await new Promise((resolve) => setImmediate(resolve));
}
test('RegistrationManager captures QR countdown and SDK polling states', async () => {
const clock = fakeClock();
const registration = deferred();
let sdkOptions;
const manager = new RegistrationManager({
registerApp: (options) => {
sdkOptions = options;
return registration.promise;
},
onCredentials: async () => {},
now: clock.now,
setTimeout: clock.setTimeout,
clearTimeout: clock.clearTimeout,
});
assert.deepEqual(manager.start({ source: 'dsh-feishu' }), {
state: 'starting',
attempt: 1,
updatedAt: 0,
});
await flushPromises();
assert.equal(sdkOptions.source, 'dsh-feishu');
assert.ok(sdkOptions.signal instanceof AbortSignal);
sdkOptions.onQRCodeReady({ url: 'https://accounts.feishu.cn/device', expireIn: 5 });
assert.deepEqual(manager.status(), {
state: 'qr_ready',
attempt: 1,
updatedAt: 0,
qrCodeUrl: 'https://accounts.feishu.cn/device',
expiresAt: 5000,
remainingSeconds: 5,
});
clock.advance(1200);
assert.equal(manager.status().remainingSeconds, 4);
sdkOptions.onStatusChange({ status: 'polling' });
assert.equal(manager.status().state, 'polling');
sdkOptions.onStatusChange({ status: 'slow_down', interval: 10 });
assert.equal(manager.status().state, 'slow_down');
assert.equal(manager.status().pollIntervalSeconds, 10);
sdkOptions.onStatusChange({ status: 'domain_switched' });
assert.equal(manager.status().state, 'domain_switched');
assert.equal(manager.status().remainingSeconds, 4);
manager.cancel();
});
test('RegistrationManager only sends credentials to callback and never exposes the secret', async () => {
const registration = deferred();
const persistence = deferred();
const received = [];
let sdkOptions;
const manager = new RegistrationManager({
registerApp: (options) => {
sdkOptions = options;
return registration.promise;
},
onCredentials: (credentials) => {
received.push(credentials);
return persistence.promise;
},
});
manager.start();
await flushPromises();
sdkOptions.onQRCodeReady({ url: 'https://example.test/qr', expireIn: 60 });
registration.resolve({
client_id: 'cli_test',
client_secret: 'super-secret-value',
user_info: { open_id: 'ou_test', tenant_brand: 'feishu' },
unexpected: 'must-not-be-forwarded',
});
await flushPromises();
assert.deepEqual(received, [{
client_id: 'cli_test',
client_secret: 'super-secret-value',
user_info: { open_id: 'ou_test', tenant_brand: 'feishu' },
}]);
assert.equal(manager.status().state, 'saving');
assert.equal('qrCodeUrl' in manager.status(), false);
assert.equal('remainingSeconds' in manager.status(), false);
assert.doesNotMatch(JSON.stringify(manager.status()), /super-secret-value|client_secret/);
persistence.resolve();
await flushPromises();
assert.equal(manager.status().state, 'succeeded');
assert.doesNotMatch(JSON.stringify(manager.status()), /super-secret-value|client_secret|cli_test|ou_test/);
});
test('a concurrent start aborts and ignores the previous attempt', async () => {
const registrations = [deferred(), deferred()];
const sdkCalls = [];
const received = [];
const manager = new RegistrationManager({
registerApp: (options) => {
const index = sdkCalls.length;
sdkCalls.push(options);
return registrations[index].promise;
},
onCredentials: async (credentials) => received.push(credentials.client_id),
});
manager.start();
await flushPromises();
manager.start();
assert.equal(sdkCalls[0].signal.aborted, true);
assert.equal(manager.status().attempt, 2);
await flushPromises();
sdkCalls[0].onQRCodeReady({ url: 'https://stale.test', expireIn: 30 });
registrations[0].resolve({ client_id: 'cli_stale', client_secret: 'stale-secret' });
registrations[1].resolve({ client_id: 'cli_current', client_secret: 'current-secret' });
await flushPromises();
await flushPromises();
assert.deepEqual(received, ['cli_current']);
assert.equal(manager.status().state, 'succeeded');
assert.doesNotMatch(JSON.stringify(manager.status()), /stale-secret|current-secret/);
});
test('cancel is terminal and ignores a late SDK result', async () => {
const registration = deferred();
const received = [];
let sdkOptions;
const manager = new RegistrationManager({
registerApp: (options) => {
sdkOptions = options;
return registration.promise;
},
onCredentials: async (credentials) => received.push(credentials),
});
manager.start();
await flushPromises();
const status = manager.cancel();
assert.equal(status.state, 'cancelled');
assert.equal(status.error.code, 'abort');
assert.equal(sdkOptions.signal.aborted, true);
registration.resolve({ client_id: 'cli_late', client_secret: 'late-secret' });
await flushPromises();
assert.deepEqual(received, []);
assert.equal(manager.status().state, 'cancelled');
assert.doesNotMatch(JSON.stringify(manager.status()), /late-secret/);
});
test('QR expiry aborts polling and reports an explicit expired state', async () => {
const clock = fakeClock();
const registration = deferred();
let sdkOptions;
const manager = new RegistrationManager({
registerApp: (options) => {
sdkOptions = options;
return registration.promise;
},
onCredentials: async () => assert.fail('expired credentials must not be stored'),
now: clock.now,
setTimeout: clock.setTimeout,
clearTimeout: clock.clearTimeout,
});
manager.start();
await flushPromises();
sdkOptions.onQRCodeReady({ url: 'https://example.test/expiring', expireIn: 3 });
clock.advance(2999);
assert.equal(manager.status().remainingSeconds, 1);
clock.advance(1);
assert.equal(manager.status().state, 'expired');
assert.equal(manager.status().error.code, 'expired_token');
assert.equal('qrCodeUrl' in manager.status(), false);
assert.equal(sdkOptions.signal.aborted, true);
});
test('SDK expiration and errors become distinct safe terminal states', async (t) => {
await t.test('expired_token is expired', async () => {
const manager = new RegistrationManager({
registerApp: async () => {
throw { code: 'expired_token', description: 'Polling timed out' };
},
onCredentials: async () => {},
});
manager.start();
await flushPromises();
assert.equal(manager.status().state, 'expired');
assert.equal(manager.status().error.code, 'expired_token');
});
await t.test('unknown error does not reflect its message', async () => {
const manager = new RegistrationManager({
registerApp: async () => {
throw new Error('request failed with client_secret=do-not-leak');
},
onCredentials: async () => {},
});
manager.start();
await flushPromises();
const status = manager.status();
assert.equal(status.state, 'error');
assert.equal(status.error.code, 'registration_failed');
assert.doesNotMatch(JSON.stringify(status), /do-not-leak|client_secret/);
});
});
test('credential persistence failure is safe and does not expose the secret', async () => {
const manager = new RegistrationManager({
registerApp: async () => ({
client_id: 'cli_test',
client_secret: 'secret-mentioned-by-callback',
user_info: { open_id: 'ou_test' },
}),
onCredentials: async ({ client_secret: secret }) => {
throw new Error(`failed to persist ${secret}`);
},
});
manager.start();
await flushPromises();
await flushPromises();
const status = manager.status();
assert.equal(status.state, 'error');
assert.equal(status.error.code, 'credentials_callback_failed');
assert.doesNotMatch(JSON.stringify(status), /secret-mentioned-by-callback|client_secret/);
});

View file

@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { StateStore } from '../../../src/channels/feishu/state-store.mjs';
test('StateStore persists sessions and dedupe ids', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-state-'));
const path = join(dir, 'state.json');
const first = await new StateStore(path).load();
await first.setSession('group:one', 'session-one');
await first.markSeen('message-one');
const second = await new StateStore(path).load();
assert.equal(second.sessionFor('group:one'), 'session-one');
assert.equal(second.hasSeen('message-one'), true);
assert.equal(JSON.parse(await readFile(path, 'utf8')).version, 1);
});
test('separate bot StateStores isolate identical conversations and message ids', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-feishu-state-bots-'));
const alpha = await new StateStore(join(dir, 'bot-alpha', 'state.json')).load();
const beta = await new StateStore(join(dir, 'bot-beta', 'state.json')).load();
await alpha.setSession('p2p:ou_same', 'session-alpha');
await beta.setSession('p2p:ou_same', 'session-beta');
await alpha.markSeen('om_same');
assert.equal(alpha.sessionFor('p2p:ou_same'), 'session-alpha');
assert.equal(beta.sessionFor('p2p:ou_same'), 'session-beta');
assert.equal(alpha.hasSeen('om_same'), true);
assert.equal(beta.hasSeen('om_same'), false);
});

View file

@ -0,0 +1,68 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ConnectionSupervisor } from '../../../plugin-src/host/channels/weixin/connection-supervisor.mjs';
const flush = () => new Promise((resolve) => setImmediate(resolve));
function scheduler() {
const pending = [];
return {
pending,
setTimeoutImpl(callback, delay) {
const handle = { callback, delay, cancelled: false, unref() {} };
pending.push(handle);
return handle;
},
clearTimeoutImpl(handle) { handle.cancelled = true; },
async runNext() {
const handle = pending.shift();
assert.ok(handle);
assert.equal(handle.cancelled, false);
handle.callback();
await flush();
await flush();
return handle.delay;
},
};
}
test('supervisor waits for the in-process Harness API and retries offline Weixin accounts', async () => {
const timers = scheduler();
let healthChecks = 0;
let initializations = 0;
const supervisor = new ConnectionSupervisor({
harness: {
async ensureRunning() {
healthChecks += 1;
if (healthChecks === 1) throw new Error('Host is still starting');
},
},
controller: {
async initialize() {
initializations += 1;
return {
totals: { configured: 1, connected: initializations > 1 ? 1 : 0 },
};
},
status() {},
},
logger: { warn() {} },
retryDelaysMs: [5, 10],
healthyIntervalMs: 100,
setTimeoutImpl: timers.setTimeoutImpl,
clearTimeoutImpl: timers.clearTimeoutImpl,
}).start();
assert.equal(await timers.runNext(), 0);
assert.equal(timers.pending[0].delay, 5);
await timers.runNext();
assert.equal(initializations, 1);
assert.equal(timers.pending[0].delay, 10);
await timers.runNext();
assert.equal(initializations, 2);
assert.equal((await supervisor.ready).totals.connected, 0);
assert.equal(timers.pending[0].delay, 100);
await supervisor.close();
assert.equal(timers.pending[0].cancelled, true);
});

View file

@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { HarnessReplyTracker } from '../../../src/channels/weixin/harness-client.mjs';
test('reply tracker associates only the Harness turn created by the Weixin prompt RPC', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'weixin-prompt', afterSeq: 2 });
const first = tracker.consume([
{ event: { seq: 3, type: 'turn/start', data: { turn: 9 } } },
{ event: {
seq: 4,
type: 'user/message',
data: { turn: 9, source: { rpcId: 'weixin-prompt' } },
} },
{ event: {
seq: 5,
type: 'assistant/chunk',
data: { turn: 9, step: 0, chunk: { type: 'text-delta', index: 0, text: '微信' } },
} },
]);
assert.deepEqual(first, { type: 'text', text: '微信' });
tracker.consume([
{ event: {
seq: 6,
type: 'assistant/message',
data: { turn: 9, message: { content: [{ type: 'text', text: '微信回复完成' }] } },
} },
{ event: { seq: 7, type: 'turn/end', data: { turn: 9, reason: 'completed' } } },
]);
assert.equal(tracker.finished, true);
assert.equal(tracker.answer, '微信回复完成');
});
test('reply tracker ignores interleaved turns and older events', () => {
const tracker = new HarnessReplyTracker({ promptRpcId: 'target', afterSeq: 10 });
tracker.consume([
{ event: { seq: 9, type: 'turn/start', data: { turn: 1 } } },
{ event: { seq: 11, type: 'turn/start', data: { turn: 2 } } },
{ event: { seq: 12, type: 'user/message', data: { turn: 2, source: { rpcId: 'other' } } } },
{ event: {
seq: 13,
type: 'assistant/message',
data: { turn: 2, message: { content: [{ type: 'text', text: 'wrong' }] } },
} },
]);
assert.equal(tracker.answer, '');
assert.equal(tracker.finished, false);
});

View file

@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
WEIXIN_ENDPOINTS,
apply,
createWeixinRpcHandler,
} from '../../../plugin-src/host/channels/weixin/index.mjs';
function snapshot(overrides = {}) {
return {
schemaVersion: 1,
revision: 1,
state: 'disconnected',
bots: [],
totals: { configured: 0, connected: 0 },
...overrides,
};
}
function controllerFixture() {
const attempts = new Map();
const controller = {
status: () => snapshot(),
startProvisioning: async () => {
const value = {
attemptId: 'attempt-1',
status: 'pending',
verificationUrl: 'https://liteapp.weixin.qq.com/q/test',
expiresAt: Date.now() + 60_000,
pollIntervalMs: 1_000,
};
attempts.set(value.attemptId, value);
return value;
},
registrationStatus: (id) => attempts.get(id) ?? null,
submitVerification: async (id) => ({ ...attempts.get(id), status: 'scanned' }),
cancelProvisioning: async (id) => ({ ...attempts.get(id), status: 'cancelled' }),
reconnectBot: async () => snapshot(),
deleteBot: async () => snapshot(),
};
return controller;
}
test('Host plugin registers the Weixin RPC channel as loopback-only', async () => {
let registration;
const dispose = async () => {};
const ctx = {
connection: { rpc: { handle: (channel, handler, options) => {
registration = { channel, handler, options };
return dispose;
} } },
};
const returned = await apply(ctx, { controller: controllerFixture() });
assert.equal(returned, dispose);
assert.equal(registration.channel, '/weixin');
assert.deepEqual(registration.options, { authority: 'loopback' });
});
test('RPC returns QR data and verification states without exposing secret-shaped fields', async () => {
const controller = controllerFixture();
const handler = createWeixinRpcHandler(controller, {
encodeQr: async (url) => `data:image/png;base64,${Buffer.from(url).toString('base64')}`,
});
const signal = new AbortController().signal;
const begun = await handler(WEIXIN_ENDPOINTS.beginProvisioning, {}, signal);
assert.equal(begun.ok, true);
assert.match(begun.value.qrCodeDataUrl, /^data:image\/png;base64,/);
const verified = await handler(WEIXIN_ENDPOINTS.submitVerification, {
attemptId: 'attempt-1', verifyCode: '123456',
}, signal);
assert.equal(verified.ok, true);
assert.equal(verified.value.status, 'scanned');
const secretAttempt = await handler(WEIXIN_ENDPOINTS.beginProvisioning, {
bot_token: 'must-never-cross-the-browser-boundary',
}, signal);
assert.equal(secretAttempt.ok, false);
assert.equal(secretAttempt.error.code, 'bad-request');
assert.doesNotMatch(JSON.stringify(secretAttempt), /must-never-cross/);
});
test('RPC requires explicit confirmation before removing a Weixin account', async () => {
const handler = createWeixinRpcHandler(controllerFixture());
const result = await handler(WEIXIN_ENDPOINTS.deleteBot, {
botId: 'wx_0123456789abcdef01234567',
confirm: false,
}, new AbortController().signal);
assert.equal(result.ok, false);
assert.equal(result.error.code, 'bad-request');
});

View file

@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import {
deriveWeixinBotIdentity,
WeixinConfigStore,
} from '../../../src/channels/weixin/config-store.mjs';
import { WeixinStateStore } from '../../../src/channels/weixin/state-store.mjs';
test('config store persists non-secret account facts atomically with restrictive permissions', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-weixin-config-'));
const path = join(root, 'nested', 'config.json');
const store = await new WeixinConfigStore(path).load();
const identity = deriveWeixinBotIdentity('account@im.bot');
await store.save({
...identity,
accountId: 'account@im.bot',
ownerUserId: 'owner-user',
baseUrl: 'https://ilinkai.weixin.qq.com',
createdAt: '2026-08-15T00:00:00.000Z',
});
const raw = await readFile(path, 'utf8');
assert.match(raw, /"accountId": "account@im\.bot"/);
assert.doesNotMatch(raw, /bot_token|secret-token/);
assert.equal((await stat(path)).mode & 0o777, 0o600);
assert.deepEqual((await new WeixinConfigStore(path).load()).list(), store.list());
});
test('config store rejects duplicate or tampered identities', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-weixin-invalid-'));
const path = join(root, 'config.json');
await writeFile(path, JSON.stringify({
version: 1,
accounts: [{
botId: 'wx_000000000000000000000000',
accountId: 'real@im.bot',
tokenRef: 'DSH_WEIXIN_BOT_TOKEN_000000000000000000000000',
ownerUserId: 'owner',
baseUrl: 'https://ilinkai.weixin.qq.com',
}],
}));
await assert.rejects(new WeixinConfigStore(path).load(), /invalid account data/);
});
test('state store retains sessions, deduplication, and the getUpdates cursor', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-weixin-state-'));
const path = join(root, 'account', 'state.json');
const state = await new WeixinStateStore(path).load();
await state.setSession('p2p:user', 'session-1');
await state.markSeen('message-1');
await state.setGetUpdatesBuf('cursor-2');
const restored = await new WeixinStateStore(path).load();
assert.equal(restored.sessionFor('p2p:user'), 'session-1');
assert.equal(restored.hasSeen('message-1'), true);
assert.equal(restored.getUpdatesBuf(), 'cursor-2');
assert.equal((await stat(path)).mode & 0o777, 0o600);
});

View file

@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createWeixinApi,
extractWeixinText,
normalizeWeixinApiBaseUrl,
splitWeixinText,
WeixinApiError,
} from '../../../src/channels/weixin/weixin-api.mjs';
function jsonResponse(value, init) {
return new Response(JSON.stringify(value), {
status: 200,
headers: { 'content-type': 'application/json' },
...init,
});
}
test('QR login uses the Tencent iLink headers and keeps local tokens out of the URL', async () => {
const calls = [];
const api = createWeixinApi({
fetchImpl: async (url, init) => {
calls.push({ url: url.toString(), init });
return jsonResponse({
qrcode: 'private-qr-token',
qrcode_img_content: 'https://liteapp.weixin.qq.com/q/example',
});
},
});
const login = await api.beginLogin({ localTokens: [' token-a ', 'token-a', 'token-b'] });
assert.deepEqual(login, {
qrcode: 'private-qr-token',
qrcodeUrl: 'https://liteapp.weixin.qq.com/q/example',
});
assert.match(calls[0].url, /ilink\/bot\/get_bot_qrcode\?bot_type=3$/);
assert.doesNotMatch(calls[0].url, /token-a|token-b/);
assert.equal(calls[0].init.headers['iLink-App-Id'], 'bot');
assert.equal(calls[0].init.headers['iLink-App-ClientVersion'], String((2 << 16) | (4 << 8) | 6));
assert.equal(calls[0].init.headers.Authorization, undefined);
assert.deepEqual(JSON.parse(calls[0].init.body), { local_token_list: ['token-a', 'token-b'] });
});
test('login polling submits a verification code only to an approved Weixin host', async () => {
const calls = [];
const api = createWeixinApi({
fetchImpl: async (url, init) => {
calls.push({ url: url.toString(), init });
return jsonResponse({ status: 'scaned' });
},
});
const status = await api.pollLogin({
qrcode: 'secret-qr',
baseUrl: 'https://shard.ilinkai.weixin.qq.com',
verifyCode: '123456',
});
assert.equal(status.status, 'scaned');
assert.match(calls[0].url, /qrcode=secret-qr&verify_code=123456$/);
assert.equal(calls[0].init.method, 'GET');
assert.equal(calls[0].init.headers.AuthorizationType, undefined);
await assert.rejects(
api.pollLogin({ qrcode: 'secret', baseUrl: 'https://attacker.test' }),
(error) => error instanceof WeixinApiError && error.code === 'untrusted-base-url',
);
assert.equal(calls.length, 1);
});
test('sendText emits the iLink message envelope without reflecting the token in its body', async () => {
const calls = [];
const api = createWeixinApi({
fetchImpl: async (url, init) => {
calls.push({ url: url.toString(), init });
return jsonResponse({ ret: 0 });
},
});
await api.sendText({
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'host-only-token',
toUserId: 'wx-user',
text: 'Harness reply',
contextToken: 'message-context',
runId: 'run-1',
});
assert.equal(calls[0].init.headers.Authorization, 'Bearer host-only-token');
const body = JSON.parse(calls[0].init.body);
assert.equal(body.msg.to_user_id, 'wx-user');
assert.equal(body.msg.context_token, 'message-context');
assert.equal(body.msg.item_list[0].text_item.text, 'Harness reply');
assert.equal(body.base_info.channel_version, '2.4.6');
assert.equal(body.base_info.bot_agent, 'DeepSeekHarness/0.1.0');
assert.doesNotMatch(calls[0].init.body, /host-only-token/);
});
test('getUpdates converts its own long-poll timeout into an empty successful poll', async () => {
const api = createWeixinApi({
fetchImpl: async (_url, init) => new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
}),
});
const result = await api.getUpdates({
baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'token',
getUpdatesBuf: 'cursor',
timeoutMs: 2,
});
assert.deepEqual(result, { ret: 0, msgs: [], get_updates_buf: 'cursor' });
});
test('Weixin URL, inbound text, and reply chunk helpers enforce their narrow formats', () => {
assert.equal(
normalizeWeixinApiBaseUrl('https://ilinkai.weixin.qq.com/path'),
'https://ilinkai.weixin.qq.com/path/',
);
assert.throws(() => normalizeWeixinApiBaseUrl('https://ilinkai.weixin.qq.com:444/'));
assert.equal(extractWeixinText({ item_list: [{ type: 1, text_item: { text: ' 你好 ' } }] }), '你好');
assert.equal(extractWeixinText({ item_list: [{ type: 3, voice_item: { text: '语音转写' } }] }), '语音转写');
assert.deepEqual(splitWeixinText('abcdefgh', 5), ['abcde', 'fgh']);
});

View file

@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
function message(id, text, overrides = {}) {
return {
message_id: id,
message_type: 1,
from_user_id: 'owner-user',
context_token: `context-${id}`,
item_list: [{ type: 1, text_item: { text } }],
...overrides,
};
}
function stateFixture() {
const sessions = new Map();
const seen = new Set();
return {
sessions,
seen,
state: {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: (key) => sessions.get(key) ?? null,
setSession: async (key, sessionId) => sessions.set(key, sessionId),
clearSession: async (key) => sessions.delete(key),
},
};
}
test('bridge maps the scanning Weixin user to one persistent Harness session and echoes context_token', async () => {
const sent = [];
const asked = [];
const fixture = stateFixture();
const status = createWeixinBridgeStatus();
const bridge = new WeixinHarnessBridge({
api: { sendText: async (request) => sent.push(request) },
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
harness: {
sessionExists: async (sessionId) => sessionId === 'session-1',
createSession: async () => 'session-1',
ask: async (sessionId, text) => {
asked.push({ sessionId, text });
return 'Harness 的回答';
},
},
state: fixture.state,
status,
});
await bridge.accept(message('1', '你好'));
await bridge.accept(message('2', '继续'));
assert.deepEqual(asked, [
{ sessionId: 'session-1', text: '你好' },
{ sessionId: 'session-1', text: '继续' },
]);
assert.equal(fixture.sessions.get('p2p:owner-user'), 'session-1');
assert.deepEqual(sent.map(({ toUserId, text, contextToken }) => ({ toUserId, text, contextToken })), [
{ toUserId: 'owner-user', text: 'Harness 的回答', contextToken: 'context-1' },
{ toUserId: 'owner-user', text: 'Harness 的回答', contextToken: 'context-2' },
]);
assert.equal(status.messagesReceived, 2);
assert.equal(status.messagesReplied, 2);
});
test('bridge rejects every user except the account owner returned by QR login', async () => {
const fixture = stateFixture();
let asked = 0;
const status = createWeixinBridgeStatus();
const bridge = new WeixinHarnessBridge({
api: { sendText: async () => assert.fail('unauthorized users must not receive a reply') },
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
harness: { ask: async () => { asked += 1; } },
state: fixture.state,
status,
});
await bridge.accept(message('unauthorized', '越权', { from_user_id: 'other-user' }));
assert.equal(asked, 0);
assert.equal(status.messagesRejected, 1);
});
test('bridge commands are local and internal failures return a generic message', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'old-session');
const sent = [];
const bridge = new WeixinHarnessBridge({
api: { sendText: async (request) => sent.push(request.text) },
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
ask: async () => { throw new Error('private path /secret and token-shaped detail'); },
},
state: fixture.state,
logger: { error() {} },
});
await bridge.accept(message('new', '/new'));
assert.equal(fixture.sessions.has('p2p:owner-user'), false);
await bridge.accept(message('failure', '触发失败'));
assert.match(sent.at(-1), /消息处理失败/);
assert.doesNotMatch(sent.at(-1), /private path|secret|token-shaped/);
});

View file

@ -0,0 +1,218 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { WeixinController } from '../../../src/channels/weixin/weixin-controller.mjs';
const flush = () => new Promise((resolve) => setImmediate(resolve));
async function waitFor(read, predicate, attempts = 100) {
for (let index = 0; index < attempts; index += 1) {
const value = read();
if (predicate(value)) return value;
await flush();
}
throw new Error('condition was not reached');
}
function credentialsFixture() {
const values = new Map();
const calls = [];
return {
values,
calls,
provider: {
resolve: async (ref) => values.has(ref)
? { configured: true, source: 'settings', value: values.get(ref) }
: { configured: false },
set: async (ref, value) => { calls.push(['set', ref]); values.set(ref, value); },
unset: async (ref) => { calls.push(['unset', ref]); values.delete(ref); },
},
};
}
function configFixture() {
const accounts = new Map();
return {
accounts,
store: {
list: () => [...accounts.values()].map((account) => structuredClone(account)),
get: (botId) => accounts.has(botId) ? structuredClone(accounts.get(botId)) : null,
getByAccountId: (accountId) => {
const found = [...accounts.values()].find((account) => account.accountId === accountId);
return found ? structuredClone(found) : null;
},
save: async (account) => { accounts.set(account.botId, structuredClone(account)); return account; },
remove: async (botId) => accounts.delete(botId),
},
};
}
function runtimeFactory({ failStart = false } = {}) {
const runtimes = [];
const createRuntime = async ({ config, token }) => {
let ready = false;
const runtime = {
config,
token,
get status() {
return {
ready,
weixinConnectionState: ready ? 'connected' : 'idle',
harnessReachable: ready,
lastCheckedAt: ready ? 100 : null,
};
},
async start() {
if (failStart) throw new Error('runtime start failed with host-only detail');
ready = true;
},
async stop() { ready = false; },
};
runtimes.push(runtime);
return runtime;
};
return { runtimes, createRuntime };
}
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const runtimes = runtimeFactory();
const controller = new WeixinController({
api: {
beginLogin: async ({ localTokens }) => {
assert.deepEqual(localTokens, []);
return { qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' };
},
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'private-bot-token',
ilink_bot_id: 'account@im.bot',
ilink_user_id: 'owner-user',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
});
const begun = await controller.startProvisioning();
const completed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'connected',
);
assert.match(completed.botId, /^wx_[a-f0-9]{24}$/);
assert.equal(credentials.values.size, 1);
assert.equal([...credentials.values.values()][0], 'private-bot-token');
const stored = [...configs.accounts.values()][0];
assert.equal(stored.ownerUserId, 'owner-user');
assert.equal('token' in stored, false);
assert.equal(runtimes.runtimes[0].token, 'private-bot-token');
const publicJson = JSON.stringify(controller.status());
assert.doesNotMatch(publicJson, /private-bot-token|owner-user|account@im\.bot|tokenRef/);
assert.equal(controller.status().totals.connected, 1);
await controller.deleteBot(completed.botId);
assert.equal(credentials.values.size, 0);
assert.equal(configs.accounts.size, 0);
await controller.close();
});
test('verification-code state pauses polling and resumes with the submitted digits', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const runtimes = runtimeFactory();
const verifyCodes = [];
let polls = 0;
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async ({ verifyCode }) => {
polls += 1;
verifyCodes.push(verifyCode);
if (polls === 1) return { status: 'need_verifycode' };
return {
status: 'confirmed',
bot_token: 'token-after-code',
ilink_bot_id: 'verify@im.bot',
ilink_user_id: 'verify-owner',
baseurl: 'https://ilinkai.weixin.qq.com',
};
},
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
});
const begun = await controller.startProvisioning();
await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'needs_verification',
);
assert.equal(polls, 1);
await controller.submitVerification(begun.attemptId, '123456');
await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'connected',
);
assert.deepEqual(verifyCodes, [null, '123456']);
await controller.close();
});
test('activation failure rolls credentials and non-secret config back', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const runtimes = runtimeFactory({ failStart: true });
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async () => ({
status: 'confirmed',
bot_token: 'must-be-rolled-back',
ilink_bot_id: 'rollback@im.bot',
ilink_user_id: 'owner',
baseurl: 'https://ilinkai.weixin.qq.com',
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimes.createRuntime,
logger: { error() {}, warn() {} },
});
const begun = await controller.startProvisioning();
const failed = await waitFor(
() => controller.registrationStatus(begun.attemptId),
(value) => value.status === 'failed',
);
assert.equal(failed.error.code, 'activation-failed');
assert.equal(credentials.values.size, 0);
assert.equal(configs.accounts.size, 0);
assert.doesNotMatch(JSON.stringify(failed), /must-be-rolled-back|host-only detail/);
await controller.close();
});
test('cancelling an in-flight QR long poll is terminal and writes no credentials', async () => {
const credentials = credentialsFixture();
const configs = configFixture();
const controller = new WeixinController({
api: {
beginLogin: async () => ({ qrcode: 'qr-secret', qrcodeUrl: 'https://liteapp.weixin.qq.com/q/test' }),
pollLogin: async ({ signal }) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
}),
},
credentials: credentials.provider,
configStore: configs.store,
createRuntime: runtimeFactory().createRuntime,
});
const begun = await controller.startProvisioning();
const cancelled = await controller.cancelProvisioning(begun.attemptId);
assert.equal(cancelled.status, 'cancelled');
assert.equal(credentials.values.size, 0);
assert.equal(configs.accounts.size, 0);
await controller.close();
});

View file

@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { WeixinRuntime } from '../../../src/channels/weixin/weixin-runtime.mjs';
const flush = () => new Promise((resolve) => setImmediate(resolve));
test('runtime verifies the token, consumes getUpdates, replies, persists cursor, and aborts on stop', async () => {
const calls = [];
let pollCount = 0;
const stateData = { cursor: '', seen: new Set(), session: null };
const api = {
notifyStart: async (request) => calls.push(['start', request.token]),
notifyStop: async (request) => calls.push(['stop', request.token]),
sendText: async (request) => calls.push(['send', request.text, request.contextToken]),
getUpdates: async ({ signal }) => {
pollCount += 1;
if (pollCount === 1) {
return {
ret: 0,
get_updates_buf: 'cursor-next',
msgs: [{
message_id: 7,
message_type: 1,
from_user_id: 'owner',
context_token: 'context-7',
item_list: [{ type: 1, text_item: { text: '问题' } }],
}],
};
}
return new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')));
});
},
};
const state = {
getUpdatesBuf: () => stateData.cursor,
setGetUpdatesBuf: async (value) => { stateData.cursor = value; },
hasSeen: (id) => stateData.seen.has(id),
markSeen: async (id) => stateData.seen.add(id),
sessionFor: () => stateData.session,
setSession: async (_key, value) => { stateData.session = value; },
clearSession: async () => { stateData.session = null; },
};
const runtime = new WeixinRuntime({
api,
config: {
botId: 'wx_bot',
baseUrl: 'https://ilinkai.weixin.qq.com/',
ownerUserId: 'owner',
},
token: 'bot-token',
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
createSession: async () => 'session-1',
ask: async () => '回答',
},
state,
logger: { warn() {}, error() {} },
});
const started = await runtime.start();
assert.equal(started.ready, true);
await flush();
await flush();
assert.equal(stateData.cursor, 'cursor-next');
assert.deepEqual(calls.slice(0, 2), [
['start', 'bot-token'],
['send', '回答', 'context-7'],
]);
await runtime.stop();
assert.deepEqual(calls.at(-1), ['stop', 'bot-token']);
assert.equal(runtime.status.ready, false);
});
test('runtime refuses to report ready when notifyStart rejects the stored token', async () => {
const runtime = new WeixinRuntime({
api: {
notifyStart: async () => { throw new Error('rejected'); },
notifyStop: async () => {},
},
config: { botId: 'wx_bad', baseUrl: 'https://ilinkai.weixin.qq.com/', ownerUserId: 'owner' },
token: 'bad-token',
harness: { ensureRunning: async () => true },
state: {},
});
await assert.rejects(runtime.start(), /rejected/);
assert.equal(runtime.status.ready, false);
assert.equal(runtime.status.weixinConnectionState, 'failed');
});

View file

@ -6,11 +6,13 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { IMSettingsTab } from '../plugin-src/client/index.js';
import { DINGTALK_ENDPOINTS } from '../plugin-src/client/channels/dingtalk/api.js';
const STYLES_URL = new URL('../plugin-src/client/styles.js', import.meta.url);
const CLIENT_BUNDLE_URL = new URL('../lib/client.js', import.meta.url);
const DINGTALK_CLIENT_SOURCE_URL = new URL(
import.meta.resolve('@xmanrui/dsh-dingtalk/client-source'),
'../plugin-src/client/channels/dingtalk/index.js',
import.meta.url,
);
test('IM settings renders three compact logo channel tabs without enable switches', () => {
@ -48,8 +50,7 @@ test('the DingTalk QR card stacks within the narrow combined-channel panel', asy
});
test('the bundled DingTalk channel has no local sender approval workflow', async () => {
const [{ DINGTALK_ENDPOINTS }, source, bundle] = await Promise.all([
import('@xmanrui/dsh-dingtalk/client-api'),
const [source, bundle] = await Promise.all([
readFile(DINGTALK_CLIENT_SOURCE_URL, 'utf8'),
readFile(CLIENT_BUNDLE_URL, 'utf8'),
]);