feat: send test messages during bot connection checks

This commit is contained in:
xmanrui 2026-08-19 03:52:52 +08:00
parent 94da24538f
commit fb61c4235f
78 changed files with 3460 additions and 210 deletions

View file

@ -4,6 +4,7 @@ import test from 'node:test';
import {
DINGTALK_ENDPOINTS,
DINGTALK_RPC_CHANNEL,
connectionTestFeedback,
formatRemaining,
normalizeProvisioning,
normalizeSnapshot,
@ -83,6 +84,11 @@ test('snapshot derives totals and exposes only browser-safe bot state', () => {
schemaVersion: 1,
revision: 7,
totals: { configured: 99, connected: 99 },
testMessage: {
sent: false,
code: 'test-target-unavailable',
providerDetail: 'must-not-cross-normalization',
},
bots: [{
botId: 'bot-safe',
connected: true,
@ -104,12 +110,31 @@ test('snapshot derives totals and exposes only browser-safe bot state', () => {
assert.deepEqual(snapshot.totals, { configured: 1, connected: 1 });
assert.equal(snapshot.bots[0].state, 'connected');
assert.equal('senders' in snapshot.bots[0], false);
assert.deepEqual(snapshot.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.doesNotMatch(
JSON.stringify(snapshot),
/raw-client-id|raw-client-secret|credential-ref|device-code|raw-staff-id/,
);
});
test('connection-test feedback uses fixed client-owned messages', () => {
assert.equal(
connectionTestFeedback({ sent: true }),
'钉钉连接检查完成,测试消息已发送。',
);
assert.equal(
connectionTestFeedback({ sent: false, code: 'test-target-unavailable' }),
'钉钉连接检查完成。请先私聊机器人发送 /status,再重新检查。',
);
assert.equal(
connectionTestFeedback({ sent: false, code: 'test-message-failed' }),
'钉钉连接检查完成,但测试消息发送失败。',
);
assert.equal(connectionTestFeedback(null), null);
});
test('presentation helpers redact sensitive messages and format countdowns', () => {
assert.deepEqual(
presentError({ code: 'UPSTREAM_FAILED', message: 'accessToken: visible-value' }),

View file

@ -124,6 +124,192 @@ function findButton(renderer, label) {
return button;
}
test('connection check requests a test message and announces its delivery', async (t) => {
const clock = createBrowserClock();
t.after(() => clock.restore());
const bot = {
botId: 'dt_test',
connected: true,
state: 'connected',
bot: { name: '钉钉机器人', clientIdMasked: 'ding••••test' },
health: { status: 'healthy', summary: '连接正常', lastCheckedAt: Date.now() },
};
const calls = [];
const rpcCall = async (endpoint, payload) => {
calls.push({ endpoint, payload });
if (endpoint === DINGTALK_ENDPOINTS.status) {
return ok(snapshot({ state: 'connected', bots: [bot] }));
}
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
return ok(snapshot({
state: 'connected',
bots: [bot],
testMessage: { sent: true },
}));
}
throw new Error(`unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
await flushMicrotasks();
});
await act(async () => {
findButton(renderer, '检查连接').props.onClick();
await flushMicrotasks();
});
assert.deepEqual(calls.find((call) => call.endpoint === DINGTALK_ENDPOINTS.reconnectBot)?.payload, {
botId: 'dt_test', sendTest: true,
});
const announcement = [...clock.frames.entries()].at(-1);
assert.ok(announcement, 'successful connection test schedules an announcement');
await act(async () => {
clock.frames.delete(announcement[0]);
announcement[1]();
await flushMicrotasks();
});
const liveRegion = renderer.root.find(
(node) => node.props.role === 'status' && node.props['aria-live'] === 'polite',
);
assert.equal(nodeText(liveRegion), '钉钉连接检查完成,测试消息已发送。');
const botCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_test');
const visibleFeedback = botCard.find(
(node) => node.props.role === 'status' && node.props['aria-live'] === undefined,
);
assert.equal(nodeText(visibleFeedback), '钉钉连接检查完成,测试消息已发送。');
act(() => renderer.unmount());
});
test('connection-check failure stays on the matching card with locale-safe wording', async (t) => {
const clock = createBrowserClock();
t.after(() => clock.restore());
const bots = ['dt_one', 'dt_two'].map((botId) => {
const connected = botId === 'dt_one';
return {
botId,
connected,
state: connected ? 'connected' : 'error',
bot: { name: botId, clientIdMasked: `ding••••${botId.slice(-3)}` },
health: {
status: connected ? 'healthy' : 'offline',
summary: connected ? '连接正常' : '现有连接错误',
lastCheckedAt: Date.now(),
},
error: connected ? null : { code: 'STREAM_DOWN', message: '现有连接错误' },
};
});
const rpcCall = async (endpoint, payload) => {
if (endpoint === DINGTALK_ENDPOINTS.status) {
return ok(snapshot({ state: 'degraded', bots }));
}
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
assert.equal(payload.botId, 'dt_two');
return {
ok: false,
error: { code: 'UPSTREAM_FAILED', message: 'provider-specific failure' },
};
}
throw new Error(`unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const targetCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_two');
const targetButton = targetCard.findAllByType('button')
.find((candidate) => nodeText(candidate) === '重试连接');
assert.ok(targetButton);
await act(async () => {
targetButton.props.onClick();
await flushMicrotasks();
});
const firstCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_one');
assert.equal(firstCard.findAll((node) => node.props.role === 'status').length, 0);
const visibleFeedback = targetCard.find((node) => node.props.role === 'status');
assert.equal(nodeText(visibleFeedback), '连接检查失败,请稍后重试。');
assert.match(nodeText(targetCard), /现有连接错误/);
assert.equal(
targetCard.findAll((node) => node.props.className?.includes('dim-cardSummary')).length,
2,
);
assert.doesNotMatch(nodeText(targetCard), /provider-specific failure/);
const announcement = [...clock.frames.entries()].at(-1);
assert.ok(announcement, 'failed connection check schedules an announcement');
await act(async () => {
clock.frames.delete(announcement[0]);
announcement[1]();
await flushMicrotasks();
});
const liveRegion = renderer.root.find(
(node) => node.props.role === 'status' && node.props['aria-live'] === 'polite',
);
assert.equal(nodeText(liveRegion), '连接检查失败,请稍后重试。');
act(() => renderer.unmount());
});
test('a later disconnect removes stale success feedback and exposes the account error', async (t) => {
const clock = createBrowserClock();
t.after(() => clock.restore());
const connectedBot = {
botId: 'dt_stale',
connected: true,
state: 'connected',
bot: { name: '状态测试机器人', clientIdMasked: 'ding••••ale' },
health: { status: 'healthy', summary: '连接正常', lastCheckedAt: Date.now() },
};
const disconnectedBot = {
...connectedBot,
connected: false,
state: 'error',
health: { status: 'offline', summary: 'Stream 已断开', lastCheckedAt: Date.now() },
error: { code: 'STREAM_DOWN', message: 'Stream 已断开' },
};
let disconnected = false;
const rpcCall = async (endpoint) => {
if (endpoint === DINGTALK_ENDPOINTS.status) {
const bot = disconnected ? disconnectedBot : connectedBot;
return ok(snapshot({ state: bot.connected ? 'connected' : 'degraded', bots: [bot] }));
}
if (endpoint === DINGTALK_ENDPOINTS.reconnectBot) {
return ok(snapshot({
state: 'connected',
bots: [connectedBot],
testMessage: { sent: true },
}));
}
throw new Error(`unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DingtalkSettingsTab, { rpcCall }));
await flushMicrotasks();
});
await act(async () => {
findButton(renderer, '检查连接').props.onClick();
await flushMicrotasks();
});
let botCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_stale');
assert.match(nodeText(botCard), /测试消息已发送/);
disconnected = true;
await act(async () => {
await clock.runInterval(15_000);
await flushMicrotasks();
});
botCard = renderer.root.find((node) => node.props['data-bot-id'] === 'dt_stale');
assert.match(nodeText(botCard), /Stream 已断开/);
assert.doesNotMatch(nodeText(botCard), /测试消息已发送/);
assert.equal(botCard.findAll((node) => node.props.role === 'status').length, 0);
act(() => renderer.unmount());
});
test('a late poll response cannot issue status RPCs or schedule work after effect cleanup', async (t) => {
const clock = createBrowserClock();
t.after(() => clock.restore());

View file

@ -5,6 +5,7 @@ import {
createDingtalkBridgeStatus,
DingtalkHarnessBridge,
} from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
function deferred() {
let resolve;
@ -81,6 +82,38 @@ const PNG_BYTES = Buffer.from([
0x01, 0x02, 0x03,
]);
test('DingTalk remembers only a private /status session webhook for connection tests', async () => {
const privateFixture = stateFixture();
const privateSent = [];
const privateBridge = new DingtalkHarnessBridge({
api: { sendText: async (request) => privateSent.push(request) },
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: { ensureRunning: async () => true },
state: privateFixture.state,
});
await privateBridge.accept(message('status-private', '/status'));
assert.deepEqual(connectionTestTarget(privateFixture.state), {
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=status-private',
});
assert.equal(privateSent.at(-1).text, '钉钉机器人与 DeepSeek Harness 连接正常。');
const groupFixture = stateFixture();
const groupBridge = new DingtalkHarnessBridge({
api: { sendText: async () => {} },
clientId: 'ding-client',
clientSecret: 'host-secret',
harness: { ensureRunning: async () => true },
state: groupFixture.state,
});
await groupBridge.accept(message('status-group', '/status', {
conversationType: '2',
conversationId: 'group-status',
isInAtList: true,
}));
assert.equal(connectionTestTarget(groupFixture.state), null);
});
test('DingTalk resolves picture downloadCode lazily and sends image-only content to Harness', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:staff-approved', 'session-image');

View file

@ -56,8 +56,10 @@ function configFixture(initial = [], events = []) {
function runtimeFactory({ events = [], pendingByClient = new Map(), failStart = false } = {}) {
const runtimes = [];
const connectionTests = [];
return {
runtimes,
connectionTests,
createRuntime: async ({ botId, config, clientSecret }) => {
events.push(['create', botId]);
let ready = false;
@ -91,6 +93,9 @@ function runtimeFactory({ events = [], pendingByClient = new Map(), failStart =
events.push(['stop', botId]);
ready = false;
},
async sendConnectionTest(text) {
connectionTests.push({ botId, text });
},
};
runtimes.push(runtime);
return runtime;
@ -145,6 +150,10 @@ test('successful QR poll stores secret then config then starts runtime without p
/device-code-private|client-secret-private|secretRef|ding-client-private/,
);
assert.equal(controller.status().totals.connected, 1);
await controller.sendConnectionTest(completed.botId);
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
assert.match(runtimes.connectionTests[0].text, /钉钉机器人(ding••••vate)/);
await controller.close();
});

View file

@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { DingtalkRuntime } from '../../../src/channels/dingtalk/dingtalk-runtime.mjs';
import { rememberConnectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
async function eventually(predicate, timeoutMs = 1_000) {
const deadline = Date.now() + timeoutMs;
@ -44,6 +45,42 @@ function stateFixture() {
};
}
test('runtime sends a DingTalk connection test only through the remembered private status webhook', async () => {
const state = stateFixture();
const sends = [];
const client = {
connected: true,
socket: { readyState: 1 },
registerCallbackListener() {},
async connect() {},
socketCallBackResponse() {},
disconnect() {},
};
const runtime = new DingtalkRuntime({
config: { clientId: 'ding-client', approvedSenders: [] },
clientSecret: 'host-secret',
harness: { ensureRunning: async () => true },
state,
api: { sendText: async (request) => sends.push(request) },
streamFactory: async () => ({ client, topic: 'robot-topic' }),
});
await runtime.start();
await assert.rejects(() => runtime.sendConnectionTest('连接测试'), {
code: 'test-target-unavailable',
});
rememberConnectionTestTarget(state, {
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=status-private',
});
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
assert.equal(sends.length, 1);
assert.equal(sends[0].clientId, 'ding-client');
assert.equal(sends[0].clientSecret, 'host-secret');
assert.equal(sends[0].sessionWebhook, 'https://oapi.dingtalk.com/robot/reply?ticket=status-private');
assert.equal(sends[0].text, '连接测试');
await runtime.stop();
});
test('runtime owns one DWClient, waits for socket OPEN, acknowledges first, and disconnects on stop', async () => {
const order = [];
const state = stateFixture();

View file

@ -21,6 +21,7 @@ function controller(overrides = {}) {
cancelProvisioning: async () => ({ attemptId: 'attempt_1', status: 'cancelled' }),
bindCredentials: async () => ({ bots: [] }),
reconnectBot: async () => ({ bots: [] }),
sendConnectionTest: async () => ({ sent: true }),
deleteBot: async () => ({ bots: [] }),
approveSender: async () => ({ bots: [] }),
revokeSender: async () => ({ bots: [] }),
@ -113,3 +114,55 @@ test('RPC is registered for loopback clients only', () => {
assert.equal(registrations[0][0], '/dingtalk');
assert.deepEqual(registrations[0][2], { authority: 'loopback' });
});
test('reconnect sends a DingTalk test message only for a connected bot and isolates send failures', async () => {
const sent = [];
const connectedSnapshot = {
bots: [{ botId: 'dt_abc', connected: true }],
totals: { configured: 1, connected: 1 },
};
const success = await createDingtalkRpcHandler(controller({
reconnectBot: async () => connectedSnapshot,
sendConnectionTest: async (botId) => { sent.push(botId); },
}))(DINGTALK_ENDPOINTS.reconnectBot, { botId: 'dt_abc', sendTest: true });
assert.equal(success.ok, true);
assert.deepEqual(success.value.testMessage, { sent: true });
assert.deepEqual(sent, ['dt_abc']);
const failedSend = await createDingtalkRpcHandler(controller({
reconnectBot: async () => connectedSnapshot,
sendConnectionTest: async () => { throw new Error('expired webhook'); },
}))(DINGTALK_ENDPOINTS.reconnectBot, { botId: 'dt_abc', sendTest: true });
assert.equal(failedSend.ok, true);
assert.deepEqual(failedSend.value.testMessage, {
sent: false, code: 'test-message-failed',
});
let offlineSendCalled = false;
const offline = await createDingtalkRpcHandler(controller({
reconnectBot: async () => ({
bots: [{ botId: 'dt_abc', connected: false }],
totals: { configured: 1, connected: 0 },
}),
sendConnectionTest: async () => { offlineSendCalled = true; },
}))(DINGTALK_ENDPOINTS.reconnectBot, { botId: 'dt_abc', sendTest: true });
assert.equal(offline.ok, true);
assert.deepEqual(offline.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.equal(offlineSendCalled, false);
const withoutMethod = controller({ reconnectBot: async () => connectedSnapshot });
delete withoutMethod.sendConnectionTest;
const unavailableWithoutMethod = await createDingtalkRpcHandler(withoutMethod)(
DINGTALK_ENDPOINTS.reconnectBot,
{ botId: 'dt_abc', sendTest: true },
);
assert.equal(unavailableWithoutMethod.ok, true);
assert.deepEqual(unavailableWithoutMethod.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.equal((await createDingtalkRpcHandler(controller())(
DINGTALK_ENDPOINTS.reconnectBot,
{ botId: 'dt_abc', sendTest: false },
)).ok, false);
});

View file

@ -185,6 +185,7 @@ test('Discord normalizes DMs and only addressed server messages', () => {
}, '1234567890123456789');
assert.equal(direct.kind, 'direct');
assert.equal(direct.addressed, true);
assert.deepEqual(direct.connectionTestTarget, { channelId: '222222222222222222' });
const group = normalizeDiscordMessage({
id: '111111111111111112',

View file

@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { act, create } from 'react-test-renderer';
import { FEISHU_ENDPOINTS } from '../../../plugin-src/client/channels/feishu/api.js';
import {
BotCard,
FeishuSettingsTab,
} from '../../../plugin-src/client/channels/feishu/index.js';
import {
en,
setImTranslator,
} from '../../../plugin-src/client/i18n.js';
const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve));
function textOf(node) {
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (!node) return '';
const children = node.children ?? node.props?.children ?? [];
return (Array.isArray(children) ? children : [children]).map(textOf).join('');
}
test('Feishu connection check requests and displays test-message feedback', async () => {
const source = await readFile(new URL(
'../../../plugin-src/client/channels/feishu/index.js',
import.meta.url,
), 'utf8');
assert.match(source, /FEISHU_ENDPOINTS\.reconnectBot, \{ botId, sendTest: true \}/);
const markup = renderToStaticMarkup(React.createElement(BotCard, {
connection: {
botId: 'bot-feishu-test',
state: 'connected',
connected: true,
bot: { name: '飞书测试机器人', appIdMasked: 'cli_test••••1234' },
health: { summary: '长连接运行正常', lastCheckedAt: Date.now() },
},
testNotice: '测试消息已发送,请到飞书会话中确认。',
onReconnect() {},
onRequestRemove() {},
onConfirmRemove() {},
onCancelRemove() {},
}));
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
});
test('Feishu reconnect failures render fixed English-safe feedback', async (t) => {
const previousWindow = globalThis.window;
let nextFrame = 0;
const frames = new Map();
globalThis.window = {
setInterval() { return 1; },
clearInterval() {},
requestAnimationFrame(callback) {
const id = ++nextFrame;
frames.set(id, callback);
queueMicrotask(() => {
const pending = frames.get(id);
if (!pending) return;
frames.delete(id);
pending();
});
return id;
},
cancelAnimationFrame(id) { frames.delete(id); },
};
setImTranslator((key) => en[key] ?? key);
t.after(() => {
setImTranslator(null);
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const snapshot = {
schemaVersion: 2,
revision: 1,
state: 'connected',
bots: [{
botId: 'bot_feishu_test',
state: 'connected',
connected: true,
configured: true,
workspace: '/workspace/current',
bot: { name: '今天是牢梁', appIdMasked: 'cli_test••••1234' },
health: { status: 'healthy', summary: 'Long connection is healthy' },
}],
};
const rpcCall = async (endpoint) => {
if (endpoint === FEISHU_ENDPOINTS.status) return { ok: true, value: snapshot };
if (endpoint === FEISHU_ENDPOINTS.reconnectBot) {
return {
ok: false,
error: { code: 'FEISHU_UPSTREAM_FAILED', message: '飞书上游操作失败' },
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(FeishuSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const card = renderer.root.findByProps({ 'data-bot-id': 'bot_feishu_test' });
await act(async () => {
card.findAllByType('button')
.find((button) => textOf(button) === 'Check connection').props.onClick();
await flushMicrotasks();
});
const announcement = renderer.root.find(
(node) => node.props.role === 'status' && node.props['aria-live'] === 'polite',
);
assert.equal(textOf(announcement), 'Connection check failed. Try again later.');
assert.doesNotMatch(textOf(announcement), /[\p{Script=Han}]/u);
assert.match(textOf(card), /Connection check failed\. Try again later\./);
assert.doesNotMatch(textOf(card), /飞书上游操作失败/);
await act(async () => { renderer.unmount(); });
});

View file

@ -4,9 +4,20 @@ import { FeishuRuntime } from '../../../src/channels/feishu/feishu-runtime.mjs';
class FakeClient {
static instances = [];
static sent = [];
constructor(options) {
this.options = options;
this.im = {
v1: {
message: {
create: async (payload) => {
FakeClient.sent.push(payload);
return { code: 0, data: { message_id: `message-${FakeClient.sent.length}` } };
},
},
},
};
FakeClient.instances.push(this);
}
}
@ -48,6 +59,7 @@ class FakeWSClient {
function fakeLark() {
FakeWSClient.instances.length = 0;
FakeClient.instances.length = 0;
FakeClient.sent.length = 0;
return {
Domain: { Feishu: 'feishu-domain', Lark: 'lark-domain' },
LoggerLevel: { info: 'info' },
@ -104,6 +116,16 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
url: 'https://open.feishu.cn/test',
})).timeout, 15_000);
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
assert.deepEqual(FakeClient.sent, [{
params: { receive_id_type: 'open_id' },
data: {
receive_id: 'ou_owner',
msg_type: 'text',
content: JSON.stringify({ text: '连接测试' }),
},
}]);
const stopped = await runtime.stop();
assert.equal(stopped.ready, false);
assert.equal(stopped.feishuLongConnectionState, 'idle');

View file

@ -100,6 +100,7 @@ function fixture({
appSecret,
starts: 0,
stops: 0,
sentTests: [],
get status() { return structuredClone(status); },
async start() {
runtime.starts += 1;
@ -113,6 +114,10 @@ function fixture({
status.ready = false;
status.feishuLongConnectionState = 'idle';
},
async sendConnectionTest(text) {
runtime.sentTests.push(text);
return { sent: true };
},
};
const history = runtimes.get(botId) ?? [];
history.push(runtime);
@ -197,6 +202,22 @@ test('repeated initialization never restarts an already healthy bot', async () =
assert.equal(fx.runtimes.get(healthy.id)[0].stops, 0);
});
test('connection test uses the selected bot runtime and shared message copy', async () => {
const healthy = bot('bot_healthy', 'healthy');
healthy.appId = 'cli_healthy_1234567890';
const fx = fixture({
bots: [healthy],
secrets: { [healthy.secretRef]: 'healthy-secret' },
});
await fx.controller.initialize();
assert.deepEqual(await fx.controller.sendConnectionTest(healthy.id), { sent: true });
assert.deepEqual(fx.runtimes.get(healthy.id)[0].sentTests, [
'✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“机器人 healthy(cli_heal••••7890)”机器人卡片发出。',
]);
await fx.controller.close();
});
test('multiple scans create independent bots, credential refs and runtimes', async () => {
const fx = fixture({ createBotIds: ['bot_alpha', 'bot_beta'] });
const alpha = completeScan(fx, {

View file

@ -398,22 +398,27 @@ test('multi-bot RPC operations require a botId and never expose host-only fields
cancelRegistration: async () => multi,
disconnect: async () => status(),
reconnectBot: async (botId) => { calls.push(['reconnect', botId]); return multi; },
sendConnectionTest: async (botId) => { calls.push(['test-message', botId]); },
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.reconnectBot, { botId: 'bot_safe', sendTest: true }],
[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/);
if (endpoint === FEISHU_MULTI_ENDPOINTS.reconnectBot) {
assert.deepEqual(result.value.testMessage, { sent: true });
}
}
assert.deepEqual(calls, [
['reconnect', 'bot_safe'],
['test-message', 'bot_safe'],
['disconnect', 'bot_safe'],
['delete', 'bot_safe'],
]);
@ -428,6 +433,105 @@ test('multi-bot RPC operations require a botId and never expose host-only fields
assert.doesNotMatch(JSON.stringify(invalid), /\.\.\/private/);
});
test('Feishu reconnect test delivery is best-effort and requires the target bot to be connected', async () => {
let connected = true;
let sendCalls = 0;
const snapshot = () => status({
schemaVersion: 2,
revision: 1,
configured: true,
bots: [{
botId: 'bot_safe',
phase: connected ? 'connected' : 'disconnected',
connected,
configured: true,
bot: { name: '安全机器人', appIdMasked: 'cli_safe••••1234', domain: 'feishu' },
connection: {
ready: connected,
feishuLongConnectionState: connected ? 'connected' : 'idle',
harnessReachable: connected,
},
}],
});
const controller = {
status: async () => snapshot(),
startRegistration: async () => snapshot(),
cancelRegistration: async () => snapshot(),
disconnect: async () => status(),
reconnectBot: async () => snapshot(),
sendConnectionTest: async () => {
sendCalls += 1;
throw new Error('private Feishu provider failure');
},
};
const fx = await rpcFixture(controller);
const failedSend = await fx.registration.handler(
FEISHU_MULTI_ENDPOINTS.reconnectBot,
{ botId: 'bot_safe', sendTest: true },
signal(),
);
assert.equal(failedSend.ok, true);
assert.deepEqual(failedSend.value.testMessage, {
sent: false,
code: 'test-message-failed',
});
assert.doesNotMatch(JSON.stringify(failedSend), /private Feishu provider failure/);
assert.equal(sendCalls, 1);
connected = false;
const unavailable = await fx.registration.handler(
FEISHU_MULTI_ENDPOINTS.reconnectBot,
{ botId: 'bot_safe', sendTest: true },
signal(),
);
assert.equal(unavailable.ok, true);
assert.deepEqual(unavailable.value.testMessage, {
sent: false,
code: 'test-target-unavailable',
});
assert.equal(sendCalls, 1);
const invalid = await fx.registration.handler(
FEISHU_MULTI_ENDPOINTS.reconnectBot,
{ botId: 'bot_safe', sendTest: 'yes' },
signal(),
);
assert.equal(invalid.ok, false);
assert.equal(invalid.error.code, 'bad-request');
await fx.dispose();
});
test('Feishu RPC never sends a connection test after reconnect is cancelled', async () => {
let resolveReconnect;
let sendCalls = 0;
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
const controller = {
status: async () => status(),
startRegistration: async () => status(),
cancelRegistration: async () => status(),
disconnect: async () => status(),
reconnectBot: async () => reconnect,
sendConnectionTest: async () => { sendCalls += 1; },
};
const fx = await rpcFixture(controller);
const abort = new AbortController();
const result = fx.registration.handler(FEISHU_MULTI_ENDPOINTS.reconnectBot, {
botId: 'bot_safe',
sendTest: true,
}, abort.signal);
abort.abort();
resolveReconnect(status({
schemaVersion: 2,
bots: [{ botId: 'bot_safe', connected: true }],
}));
assert.equal((await result).error.code, 'cancelled');
assert.equal(sendCalls, 0);
await fx.dispose();
});
test('dependency failures and AbortSignal use valid RpcResult error branches', async () => {
const secret = 'should-never-be-reflected';
const controller = {

View file

@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
function deferred() {
let resolve;
@ -258,6 +259,45 @@ test('QQ executes /compact for the bound Session without prompting the model', a
assert.equal(fixture.seen.has('compact-qq'), true);
});
test('QQ remembers a connection-test target only for an authorized private /status', async () => {
const fixture = stateFixture();
const sent = [];
const bridge = new QqHarnessBridge({
bot: { sendText: async (target, text) => sent.push({ target, text }) },
ownerUserOpenid: 'owner-openid',
harness: { ensureRunning: async () => true },
state: fixture.state,
});
await bridge.accept(message({
messageId: 'status-rejected',
senderId: 'other-openid',
content: '/status',
replyTarget: { scope: 'c2c', targetId: 'other-openid', msgId: 'status-rejected' },
}));
await bridge.accept(message({
kind: 'group',
rawEventType: 'GROUP_AT_MESSAGE_CREATE',
groupOpenid: 'group-1',
messageId: 'status-group',
content: '/status',
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'status-group' },
}));
assert.equal(connectionTestTarget(fixture.state), null);
const privateTarget = {
scope: 'c2c', targetId: 'owner-openid', msgId: 'status-private',
};
await bridge.accept(message({
messageId: 'status-private',
content: '/status',
replyTarget: privateTarget,
}));
assert.deepEqual(connectionTestTarget(fixture.state), privateTarget);
assert.equal(sent.filter(({ text }) => text.includes('连接正常')).length, 2);
});
test('QQ private messages stream Harness snapshots and finalize once', async () => {
const frames = [];
const sent = [];

View file

@ -1,7 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { normalizeProvisioning, normalizeSnapshot, safeQrSource } from '../../../plugin-src/client/channels/qq/api.js';
import {
connectionTestFeedback,
normalizeProvisioning,
normalizeSnapshot,
safeQrSource,
} from '../../../plugin-src/client/channels/qq/api.js';
test('QQ client keeps only redacted bot and host-rendered QR state', () => {
const qr = 'data:image/png;base64,YWJjZA==';
@ -20,3 +25,26 @@ test('QQ client keeps only redacted bot and host-rendered QR state', () => {
assert.equal(snapshot.totals.connected, 1);
assert.equal(snapshot.bots[0].bot.appIdMasked, '123••••456');
});
test('QQ client normalizes and presents connection-test outcomes', () => {
const sent = normalizeSnapshot({ bots: [], testMessage: { sent: true, code: 'ignored' } });
assert.deepEqual(sent.testMessage, { sent: true });
assert.equal(
connectionTestFeedback(sent.testMessage),
'测试消息已发送,请到对应机器人会话中确认。',
);
const unavailable = normalizeSnapshot({
bots: [], testMessage: { sent: false, code: 'test-target-unavailable' },
});
assert.deepEqual(unavailable.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.match(connectionTestFeedback(unavailable.testMessage), /\/status/);
const invalid = normalizeSnapshot({
bots: [], testMessage: { sent: false, code: 'private-provider-error' },
});
assert.deepEqual(invalid.testMessage, { sent: false, code: 'test-message-failed' });
assert.equal(connectionTestFeedback(undefined), null);
});

View file

@ -1,10 +1,14 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { AccountCard, QqSettingsTab } from '../../../plugin-src/client/channels/qq/index.js';
import { en, setImTranslator } from '../../../plugin-src/client/i18n.js';
const CLIENT_URL = new URL('../../../plugin-src/client/channels/qq/index.js', import.meta.url);
test('QQ settings uses the shared compact channel toolbar', () => {
const markup = renderToStaticMarkup(React.createElement(QqSettingsTab, {
@ -50,3 +54,56 @@ test('QQ bot cards match the shared two-metric card treatment', () => {
}));
assert.match(offlineMarkup, /class="ddt-summary dim-cardSummary">连接失败,请检查凭据</);
});
test('QQ connection checks request a test message and show concise card feedback', async () => {
const source = await readFile(CLIENT_URL, 'utf8');
assert.match(source, /\{ botId: account\.botId, sendTest: true \}/);
assert.match(source, /'连接检查失败,请稍后重试。'/);
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
account: {
botId: 'qq_bot', connected: true, state: 'connected',
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
health: { summary: 'QQ WebSocket 长连接运行正常', lastCheckedAt: Date.now() },
error: null,
},
feedback: '测试消息已发送,请到对应机器人会话中确认。',
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
}));
assert.match(markup, /role="status"/);
assert.match(markup, /测试消息已发送/);
const offlineMarkup = renderToStaticMarkup(React.createElement(AccountCard, {
account: {
botId: 'qq_bot', connected: false, state: 'error',
bot: { name: 'QQ机器人', appIdMasked: '123••••456' },
health: { summary: 'QQ 连接尚未就绪', lastCheckedAt: Date.now() },
error: { code: 'offline', message: '连接凭据已失效' },
},
feedback: '测试消息已发送,请到对应机器人会话中确认。',
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
}));
assert.match(offlineMarkup, />连接凭据已失效</);
assert.match(offlineMarkup, /role="status"[^>]*>测试消息已发送/);
});
test('fixed reconnect failure copy renders fully in English', () => {
setImTranslator((key) => en[key] ?? key);
try {
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
account: {
botId: 'qq_bot', connected: true, state: 'connected',
bot: { name: 'QQ Bot', appIdMasked: '123••••456' },
health: { summary: 'healthy', lastCheckedAt: Date.now() },
error: null,
},
feedback: '连接检查失败,请稍后重试。',
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
}));
assert.match(markup, /Connection check failed\. Try again later\./);
assert.doesNotMatch(markup, /[\p{Script=Han}]/u);
} finally {
setImTranslator(null);
}
});

View file

@ -1,7 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { maskQqAppId } from '../../../src/channels/qq/config-store.mjs';
import { QqController } from '../../../src/channels/qq/qq-controller.mjs';
import { connectionTestMessage } from '../../../src/channels/shared/connection-test.mjs';
import { QQ_ENDPOINTS, createQqRpcHandler } from '../../../plugin-src/host/channels/qq/rpc.mjs';
function deferred() {
@ -90,6 +92,41 @@ test('QQ AppID and AppSecret binding stores the secret and accepts the platform
await controller.close();
});
test('QQ controller delegates the shared connection-test message to the selected runtime', async () => {
const config = {
botId: 'qq_bot', appId: 'app-id', secretRef: 'qq.secret', ownerUserOpenid: 'owner-openid',
};
const sent = [];
const controller = new QqController({
qrAuth: { start() { return () => {}; } },
credentials: {
resolve: async () => ({ value: 'secret' }),
set: async () => {},
unset: async () => {},
},
configStore: {
list: () => [config],
get: (botId) => botId === config.botId ? config : null,
getByAppId: () => config,
save: async () => {},
remove: async () => null,
},
createRuntime: async () => ({
status: { ready: true, qqConnectionState: 'connected', harnessReachable: true },
start: async () => {},
stop: async () => {},
sendConnectionTest: async (text) => sent.push(text),
}),
});
await controller.initialize();
assert.deepEqual(await controller.sendConnectionTest(config.botId), { sent: true });
assert.deepEqual(sent, [
connectionTestMessage(`QQ 机器人(${maskQqAppId(config.appId)})`),
]);
await controller.close();
});
test('QQ RPC turns the host-only QR URL into an image and strips credential fields', async () => {
const handler = createQqRpcHandler({
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
@ -100,13 +137,22 @@ test('QQ RPC turns the host-only QR URL into an image and strips credential fiel
registrationStatus: () => null,
cancelProvisioning: async () => ({}),
bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
reconnectBot: async () => ({}),
reconnectBot: async (botId) => ({
bots: [{ botId, connected: true }], totals: { configured: 1, connected: 1 },
}),
deleteBot: async () => ({}),
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
const result = await handler(QQ_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' });
assert.equal(result.ok, true);
assert.equal(result.value.qrCodeDataUrl, 'data:image/png;base64,YWJjZA==');
assert.doesNotMatch(JSON.stringify(result), /q\.qq\.com|never-public|ownerUserOpenid|appSecret/);
const legacyReconnect = await handler(QQ_ENDPOINTS.reconnectBot, {
botId: 'qq_bot', sendTest: true,
});
assert.equal(legacyReconnect.ok, true);
assert.deepEqual(legacyReconnect.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
});
test('QQ credential RPC validates the pair and never returns AppSecret', async () => {
@ -128,3 +174,71 @@ test('QQ credential RPC validates the pair and never returns AppSecret', async (
assert.doesNotMatch(JSON.stringify(result), /manual-secret|appSecret/);
assert.equal((await handler(QQ_ENDPOINTS.bindCredentials, { appId: 'manual-app' })).ok, false);
});
test('QQ reconnect RPC reports test-message outcomes without discarding the snapshot', async () => {
const calls = [];
let sendError = null;
let connected = true;
const handler = createQqRpcHandler({
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
startProvisioning: async () => ({}),
registrationStatus: () => null,
cancelProvisioning: async () => ({}),
bindCredentials: async () => ({}),
reconnectBot: async (botId) => {
calls.push(['reconnect', botId]);
return {
revision: 7,
bots: [{ botId, connected, bot: { name: 'QQ机器人' } }],
totals: { configured: 1, connected: connected ? 1 : 0 },
};
},
sendConnectionTest: async (botId) => {
calls.push(['test', botId]);
if (sendError) throw sendError;
return { sent: true };
},
deleteBot: async () => ({}),
});
const success = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
assert.equal(success.ok, true);
assert.equal(success.value.revision, 7);
assert.deepEqual(success.value.testMessage, { sent: true });
assert.deepEqual(calls, [['reconnect', 'qq_bot'], ['test', 'qq_bot']]);
sendError = Object.assign(new Error('no recent target'), { code: 'test-target-unavailable' });
const unavailable = await handler(QQ_ENDPOINTS.reconnectBot, {
botId: 'qq_bot', sendTest: true,
});
assert.equal(unavailable.ok, true);
assert.equal(unavailable.value.bots[0].connected, true);
assert.deepEqual(unavailable.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
sendError = new Error('QQ API rejected the message');
const failed = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
assert.equal(failed.ok, true);
assert.deepEqual(failed.value.testMessage, { sent: false, code: 'test-message-failed' });
calls.length = 0;
connected = false;
sendError = null;
const offline = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot', sendTest: true });
assert.equal(offline.ok, true);
assert.deepEqual(offline.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.deepEqual(calls, [['reconnect', 'qq_bot']]);
calls.length = 0;
connected = true;
const reconnectOnly = await handler(QQ_ENDPOINTS.reconnectBot, { botId: 'qq_bot' });
assert.equal(reconnectOnly.ok, true);
assert.equal(reconnectOnly.value.testMessage, undefined);
assert.deepEqual(calls, [['reconnect', 'qq_bot']]);
assert.equal((await handler(QQ_ENDPOINTS.reconnectBot, {
botId: 'qq_bot', sendTest: false,
})).ok, false);
});

View file

@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
import test from 'node:test';
import { QqRuntime } from '../../../src/channels/qq/qq-runtime.mjs';
import { rememberConnectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
function deferred() {
let resolve;
@ -12,6 +13,7 @@ function deferred() {
class FakeBot extends EventEmitter {
middlewares = [];
sent = [];
stopped = false;
use(value) { this.middlewares.push(value); }
async start(signal) {
@ -19,7 +21,7 @@ class FakeBot extends EventEmitter {
await new Promise((resolve) => signal.addEventListener('abort', resolve, { once: true }));
}
stop() { this.stopped = true; }
async sendText() {}
async sendText(target, text) { this.sent.push({ target, text }); }
}
test('QQ runtime waits for gateway ready, installs typing, and stops its client', async () => {
@ -77,6 +79,56 @@ test('QQ runtime never reports ready when the gateway does not emit ready', asyn
assert.equal(runtime.status.ready, false);
});
test('QQ runtime sends a proactive connection test to the explicit owner fallback', async () => {
const bot = new FakeBot();
const runtime = new QqRuntime({
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: 'owner-openid' },
appSecret: 'secret',
harness: { ensureRunning: async () => true },
state: {},
createBot: () => bot,
typingMiddleware: () => 'typing',
connectTimeoutMs: 100,
});
await runtime.start();
assert.deepEqual(await runtime.sendConnectionTest('connection-test'), { sent: true });
assert.deepEqual(bot.sent, [{
target: { scope: 'c2c', targetId: 'owner-openid' },
text: 'connection-test',
}]);
await runtime.stop();
});
test('QQ runtime requires /status for wildcard owners and strips the remembered reply id', async () => {
const bot = new FakeBot();
const state = {};
const runtime = new QqRuntime({
config: { botId: 'qq_bot', appId: 'app', ownerUserOpenid: '*' },
appSecret: 'secret',
harness: { ensureRunning: async () => true },
state,
createBot: () => bot,
typingMiddleware: () => 'typing',
connectTimeoutMs: 100,
});
await runtime.start();
await assert.rejects(
() => runtime.sendConnectionTest('unavailable'),
(error) => error?.code === 'test-target-unavailable',
);
rememberConnectionTestTarget(state, {
scope: 'c2c', targetId: 'recent-user', msgId: 'old-status-message',
});
await runtime.sendConnectionTest('remembered-target');
assert.deepEqual(bot.sent, [{
target: { scope: 'c2c', targetId: 'recent-user' },
text: 'remembered-target',
}]);
await runtime.stop();
});
test('QQ runtime aborts an in-flight Harness interaction when stopped', async () => {
const bot = new FakeBot();
const askStarted = deferred();

View file

@ -280,6 +280,7 @@ test('Slack controller stores two protected credential references and exposes ne
const configPath = join(directory, 'config.json');
const configStore = await new SlackConfigStore(configPath).load();
const credentialStore = credentials();
const connectionTests = [];
const controller = new SlackController({
credentials: credentialStore,
configStore,
@ -299,6 +300,7 @@ test('Slack controller stores two protected credential references and exposes ne
},
async start() {},
async stop() {},
async sendConnectionTest(text) { connectionTests.push(text); },
}),
});
const status = await controller.bindCredentials({ botToken: BOT_TOKEN, appToken: APP_TOKEN });
@ -311,12 +313,16 @@ test('Slack controller stores two protected credential references and exposes ne
const stored = await readFile(configPath, 'utf8');
assert.doesNotMatch(stored, new RegExp(BOT_TOKEN));
assert.doesNotMatch(stored, new RegExp(APP_TOKEN));
await controller.sendConnectionTest(identity.botId);
assert.match(connectionTests[0], /DeepSeek Harness/);
assert.match(connectionTests[0], /T1234••• · U1234•••/);
await controller.deleteBot(identity.botId);
assert.equal(credentialStore.values.has(identity.botTokenRef), false);
assert.equal(credentialStore.values.has(identity.appTokenRef), false);
});
test('Slack RPC requires exactly two tokens and strips all credential internals', async () => {
const connectionTests = [];
const controller = {
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
bindCredentials: async () => ({
@ -331,7 +337,11 @@ test('Slack RPC requires exactly two tokens and strips all credential internals'
}],
totals: { configured: 1, connected: 0 },
}),
reconnectBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
reconnectBot: async (botId) => ({
bots: [{ botId, connected: true }],
totals: { configured: 1, connected: 1 },
}),
sendConnectionTest: async (botId) => { connectionTests.push(botId); },
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
};
const handler = createSlackRpcHandler(controller);
@ -352,6 +362,42 @@ test('Slack RPC requires exactly two tokens and strips all credential internals'
});
assert.equal(rejected.ok, false);
assert.equal(rejected.error.code, 'bad-request');
const tested = await handler(SLACK_ENDPOINTS.reconnectBot, {
botId: 'slack_1234567890abcdef12345678',
sendTest: true,
});
assert.equal(tested.ok, true);
assert.deepEqual(tested.value.testMessage, { sent: true });
assert.deepEqual(connectionTests, ['slack_1234567890abcdef12345678']);
});
test('Slack RPC never sends a connection test after reconnect is cancelled', async () => {
let resolveReconnect;
let sendCalls = 0;
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
const botId = 'slack_1234567890abcdef12345678';
const controller = {
status: async () => ({ bots: [] }),
bindCredentials: async () => ({ bots: [] }),
reconnectBot: async () => reconnect,
sendConnectionTest: async () => { sendCalls += 1; },
deleteBot: async () => ({ bots: [] }),
};
const abort = new AbortController();
const result = createSlackRpcHandler(controller)(SLACK_ENDPOINTS.reconnectBot, {
botId,
sendTest: true,
}, abort.signal);
abort.abort();
resolveReconnect({ bots: [{ botId, connected: true }] });
assert.deepEqual(await result, {
ok: false,
error: { code: 'cancelled', message: 'The request was cancelled.' },
});
assert.equal(sendCalls, 0);
});
test('Slack normalizes direct messages and addressed channel events', () => {
@ -372,6 +418,7 @@ test('Slack normalizes direct messages and addressed channel events', () => {
assert.equal(direct.content, 'hello & welcome');
assert.equal(direct.conversationId, 'D12345678');
assert.equal(direct.replyTarget.threadTs, '1700000000.001');
assert.deepEqual(direct.connectionTestTarget, { channelId: 'D12345678' });
const group = normalizeSlackEvent({
event_id: 'Ev002',

View file

@ -158,6 +158,7 @@ test('Telegram config and controller store only a credential reference in bot da
const configStore = await new TelegramConfigStore(configPath).load();
const credentialStore = credentials();
const runtimes = [];
const connectionTests = [];
const controller = new TelegramController({
credentials: credentialStore,
configStore,
@ -174,6 +175,7 @@ test('Telegram config and controller store only a credential reference in bot da
},
async start() {},
async stop() {},
async sendConnectionTest(text) { connectionTests.push(text); },
};
runtimes.push(runtime);
return runtime;
@ -192,6 +194,9 @@ test('Telegram config and controller store only a credential reference in bot da
await controller.reconnectBot(identity.botId);
assert.equal(runtimes.length, 2);
await controller.sendConnectionTest(identity.botId);
assert.match(connectionTests[0], /Harness Telegram/);
assert.match(connectionTests[0], /123•••/);
await controller.deleteBot(identity.botId);
assert.equal(credentialStore.values.has(identity.tokenRef), false);
assert.equal(controller.status().totals.configured, 0);
@ -199,6 +204,7 @@ test('Telegram config and controller store only a credential reference in bot da
test('Telegram RPC accepts only token binding and strips credential internals', async () => {
const calls = [];
const connectionTests = [];
const controller = {
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
bindCredentials: async (payload) => {
@ -213,7 +219,11 @@ test('Telegram RPC accepts only token binding and strips credential internals',
totals: { configured: 1, connected: 0 },
};
},
reconnectBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
reconnectBot: async (botId) => ({
bots: [{ botId, connected: true }],
totals: { configured: 1, connected: 1 },
}),
sendConnectionTest: async (botId) => { connectionTests.push(botId); },
deleteBot: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
};
const handler = createTelegramRpcHandler(controller);
@ -225,6 +235,63 @@ test('Telegram RPC accepts only token binding and strips credential internals',
const rejected = await handler(TELEGRAM_ENDPOINTS.bindCredentials, { token: TOKEN, extra: true });
assert.equal(rejected.ok, false);
assert.equal(rejected.error.code, 'bad-request');
const legacyReconnect = await handler(TELEGRAM_ENDPOINTS.reconnectBot, {
botId: 'telegram_123',
});
assert.equal(legacyReconnect.ok, true);
assert.equal('testMessage' in legacyReconnect.value, false);
assert.deepEqual(connectionTests, []);
const tested = await handler(TELEGRAM_ENDPOINTS.reconnectBot, {
botId: 'telegram_123',
sendTest: true,
});
assert.equal(tested.ok, true);
assert.deepEqual(tested.value.testMessage, { sent: true });
assert.deepEqual(connectionTests, ['telegram_123']);
controller.sendConnectionTest = async () => {
const error = new Error('No explicit recipient');
error.code = 'test-target-unavailable';
throw error;
};
const missingTarget = await handler(TELEGRAM_ENDPOINTS.reconnectBot, {
botId: 'telegram_123',
sendTest: true,
});
assert.equal(missingTarget.ok, true);
assert.deepEqual(missingTarget.value.testMessage, {
sent: false,
code: 'test-target-unavailable',
});
});
test('shared token RPC never sends a connection test after reconnect is cancelled', async () => {
let resolveReconnect;
let sendCalls = 0;
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
const controller = {
status: async () => ({ bots: [] }),
bindCredentials: async () => ({ bots: [] }),
reconnectBot: async () => reconnect,
sendConnectionTest: async () => { sendCalls += 1; },
deleteBot: async () => ({ bots: [] }),
};
const abort = new AbortController();
const result = createTelegramRpcHandler(controller)(TELEGRAM_ENDPOINTS.reconnectBot, {
botId: 'telegram_123',
sendTest: true,
}, abort.signal);
abort.abort();
resolveReconnect({ bots: [{ botId: 'telegram_123', connected: true }] });
assert.deepEqual(await result, {
ok: false,
error: { code: 'cancelled', message: 'The request was cancelled.' },
});
assert.equal(sendCalls, 0);
});
test('Telegram normalizes private messages and requires an explicit group address', () => {
@ -239,6 +306,7 @@ test('Telegram normalizes private messages and requires an explicit group addres
}, { botId: '123456789', username: 'HarnessBot' });
assert.equal(privateMessage.kind, 'direct');
assert.equal(privateMessage.addressed, true);
assert.deepEqual(privateMessage.connectionTestTarget, { chatId: 88, messageThreadId: undefined });
const groupMessage = normalizeTelegramUpdate({
update_id: 11,
@ -352,9 +420,13 @@ test('Telegram normalizes photo captions and image documents into one downloadab
test('Telegram bridge ignores unaddressed groups and streams direct replies', async () => {
const sent = [];
const sentTargets = [];
const updates = [];
const bot = {
sendText: async (_target, text) => sent.push(text),
sendText: async (target, text) => {
sentTargets.push(target);
sent.push(text);
},
sendTyping: async () => {},
openStream: async () => ({
update: async (text) => updates.push(text),
@ -373,7 +445,8 @@ test('Telegram bridge ignores unaddressed groups and streams direct replies', as
return '完成';
},
};
const bridge = new TelegramHarnessBridge({ bot, harness, state: memoryState() });
const state = memoryState();
const bridge = new TelegramHarnessBridge({ bot, harness, state });
await bridge.accept({
messageId: '1', senderId: 'u1', kind: 'group', conversationId: 'g1', content: 'ignored',
addressed: false, replyTarget: {},
@ -386,6 +459,26 @@ test('Telegram bridge ignores unaddressed groups and streams direct replies', as
assert.equal(askCount, 1);
assert.deepEqual(updates, ['正在使用搜索…', '处理中']);
assert.deepEqual(sent, ['完成']);
await assert.rejects(
() => bridge.sendConnectionTest('card test'),
(error) => error?.code === 'test-target-unavailable',
);
const statusTarget = { chatId: 88, replyToMessageId: 7 };
await bridge.accept({
messageId: '3', senderId: 'u1', kind: 'direct', conversationId: 'u1', content: '/status',
addressed: true, replyTarget: statusTarget, connectionTestTarget: { chatId: 88 },
});
await bridge.sendConnectionTest('card test');
assert.deepEqual(sent.slice(-2), [
'Telegram机器人与 DeepSeek Harness 连接正常。',
'card test',
]);
assert.deepEqual(sentTargets.at(-1), { chatId: 88 });
const reconnectedBridge = new TelegramHarnessBridge({ bot, harness, state });
await reconnectedBridge.sendConnectionTest('after reconnect');
assert.equal(sent.at(-1), 'after reconnect');
assert.deepEqual(sentTargets.at(-1), { chatId: 88 });
});
test('Telegram runtime validates webhook state and starts a cancellable long poll', async () => {

View file

@ -6,6 +6,7 @@ import {
wecomInboundMessage,
} from '../../../src/channels/wecom/wecom-bridge.mjs';
import { DEFAULT_IMAGE_PROMPT } from '../../../src/channels/shared/image-prompt.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
const PNG_1X1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
@ -100,6 +101,33 @@ function questionInteraction({
};
}
test('Enterprise WeChat remembers only a private /status chat as a connection-test target', async () => {
const privateState = state();
const privateTransport = testClient();
const privateBridge = new WecomHarnessBridge({
client: privateTransport.client,
harness: { ensureRunning: async () => true },
state: privateState,
});
await privateBridge.accept(frame({ msgid: 'status-private', text: { content: '/status' } }));
assert.deepEqual(connectionTestTarget(privateState), { chatId: 'member-1' });
const groupState = state();
const groupTransport = testClient();
const groupBridge = new WecomHarnessBridge({
client: groupTransport.client,
harness: { ensureRunning: async () => true },
state: groupState,
});
await groupBridge.accept(frame({
msgid: 'status-group',
chattype: 'group',
chatid: 'group-1',
text: { content: '@机器人 /status' },
}));
assert.equal(connectionTestTarget(groupState), null);
});
test('Enterprise WeChat executes /compact for the bound Session without prompting the model', async () => {
const store = state();
const transport = testClient();

View file

@ -15,6 +15,7 @@ test('Enterprise WeChat client keeps only redacted bot and Host-rendered QR stat
});
assert.equal(provision.qrCodeDataUrl, qr);
const snapshot = normalizeSnapshot({
testMessage: { sent: false, code: 'test-target-unavailable', ignored: 'secret' },
bots: [{
botId: 'wecom_abc', connected: true, state: 'connected',
bot: { name: '企业微信机器人', appIdMasked: 'bot••••001' },
@ -23,4 +24,7 @@ test('Enterprise WeChat client keeps only redacted bot and Host-rendered QR stat
});
assert.equal(snapshot.totals.connected, 1);
assert.equal(snapshot.bots[0].bot.appIdMasked, 'bot••••001');
assert.deepEqual(snapshot.testMessage, {
sent: false, code: 'test-target-unavailable',
});
});

View file

@ -1,14 +1,44 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer from 'react-test-renderer';
import {
AccountCard,
WecomSettingsTab,
} from '../../../plugin-src/client/channels/wecom/index.js';
const { act, create } = TestRenderer;
const CLIENT_URL = new URL('../../../plugin-src/client/channels/wecom/index.js', import.meta.url);
async function flushMicrotasks() {
for (let index = 0; index < 6; index += 1) await Promise.resolve();
}
function textOf(node) {
if (typeof node === 'string' || typeof node === 'number') return String(node);
return node?.children?.map(textOf).join('') ?? '';
}
function buttonNamed(root, name) {
return root.findAllByType('button').find((button) => textOf(button) === name);
}
function account(botId, name) {
return {
botId,
connected: true,
state: 'connected',
workspace: '/workspace/current',
bot: { name, appIdMasked: `${botId}•••` },
health: { summary: '企业微信 WebSocket 长连接运行正常', lastCheckedAt: Date.now() },
error: null,
};
}
test('Enterprise WeChat settings uses the shared compact channel toolbar', () => {
const markup = renderToStaticMarkup(React.createElement(WecomSettingsTab, {
rpcCall: async () => ({ ok: true, value: {} }),
@ -42,3 +72,67 @@ test('Enterprise WeChat bot cards match the shared two-metric card treatment', (
assert.match(markup, />检查连接<[^]*>移除接入</);
assert.doesNotMatch(markup, /收到\s*\/\s*回复|dim-cardSummary|企业微信 WebSocket 长连接运行正常/);
});
test('Enterprise WeChat card feedback stays visible without hiding connection errors', () => {
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
account: {
...account('wecom_bot', '企业微信机器人'),
connected: false,
state: 'error',
error: { code: 'offline', message: '连接凭据已失效' },
},
feedback: '企业微信连接检查完成,测试消息已发送。',
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
}));
assert.match(markup, />连接凭据已失效</);
assert.match(markup, /role="status"[^>]*>企业微信连接检查完成,测试消息已发送。</);
});
test('Enterprise WeChat connection feedback is scoped to the checked bot', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = {
setInterval() { return 1; }, clearInterval() {},
setTimeout() { return 1; }, clearTimeout() {},
requestAnimationFrame(callback) { callback(); return 1; }, cancelAnimationFrame() {},
};
t.after(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const bots = [account('wecom_first', 'First Bot'), account('wecom_second', 'Second Bot')];
const calls = [];
const rpcCall = async (endpoint, payload) => {
if (endpoint === 'connection.status') return { ok: true, value: { bots } };
if (endpoint === 'bot.reconnect') {
calls.push(payload);
return { ok: true, value: { bots, testMessage: { sent: true } } };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(WecomSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const first = renderer.root.findByProps({ 'data-bot-id': 'wecom_first' });
await act(async () => {
buttonNamed(first, '检查连接').props.onClick();
await flushMicrotasks();
});
const firstAfter = renderer.root.findByProps({ 'data-bot-id': 'wecom_first' });
const secondAfter = renderer.root.findByProps({ 'data-bot-id': 'wecom_second' });
assert.match(textOf(firstAfter), /测试消息已发送/);
assert.doesNotMatch(textOf(secondAfter), /测试消息已发送/);
assert.deepEqual(calls, [{ botId: 'wecom_first', sendTest: true }]);
await act(async () => { renderer.unmount(); });
});
test('Enterprise WeChat reconnect failure uses fixed translatable copy', async () => {
const source = await readFile(CLIENT_URL, 'utf8');
assert.match(source, /'连接检查失败,请稍后重试。'/);
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
});

View file

@ -56,6 +56,7 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
const values = new Map();
const configs = [];
let runtimeArgs;
let connectionTestText;
const controller = new WecomController({
qrAuth: { start: async () => ({}), poll: async () => ({ status: 'waiting' }) },
credentials: {
@ -75,6 +76,7 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
return {
status: { ready: true, wecomConnectionState: 'connected', harnessReachable: true },
start: async () => {}, stop: async () => {},
sendConnectionTest: async (text) => { connectionTestText = text; },
};
},
});
@ -83,6 +85,9 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
assert.equal(values.get(configs[0].secretRef), 'manual-secret');
assert.equal(runtimeArgs.secret, 'manual-secret');
assert.doesNotMatch(JSON.stringify(status), /manual-secret|remote-manual|secretRef/);
await controller.sendConnectionTest(status.bots[0].botId);
assert.match(connectionTestText, /DeepSeek Harness 连接测试成功/);
assert.match(connectionTestText, /企业微信机器人(remote••••nual)/);
await controller.close();
});
@ -102,6 +107,7 @@ test('Enterprise WeChat RPC encodes the QR on Host and strips every authorizatio
cancelProvisioning: async () => ({}),
bindCredentials: async () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
reconnectBot: async () => ({}),
sendConnectionTest: async () => ({ sent: true }),
deleteBot: async () => ({}),
}, { encodeQr: async () => 'data:image/png;base64,YWJjZA==' });
const result = await handler(WECOM_ENDPOINTS.beginProvisioning, { locale: 'zh-CN' });
@ -115,7 +121,8 @@ test('Enterprise WeChat credential RPC accepts official Bot ID fields and redact
const handler = createWecomRpcHandler({
status: () => ({ bots: [], totals: { configured: 0, connected: 0 } }),
startProvisioning: async () => ({}), registrationStatus: async () => null,
cancelProvisioning: async () => ({}), reconnectBot: async () => ({}), deleteBot: async () => ({}),
cancelProvisioning: async () => ({}), reconnectBot: async () => ({}),
sendConnectionTest: async () => ({ sent: true }), deleteBot: async () => ({}),
bindCredentials: async (payload) => {
received = payload;
return { bots: [], totals: { configured: 0, connected: 0 }, secret: payload.secret };
@ -129,3 +136,63 @@ test('Enterprise WeChat credential RPC accepts official Bot ID fields and redact
assert.doesNotMatch(JSON.stringify(result), /manual-secret|"secret"/);
assert.equal((await handler(WECOM_ENDPOINTS.bindCredentials, { botId: 'remote-manual' })).ok, false);
});
test('Enterprise WeChat reconnect optionally sends a test message without changing connection success', async () => {
const sent = [];
const base = {
status: () => ({ bots: [] }),
startProvisioning: async () => ({}), registrationStatus: async () => null,
cancelProvisioning: async () => ({}), bindCredentials: async () => ({}),
deleteBot: async () => ({}),
};
const connected = {
bots: [{ botId: 'wecom_bot', connected: true }],
totals: { configured: 1, connected: 1 },
};
const handler = createWecomRpcHandler({
...base,
reconnectBot: async () => connected,
sendConnectionTest: async (botId) => { sent.push(botId); },
});
const success = await handler(WECOM_ENDPOINTS.reconnectBot, {
botId: 'wecom_bot', sendTest: true,
});
assert.equal(success.ok, true);
assert.deepEqual(success.value.testMessage, { sent: true });
assert.deepEqual(sent, ['wecom_bot']);
const failedSend = await createWecomRpcHandler({
...base,
reconnectBot: async () => connected,
sendConnectionTest: async () => { throw new Error('provider rejected'); },
})(WECOM_ENDPOINTS.reconnectBot, { botId: 'wecom_bot', sendTest: true });
assert.equal(failedSend.ok, true);
assert.deepEqual(failedSend.value.testMessage, { sent: false, code: 'test-message-failed' });
let offlineSendCalled = false;
const offline = await createWecomRpcHandler({
...base,
reconnectBot: async () => ({
bots: [{ botId: 'wecom_bot', connected: false }],
totals: { configured: 1, connected: 0 },
}),
sendConnectionTest: async () => { offlineSendCalled = true; },
})(WECOM_ENDPOINTS.reconnectBot, { botId: 'wecom_bot', sendTest: true });
assert.equal(offline.ok, true);
assert.deepEqual(offline.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.equal(offlineSendCalled, false);
const missingMethod = await createWecomRpcHandler({
...base,
reconnectBot: async () => connected,
})(WECOM_ENDPOINTS.reconnectBot, { botId: 'wecom_bot', sendTest: true });
assert.equal(missingMethod.ok, true);
assert.deepEqual(missingMethod.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.equal((await handler(WECOM_ENDPOINTS.reconnectBot, {
botId: 'wecom_bot', sendTest: false,
})).ok, false);
});

View file

@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events';
import test from 'node:test';
import { WecomRuntime } from '../../../src/channels/wecom/wecom-runtime.mjs';
import { rememberConnectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
function deferred() {
let resolve;
@ -12,13 +13,38 @@ function deferred() {
class FakeClient extends EventEmitter {
disconnected = false;
sent = [];
connect() { queueMicrotask(() => this.emit('authenticated')); }
disconnect() { this.disconnected = true; }
async replyStream() {}
async replyStreamNonBlocking() {}
async sendMessage() {}
async sendMessage(chatId, body) { this.sent.push({ chatId, body }); }
}
test('Enterprise WeChat runtime sends a connection test only to the remembered private status target', async () => {
const client = new FakeClient();
const state = {};
const runtime = new WecomRuntime({
config: { botId: 'wecom_bot', remoteBotId: 'remote-bot' },
secret: 'private-secret',
harness: { ensureRunning: async () => true },
state,
createClient: () => client,
connectTimeoutMs: 100,
});
await runtime.start();
await assert.rejects(() => runtime.sendConnectionTest('测试'), {
code: 'test-target-unavailable',
});
rememberConnectionTestTarget(state, { chatId: 'member-private' });
assert.deepEqual(await runtime.sendConnectionTest('测试'), { sent: true });
assert.deepEqual(client.sent, [{
chatId: 'member-private',
body: { msgtype: 'markdown', markdown: { content: '测试' } },
}]);
await runtime.stop();
});
test('Enterprise WeChat runtime waits for authentication, suppresses SDK payload logs, and reconnects', async () => {
const client = new FakeClient();
let options;

View file

@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer from 'react-test-renderer';
import { normalizeSnapshot } from '../../../plugin-src/client/channels/weixin/api.js';
import {
AccountCard,
WeixinSettingsTab,
} from '../../../plugin-src/client/channels/weixin/index.js';
const { act, create } = TestRenderer;
const CLIENT_URL = new URL('../../../plugin-src/client/channels/weixin/index.js', import.meta.url);
async function flushMicrotasks() {
for (let index = 0; index < 6; index += 1) await Promise.resolve();
}
function textOf(node) {
if (typeof node === 'string' || typeof node === 'number') return String(node);
return node?.children?.map(textOf).join('') ?? '';
}
function buttonNamed(root, name) {
return root.findAllByType('button').find((button) => textOf(button) === name);
}
function account(botId, name) {
return {
botId,
connected: true,
state: 'connected',
configured: true,
workspace: '/workspace/current',
bot: { name, accountIdMasked: `${botId}•••` },
health: { summary: '微信消息长轮询运行正常', lastCheckedAt: Date.now() },
error: null,
};
}
test('Weixin client keeps only the public connection-test result', () => {
const snapshot = normalizeSnapshot({
schemaVersion: 1,
revision: 1,
state: 'connected',
testMessage: {
sent: false,
code: 'test-target-unavailable',
providerDetail: 'must-not-cross-client-normalization',
},
bots: [{
botId: 'wx_0123456789abcdef01234567',
connected: true,
state: 'connected',
configured: true,
bot: { name: '微信机器人', accountIdMasked: 'account••••1234' },
}],
});
assert.deepEqual(snapshot.testMessage, {
sent: false,
code: 'test-target-unavailable',
});
});
test('Weixin card feedback stays visible without hiding connection errors', () => {
const markup = renderToStaticMarkup(React.createElement(AccountCard, {
account: {
...account('wx_first', '微信机器人'),
connected: false,
state: 'error',
error: { code: 'offline', message: '连接凭据已失效' },
},
feedback: '微信连接检查完成,测试消息已发送。',
onReconnect() {}, onRequestRemove() {}, onConfirmRemove() {}, onCancelRemove() {},
}));
assert.match(markup, />连接凭据已失效</);
assert.match(markup, /role="status"[^>]*>微信连接检查完成,测试消息已发送。</);
});
test('Weixin connection feedback is scoped to the checked bot', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = {
setInterval() { return 1; }, clearInterval() {},
setTimeout() { return 1; }, clearTimeout() {},
requestAnimationFrame(callback) { callback(); return 1; }, cancelAnimationFrame() {},
};
t.after(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const bots = [account('wx_first', 'First Bot'), account('wx_second', 'Second Bot')];
const calls = [];
const rpcCall = async (endpoint, payload) => {
if (endpoint === 'connection.status') return { ok: true, value: { revision: 1, bots } };
if (endpoint === 'bot.reconnect') {
calls.push(payload);
return { ok: true, value: { revision: 2, bots, testMessage: { sent: true } } };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(WeixinSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const first = renderer.root.findByProps({ 'data-bot-id': 'wx_first' });
await act(async () => {
buttonNamed(first, '检查连接').props.onClick();
await flushMicrotasks();
});
const firstAfter = renderer.root.findByProps({ 'data-bot-id': 'wx_first' });
const secondAfter = renderer.root.findByProps({ 'data-bot-id': 'wx_second' });
assert.match(textOf(firstAfter), /测试消息已发送/);
assert.doesNotMatch(textOf(secondAfter), /测试消息已发送/);
assert.deepEqual(calls, [{ botId: 'wx_first', sendTest: true }]);
await act(async () => { renderer.unmount(); });
});
test('Weixin reconnect failure uses fixed translatable copy', async () => {
const source = await readFile(CLIENT_URL, 'utf8');
assert.match(source, /'连接检查失败,请稍后重试。'/);
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
});

View file

@ -37,6 +37,7 @@ function controllerFixture() {
submitVerification: async (id) => ({ ...attempts.get(id), status: 'scanned' }),
cancelProvisioning: async (id) => ({ ...attempts.get(id), status: 'cancelled' }),
reconnectBot: async () => snapshot(),
sendConnectionTest: async () => ({ sent: true }),
deleteBot: async () => snapshot(),
};
return controller;
@ -105,3 +106,60 @@ test('RPC requires explicit confirmation before removing a Weixin account', asyn
assert.equal(result.ok, false);
assert.equal(result.error.code, 'bad-request');
});
test('RPC sends a Weixin connection test only after reconnect reports the account connected', async () => {
const botId = 'wx_0123456789abcdef01234567';
const sent = [];
const connected = controllerFixture();
connected.reconnectBot = async () => snapshot({
state: 'connected',
bots: [{ botId, connected: true }],
totals: { configured: 1, connected: 1 },
});
connected.sendConnectionTest = async (id) => { sent.push(id); };
const success = await createWeixinRpcHandler(connected)(WEIXIN_ENDPOINTS.reconnectBot, {
botId, sendTest: true,
});
assert.equal(success.ok, true);
assert.deepEqual(success.value.testMessage, { sent: true });
assert.deepEqual(sent, [botId]);
connected.sendConnectionTest = async () => { throw new Error('send rejected'); };
const failedSend = await createWeixinRpcHandler(connected)(WEIXIN_ENDPOINTS.reconnectBot, {
botId, sendTest: true,
});
assert.equal(failedSend.ok, true);
assert.deepEqual(failedSend.value.testMessage, {
sent: false, code: 'test-message-failed',
});
let offlineSendCalled = false;
const offline = controllerFixture();
offline.reconnectBot = async () => snapshot({
state: 'offline',
bots: [{ botId, connected: false }],
totals: { configured: 1, connected: 0 },
});
offline.sendConnectionTest = async () => { offlineSendCalled = true; };
const unavailable = await createWeixinRpcHandler(offline)(WEIXIN_ENDPOINTS.reconnectBot, {
botId, sendTest: true,
});
assert.equal(unavailable.ok, true);
assert.deepEqual(unavailable.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.equal(offlineSendCalled, false);
const missingMethod = controllerFixture();
missingMethod.reconnectBot = connected.reconnectBot;
delete missingMethod.sendConnectionTest;
const missing = await createWeixinRpcHandler(missingMethod)(WEIXIN_ENDPOINTS.reconnectBot, {
botId, sendTest: true,
});
assert.equal(missing.ok, true);
assert.deepEqual(missing.value.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.equal((await createWeixinRpcHandler(connected)(WEIXIN_ENDPOINTS.reconnectBot, {
botId, sendTest: false,
})).ok, false);
});

View file

@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { createWeixinBridgeStatus, WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
function deferred() {
let resolve;
@ -68,6 +69,35 @@ const PNG_BYTES = Buffer.from([
0x01, 0x02, 0x03,
]);
test('Weixin remembers only the authorized owner /status as a connection-test target', async () => {
const fixture = stateFixture();
const sent = [];
const bridge = new WeixinHarnessBridge({
api: { sendText: async (request) => sent.push(request) },
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
harness: { ensureRunning: async () => true },
state: fixture.state,
});
await bridge.accept(message('status-owner', '/status'));
assert.deepEqual(connectionTestTarget(fixture.state), { toUserId: 'owner-user' });
assert.equal(sent.at(-1).text, '微信与 DeepSeek Harness 连接正常。');
const rejectedFixture = stateFixture();
const rejectedBridge = new WeixinHarnessBridge({
api: { sendText: async () => assert.fail('unauthorized status must not be answered') },
baseUrl: 'https://ilinkai.weixin.qq.com/',
token: 'host-token',
ownerUserId: 'owner-user',
harness: { ensureRunning: async () => true },
state: rejectedFixture.state,
});
await rejectedBridge.accept(message('status-other', '/status', { from_user_id: 'other-user' }));
assert.equal(connectionTestTarget(rejectedFixture.state), null);
});
test('Weixin sends image-only messages to Harness as structured content', async () => {
const fixture = stateFixture();
fixture.sessions.set('p2p:owner-user', 'session-image');

View file

@ -49,6 +49,7 @@ function configFixture() {
function runtimeFactory({ failStart = false } = {}) {
const runtimes = [];
const connectionTests = [];
const createRuntime = async ({ config, token }) => {
let ready = false;
const runtime = {
@ -67,11 +68,12 @@ function runtimeFactory({ failStart = false } = {}) {
ready = true;
},
async stop() { ready = false; },
async sendConnectionTest(text) { connectionTests.push({ botId: config.botId, text }); },
};
runtimes.push(runtime);
return runtime;
};
return { runtimes, createRuntime };
return { runtimes, connectionTests, createRuntime };
}
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
@ -114,6 +116,11 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
assert.doesNotMatch(publicJson, /private-bot-token|owner-user|account@im\.bot|tokenRef/);
assert.equal(controller.status().totals.connected, 1);
await controller.sendConnectionTest(completed.botId);
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
assert.match(runtimes.connectionTests[0].text, /微信机器人(accoun••••\.bot)/);
await controller.deleteBot(completed.botId);
assert.equal(credentials.values.size, 0);
assert.equal(configs.accounts.size, 0);

View file

@ -50,6 +50,37 @@ function abortable(promise, signal) {
});
}
test('runtime sends a connection test to the bound Weixin owner without reply context', async () => {
const sends = [];
const runtime = new WeixinRuntime({
api: {
notifyStart: async () => {},
notifyStop: async () => {},
sendText: async (request) => sends.push(request),
getUpdates: async ({ signal }) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
}),
},
config: {
botId: 'wx_owner',
baseUrl: 'https://ilinkai.weixin.qq.com/',
ownerUserId: 'owner-user',
},
token: 'bot-token',
harness: { ensureRunning: async () => true },
state: { getUpdatesBuf: () => '' },
});
await runtime.start();
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
assert.equal(sends.length, 1);
assert.equal(sends[0].toUserId, 'owner-user');
assert.equal(sends[0].text, '连接测试');
assert.equal(sends[0].contextToken, undefined);
assert.equal(sends[0].runId, undefined);
await runtime.stop();
});
test('runtime verifies the token, consumes getUpdates, replies, persists cursor, and aborts on stop', async () => {
const calls = [];
let pollCount = 0;

View file

@ -1,15 +1,30 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import TestRenderer from 'react-test-renderer';
import {
EmptyView,
ProvisionView,
QrPanel,
WhatsappAccountCard,
WhatsappSettingsTab,
} from '../../../plugin-src/client/channels/whatsapp/index.js';
import { en, setImTranslator } from '../../../plugin-src/client/i18n.js';
const { act, create } = TestRenderer;
async function flushMicrotasks() {
for (let index = 0; index < 6; index += 1) await Promise.resolve();
}
function textOf(node) {
if (typeof node === 'string' || typeof node === 'number') return String(node);
return node?.children?.map(textOf).join('') ?? '';
}
test('WhatsApp onboarding is QR-only with no Cloud API credential form', () => {
const empty = renderToStaticMarkup(React.createElement(EmptyView, {}));
@ -48,10 +63,78 @@ test('WhatsApp account card uses the unified compact channel layout', () => {
health: { summary: 'WhatsApp Web 关联设备运行正常', lastCheckedAt: Date.now() },
error: null,
},
testNotice: '测试消息已发送,请到 WhatsApp 自聊会话中确认。',
}));
assert.match(markup, /data-im-channel-logo="whatsapp"/);
assert.match(markup, /WhatsApp Web/);
assert.match(markup, /检查连接/);
assert.match(markup, /移除接入/);
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
assert.equal((markup.match(/class="ddt-metric dim-botMetric"/g) ?? []).length, 2);
});
test('WhatsApp connection check requests a test message from the existing reconnect endpoint', async () => {
const source = await readFile(new URL(
'../../../plugin-src/client/channels/whatsapp/index.js',
import.meta.url,
), 'utf8');
assert.match(source, /WHATSAPP_ENDPOINTS\.reconnectBot,[\s\S]*\{ botId: account\.botId, sendTest: true \}/);
assert.match(source, /\[account\.botId\]: '连接检查失败,请稍后重试。'/);
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
});
test('WhatsApp reconnect failures render a fixed English-safe notice', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = {
setInterval() { return 1; },
clearInterval() {},
setTimeout() { return 1; },
clearTimeout() {},
};
setImTranslator((key) => en[key] ?? key);
t.after(() => {
setImTranslator(null);
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const snapshot = {
schemaVersion: 1,
bots: [{
botId: 'whatsapp_test',
state: 'connected',
connected: true,
workspace: '/workspace/current',
bot: { name: 'Harness WhatsApp', idMasked: '1650••••0123' },
health: { summary: 'WhatsApp Web is healthy', lastCheckedAt: Date.now() },
error: null,
}],
totals: { configured: 1, connected: 1 },
};
const rpcCall = async (endpoint) => {
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
if (endpoint === 'bot.reconnect') {
return {
ok: false,
error: { code: 'whatsapp-operation-failed', message: 'WhatsApp 操作失败,请稍后重试。' },
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(WhatsappSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const card = renderer.root.findByProps({ 'data-bot-id': 'whatsapp_test' });
await act(async () => {
card.findAllByType('button')
.find((button) => textOf(button) === 'Check connection').props.onClick();
await flushMicrotasks();
});
const notice = textOf(renderer.root.findByProps({ role: 'status' }));
assert.equal(notice, 'Connection check failed. Try again later.');
assert.doesNotMatch(notice, /[\p{Script=Han}]/u);
await act(async () => { renderer.unmount(); });
});

View file

@ -426,6 +426,193 @@ test('WhatsApp runtime answers self-chat without processing its own reply echo',
await runtime.stop();
});
test('WhatsApp runtime sends a connection test to self and suppresses its outbound echo', async () => {
let callbacks;
let askCount = 0;
const sent = [];
const socket = {
sendPresenceUpdate: async () => {},
readMessages: async () => {},
sendMessage: async (jid, content) => {
sent.push([jid, content]);
return { key: { id: 'connection-test-1' } };
},
};
const runtime = new WhatsappRuntime({
config: linkedConfig(),
authDir: '/tmp/test-whatsapp-connection-test',
harness: {
ensureRunning: async () => {},
sessionExists: async () => true,
ask: async () => { askCount += 1; return 'unexpected'; },
},
state: {
hasSeen: () => false,
markSeen: async () => {},
sessionFor: () => 'session-connection-test',
sessionExists: async () => true,
},
createSession: async (options) => {
callbacks = options;
return {
socket,
ready: Promise.resolve({ accountJid: ACCOUNT_JID, name: 'Harness WhatsApp' }),
close: async () => {},
logout: async () => {},
};
},
});
await runtime.start();
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
assert.deepEqual(sent, [[ACCOUNT_JID, { text: '连接测试' }]]);
await callbacks.onMessage({
key: { remoteJid: ACCOUNT_JID, id: 'connection-test-1', fromMe: true },
message: { conversation: '连接测试' },
});
assert.equal(askCount, 0);
await runtime.stop();
});
test('WhatsApp controller delegates connection test copy to the current runtime', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-test-message-'));
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
const config = await configStore.save(linkedConfig());
const sent = [];
const controller = new WhatsappController({
configStore,
authPath: (name) => join(root, 'auth', name),
createSession: async () => { throw new Error('not used'); },
createRuntime: async () => ({
status: {
ready: true,
connectionState: 'connected',
harnessReachable: true,
},
start: async () => {},
stop: async () => {},
sendConnectionTest: async (text) => {
sent.push(text);
return { sent: true };
},
}),
});
t.after(() => controller.close());
await controller.initialize();
assert.deepEqual(await controller.sendConnectionTest(config.botId), { sent: true });
assert.deepEqual(sent, [
'✅ DeepSeek Harness 连接测试成功\n这条消息由插件页面中的“Harness WhatsApp(1650••••0123)”机器人卡片发出。',
]);
});
test('WhatsApp reconnect RPC sends tests only for the connected target and keeps failures non-fatal', async () => {
const botId = deriveWhatsappBotId(ACCOUNT_JID);
let connected = true;
let sendFailure = false;
let sendCalls = 0;
const snapshot = () => ({
schemaVersion: 1,
revision: 1,
bots: [{
botId,
state: connected ? 'connected' : 'offline',
connected,
configured: true,
bot: { name: 'Harness WhatsApp', idMasked: '1650••••0123' },
health: { summary: 'status', lastCheckedAt: Date.now() },
}],
totals: { configured: 1, connected: connected ? 1 : 0 },
});
const controller = {
status: async () => snapshot(),
startProvisioning: async () => null,
registrationStatus: async () => null,
cancelProvisioning: async () => null,
reconnectBot: async () => snapshot(),
deleteBot: async () => snapshot(),
sendConnectionTest: async () => {
sendCalls += 1;
if (sendFailure) throw new Error('private provider failure');
return { sent: true };
},
};
const handler = createWhatsappRpcHandler(controller);
const legacy = await handler(WHATSAPP_ENDPOINTS.reconnectBot, { botId });
assert.equal(legacy.ok, true);
assert.equal('testMessage' in legacy.value, false);
assert.equal(sendCalls, 0);
const success = await handler(
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId, sendTest: true },
);
assert.deepEqual(success.value.testMessage, { sent: true });
assert.equal(sendCalls, 1);
sendFailure = true;
const failedSend = await handler(
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId, sendTest: true },
);
assert.equal(failedSend.ok, true);
assert.deepEqual(failedSend.value.testMessage, {
sent: false,
code: 'test-message-failed',
});
assert.doesNotMatch(JSON.stringify(failedSend), /private provider failure/);
connected = false;
const unavailable = await handler(
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId, sendTest: true },
);
assert.equal(unavailable.ok, true);
assert.deepEqual(unavailable.value.testMessage, {
sent: false,
code: 'test-target-unavailable',
});
assert.equal(sendCalls, 2);
const invalid = await handler(
WHATSAPP_ENDPOINTS.reconnectBot,
{ botId, sendTest: 'yes' },
);
assert.equal(invalid.ok, false);
assert.equal(invalid.error.code, 'bad-request');
});
test('WhatsApp RPC never sends a connection test after reconnect is cancelled', async () => {
let resolveReconnect;
let sendCalls = 0;
const reconnect = new Promise((resolve) => { resolveReconnect = resolve; });
const botId = deriveWhatsappBotId(ACCOUNT_JID);
const controller = {
status: async () => ({ bots: [] }),
startProvisioning: async () => null,
registrationStatus: async () => null,
cancelProvisioning: async () => null,
reconnectBot: async () => reconnect,
sendConnectionTest: async () => { sendCalls += 1; },
deleteBot: async () => ({ bots: [] }),
};
const abort = new AbortController();
const result = createWhatsappRpcHandler(controller)(WHATSAPP_ENDPOINTS.reconnectBot, {
botId,
sendTest: true,
}, abort.signal);
abort.abort();
resolveReconnect({ bots: [{ botId, connected: true }] });
assert.deepEqual(await result, {
ok: false,
error: { code: 'cancelled', message: 'The request was cancelled.' },
});
assert.equal(sendCalls, 0);
});
test('WhatsApp QR controller and RPC keep the raw QR and linked identity host-only', async (t) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-whatsapp-controller-'));
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();

View file

@ -492,6 +492,84 @@ test('an older reconnect snapshot from another bot cannot restore a saved worksp
await act(async () => { renderer.unmount(); });
});
test('connection check requests a test message and shows its delivery result', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
t.after(() => {
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const calls = [];
const snapshot = discordSnapshot('/workspace/current');
const rpcCall = async (endpoint, payload) => {
calls.push({ endpoint, payload });
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
if (endpoint === 'bot.reconnect') {
return { ok: true, value: { ...snapshot, testMessage: { sent: true } } };
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const card = renderer.root.findByProps({ 'data-bot-id': 'discord_test' });
await act(async () => {
buttonNamed(card, '检查连接').props.onClick();
await flushMicrotasks();
});
assert.deepEqual(calls.find((call) => call.endpoint === 'bot.reconnect')?.payload, {
botId: 'discord_test',
sendTest: true,
});
assert.equal(
textOf(renderer.root.findByProps({ role: 'status' })),
'测试消息已发送,请到对应机器人会话中确认。',
);
await act(async () => { renderer.unmount(); });
});
test('shared token connection failures render a fixed English-safe notice', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };
setImTranslator((key) => en[key] ?? key);
t.after(() => {
setImTranslator(null);
if (previousWindow === undefined) delete globalThis.window;
else globalThis.window = previousWindow;
});
const snapshot = discordSnapshot('/workspace/current');
const rpcCall = async (endpoint) => {
if (endpoint === 'connection.status') return { ok: true, value: snapshot };
if (endpoint === 'bot.reconnect') {
return {
ok: false,
error: { code: 'discord-operation-failed', message: 'Discord 操作失败,请稍后重试。' },
};
}
throw new Error(`Unexpected endpoint: ${endpoint}`);
};
let renderer;
await act(async () => {
renderer = create(React.createElement(DiscordSettingsTab, { rpcCall }));
await flushMicrotasks();
});
const card = renderer.root.findByProps({ 'data-bot-id': 'discord_test' });
await act(async () => {
buttonNamed(card, 'Check connection').props.onClick();
await flushMicrotasks();
});
const notice = textOf(renderer.root.findByProps({ role: 'status' }));
assert.equal(notice, 'Connection check failed. Try again later.');
assert.doesNotMatch(notice, /[\p{Script=Han}]/u);
await act(async () => { renderer.unmount(); });
});
test('an older reconnect snapshot cannot resurrect a bot deleted by a newer mutation', async (t) => {
const previousWindow = globalThis.window;
globalThis.window = { setInterval() { return 1; }, clearInterval() {} };

View file

@ -12,6 +12,10 @@ import {
observeBotWorkspaceRemovals,
validateWorkspacePath,
} from '../src/channels/shared/bot-workspace-store.mjs';
import {
connectionTestTarget,
rememberConnectionTestTarget,
} from '../src/channels/shared/connection-test.mjs';
import {
runWorkspaceCommand,
splitWorkspaceCommandMessage,
@ -70,6 +74,23 @@ test('BotWorkspaceStore uses process.cwd() when a bot has no configured workspac
assert.equal(await store.ensure('bot_cwd'), process.cwd());
});
test('connection test targets survive a new workspace scope for the same bot', async (t) => {
const { path, defaultWorkspace } = await fixture(t);
const workspaces = await new BotWorkspaceStore(path, { defaultWorkspace }).load();
await workspaces.ensure('bot_reconnect');
const state = {};
const harness = {};
const beforeReconnect = createBotWorkspaceScope(harness, {
botId: 'bot_reconnect', workspaces, state,
});
const afterReconnect = createBotWorkspaceScope(harness, {
botId: 'bot_reconnect', workspaces, state,
});
assert.equal(rememberConnectionTestTarget(beforeReconnect.state, { channelId: 'D123' }), true);
assert.deepEqual(connectionTestTarget(afterReconnect.state), { channelId: 'D123' });
});
test('workspace writes roll back updates while committed removals stay retired in memory', async (t) => {
const { root, defaultWorkspace, alternateWorkspace } = await fixture(t);
const storeDirectory = join(root, 'workspace-store');