mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 06:30:45 +08:00
Add unified IM channel plugin
This commit is contained in:
commit
b8bd681a79
26 changed files with 7901 additions and 0 deletions
25
test/client-ui.test.mjs
Normal file
25
test/client-ui.test.mjs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { IMSettingsTab } from '../plugin-src/client/index.js';
|
||||
|
||||
test('IM settings renders two logo channel tabs without enable switches', () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(IMSettingsTab, {
|
||||
feishuRpcCall: async () => ({ ok: true, value: {} }),
|
||||
weixinRpcCall: async () => ({ ok: true, value: {} }),
|
||||
}));
|
||||
|
||||
assert.match(markup, /IM机器人/);
|
||||
assert.match(markup, /通过扫码把机器人接入 DeepSeek Harness/);
|
||||
assert.match(markup, />微信</);
|
||||
assert.match(markup, />飞书</);
|
||||
assert.match(markup, /dim-logoWeixin/);
|
||||
assert.match(markup, /dim-logoFeishu/);
|
||||
assert.equal((markup.match(/role="tab"/g) ?? []).length, 2);
|
||||
assert.equal((markup.match(/aria-selected="true"/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /role="switch"|type="checkbox"/);
|
||||
assert.doesNotMatch(markup, />INSTANT MESSAGING<|>Channel<|>微信设置</);
|
||||
});
|
||||
194
test/feishu-client-api.test.mjs
Normal file
194
test/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);
|
||||
});
|
||||
34
test/host.test.mjs
Normal file
34
test/host.test.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { createImHostPlugin, inject, name } from '../plugin-src/host/index.mjs';
|
||||
|
||||
test('Host composes Feishu and Weixin inside one plugin context', async () => {
|
||||
const calls = [];
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async (ctx, config) => calls.push(['feishu', ctx, config]),
|
||||
applyWeixin: async (ctx, config) => calls.push(['weixin', ctx, config]),
|
||||
});
|
||||
const ctx = { marker: 'shared-context' };
|
||||
const config = { feishu: { domain: 'feishu' }, weixin: { timeout: 30 } };
|
||||
|
||||
await plugin.apply(ctx, config);
|
||||
|
||||
assert.equal(name, 'dsh-im-host');
|
||||
assert.deepEqual(inject, ['connection', 'credentials', 'webServer']);
|
||||
assert.deepEqual(calls, [
|
||||
['feishu', ctx, config.feishu],
|
||||
['weixin', ctx, config.weixin],
|
||||
]);
|
||||
});
|
||||
|
||||
test('Host does not start Weixin when Feishu activation fails', async () => {
|
||||
let weixinStarted = false;
|
||||
const plugin = createImHostPlugin({
|
||||
applyFeishu: async () => { throw new Error('feishu unavailable'); },
|
||||
applyWeixin: async () => { weixinStarted = true; },
|
||||
});
|
||||
|
||||
await assert.rejects(() => plugin.apply({}, {}), /feishu unavailable/);
|
||||
assert.equal(weixinStarted, false);
|
||||
});
|
||||
55
test/weixin-client-api.test.mjs
Normal file
55
test/weixin-client-api.test.mjs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
normalizeProvisioning,
|
||||
normalizeSnapshot,
|
||||
safeQrSource,
|
||||
safeVerificationUrl,
|
||||
unwrapRpcResult,
|
||||
} from '../plugin-src/client/channels/weixin/api.js';
|
||||
|
||||
test('client normalizes the exact redacted Host account view', () => {
|
||||
const snapshot = normalizeSnapshot({
|
||||
schemaVersion: 1,
|
||||
revision: 5,
|
||||
bots: [{
|
||||
botId: 'wx_safe',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
bot: { name: '微信机器人', accountIdMasked: 'abc••••bot' },
|
||||
health: { status: 'healthy', summary: '正常', lastCheckedAt: 123 },
|
||||
stats: { messagesReceived: 3, messagesReplied: 2 },
|
||||
token: 'host-secret-that-must-be-dropped',
|
||||
}],
|
||||
});
|
||||
assert.equal(snapshot.totals.connected, 1);
|
||||
assert.doesNotMatch(JSON.stringify(snapshot), /host-secret|token/);
|
||||
});
|
||||
|
||||
test('client accepts only image data URLs and Tencent Weixin HTTPS links', () => {
|
||||
assert.match(safeQrSource('data:image/png;base64,AAAA'), /^data:image/);
|
||||
assert.equal(safeQrSource('javascript:alert(1)'), undefined);
|
||||
assert.equal(safeVerificationUrl('https://liteapp.weixin.qq.com/q/test'), 'https://liteapp.weixin.qq.com/q/test');
|
||||
assert.equal(safeVerificationUrl('https://attacker.test/q/test'), undefined);
|
||||
});
|
||||
|
||||
test('client preserves verification-required provisioning without accepting unknown states', () => {
|
||||
const value = normalizeProvisioning({
|
||||
attemptId: 'attempt',
|
||||
status: 'needs_verification',
|
||||
expiresAt: Date.now() + 1000,
|
||||
verificationRequired: true,
|
||||
});
|
||||
assert.equal(value.status, 'needs_verification');
|
||||
assert.equal(value.verificationRequired, true);
|
||||
assert.equal(normalizeProvisioning({ attemptId: 'attempt', status: 'mystery' }).status, 'failed');
|
||||
});
|
||||
|
||||
test('RPC errors are surfaced with their safe code', () => {
|
||||
assert.throws(
|
||||
() => unwrapRpcResult({ ok: false, error: { code: 'safe-code', message: '安全消息' } }),
|
||||
(error) => error.code === 'safe-code' && error.message === '安全消息',
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue