feat: add stable proactive delivery

This commit is contained in:
xmanrui 2026-08-30 11:24:53 +08:00
parent 410cb822d7
commit 31792d7bc8
88 changed files with 6427 additions and 488 deletions

View file

@ -153,6 +153,70 @@ test('session text replies include populated mention targets and omit empty ment
for (const body of sent) assert.equal(body.text.content, '回答');
});
test('DingTalk sends proactive text through stable user and group robot endpoints', async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url: url.toString(), options });
if (url.pathname.endsWith('/oauth2/accessToken')) {
return jsonResponse({ accessToken: 'proactive-access-token', expireIn: 7_200 });
}
return jsonResponse({ processQueryKey: `query-${calls.length}` });
};
const api = createDingtalkApi({ fetchImpl });
await api.sendRobotText({
clientId: 'ding-proactive-client',
clientSecret: 'host-only-secret',
target: { type: 'user', robotCode: 'ding-proactive-client', userId: 'staff-one' },
text: ' 用户主动消息\n',
});
await api.sendRobotText({
clientId: 'ding-proactive-client',
clientSecret: 'host-only-secret',
target: {
type: 'group',
robotCode: 'ding-proactive-client',
openConversationId: 'cid-one',
},
text: '群主动消息',
});
assert.equal(calls[1].url, `${DINGTALK_API_BASE_URL}v1.0/robot/oToMessages/batchSend`);
assert.equal(calls[2].url, `${DINGTALK_API_BASE_URL}v1.0/robot/groupMessages/send`);
assert.equal(calls[1].options.headers['x-acs-dingtalk-access-token'], 'proactive-access-token');
assert.equal(calls[2].options.headers['x-acs-dingtalk-access-token'], 'proactive-access-token');
assert.deepEqual(JSON.parse(calls[1].options.body), {
robotCode: 'ding-proactive-client',
msgKey: 'sampleText',
msgParam: JSON.stringify({ content: ' 用户主动消息\n' }),
userIds: ['staff-one'],
});
assert.deepEqual(JSON.parse(calls[2].options.body), {
robotCode: 'ding-proactive-client',
msgKey: 'sampleText',
msgParam: JSON.stringify({ content: '群主动消息' }),
openConversationId: 'cid-one',
});
assert.doesNotMatch(JSON.stringify(calls.slice(1)), /sessionWebhook|sendBySession/);
});
test('DingTalk exposes a stable rejection code for rejected proactive text', async () => {
const api = createDingtalkApi({
fetchImpl: async (url) => url.pathname.endsWith('/oauth2/accessToken')
? jsonResponse({ accessToken: 'rejected-text-token', expireIn: 7_200 })
: jsonResponse({ code: 'Forbidden.AccessDenied' }),
});
await assert.rejects(() => api.sendRobotText({
clientId: 'ding-rejected-text-client',
clientSecret: 'host-only-secret',
target: { type: 'user', robotCode: 'ding-rejected-text-client', userId: 'staff-one' },
text: '主动投递',
}), (error) => {
assert.equal(error.code, 'send-rejected');
assert.equal(error.providerCode, 'Forbidden.AccessDenied');
return true;
});
});
test('DingTalk adds, recalls, and replaces its native status reactions', async () => {
const calls = [];
const fetchImpl = async (url, options) => {

View file

@ -57,9 +57,11 @@ function configFixture(initial = [], events = []) {
function runtimeFactory({ events = [], pendingByClient = new Map(), failStart = false } = {}) {
const runtimes = [];
const connectionTests = [];
const proactiveSends = [];
return {
runtimes,
connectionTests,
proactiveSends,
createRuntime: async ({ botId, config, clientSecret }) => {
events.push(['create', botId]);
let ready = false;
@ -96,6 +98,10 @@ function runtimeFactory({ events = [], pendingByClient = new Map(), failStart =
async sendConnectionTest(text) {
connectionTests.push({ botId, text });
},
async sendProactiveText(...args) {
proactiveSends.push({ botId, args });
return { sent: true };
},
};
runtimes.push(runtime);
return runtime;
@ -154,6 +160,14 @@ test('successful QR poll stores secret then config then starts runtime without p
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
assert.match(runtimes.connectionTests[0].text, /钉钉机器人(ding••••vate)/);
const target = { kind: 'user', route: { userId: 'staff-one' } };
assert.deepEqual(await controller.sendProactiveText(completed.botId, target, '主动投递'), {
sent: true,
});
assert.deepEqual(runtimes.proactiveSends, [{
botId: completed.botId,
args: [target, '主动投递', {}],
}]);
await controller.close();
});

View file

@ -48,6 +48,8 @@ function stateFixture() {
test('runtime sends a DingTalk connection test only through the remembered private webhook', async () => {
const state = stateFixture();
const sends = [];
const proactiveSends = [];
let proactiveFailure = null;
const client = {
connected: true,
socket: { readyState: 1 },
@ -61,7 +63,13 @@ test('runtime sends a DingTalk connection test only through the remembered priva
clientSecret: 'host-secret',
harness: { ensureRunning: async () => true },
state,
api: { sendText: async (request) => sends.push(request) },
api: {
sendText: async (request) => sends.push(request),
sendRobotText: async (request) => {
proactiveSends.push(request);
if (proactiveFailure) throw proactiveFailure;
},
},
streamFactory: async () => ({ client, topic: 'robot-topic' }),
});
@ -78,6 +86,23 @@ test('runtime sends a DingTalk connection test only through the remembered priva
assert.equal(sends[0].clientSecret, 'host-secret');
assert.equal(sends[0].sessionWebhook, 'https://oapi.dingtalk.com/robot/reply?ticket=inbound-private');
assert.equal(sends[0].text, '连接测试');
assert.deepEqual(await runtime.sendProactiveText({
kind: 'group',
route: { openConversationId: 'cid-proactive' },
}, '主动投递'), { sent: true });
assert.equal(proactiveSends.length, 1);
assert.deepEqual(proactiveSends[0].target, {
type: 'group',
robotCode: 'ding-client',
openConversationId: 'cid-proactive',
});
assert.equal(proactiveSends[0].text, '主动投递');
assert.equal('sessionWebhook' in proactiveSends[0], false);
proactiveFailure = Object.assign(new Error('provider detail'), { code: 'send-rejected' });
await assert.rejects(() => runtime.sendProactiveText({
kind: 'user',
route: { userId: 'staff-one' },
}, '失败投递'), { code: 'target-rejected' });
await runtime.stop();
});

View file

@ -392,6 +392,7 @@ test('Discord controller persists a credential reference and exposes only masked
const configPath = join(directory, 'config.json');
const configStore = await new DiscordConfigStore(configPath).load();
const credentialStore = credentials();
const proactiveSends = [];
const controller = new DiscordController({
credentials: credentialStore,
configStore,
@ -409,6 +410,10 @@ test('Discord controller persists a credential reference and exposes only masked
},
async start() {},
async stop() {},
async sendProactiveText(...args) {
proactiveSends.push(args);
return { sent: true };
},
}),
});
const status = await controller.bindCredentials({ token: TOKEN });
@ -426,6 +431,11 @@ test('Discord controller persists a credential reference and exposes only masked
const identity = deriveDiscordBotIdentity('1234567890123456789');
assert.equal(credentialStore.values.get(identity.tokenRef), TOKEN);
assert.doesNotMatch(await readFile(configPath, 'utf8'), new RegExp(TOKEN.replaceAll('.', '\\.')));
const target = { kind: 'channel', route: { channelId: '222222222222222222' } };
assert.deepEqual(await controller.sendProactiveText(identity.botId, target, 'proactive-test'), {
sent: true,
});
assert.deepEqual(proactiveSends, [[target, 'proactive-test', {}]]);
await controller.deleteBot(identity.botId);
assert.equal(credentialStore.values.has(identity.tokenRef), false);
});
@ -1608,6 +1618,7 @@ test('Discord runtime identifies on Gateway v10 and becomes ready', async () =>
const abortMark = deferred();
let abortMarkStarted = false;
const errors = [];
const proactiveCalls = [];
const runtime = new DiscordRuntime({
config: {
botId: 'discord_test',
@ -1632,6 +1643,10 @@ test('Discord runtime identifies on Gateway v10 and becomes ready', async () =>
createApi: () => ({
getCurrentUser: async () => ({ id: '1234567890123456789', bot: true }),
getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }),
createMessage: async (request) => {
proactiveCalls.push(request);
return { id: '111111111111111900' };
},
}),
createWebSocket: () => {
socket = new FakeSocket();
@ -1648,6 +1663,14 @@ test('Discord runtime identifies on Gateway v10 and becomes ready', async () =>
});
await runtime.start();
assert.equal(runtime.status.ready, true);
assert.deepEqual(await runtime.sendProactiveText({
kind: 'channel',
route: { channelId: '222222222222222900' },
}, 'proactive-test'), { providerMessageIds: ['111111111111111900'] });
assert.equal(proactiveCalls.length, 1);
assert.equal(proactiveCalls[0].channelId, '222222222222222900');
assert.equal(proactiveCalls[0].content, 'proactive-test');
assert.equal(proactiveCalls[0].replyToMessageId, undefined);
const identify = socket.sent.find((packet) => packet.op === 2);
assert.equal(identify.d.token, TOKEN);
assert.equal(identify.d.intents, 37_377);

View file

@ -176,6 +176,19 @@ test('FeishuRuntime becomes chat-ready only after Harness and Feishu are connect
},
}]);
assert.deepEqual(await runtime.sendProactiveText({
kind: 'group',
route: { chatId: 'oc_proactive_group' },
}, '主动投递'), { sent: true });
assert.deepEqual(FakeClient.sent[1], {
params: { receive_id_type: 'chat_id' },
data: {
receive_id: 'oc_proactive_group',
msg_type: 'text',
content: JSON.stringify({ text: '主动投递' }),
},
});
const stopped = await runtime.stop();
assert.equal(stopped.ready, false);
assert.equal(stopped.feishuLongConnectionState, 'idle');

View file

@ -107,6 +107,7 @@ function fixture({
starts: 0,
stops: 0,
sentTests: [],
proactiveSends: [],
probes: [],
responseModes: [],
repair,
@ -127,6 +128,10 @@ function fixture({
runtime.sentTests.push(text);
return { sent: true };
},
async sendProactiveText(...args) {
runtime.proactiveSends.push(args);
return { sent: true };
},
setGroupResponseMode(mode) {
runtime.responseModes.push(mode);
runtime.config.groupResponseMode = mode;
@ -793,6 +798,11 @@ test('connection test uses the selected bot runtime and shared message copy', as
assert.deepEqual(fx.runtimes.get(healthy.id)[0].sentTests, [
'✅ DeepSeek Harness 连接测试成功\n这条消息由「IM机器人」设置页中的“机器人 healthy(cli_heal••••7890)”机器人卡片发出。',
]);
const target = { kind: 'group', route: { chatId: 'oc_target' } };
assert.deepEqual(await fx.controller.sendProactiveText(healthy.id, target, '主动投递'), {
sent: true,
});
assert.deepEqual(fx.runtimes.get(healthy.id)[0].proactiveSends, [[target, '主动投递', {}]]);
await fx.controller.close();
});

View file

@ -97,6 +97,7 @@ test('QQ controller delegates the shared connection-test message to the selected
botId: 'qq_bot', appId: 'app-id', secretRef: 'qq.secret', ownerUserOpenid: 'owner-openid',
};
const sent = [];
const proactiveSends = [];
const controller = new QqController({
qrAuth: { start() { return () => {}; } },
credentials: {
@ -116,6 +117,10 @@ test('QQ controller delegates the shared connection-test message to the selected
start: async () => {},
stop: async () => {},
sendConnectionTest: async (text) => sent.push(text),
sendProactiveText: async (...args) => {
proactiveSends.push(args);
return { sent: true };
},
}),
});
@ -124,6 +129,11 @@ test('QQ controller delegates the shared connection-test message to the selected
assert.deepEqual(sent, [
connectionTestMessage(`QQ 机器人(${maskQqAppId(config.appId)})`),
]);
const target = { kind: 'user', route: { userOpenId: 'user-openid' } };
assert.deepEqual(await controller.sendProactiveText(config.botId, target, '主动投递'), {
sent: true,
});
assert.deepEqual(proactiveSends, [[target, '主动投递', {}]]);
await controller.close();
});

View file

@ -98,6 +98,14 @@ test('QQ runtime sends a proactive connection test to the explicit owner fallbac
target: { scope: 'c2c', targetId: 'owner-openid' },
text: 'connection-test',
}]);
await runtime.sendProactiveText({
kind: 'group',
route: { groupOpenId: 'group-openid' },
}, 'proactive-test');
assert.deepEqual(bot.sent[1], {
target: { scope: 'group', targetId: 'group-openid' },
text: 'proactive-test',
});
await runtime.stop();
});

View file

@ -709,6 +709,7 @@ test('Slack controller stores two protected credential references and exposes ne
const configStore = await new SlackConfigStore(configPath).load();
const credentialStore = credentials();
const connectionTests = [];
const proactiveSends = [];
const controller = new SlackController({
credentials: credentialStore,
configStore,
@ -729,6 +730,10 @@ test('Slack controller stores two protected credential references and exposes ne
async start() {},
async stop() {},
async sendConnectionTest(text) { connectionTests.push(text); },
async sendProactiveText(...args) {
proactiveSends.push(args);
return { sent: true };
},
}),
});
const status = await controller.bindCredentials({ botToken: BOT_TOKEN, appToken: APP_TOKEN });
@ -744,6 +749,11 @@ test('Slack controller stores two protected credential references and exposes ne
await controller.sendConnectionTest(identity.botId);
assert.match(connectionTests[0], /DeepSeek Harness/);
assert.match(connectionTests[0], /T1234••• · U1234•••/);
const target = { kind: 'conversation', route: { channelId: 'C12345678' } };
assert.deepEqual(await controller.sendProactiveText(identity.botId, target, 'proactive-test'), {
sent: true,
});
assert.deepEqual(proactiveSends, [[target, 'proactive-test', {}]]);
await controller.deleteBot(identity.botId);
assert.equal(credentialStore.values.has(identity.botTokenRef), false);
assert.equal(credentialStore.values.has(identity.appTokenRef), false);
@ -1048,6 +1058,7 @@ test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready
const abortMark = deferred();
let abortMarkStarted = false;
const errors = [];
const proactiveCalls = [];
const runtime = new SlackRuntime({
config: {
botId: 'slack_test',
@ -1073,6 +1084,10 @@ test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready
createApi: () => ({
authTest: async () => ({ team_id: 'T12345678', user_id: 'U12345678' }),
openConnection: async () => ({ url: 'wss://wss-primary.slack.com/link/?ticket=test' }),
postMessage: async (request) => {
proactiveCalls.push(request);
return { ts: '1700000000.900' };
},
}),
createWebSocket: () => {
socket = new FakeSocket();
@ -1091,6 +1106,14 @@ test('Slack runtime opens Socket Mode, acknowledges envelopes, and becomes ready
});
await runtime.start();
assert.equal(runtime.status.ready, true);
assert.deepEqual(await runtime.sendProactiveText({
kind: 'thread',
route: { channelId: 'C12345678', threadTs: '1700000000.100' },
}, 'proactive-test'), { providerMessageIds: ['1700000000.900'] });
assert.equal(proactiveCalls.length, 1);
assert.equal(proactiveCalls[0].channelId, 'C12345678');
assert.equal(proactiveCalls[0].threadTs, '1700000000.100');
assert.equal(proactiveCalls[0].text, 'proactive-test');
socket.emit('message', {
data: JSON.stringify({
envelope_id: 'env-1',

View file

@ -630,6 +630,7 @@ test('Telegram config and controller store only a credential reference in bot da
const credentialStore = credentials();
const runtimes = [];
const connectionTests = [];
const proactiveSends = [];
const controller = new TelegramController({
credentials: credentialStore,
configStore,
@ -647,6 +648,10 @@ test('Telegram config and controller store only a credential reference in bot da
async start() {},
async stop() {},
async sendConnectionTest(text) { connectionTests.push(text); },
async sendProactiveText(...args) {
proactiveSends.push(args);
return { sent: true };
},
};
runtimes.push(runtime);
return runtime;
@ -673,6 +678,11 @@ test('Telegram config and controller store only a credential reference in bot da
await controller.sendConnectionTest(identity.botId);
assert.match(connectionTests[0], /Harness Telegram/);
assert.match(connectionTests[0], /123•••/);
const target = { kind: 'chat', route: { chatId: '123456' } };
assert.deepEqual(await controller.sendProactiveText(identity.botId, target, 'proactive-test'), {
sent: true,
});
assert.deepEqual(proactiveSends, [[target, 'proactive-test', {}]]);
await controller.deleteBot(identity.botId);
assert.equal(credentialStore.values.has(identity.tokenRef), false);
assert.equal(controller.status().totals.configured, 0);
@ -1352,6 +1362,10 @@ test('Telegram runtime validates webhook state and starts a cancellable long pol
reject(new DOMException('Aborted', 'AbortError'));
}, { once: true }));
},
sendMessage: async (request) => {
calls.push({ method: 'sendMessage', ...request });
return { message_id: 900 };
},
};
const runtime = new TelegramRuntime({
config: {
@ -1367,6 +1381,15 @@ test('Telegram runtime validates webhook state and starts a cancellable long pol
await runtime.start();
assert.equal(runtime.status.ready, true);
assert.equal(runtime.status.connectionState, 'connected');
assert.deepEqual(await runtime.sendProactiveText({
kind: 'topic',
route: { chatId: '-1001234567890', messageThreadId: 42 },
}, 'proactive-test'), { providerMessageIds: ['900'] });
const proactiveCall = calls.find(({ method }) => method === 'sendMessage');
assert.equal(proactiveCall.chatId, -1001234567890);
assert.equal(proactiveCall.messageThreadId, 42);
assert.equal(proactiveCall.replyToMessageId, undefined);
assert.equal(proactiveCall.text, 'proactive-test');
await runtime.stop();
assert.equal(runtime.status.ready, false);
assert.deepEqual(calls[0], { method: 'setMyCommands', commands: TELEGRAM_COMMAND_MENU });

View file

@ -57,6 +57,7 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
const configs = [];
let runtimeArgs;
let connectionTestText;
const proactiveSends = [];
const controller = new WecomController({
qrAuth: { start: async () => ({}), poll: async () => ({ status: 'waiting' }) },
credentials: {
@ -77,6 +78,10 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
status: { ready: true, wecomConnectionState: 'connected', harnessReachable: true },
start: async () => {}, stop: async () => {},
sendConnectionTest: async (text) => { connectionTestText = text; },
sendProactiveText: async (...args) => {
proactiveSends.push(args);
return { sent: true };
},
};
},
});
@ -88,6 +93,11 @@ test('Enterprise WeChat Bot ID and Secret binding stores credentials and starts
await controller.sendConnectionTest(status.bots[0].botId);
assert.match(connectionTestText, /DeepSeek Harness 连接测试成功/);
assert.match(connectionTestText, /企业微信机器人(remote••••nual)/);
const target = { kind: 'group', route: { chatId: 'group-target' } };
assert.deepEqual(await controller.sendProactiveText(status.bots[0].botId, target, '主动投递'), {
sent: true,
});
assert.deepEqual(proactiveSends, [[target, '主动投递', {}]]);
await controller.close();
});

View file

@ -42,6 +42,14 @@ test('Enterprise WeChat runtime sends a connection test only to the remembered p
chatId: 'member-private',
body: { msgtype: 'markdown', markdown: { content: '测试' } },
}]);
assert.deepEqual(await runtime.sendProactiveText({
kind: 'group',
route: { chatId: 'group-target' },
}, '主动投递'), { sent: true });
assert.deepEqual(client.sent[1], {
chatId: 'group-target',
body: { msgtype: 'markdown', markdown: { content: '主动投递' } },
});
await runtime.stop();
});

View file

@ -50,6 +50,7 @@ function configFixture() {
function runtimeFactory({ failStart = false, startError, lastMessageError = null } = {}) {
const runtimes = [];
const connectionTests = [];
const proactiveSends = [];
const createRuntime = async ({ config, token }) => {
let ready = false;
const runtime = {
@ -71,11 +72,15 @@ function runtimeFactory({ failStart = false, startError, lastMessageError = null
},
async stop() { ready = false; },
async sendConnectionTest(text) { connectionTests.push({ botId: config.botId, text }); },
async sendProactiveText(target, text, options) {
proactiveSends.push({ botId: config.botId, target, text, options });
return { sent: true };
},
};
runtimes.push(runtime);
return runtime;
};
return { runtimes, connectionTests, createRuntime };
return { runtimes, connectionTests, proactiveSends, createRuntime };
}
test('confirmed QR login stores bot_token only in credentials and starts a redacted account', async () => {
@ -139,6 +144,16 @@ test('confirmed QR login stores bot_token only in credentials and starts a redac
assert.equal(runtimes.connectionTests[0].botId, completed.botId);
assert.match(runtimes.connectionTests[0].text, /DeepSeek Harness 连接测试成功/);
assert.match(runtimes.connectionTests[0].text, /微信机器人(accoun••••\.bot)/);
const target = { kind: 'user', route: { toUserId: 'target-user' } };
assert.deepEqual(await controller.sendProactiveText(completed.botId, target, '主动投递'), {
sent: true,
});
assert.deepEqual(runtimes.proactiveSends[0], {
botId: completed.botId,
target,
text: '主动投递',
options: {},
});
await controller.deleteBot(completed.botId);
assert.equal(credentials.values.size, 0);

View file

@ -165,6 +165,14 @@ test('runtime sends a connection test to the bound Weixin owner without reply co
assert.equal(sends[0].text, '连接测试');
assert.equal(sends[0].contextToken, undefined);
assert.equal(sends[0].runId, undefined);
assert.deepEqual(await runtime.sendProactiveText({
kind: 'user',
route: { toUserId: 'target-user' },
}, '主动投递'), { sent: true });
assert.equal(sends[1].toUserId, 'target-user');
assert.equal(sends[1].text, '主动投递');
assert.equal(sends[1].contextToken, undefined);
assert.equal(sends[1].runId, undefined);
await runtime.stop();
});

View file

@ -1341,7 +1341,14 @@ test('WhatsApp runtime sends a connection test to self and suppresses its outbou
await runtime.start();
assert.deepEqual(await runtime.sendConnectionTest('连接测试'), { sent: true });
assert.deepEqual(sent, [[ACCOUNT_JID, { text: '连接测试' }]]);
assert.deepEqual(await runtime.sendProactiveText({
kind: 'group',
route: { jid: '120363000000000000@g.us' },
}, '主动投递'), { providerMessageIds: ['connection-test-1'] });
assert.deepEqual(sent, [
[ACCOUNT_JID, { text: '连接测试' }],
['120363000000000000@g.us', { text: '主动投递' }],
]);
await callbacks.onMessage({
key: { remoteJid: ACCOUNT_JID, id: 'connection-test-1', fromMe: true },
message: { conversation: '连接测试' },
@ -1355,6 +1362,7 @@ test('WhatsApp controller delegates connection test copy to the current runtime'
const configStore = await new WhatsappConfigStore(join(root, 'config.json')).load();
const config = await configStore.save(linkedConfig());
const sent = [];
const proactiveSends = [];
const controller = new WhatsappController({
configStore,
authPath: (name) => join(root, 'auth', name),
@ -1371,6 +1379,10 @@ test('WhatsApp controller delegates connection test copy to the current runtime'
sent.push(text);
return { sent: true };
},
sendProactiveText: async (...args) => {
proactiveSends.push(args);
return { sent: true };
},
}),
});
t.after(() => controller.close());
@ -1380,6 +1392,11 @@ test('WhatsApp controller delegates connection test copy to the current runtime'
assert.deepEqual(sent, [
'✅ DeepSeek Harness 连接测试成功\n这条消息由「IM机器人」设置页中的“Harness WhatsApp(1650••••0123)”机器人卡片发出。',
]);
const target = { kind: 'user', route: { jid: '16505550199@s.whatsapp.net' } };
assert.deepEqual(await controller.sendProactiveText(config.botId, target, 'proactive-test'), {
sent: true,
});
assert.deepEqual(proactiveSends, [[target, 'proactive-test', {}]]);
});
test('WhatsApp reconnect RPC sends tests only for the connected target and keeps failures non-fatal', async () => {