fix: remember direct recipients for connection tests

This commit is contained in:
xmanrui 2026-08-19 11:24:50 +08:00
parent fb61c4235f
commit 3ac70a7c65
34 changed files with 397 additions and 178 deletions

View file

@ -126,7 +126,7 @@ test('connection-test feedback uses fixed client-owned messages', () => {
);
assert.equal(
connectionTestFeedback({ sent: false, code: 'test-target-unavailable' }),
'钉钉连接检查完成。请先私聊机器人发送 /status,再重新检查。',
'连接检查完成。机器人尚未收到可用于测试的私聊消息。',
);
assert.equal(
connectionTestFeedback({ sent: false, code: 'test-message-failed' }),

View file

@ -82,7 +82,7 @@ const PNG_BYTES = Buffer.from([
0x01, 0x02, 0x03,
]);
test('DingTalk remembers only a private /status session webhook for connection tests', async () => {
test('DingTalk remembers any private inbound session webhook for connection tests', async () => {
const privateFixture = stateFixture();
const privateSent = [];
const privateBridge = new DingtalkHarnessBridge({
@ -92,11 +92,11 @@ test('DingTalk remembers only a private /status session webhook for connection t
harness: { ensureRunning: async () => true },
state: privateFixture.state,
});
await privateBridge.accept(message('status-private', '/status'));
await privateBridge.accept(message('help-private', '/help'));
assert.deepEqual(connectionTestTarget(privateFixture.state), {
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=status-private',
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=help-private',
});
assert.equal(privateSent.at(-1).text, '钉钉机器人与 DeepSeek Harness 连接正常。');
assert.match(privateSent.at(-1).text, /\/help/);
const groupFixture = stateFixture();
const groupBridge = new DingtalkHarnessBridge({
@ -106,9 +106,9 @@ test('DingTalk remembers only a private /status session webhook for connection t
harness: { ensureRunning: async () => true },
state: groupFixture.state,
});
await groupBridge.accept(message('status-group', '/status', {
await groupBridge.accept(message('help-group', '/help', {
conversationType: '2',
conversationId: 'group-status',
conversationId: 'group-help',
isInAtList: true,
}));
assert.equal(connectionTestTarget(groupFixture.state), null);

View file

@ -45,7 +45,7 @@ function stateFixture() {
};
}
test('runtime sends a DingTalk connection test only through the remembered private status webhook', async () => {
test('runtime sends a DingTalk connection test only through the remembered private webhook', async () => {
const state = stateFixture();
const sends = [];
const client = {
@ -70,13 +70,13 @@ test('runtime sends a DingTalk connection test only through the remembered priva
code: 'test-target-unavailable',
});
rememberConnectionTestTarget(state, {
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=status-private',
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=inbound-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].sessionWebhook, 'https://oapi.dingtalk.com/robot/reply?ticket=inbound-private');
assert.equal(sends[0].text, '连接测试');
await runtime.stop();
});

View file

@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import { FeishuHarnessBridge } from '../../../src/channels/feishu/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=',
@ -86,6 +87,54 @@ function textClient(sendText) {
};
}
test('Feishu remembers any authorized private inbound message as a connection-test target', async () => {
const groupFixture = stateFixture();
const groupBridge = new FeishuHarnessBridge({
client: textClient(async () => {}),
channel: {},
harness: { ensureRunning: async () => true },
state: groupFixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_user']),
});
await groupBridge.accept(event('target-group', '/help', {
chat_type: 'group',
chat_id: 'oc_group',
}));
await groupBridge.waitForIdle();
assert.equal(connectionTestTarget(groupFixture.state), null);
const rejectedFixture = stateFixture();
const rejectedBridge = new FeishuHarnessBridge({
client: textClient(async () => {}),
channel: {},
harness: { ensureRunning: async () => true },
state: rejectedFixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['ou_owner']),
});
await rejectedBridge.accept(event('target-rejected', '/help', {
senderOpenId: 'ou_other',
}));
await rejectedBridge.waitForIdle();
assert.equal(connectionTestTarget(rejectedFixture.state), null);
const privateFixture = stateFixture();
const privateBridge = new FeishuHarnessBridge({
client: textClient(async () => {}),
channel: {},
harness: { ensureRunning: async () => true },
state: privateFixture.state,
status: bridgeStatus(),
allowedSenderOpenIds: new Set(['*']),
});
await privateBridge.accept(event('target-private', '/help', {
chat_id: 'oc_private',
}));
await privateBridge.waitForIdle();
assert.deepEqual(connectionTestTarget(privateFixture.state), { chatId: 'oc_private' });
});
test('Feishu executes /compact for the bound Session without prompting the model', async () => {
const fixture = stateFixture([['p2p:ou_user', 'session-compact']]);
const sent = [];

View file

@ -31,6 +31,8 @@ test('Feishu connection check requests and displays test-message feedback', asyn
import.meta.url,
), 'utf8');
assert.match(source, /FEISHU_ENDPOINTS\.reconnectBot, \{ botId, sendTest: true \}/);
assert.match(source, /机器人尚未收到可用于测试的私聊消息/);
assert.doesNotMatch(source, /请先私聊机器人发送 \/status/);
const markup = renderToStaticMarkup(React.createElement(BotCard, {
connection: {

View file

@ -1,6 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { FeishuRuntime } from '../../../src/channels/feishu/feishu-runtime.mjs';
import { rememberConnectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
class FakeClient {
static instances = [];
@ -86,7 +87,7 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
lark: fakeLark(),
appId: 'cli_test',
appSecret: 'secret',
ownerOpenId: 'ou_owner',
ownerOpenIds: ['*', 'ou_owner'],
harness: {
async ensureRunning(options) {
harnessChecks += 1;
@ -133,6 +134,40 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
assert.equal(harnessSignal.aborted, true);
});
test('FeishuRuntime uses a remembered private target for wildcard-only manual bots', async () => {
const state = { hasSeen: () => false };
const runtime = new FeishuRuntime({
lark: fakeLark(),
appId: 'cli_manual',
appSecret: 'secret',
ownerOpenIds: ['*'],
harness: { async ensureRunning() {} },
state,
});
const starting = runtime.start();
await new Promise((resolve) => setImmediate(resolve));
FakeWSClient.instances[0].becomeReady();
await starting;
await assert.rejects(
runtime.sendConnectionTest('连接测试'),
(error) => error?.code === 'test-target-unavailable',
);
rememberConnectionTestTarget(state, { chatId: 'oc_manual_private' });
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
assert.deepEqual(FakeClient.sent, [{
params: { receive_id_type: 'chat_id' },
data: {
receive_id: 'oc_manual_private',
msg_type: 'text',
content: JSON.stringify({ text: '连接测试' }),
},
}]);
await runtime.stop();
});
test('FeishuRuntime fails closed when the initial WebSocket handshake times out', async () => {
const runtime = new FeishuRuntime({
lark: fakeLark(),

View file

@ -259,7 +259,7 @@ 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 () => {
test('QQ remembers any authorized private inbound as a connection-test target', async () => {
const fixture = stateFixture();
const sent = [];
const bridge = new QqHarnessBridge({
@ -270,32 +270,32 @@ test('QQ remembers a connection-test target only for an authorized private /stat
});
await bridge.accept(message({
messageId: 'status-rejected',
messageId: 'help-rejected',
senderId: 'other-openid',
content: '/status',
replyTarget: { scope: 'c2c', targetId: 'other-openid', msgId: 'status-rejected' },
content: '/help',
replyTarget: { scope: 'c2c', targetId: 'other-openid', msgId: 'help-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' },
messageId: 'help-group',
content: '/help',
replyTarget: { scope: 'group', targetId: 'group-1', msgId: 'help-group' },
}));
assert.equal(connectionTestTarget(fixture.state), null);
const privateTarget = {
scope: 'c2c', targetId: 'owner-openid', msgId: 'status-private',
scope: 'c2c', targetId: 'owner-openid', msgId: 'help-private',
};
await bridge.accept(message({
messageId: 'status-private',
content: '/status',
messageId: 'help-private',
content: '/help',
replyTarget: privateTarget,
}));
assert.deepEqual(connectionTestTarget(fixture.state), privateTarget);
assert.equal(sent.filter(({ text }) => text.includes('连接正常')).length, 2);
assert.equal(sent.length, 2);
});
test('QQ private messages stream Harness snapshots and finalize once', async () => {

View file

@ -40,7 +40,10 @@ test('QQ client normalizes and presents connection-test outcomes', () => {
assert.deepEqual(unavailable.testMessage, {
sent: false, code: 'test-target-unavailable',
});
assert.match(connectionTestFeedback(unavailable.testMessage), /\/status/);
assert.equal(
connectionTestFeedback(unavailable.testMessage),
'连接检查完成。机器人尚未收到可用于测试的私聊消息。',
);
const invalid = normalizeSnapshot({
bots: [], testMessage: { sent: false, code: 'private-provider-error' },

View file

@ -100,7 +100,7 @@ test('QQ runtime sends a proactive connection test to the explicit owner fallbac
await runtime.stop();
});
test('QQ runtime requires /status for wildcard owners and strips the remembered reply id', async () => {
test('QQ runtime requires a remembered private target for wildcard owners and strips the reply id', async () => {
const bot = new FakeBot();
const state = {};
const runtime = new QqRuntime({
@ -119,7 +119,7 @@ test('QQ runtime requires /status for wildcard owners and strips the remembered
(error) => error?.code === 'test-target-unavailable',
);
rememberConnectionTestTarget(state, {
scope: 'c2c', targetId: 'recent-user', msgId: 'old-status-message',
scope: 'c2c', targetId: 'recent-user', msgId: 'old-inbound-message',
});
await runtime.sendConnectionTest('remembered-target');
assert.deepEqual(bot.sent, [{

View file

@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
import { connectionTestTarget } from '../../../src/channels/shared/connection-test.mjs';
import { TextHarnessBridge } from '../../../src/channels/shared/text-harness-bridge.mjs';
import { SlackHarnessBridge } from '../../../src/channels/slack/slack-bridge.mjs';
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
@ -149,6 +150,78 @@ test('all four shared text channels execute /compact outside the model prompt pa
}
});
test('Slack, Telegram, and Discord remember any valid direct message per bot', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],
['telegram', TelegramHarnessBridge],
['discord', DiscordHarnessBridge],
]) {
let sessionSequence = 0;
const harness = {
createSession: async () => `${name}-session-${++sessionSequence}`,
ask: async () => `${name} reply`,
};
const first = stateFixture();
const second = stateFixture();
const firstSent = [];
const secondSent = [];
const firstBot = {
sendText: async (target, text) => firstSent.push({ target, text }),
};
const secondBot = {
sendText: async (target, text) => secondSent.push({ target, text }),
};
const firstBridge = new Bridge({ bot: firstBot, harness, state: first.state });
const secondBridge = new Bridge({ bot: secondBot, harness, state: second.state });
const firstTarget = { channelId: `${name}-private-a` };
const secondTarget = { channelId: `${name}-private-b` };
await firstBridge.accept(message(`${name}-direct-a`, 'hello', {
conversationId: `${name}-private-a`,
replyTarget: { ...firstTarget, replyToMessageId: 'reply-a' },
connectionTestTarget: firstTarget,
}));
assert.deepEqual(connectionTestTarget(first.state), firstTarget, name);
await firstBridge.accept(message(`${name}-group`, 'hello group', {
kind: 'group',
conversationId: `${name}-group`,
addressed: true,
replyTarget: { channelId: `${name}-group` },
connectionTestTarget: { channelId: `${name}-group` },
}));
assert.deepEqual(connectionTestTarget(first.state), firstTarget, `${name} group`);
await firstBridge.accept(message(`${name}-direct-a`, 'duplicate', {
conversationId: `${name}-private-replay`,
replyTarget: { channelId: `${name}-private-replay` },
connectionTestTarget: { channelId: `${name}-private-replay` },
}));
assert.deepEqual(connectionTestTarget(first.state), firstTarget, `${name} duplicate`);
await secondBridge.accept(message(`${name}-direct-b`, 'hello', {
conversationId: `${name}-private-b`,
replyTarget: { ...secondTarget, replyToMessageId: 'reply-b' },
connectionTestTarget: secondTarget,
}));
assert.deepEqual(connectionTestTarget(second.state), secondTarget, `${name} second bot`);
const reconnectedFirstBridge = new Bridge({ bot: firstBot, harness, state: first.state });
await reconnectedFirstBridge.sendConnectionTest(`${name} first test`);
await secondBridge.sendConnectionTest(`${name} second test`);
assert.deepEqual(
firstSent.find(({ text }) => text === `${name} first test`)?.target,
firstTarget,
`${name} reconnect`,
);
assert.deepEqual(
secondSent.find(({ text }) => text === `${name} second test`)?.target,
secondTarget,
`${name} bot isolation`,
);
}
});
test('answers a multi-question interaction on the fast lane with canonical values', async () => {
const fixture = stateFixture();
const sent = [];

View file

@ -454,26 +454,15 @@ test('Telegram bridge ignores unaddressed groups and streams direct replies', as
assert.equal(askCount, 0);
await bridge.accept({
messageId: '2', senderId: 'u1', kind: 'direct', conversationId: 'u1', content: 'hello',
addressed: true, replyTarget: {},
addressed: true,
replyTarget: { chatId: 88, replyToMessageId: 7 },
connectionTestTarget: { chatId: 88 },
});
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.equal(sent.at(-1), 'card test');
assert.deepEqual(sentTargets.at(-1), { chatId: 88 });
const reconnectedBridge = new TelegramHarnessBridge({ bot, harness, state });
await reconnectedBridge.sendConnectionTest('after reconnect');

View file

@ -101,7 +101,7 @@ function questionInteraction({
};
}
test('Enterprise WeChat remembers only a private /status chat as a connection-test target', async () => {
test('Enterprise WeChat remembers any private inbound as a connection-test target', async () => {
const privateState = state();
const privateTransport = testClient();
const privateBridge = new WecomHarnessBridge({
@ -109,7 +109,7 @@ test('Enterprise WeChat remembers only a private /status chat as a connection-te
harness: { ensureRunning: async () => true },
state: privateState,
});
await privateBridge.accept(frame({ msgid: 'status-private', text: { content: '/status' } }));
await privateBridge.accept(frame({ msgid: 'help-private', text: { content: '/help' } }));
assert.deepEqual(connectionTestTarget(privateState), { chatId: 'member-1' });
const groupState = state();
@ -120,10 +120,10 @@ test('Enterprise WeChat remembers only a private /status chat as a connection-te
state: groupState,
});
await groupBridge.accept(frame({
msgid: 'status-group',
msgid: 'help-group',
chattype: 'group',
chatid: 'group-1',
text: { content: '@机器人 /status' },
text: { content: '@机器人 /help' },
}));
assert.equal(connectionTestTarget(groupState), null);
});

View file

@ -134,5 +134,7 @@ test('Enterprise WeChat connection feedback is scoped to the checked bot', async
test('Enterprise WeChat reconnect failure uses fixed translatable copy', async () => {
const source = await readFile(CLIENT_URL, 'utf8');
assert.match(source, /'连接检查失败,请稍后重试。'/);
assert.match(source, /'连接检查完成。机器人尚未收到可用于测试的私聊消息。'/);
assert.doesNotMatch(source, /请先私聊机器人发送 \/status/);
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
});

View file

@ -21,7 +21,7 @@ class FakeClient extends EventEmitter {
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 () => {
test('Enterprise WeChat runtime sends a connection test only to the remembered private target', async () => {
const client = new FakeClient();
const state = {};
const runtime = new WecomRuntime({

View file

@ -127,5 +127,7 @@ test('Weixin connection feedback is scoped to the checked bot', async (t) => {
test('Weixin reconnect failure uses fixed translatable copy', async () => {
const source = await readFile(CLIENT_URL, 'utf8');
assert.match(source, /'连接检查失败,请稍后重试。'/);
assert.match(source, /'连接检查完成。机器人尚未收到可用于测试的私聊消息。'/);
assert.doesNotMatch(source, /请先私聊机器人发送 \/status/);
assert.doesNotMatch(source, /连接检查失败:\$\{presentError\(error\)\.message\}/);
});

View file

@ -69,7 +69,7 @@ const PNG_BYTES = Buffer.from([
0x01, 0x02, 0x03,
]);
test('Weixin remembers only the authorized owner /status as a connection-test target', async () => {
test('Weixin remembers any authorized private inbound as a connection-test target', async () => {
const fixture = stateFixture();
const sent = [];
const bridge = new WeixinHarnessBridge({
@ -81,20 +81,20 @@ test('Weixin remembers only the authorized owner /status as a connection-test ta
state: fixture.state,
});
await bridge.accept(message('status-owner', '/status'));
await bridge.accept(message('help-owner', '/help'));
assert.deepEqual(connectionTestTarget(fixture.state), { toUserId: 'owner-user' });
assert.equal(sent.at(-1).text, '微信与 DeepSeek Harness 连接正常。');
assert.match(sent.at(-1).text, /\/help/);
const rejectedFixture = stateFixture();
const rejectedBridge = new WeixinHarnessBridge({
api: { sendText: async () => assert.fail('unauthorized status must not be answered') },
api: { sendText: async () => assert.fail('unauthorized message 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' }));
await rejectedBridge.accept(message('help-other', '/help', { from_user_id: 'other-user' }));
assert.equal(connectionTestTarget(rejectedFixture.state), null);
});

View file

@ -532,6 +532,50 @@ test('connection check requests a test message and shows its delivery result', a
await act(async () => { renderer.unmount(); });
});
test('shared token target-unavailable feedback asks for any direct message', 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: true,
value: {
...snapshot,
testMessage: { sent: false, code: 'test-target-unavailable' },
},
};
}
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 completed. The bot has not received a direct message it can use for testing.',
);
assert.doesNotMatch(notice, /\/status|[\p{Script=Han}]/u);
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() {} };