Internalize all IM channel implementations

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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