mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 22:03:18 +08:00
Internalize all IM channel implementations
This commit is contained in:
parent
8996476693
commit
bd469f58f8
95 changed files with 25012 additions and 100 deletions
118
test/channels/dingtalk/client-api.test.mjs
Normal file
118
test/channels/dingtalk/client-api.test.mjs
Normal 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');
|
||||
});
|
||||
272
test/channels/dingtalk/client-lifecycle.test.mjs
Normal file
272
test/channels/dingtalk/client-lifecycle.test.mjs
Normal 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);
|
||||
});
|
||||
86
test/channels/dingtalk/client-ui.test.mjs
Normal file
86
test/channels/dingtalk/client-ui.test.mjs
Normal 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;/);
|
||||
});
|
||||
127
test/channels/dingtalk/config-store.test.mjs
Normal file
127
test/channels/dingtalk/config-store.test.mjs
Normal 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]);
|
||||
});
|
||||
73
test/channels/dingtalk/connection-supervisor.test.mjs
Normal file
73
test/channels/dingtalk/connection-supervisor.test.mjs
Normal 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();
|
||||
});
|
||||
120
test/channels/dingtalk/device-auth.test.mjs
Normal file
120
test/channels/dingtalk/device-auth.test.mjs
Normal 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',
|
||||
);
|
||||
});
|
||||
334
test/channels/dingtalk/dingtalk-api.test.mjs
Normal file
334
test/channels/dingtalk/dingtalk-api.test.mjs
Normal 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));
|
||||
});
|
||||
271
test/channels/dingtalk/dingtalk-bridge.test.mjs
Normal file
271
test/channels/dingtalk/dingtalk-bridge.test.mjs
Normal 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, '钉钉消息没有安全的回复地址。');
|
||||
});
|
||||
199
test/channels/dingtalk/dingtalk-card-stream.test.mjs
Normal file
199
test/channels/dingtalk/dingtalk-card-stream.test.mjs
Normal 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);
|
||||
});
|
||||
428
test/channels/dingtalk/dingtalk-controller.test.mjs
Normal file
428
test/channels/dingtalk/dingtalk-controller.test.mjs
Normal 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/,
|
||||
);
|
||||
});
|
||||
368
test/channels/dingtalk/dingtalk-runtime.test.mjs
Normal file
368
test/channels/dingtalk/dingtalk-runtime.test.mjs
Normal 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');
|
||||
});
|
||||
74
test/channels/dingtalk/harness-client.test.mjs
Normal file
74
test/channels/dingtalk/harness-client.test.mjs
Normal 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-/);
|
||||
});
|
||||
44
test/channels/dingtalk/plugin-host.test.mjs
Normal file
44
test/channels/dingtalk/plugin-host.test.mjs
Normal 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' });
|
||||
});
|
||||
74
test/channels/dingtalk/production.test.mjs
Normal file
74
test/channels/dingtalk/production.test.mjs
Normal 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);
|
||||
});
|
||||
96
test/channels/dingtalk/rpc.test.mjs
Normal file
96
test/channels/dingtalk/rpc.test.mjs
Normal 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' });
|
||||
});
|
||||
83
test/channels/dingtalk/state-store.test.mjs
Normal file
83
test/channels/dingtalk/state-store.test.mjs
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
240
test/channels/feishu/bridge.test.mjs
Normal file
240
test/channels/feishu/bridge.test.mjs
Normal 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');
|
||||
});
|
||||
194
test/channels/feishu/client-api.test.mjs
Normal file
194
test/channels/feishu/client-api.test.mjs
Normal 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);
|
||||
});
|
||||
50
test/channels/feishu/config.test.mjs
Normal file
50
test/channels/feishu/config.test.mjs
Normal 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']);
|
||||
});
|
||||
});
|
||||
98
test/channels/feishu/connection-supervisor.test.mjs
Normal file
98
test/channels/feishu/connection-supervisor.test.mjs
Normal 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();
|
||||
});
|
||||
48
test/channels/feishu/feishu-app.test.mjs
Normal file
48
test/channels/feishu/feishu-app.test.mjs
Normal 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);
|
||||
});
|
||||
131
test/channels/feishu/feishu-channel.test.mjs
Normal file
131
test/channels/feishu/feishu-channel.test.mjs
Normal 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');
|
||||
});
|
||||
120
test/channels/feishu/feishu-runtime.test.mjs
Normal file
120
test/channels/feishu/feishu-runtime.test.mjs
Normal 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');
|
||||
});
|
||||
132
test/channels/feishu/harness-client.test.mjs
Normal file
132
test/channels/feishu/harness-client.test.mjs
Normal 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: '正在整理结果…' });
|
||||
});
|
||||
50
test/channels/feishu/message-utils.test.mjs
Normal file
50
test/channels/feishu/message-utils.test.mjs
Normal 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);
|
||||
});
|
||||
485
test/channels/feishu/multi-bot-controller.test.mjs
Normal file
485
test/channels/feishu/multi-bot-controller.test.mjs
Normal 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);
|
||||
});
|
||||
101
test/channels/feishu/plugin-config-store.test.mjs
Normal file
101
test/channels/feishu/plugin-config-store.test.mjs
Normal 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/);
|
||||
});
|
||||
139
test/channels/feishu/plugin-controller.test.mjs
Normal file
139
test/channels/feishu/plugin-controller.test.mjs
Normal 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');
|
||||
});
|
||||
716
test/channels/feishu/plugin-host.test.mjs
Normal file
716
test/channels/feishu/plugin-host.test.mjs
Normal 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();
|
||||
});
|
||||
280
test/channels/feishu/registration-manager.test.mjs
Normal file
280
test/channels/feishu/registration-manager.test.mjs
Normal 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/);
|
||||
});
|
||||
34
test/channels/feishu/state-store.test.mjs
Normal file
34
test/channels/feishu/state-store.test.mjs
Normal 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);
|
||||
});
|
||||
68
test/channels/weixin/connection-supervisor.test.mjs
Normal file
68
test/channels/weixin/connection-supervisor.test.mjs
Normal 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);
|
||||
});
|
||||
48
test/channels/weixin/harness-client.test.mjs
Normal file
48
test/channels/weixin/harness-client.test.mjs
Normal 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);
|
||||
});
|
||||
92
test/channels/weixin/plugin-host.test.mjs
Normal file
92
test/channels/weixin/plugin-host.test.mjs
Normal 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');
|
||||
});
|
||||
62
test/channels/weixin/stores.test.mjs
Normal file
62
test/channels/weixin/stores.test.mjs
Normal 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);
|
||||
});
|
||||
124
test/channels/weixin/weixin-api.test.mjs
Normal file
124
test/channels/weixin/weixin-api.test.mjs
Normal 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']);
|
||||
});
|
||||
113
test/channels/weixin/weixin-bridge.test.mjs
Normal file
113
test/channels/weixin/weixin-bridge.test.mjs
Normal 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/);
|
||||
});
|
||||
218
test/channels/weixin/weixin-controller.test.mjs
Normal file
218
test/channels/weixin/weixin-controller.test.mjs
Normal 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();
|
||||
});
|
||||
91
test/channels/weixin/weixin-runtime.test.mjs
Normal file
91
test/channels/weixin/weixin-runtime.test.mjs
Normal 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');
|
||||
});
|
||||
|
|
@ -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'),
|
||||
]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue