feat: add message source context enhancement

This commit is contained in:
xmanrui 2026-08-29 10:46:06 +08:00
parent 5928b33057
commit 0cb79f74e9
69 changed files with 6217 additions and 597 deletions

View file

@ -25,6 +25,7 @@ test('client exposes the fixed DingTalk RPC channel and endpoint names', () => {
deleteBot: 'bot.delete',
setWorkspace: 'bot.workspace.set',
setAgentPreset: 'bot.preset.set',
setContextEnhancement: 'bot.context-enhancement.set',
});
});

View file

@ -226,6 +226,84 @@ test('runtime sends visible-scope messages to Harness without local sender appro
await runtime.stop();
});
for (const scenario of [
{ name: 'private enabled to disabled', conversationType: '1', before: [true, false], after: [false, true] },
{ name: 'group enabled to disabled', conversationType: '2', before: [false, true], after: [true, false] },
{ name: 'private disabled to enabled', conversationType: '1', before: [false, true], after: [true, false] },
]) {
test(`DingTalk received callback retains settings before its parse microtask: ${scenario.name}`, async () => {
const order = [];
const asked = [];
const configFor = ([directEnabled, groupEnabled], guidance) => ({
directEnabled, groupEnabled, fields: ['channel', 'botId'], guidance,
});
let config = configFor(scenario.before, 'before callback returned');
let callback;
const client = {
connected: true,
socket: { readyState: 1 },
registerCallbackListener(_topic, listener) { callback = listener; },
async connect() {},
socketCallBackResponse(messageId) { order.push(['ack', messageId]); },
disconnect() {},
};
const runtime = new DingtalkRuntime({
config: { clientId: 'ding-client', approvedSenders: [] },
clientSecret: 'host-secret',
contextEnhancement: {
botId: 'dingtalk_internal',
getSettings: () => { order.push(['settings']); return config; },
},
harness: {
ensureRunning: async () => true,
createSession: async () => 'session-existing',
sessionExists: async () => true,
ask: async (_sessionId, text) => { asked.push(text); order.push(['ask']); return 'done'; },
},
state: stateFixture(),
api: { sendText: async () => {} },
streamFactory: async () => ({ client, topic: 'robot-topic' }),
logger: { warn() {}, error() {} },
});
const response = (id) => ({
headers: { messageId: `callback-${id}` },
get data() {
order.push(['read-data', id]);
return JSON.stringify({
msgId: `message-${id}`, msgtype: 'text', text: { content: `message ${id}` },
conversationType: scenario.conversationType, conversationId: 'group-chat',
isInAtList: true, senderStaffId: 'staff-id', senderNick: 'Ada',
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=test',
});
},
});
try {
await runtime.start();
callback(response(1));
assert.deepEqual(order, [['ack', 'callback-1'], ['settings']], 'ACK stays first and JSON remains unparsed until the original microtask');
config = configFor(scenario.after, 'after callback returned');
await eventually(() => runtime.status.messagesReplied === 1, 5_000);
callback(response(2));
await eventually(() => runtime.status.messagesReplied === 2, 5_000);
for (const [index, switches] of [scenario.before, scenario.after].entries()) {
const enabled = switches[scenario.conversationType === '1' ? 0 : 1];
if (enabled) {
assert.match(asked[index], index === 0 ? /before callback returned/ : /after callback returned/);
assert.deepEqual(JSON.parse(/^<dsh_im_source>(.*?)<\/dsh_im_source>/su.exec(asked[index])[1]), {
channel: 'dingtalk', botId: 'dingtalk_internal',
});
} else {
assert.equal(asked[index], `message ${index + 1}`);
}
}
assert.equal(order.filter(([action]) => action === 'settings').length, 2, 'one source configuration read per callback');
assert.equal(order.filter(([action]) => action === 'read-data').length, 4, 'preserves the original data parsing path');
} finally {
await runtime.stop();
}
});
}
test('runtime accepts an OPEN DingTalk socket when the SDK registered flag remains false', async () => {
const client = {
connected: true,

View file

@ -1425,6 +1425,92 @@ test('Discord runtime keeps one isolated Session per managed thread and reuses i
await runtime.stop();
});
test('Discord captures context settings before asynchronous Thread routing and updates future messages live', async (t) => {
const botId = '1234567890123456789';
const parentId = '222222222222222290';
const threadId = '111111111111111190';
const routingStarted = deferred();
const releaseRouting = deferred();
const seen = new Set();
const prompts = [];
let socket;
let reads = 0;
let config = {
groupEnabled: true,
directEnabled: false,
fields: ['channel', 'conversationType', 'senderId', 'senderName', 'botId'],
guidance: 'accepted before routing',
};
const runtime = new DiscordRuntime({
config: { botId: 'discord_internal', platformId: botId, name: 'Harness Discord' },
token: TOKEN,
contextEnhancement: {
botId: 'discord_internal',
getSettings: () => { reads += 1; return config; },
},
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
ask: async (_sessionId, content) => { prompts.push(content); return 'answer'; },
},
state: {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: () => 'session-existing',
},
createApi: () => ({
getCurrentUser: async () => ({ id: botId, bot: true }),
getGatewayBot: async () => ({ url: 'wss://gateway.discord.gg' }),
getChannel: async () => assert.fail('The channel is already in the gateway cache'),
startThreadFromMessage: async () => {
routingStarted.resolve();
await releaseRouting.promise;
return { id: threadId, type: 11, parent_id: parentId, owner_id: botId };
},
sendTyping: async () => {},
createMessage: async () => ({ id: '888888888888888890' }),
editMessage: async ({ messageId }) => ({ id: messageId }),
}),
createWebSocket: () => {
socket = new FakeSocket();
queueMicrotask(() => socket.emit('message', {
data: JSON.stringify({ op: 10, d: { heartbeat_interval: 45_000 } }),
}));
return socket;
},
random: () => 0.5,
logger: { warn() {}, error(...args) { assert.fail(args.join(' ')); } },
});
t.after(async () => { releaseRouting.resolve(); await runtime.stop(); });
await runtime.start();
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'GUILD_CREATE', s: 2,
d: { id: '444444444444444444', channels: [{ id: parentId, type: 0 }], threads: [] },
}) });
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 3, d: {
id: threadId, channel_id: parentId, guild_id: '444444444444444444',
author: { id: '333333333333333333', bot: false, global_name: 'Global Name', username: 'username' },
member: { nick: 'Group Nick' }, mentions: [{ id: botId }], content: `<@${botId}> first`,
} }) });
await routingStarted.promise;
config = { ...config, groupEnabled: false };
releaseRouting.resolve();
await eventually(() => runtime.status.messagesReplied === 1);
assert.match(prompts[0], /accepted before routing/);
assert.deepEqual(JSON.parse(/^<dsh_im_source>(.*?)<\/dsh_im_source>/su.exec(prompts[0])[1]), {
channel: 'discord', conversationType: 'group', senderId: '333333333333333333',
senderName: 'Group Nick', botId: 'discord_internal',
});
assert.equal(reads, 1, 'routing and Bridge share one accepted configuration read');
socket.emit('message', { data: JSON.stringify({ op: 0, t: 'MESSAGE_CREATE', s: 4, d: {
id: '111111111111111191', channel_id: threadId, guild_id: '444444444444444444',
author: { id: '333333333333333333', bot: false }, content: 'second without enhancement',
} }) });
await eventually(() => runtime.status.messagesReplied === 2);
assert.equal(prompts[1], 'second without enhancement');
assert.equal(reads, 2);
});
test('Discord runtime records one uncertain Thread result and suppresses Gateway replays', async () => {
const botId = '1234567890123456789';
const parentChannelId = '222222222222222290';

View file

@ -0,0 +1,492 @@
import assert from 'node:assert/strict';
import { Readable } from 'node:stream';
import test from 'node:test';
import { DingtalkHarnessBridge } from '../../../src/channels/dingtalk/dingtalk-bridge.mjs';
import { DiscordHarnessBridge } from '../../../src/channels/discord/discord-bridge.mjs';
import { normalizeDiscordMessage } from '../../../src/channels/discord/discord-runtime.mjs';
import { FeishuHarnessBridge } from '../../../src/channels/feishu/bridge.mjs';
import { QqHarnessBridge } from '../../../src/channels/qq/qq-bridge.mjs';
import { SlackHarnessBridge } from '../../../src/channels/slack/slack-bridge.mjs';
import { normalizeSlackEvent } from '../../../src/channels/slack/slack-runtime.mjs';
import { TelegramHarnessBridge } from '../../../src/channels/telegram/telegram-bridge.mjs';
import { normalizeTelegramUpdate } from '../../../src/channels/telegram/telegram-runtime.mjs';
import { WecomHarnessBridge } from '../../../src/channels/wecom/wecom-bridge.mjs';
import { WeixinHarnessBridge } from '../../../src/channels/weixin/weixin-bridge.mjs';
import { WhatsappHarnessBridge } from '../../../src/channels/whatsapp/whatsapp-bridge.mjs';
import { normalizeWhatsappMessage } from '../../../src/channels/whatsapp/whatsapp-runtime.mjs';
import { CONTEXT_ENHANCEMENT_FIELDS } from '../../../src/channels/shared/context-enhancement.mjs';
const CHANNELS = ['wecom', 'weixin', 'feishu', 'dingtalk', 'qq', 'slack', 'telegram', 'discord', 'whatsapp'];
const TEXT_BRIDGES = { slack: SlackHarnessBridge, telegram: TelegramHarnessBridge,
discord: DiscordHarnessBridge, whatsapp: WhatsappHarnessBridge };
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64');
const FILE = Buffer.from('unchanged file bytes');
const logger = { info() {}, warn() {}, error() {} };
function settings(overrides = {}) {
return { groupEnabled: true, directEnabled: true, fields: [...CONTEXT_ENHANCEMENT_FIELDS], guidance: '', ...overrides };
}
function provider(channel, config) {
return { botId: `${channel}_internal`, getSettings: () => config };
}
function deferred() {
let resolve;
const promise = new Promise((done) => { resolve = done; });
return { promise, resolve };
}
async function eventually(predicate) {
for (let attempt = 0; attempt < 200; attempt += 1) {
if (predicate()) return;
await new Promise((resolve) => setTimeout(resolve, 5));
}
assert.fail('Expected message processing did not finish');
}
function sourceOf(content) {
const text = Array.isArray(content) ? content[0].text : content;
const match = /^<dsh_im_source>(.*?)<\/dsh_im_source>/su.exec(text);
assert.ok(match, `No source in ${text}`);
return JSON.parse(match[1]);
}
function withoutPrompt(calls) {
return calls.map(([operation, ...args]) => operation === 'ask'
? [operation, args[0], '(prompt)', args[2]] : [operation, ...args]);
}
function fixture(channel, { contextEnhancement, onAsk } = {}) {
const calls = [];
const prompts = [];
const sessions = new Map();
const seen = new Set();
let sourceReads = 0;
const state = {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => { seen.add(id); calls.push(['seen', id]); },
sessionFor: (key) => sessions.get(key) ?? null,
setSession: async (key, id) => { sessions.set(key, id); calls.push(['bind', key, id]); },
clearSession: async (key) => { sessions.delete(key); calls.push(['clear', key]); },
pendingSenders: () => [],
};
const harness = {
ensureRunning: async () => { calls.push(['ensureRunning']); },
createSession: async () => { calls.push(['createSession']); return 'session-existing'; },
sessionExists: async (id) => { calls.push(['sessionExists', id]); return true; },
hasActiveTurn: async () => false,
isSessionRunning: async () => false,
currentWorkspace: () => null,
ask: async (sessionId, content, options) => {
prompts.push(content);
calls.push(['ask', sessionId, content, Object.keys(options).sort()]);
for (const file of options.files ?? []) {
const loaded = await file.load({ signal: options.signal });
calls.push(['file', file.name, Buffer.from(loaded?.data ?? loaded).toString('hex')]);
}
return onAsk ? onAsk({ sessionId, content, options, prompts }) : 'answer unchanged';
},
};
const sendText = async (...args) => {
calls.push(['sendText', ...args]);
return { messageId: 'reply-one', id: 'reply-one' };
};
const image = (name) => ({ name, mediaType: 'image/png', load: async () => {
calls.push(['image', name]); return PNG;
} });
const file = () => ({ name: 'report.txt', load: async () => {
calls.push(['file-download']); return FILE;
} });
const dependencies = { harness, state, contextEnhancement, logger };
let bridge;
if (TEXT_BRIDGES[channel]) {
bridge = new TEXT_BRIDGES[channel]({ ...dependencies, bot: { sendText } });
} else if (channel === 'wecom') {
bridge = new WecomHarnessBridge({ ...dependencies, generateStreamId: () => 'stream-one', client: {
replyStream: async (frame, streamId, text, finish) => {
calls.push(['replyStream', frame.body.msgid, streamId, text, finish]);
return { body: { msgid: 'reply-one' } };
},
sendMessage: sendText,
downloadFile: async (url) => {
calls.push(['downloadFile', url]);
return { buffer: url.endsWith('.png') ? PNG : FILE, filename: url.split('/').at(-1) };
},
} });
} else if (channel === 'weixin') {
bridge = new WeixinHarnessBridge({ ...dependencies, baseUrl: 'https://ilinkai.weixin.qq.com',
token: 'private-token', ownerUserId: 'actor', api: {
sendText: async ({ toUserId, text, contextToken, runId }) => sendText({ toUserId, text, contextToken, runId }),
inboundImages: (event) => event.item_list.filter((item) => item.image_item).map((_item, index) => image(`image-${index}.png`)),
inboundFiles: (event) => event.item_list.filter((item) => item.file_item).map(file),
} });
} else if (channel === 'dingtalk') {
bridge = new DingtalkHarnessBridge({ ...dependencies, clientId: 'app', clientSecret: 'private-secret', api: {
sendText: async ({ sessionWebhook, text, at }) => sendText({ sessionWebhook, text, at }),
downloadImage: async ({ downloadCode }) => { calls.push(['downloadImage', downloadCode]); return PNG; },
downloadFile: async ({ downloadCode }) => { calls.push(['downloadFile', downloadCode]); return FILE; },
} });
} else if (channel === 'qq') {
bridge = new QqHarnessBridge({ ...dependencies, ownerUserOpenid: '*', bot: { sendText },
fetchImpl: async (url) => {
calls.push(['fetch', String(url)]);
const isImage = String(url).endsWith('.png');
return new Response(isImage ? PNG : FILE, { headers: { 'content-type': isImage ? 'image/png' : 'text/plain' } });
},
});
} else {
bridge = new FeishuHarnessBridge({ ...dependencies, status: {}, allowedSenderOpenIds: new Set(['*']),
channel: {}, client: { im: { v1: {
message: { create: async (request) => {
calls.push(['createMessage', request]);
return { code: 0, data: { message_id: 'reply-one' } };
} },
messageResource: { get: async ({ path, params }) => {
calls.push(['messageResource', path, params]);
const bytes = params.type === 'image' ? PNG : FILE;
return { headers: { 'content-length': String(bytes.length) }, getReadableStream: () => Readable.from([bytes]) };
} },
} } },
});
}
function event(id, text = ' \t user text\nsecond line ', { kind = 'direct', name = 'Ada', actor = 'actor', media, poisonName = false } = {}) {
const group = kind === 'group';
const nameValue = (object, key) => Object.defineProperty(object, key, { configurable: true, get() {
sourceReads += 1;
if (poisonName) throw new Error('enhancement-only source must not be read');
return name;
} });
let value;
if (channel === 'telegram') {
const from = { id: actor === 'actor' ? 42 : 43, is_bot: false };
nameValue(from, 'first_name');
value = normalizeTelegramUpdate({ update_id: id, message: {
message_id: id, chat: { id: 100, type: group ? 'supergroup' : 'private' }, from,
text: group ? `@testbot ${text}` : text, entities: group ? [{ type: 'mention', offset: 0, length: 8 }] : [],
} }, { botId: 'bot', username: 'testbot' });
} else if (channel === 'discord') {
const author = { id: actor, bot: false };
nameValue(author, 'global_name');
value = normalizeDiscordMessage({ id: String(id), channel_id: 'chat', author,
guild_id: group ? 'guild' : undefined, content: text, mentions: [{ id: 'bot' }],
}, 'bot');
} else if (channel === 'whatsapp') {
const raw = { key: { id: String(id), remoteJid: group ? 'chat@g.us' : `${actor}@s.whatsapp.net`,
participant: group ? `${actor}@s.whatsapp.net` : undefined, fromMe: false },
message: { extendedTextMessage: { text, contextInfo: { mentionedJid: ['bot@s.whatsapp.net'] } } } };
nameValue(raw, 'pushName');
value = normalizeWhatsappMessage(raw, 'bot@s.whatsapp.net');
} else if (channel === 'slack') {
value = normalizeSlackEvent({ event_id: String(id), event: { type: group ? 'app_mention' : 'message',
ts: String(id), channel: 'chat', channel_type: group ? 'channel' : 'im', user: actor,
thread_ts: group ? 'thread-existing' : undefined,
text: group ? `<@bot> ${text}` : text,
} }, 'bot');
} else if (channel === 'wecom') {
value = { headers: { req_id: String(id) }, body: { msgid: String(id), from: { userid: actor },
chattype: group ? 'group' : 'single', chatid: 'chat', msgtype: 'text', text: { content: text } } };
} else if (channel === 'weixin') {
value = { message_id: String(id), message_type: 1, from_user_id: actor, context_token: 'context',
item_list: [{ type: 1, text_item: { text } }] };
} else if (channel === 'dingtalk') {
value = { msgId: String(id), msgtype: 'text', text: { content: text }, senderStaffId: actor,
conversationType: group ? '2' : '1', conversationId: 'chat', isInAtList: true,
sessionWebhook: 'https://oapi.dingtalk.com/robot/reply?ticket=test' };
nameValue(value, 'senderNick');
} else if (channel === 'qq') {
value = { messageId: String(id), senderId: actor, kind: group ? 'group' : 'c2c', groupOpenid: 'chat',
rawEventType: group ? 'GROUP_AT_MESSAGE_CREATE' : 'C2C_MESSAGE_CREATE', senderIsBot: false, content: text,
replyTarget: { scope: group ? 'group' : 'c2c', targetId: group ? 'chat' : actor, msgId: String(id) } };
nameValue(value, 'senderName');
} else {
value = { sender: { sender_type: 'user', sender_id: { open_id: actor } }, message: {
message_id: String(id), message_type: 'text', chat_id: 'chat', chat_type: group ? 'group' : 'p2p',
content: JSON.stringify({ text }), mentions: group ? [{ key: '@bot', id: { open_id: 'bot' } }] : [],
} };
}
assert.ok(value, `${channel} event should normalize`);
if (!media) return value;
const imageNames = media === 'mixed' ? ['first.png', 'second.png'] : media === 'image' ? ['first.png'] : [];
const includeFile = media === 'file' || media === 'mixed';
if (TEXT_BRIDGES[channel]) {
value.images = imageNames.map(image);
value.files = includeFile ? [file()] : [];
} else if (channel === 'wecom') {
value.body.msgtype = 'mixed';
value.body.mixed = { msg_item: [
{ msgtype: 'text', text: { content: text } },
...imageNames.map((name) => ({ msgtype: 'image', image: { url: `https://wecom.test/${name}` } })),
...(includeFile ? [{ msgtype: 'file', file: { url: 'https://wecom.test/report.txt', filename: 'report.txt' } }] : []),
] };
} else if (channel === 'weixin') {
value.item_list.push(...imageNames.map(() => ({ type: 2, image_item: {} })),
...(includeFile ? [{ type: 4, file_item: { file_name: 'report.txt' } }] : []));
} else if (channel === 'dingtalk') {
value.msgtype = media === 'file' ? 'file' : 'richText';
value.content = media === 'file' ? { downloadCode: 'file-one', fileName: 'report.txt' }
: { richText: [{ text }, ...imageNames.map((downloadCode) => ({ type: 'picture', downloadCode }))] };
} else if (channel === 'qq') {
value.attachments = [
...imageNames.map((name) => ({ filename: name, url: `https://gchat.qpic.cn/${name}`, content_type: 'image/png' })),
...(includeFile ? [{ filename: 'report.txt', url: 'https://gchat.qpic.cn/report.txt', content_type: 'text/plain' }] : []),
];
} else {
value.message.message_type = media === 'file' ? 'file' : 'post';
value.message.content = JSON.stringify(media === 'file' ? { file_key: 'file-one', file_name: 'report.txt' }
: { content: [[{ tag: 'text', text }, ...imageNames.map((image_key) => ({ tag: 'img', image_key }))]] });
}
return value;
}
return { bridge, event, calls, prompts, sessions, seen, get sourceReads() { return sourceReads; } };
}
for (const channel of CHANNELS) {
const kinds = channel === 'weixin' ? ['direct'] : ['direct', 'group'];
for (const kind of kinds) {
test(`${channel} ${kind}: disabled settings preserve text/media, calls and session bindings`, async () => {
const oppositeOnly = settings({ groupEnabled: kind === 'direct', directEnabled: kind === 'group' });
const poisonedOff = {
groupEnabled: false, directEnabled: false,
get fields() { throw new Error('off fields accessed'); },
get guidance() { throw new Error('off guidance accessed'); },
};
const variants = [
provider(channel, undefined),
provider(channel, settings({ groupEnabled: false, directEnabled: false })),
provider(channel, oppositeOnly),
provider(channel, poisonedOff),
provider(channel, { groupEnabled: 'true', directEnabled: 1 }),
{ getSettings() { throw new Error('damaged optional settings'); } },
];
for (const media of [undefined, 'image', 'file', 'mixed']) {
const baseline = fixture(channel);
await baseline.bridge.accept(baseline.event(1, undefined, { kind, media, poisonName: true }));
assert.equal(baseline.prompts.length, 1);
assert.equal(baseline.sourceReads, 0);
for (const contextEnhancement of variants) {
const current = fixture(channel, { contextEnhancement });
await current.bridge.accept(current.event(1, undefined, { kind, media, poisonName: true }));
assert.deepEqual(current.calls, baseline.calls, `${media ?? 'text'}: preserve platform/Harness call order and arguments`);
assert.deepEqual(current.sessions, baseline.sessions);
assert.deepEqual(current.seen, baseline.seen);
assert.equal(current.sourceReads, 0);
assert.equal(current.bridge.status?.lastMessageError ?? null, null);
}
}
});
test(`${channel} ${kind}: enabled source uses the actual event, optional name and internal bot ID`, async () => {
const current = fixture(channel, { contextEnhancement: provider(channel, settings()) });
await current.bridge.accept(current.event(1, 'hello', { kind }));
const expected = {
channel, conversationType: kind,
senderId: channel === 'telegram' ? '42' : channel === 'whatsapp' ? 'actor@s.whatsapp.net' : 'actor',
...(['dingtalk', 'telegram', 'discord', 'whatsapp'].includes(channel) || (channel === 'qq' && kind === 'group')
? { senderName: 'Ada' } : {}),
botId: `${channel}_internal`,
};
assert.deepEqual(sourceOf(current.prompts[0]), expected);
assert.ok(current.prompts[0].endsWith('\n\nhello'));
assert.doesNotMatch(current.prompts[0], /source_guidance|private-token|private-secret/);
// Explicit null represents a provider event that does not include a nickname.
await current.bridge.accept(current.event(2, 'missing name', { kind, name: null }));
assert.equal(Object.hasOwn(sourceOf(current.prompts[1]), 'senderName'), false);
if (kind === 'group') {
await current.bridge.accept(current.event(3, 'another sender', { kind, name: 'Grace', actor: 'actor-two' }));
const next = sourceOf(current.prompts[2]);
assert.notEqual(next.senderId, expected.senderId);
if (expected.senderName) assert.equal(next.senderName, 'Grace');
assert.equal(current.sessions.size, 1, 'group speakers keep sharing the existing group Session');
}
});
}
test(`${channel}: enabled image/file assembly adds one prefix and preserves the remaining payload`, async () => {
for (const media of ['image', 'file', 'mixed']) {
const plain = fixture(channel);
const enabled = fixture(channel, { contextEnhancement: provider(channel, settings({ guidance: 'Use only provided fields.' })) });
await plain.bridge.accept(plain.event(1, 'caption', { media }));
await enabled.bridge.accept(enabled.event(1, 'caption', { media }));
const original = plain.prompts[0];
const enhanced = enabled.prompts[0];
if (Array.isArray(original)) {
assert.equal(enhanced[0].type, 'text');
assert.deepEqual(enhanced.slice(1), original);
assert.match(enhanced[0].text, /<dsh_im_source_guidance>\nUse only provided fields\./);
} else {
assert.ok(enhanced.endsWith(`\n\n${original}`));
}
assert.deepEqual(enabled.calls.filter(([op]) => op === 'file'), plain.calls.filter(([op]) => op === 'file'));
assert.deepEqual(withoutPrompt(enabled.calls), withoutPrompt(plain.calls), 'enhancement adds no provider or Harness requests');
}
});
test(`${channel}: queued messages keep their accepted snapshot across saves and disablement`, async () => {
let config = settings({ fields: ['channel'], guidance: 'version one' });
const started = deferred();
const release = deferred();
const current = fixture(channel, {
contextEnhancement: { botId: `${channel}_internal`, getSettings: () => config },
onAsk: async ({ prompts }) => {
if (prompts.length === 1) { started.resolve(); await release.promise; }
return 'answer unchanged';
},
});
const first = current.bridge.accept(current.event(1, 'first'));
await started.promise;
const queued = current.bridge.accept(current.event(2, 'queued'));
config.guidance = 'mutated after acceptance';
config = settings({ fields: ['botId'], guidance: 'version two' });
const newer = current.bridge.accept(current.event(3, 'newer'));
config = settings({ groupEnabled: false, directEnabled: false });
const off = current.bridge.accept(current.event(4, 'off'));
config = settings({ fields: ['botId'], guidance: 'version three' });
const reenabled = current.bridge.accept(current.event(5, 'reenabled'));
release.resolve();
await Promise.all([first, queued, newer, off, reenabled]);
assert.match(current.prompts[0], /version one/);
assert.match(current.prompts[1], /version one/);
assert.doesNotMatch(current.prompts[1], /mutated|version two/);
assert.deepEqual(sourceOf(current.prompts[2]), { botId: `${channel}_internal` });
assert.match(current.prompts[2], /version two/);
assert.equal(current.prompts[3], 'off');
assert.match(current.prompts[4], /version three/);
assert.equal(current.calls.filter(([op]) => op === 'createSession').length, 1);
});
test(`${channel}: batches gain one prefix only at final submission`, async () => {
const current = fixture(channel, { contextEnhancement: provider(channel, settings()) });
await current.bridge.accept(current.event(1, '/batch'));
await current.bridge.accept(current.event(2, 'first item'));
await current.bridge.accept(current.event(3, 'second item'));
assert.equal(current.prompts.length, 0);
await current.bridge.accept(current.event(4, '/send'));
assert.equal(current.prompts.length, 1);
assert.equal(current.prompts[0].split('<dsh_im_source>').length - 1, 1);
assert.match(current.prompts[0], /first item[\s\S]*second item/);
});
test(`${channel}: disabled batches retain the original one-submission behavior and calls`, async () => {
const baseline = fixture(channel);
const disabled = fixture(channel, {
contextEnhancement: provider(channel, settings({ groupEnabled: false, directEnabled: false })),
});
for (const current of [baseline, disabled]) {
for (const [index, text] of ['/batch', ' first item\n', 'second\nitem ', '/send'].entries()) {
await current.bridge.accept(current.event(index + 1, text, { poisonName: true }));
}
assert.equal(current.prompts.length, 1);
assert.equal(current.sourceReads, 0);
}
assert.deepEqual(disabled.calls, baseline.calls);
assert.deepEqual(disabled.sessions, baseline.sessions);
assert.deepEqual(disabled.seen, baseline.seen);
});
test(`${channel}: filtered and duplicate events never assemble source metadata`, async () => {
const current = fixture(channel, { contextEnhancement: provider(channel, settings()) });
const rejected = current.event(1, 'ignored', { kind: channel === 'weixin' ? 'direct' : 'group', poisonName: true });
if (TEXT_BRIDGES[channel]) rejected.addressed = false;
else if (channel === 'wecom') rejected.body.chattype = 'unknown';
else if (channel === 'weixin') rejected.from_user_id = 'not-the-owner';
else if (channel === 'feishu') rejected.sender.sender_type = 'bot';
else if (channel === 'dingtalk') rejected.isInAtList = false;
else rejected.rawEventType = 'GROUP_MESSAGE_CREATE';
await current.bridge.accept(rejected);
assert.equal(current.prompts.length, 0);
assert.equal(current.sourceReads, 0);
await current.bridge.accept(current.event(2, 'accepted'));
const reads = current.sourceReads;
await current.bridge.accept(current.event(2, 'duplicate', { poisonName: true }));
assert.equal(current.prompts.length, 1);
assert.equal(current.sourceReads, reads);
});
test(`${channel}: field subsets and guidance-only/empty configurations do not restore defaults`, async () => {
for (const config of [settings({ fields: ['channel'] }), settings({ fields: [], guidance: 'custom' }), settings({ fields: [], guidance: '' })]) {
const current = fixture(channel, { contextEnhancement: provider(channel, config) });
await current.bridge.accept(current.event(1, 'hello'));
if (config.fields.length) assert.deepEqual(sourceOf(current.prompts[0]), { channel });
else if (config.guidance) assert.equal(current.prompts[0], '<dsh_im_source_guidance>\ncustom\n</dsh_im_source_guidance>\n\nhello');
else assert.equal(current.prompts[0], 'hello');
}
});
test(`${channel}: local help/new/steer do not inspect enhancement-only sources`, async () => {
const current = fixture(channel, { contextEnhancement: provider(channel, settings()) });
for (const [index, text] of ['/help', '/new', '/steer supplement'].entries()) {
await current.bridge.accept(current.event(index + 1, text, { poisonName: true }));
}
assert.equal(current.prompts.length, 0);
assert.equal(current.sourceReads, 0);
assert.doesNotMatch(JSON.stringify(current.calls), /<dsh_im_source/);
});
for (const kind of ['question', 'approval']) {
test(`${channel}: ${kind} replies remain unenhanced while ordinary messages are enabled`, async () => {
const finished = deferred();
const responses = [];
const current = fixture(channel, {
contextEnhancement: provider(channel, settings()),
onAsk: async ({ sessionId, options }) => {
await options.onInteraction({
kind, interactionId: 'interaction', rpcId: 'interaction', sessionId,
payload: kind === 'question'
? { type: 'question/requested', sessionId, questions: [{ id: 'answer', question: 'waiting for answer' }] }
: { type: 'approval/requested', sessionId, approvalId: 'interaction', toolName: 'fixture-tool', callId: 'call', reason: 'waiting for approval' },
toolCall: kind === 'approval' ? { callId: 'call', name: 'fixture-tool', arguments: '{}' } : undefined,
respond: async (result) => { responses.push(result); finished.resolve(); return { accepted: true }; },
});
await finished.promise;
return 'answer unchanged';
},
});
const processing = current.bridge.accept(current.event(1, 'ordinary question'));
await eventually(() => JSON.stringify(current.calls).includes('waiting for'));
const readsBeforeReply = current.sourceReads;
await current.bridge.accept(current.event(2, kind === 'question' ? 'blue' : '批准', { poisonName: true }));
await processing;
assert.equal(current.prompts.length, 1);
assert.equal(responses.length, 1);
assert.equal(current.sourceReads, readsBeforeReply);
assert.doesNotMatch(JSON.stringify(responses), /dsh_im_source/);
});
}
}
test('Telegram retains event first/last names and falls back only to an existing username', async () => {
const current = fixture('telegram', { contextEnhancement: provider('telegram', settings()) });
for (const [index, from] of [
{ first_name: 'Ada', last_name: 'Lovelace', username: 'ada' },
{ first_name: '', last_name: ' ', username: 'ada' },
{},
].entries()) {
const message = normalizeTelegramUpdate({ update_id: index + 1, message: {
message_id: index + 1, chat: { id: 100, type: 'private' }, from: { id: 42, ...from }, text: 'hello',
} }, { botId: 'bot', username: 'testbot' });
await current.bridge.accept(message);
}
assert.equal(sourceOf(current.prompts[0]).senderName, 'Ada Lovelace');
assert.equal(sourceOf(current.prompts[1]).senderName, 'ada');
assert.equal(Object.hasOwn(sourceOf(current.prompts[2]), 'senderName'), false);
});
test('Discord prefers event member nickname, global name, then username without a profile lookup', async () => {
const current = fixture('discord', { contextEnhancement: provider('discord', settings()) });
for (const [index, names] of [
{ member: { nick: 'Group Nick' }, author: { global_name: 'Global Name', username: 'username' } },
{ member: { nick: '' }, author: { global_name: 'Global Name', username: 'username' } },
{ author: { global_name: null, username: 'username' } },
{ author: {} },
].entries()) {
await current.bridge.accept(normalizeDiscordMessage({
...names, id: String(index + 1), channel_id: 'chat', guild_id: 'guild',
author: { id: 'actor', ...names.author }, content: 'hello', mentions: [{ id: 'bot' }],
}, 'bot'));
}
assert.deepEqual(current.prompts.map((content) => sourceOf(content).senderName),
['Group Nick', 'Global Name', 'username', undefined]);
});

View file

@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { BotWorkspaceStore } from '../../../src/channels/shared/bot-workspace-store.mjs';
import {
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
captureContextEnhancement,
} from '../../../src/channels/shared/context-enhancement.mjs';
for (const channel of ['wecom', 'weixin', 'feishu', 'dingtalk', 'qq', 'slack', 'telegram', 'discord', 'whatsapp']) {
test(`${channel} production passes live, isolated context settings without reconnecting or changing Harness options`, async (t) => {
const dataDir = await mkdtemp(join(tmpdir(), `dsh-context-${channel}-`));
t.after(() => rm(dataDir, { recursive: true, force: true }));
const { createProductionController } = await import(`../../../plugin-src/host/channels/${channel}/production.mjs`);
const workspaces = await new BotWorkspaceStore(join(dataDir, 'workspaces.json'), { defaultWorkspace: dataDir }).load();
const botIds = [`${channel}_one`, `${channel}_two`];
const bots = botIds.map((botId) => ({ botId, id: botId, appId: 'cli_fixture', secretRef: 'fixture-only' }));
const runtimes = [];
let controllerOptions;
let harnessOptions;
let stateLoads = 0;
class ConfigStore {
async load() { return this; }
list() { return bots; }
}
class StateStore {
async load() { stateLoads += 1; return this; }
clearSessions() { throw new Error('enhancement must not reset sessions'); }
}
class Harness {
constructor(options) { harnessOptions = options; }
stopManagedProcess() {}
}
class Runtime {
constructor(options) { runtimes.push(options); }
}
class Controller {
constructor(options) { controllerOptions = options; }
status() { return { bots: botIds.map((botId) => ({ botId, connected: true })) }; }
reconnectBot() { throw new Error('enhancement must not reconnect'); }
async close() {}
}
const production = await createProductionController({
credentials: {},
apiProxy: {},
logger: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
}, { dataDir, workspace: dataDir }, {
ConfigStore,
StateStore,
HarnessClient: Harness,
Controller,
Runtime,
FeishuRuntime: Runtime,
workspaces,
api: {},
qrAuth: {},
deviceAuth: {},
lark: {},
proxyEnv: {},
createConnectionSupervisor: () => ({
ready: Promise.resolve(), start() { return this; }, async close() {},
}),
});
t.after(() => production.close());
for (const bot of bots) {
await controllerOptions.createRuntime({ botId: bot.botId, config: bot });
}
assert.equal(runtimes.length, 2);
assert.equal(stateLoads, 2);
assert.equal(Object.hasOwn(harnessOptions, 'contextEnhancement'), false);
assert.equal(Object.hasOwn(harnessOptions, 'source'), false);
const provider = runtimes[0].contextEnhancement;
assert.equal(provider.botId, botIds[0]);
assert.equal(provider.getSettings(), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
assert.equal(captureContextEnhancement(provider, 'direct'), null);
const generation = workspaces.generationFor(botIds[0]);
const selected = { groupEnabled: false, directEnabled: true, fields: [], guidance: 'runtime update' };
const saved = await production.controller.updateContextEnhancement(botIds[0], selected);
assert.deepEqual(saved.bots[0].contextEnhancement, selected);
assert.deepEqual(provider.getSettings(), selected);
const beforeDisable = captureContextEnhancement(provider, 'direct');
assert.deepEqual(beforeDisable.config, selected);
assert.equal(runtimes[0].contextEnhancement, provider, 'runtime does not need reconstruction');
assert.equal(runtimes[1].contextEnhancement.getSettings(), DEFAULT_CONTEXT_ENHANCEMENT_CONFIG);
assert.equal(runtimes[1].contextEnhancement.botId, botIds[1]);
await production.controller.updateContextEnhancement(botIds[0], { ...selected, directEnabled: false });
assert.equal(captureContextEnhancement(provider, 'direct'), null);
assert.equal(beforeDisable.config.directEnabled, true, 'an already received message keeps its snapshot');
assert.equal(workspaces.generationFor(botIds[0]), generation);
assert.equal(runtimes.length, 2);
assert.equal(stateLoads, 2);
});
}

View file

@ -1515,6 +1515,125 @@ test('Telegram runtime enforces the selected bot private allowlist', async () =>
}
});
for (const scenario of [
{ name: 'two private messages', kinds: ['private', 'private'], before: [true, true], after: [false, false] },
{ name: 'mixed private/group messages', kinds: ['private', 'supergroup'], before: [false, true], after: [true, false] },
{ name: 'disabled private messages before enabling', kinds: ['private', 'private'], before: [false, false], after: [true, true] },
]) {
test(`Telegram received poll batch retains its settings across cursor writes: ${scenario.name}`, async () => {
const firstCursorStarted = deferred();
const releaseFirstCursor = deferred();
const allPrompts = deferred();
const seen = new Set();
const asked = [];
const pollOffsets = [];
const cursorWrites = [];
let cursor = 10;
let settingsReads = 0;
let nextMessageId = 500;
const configFor = ([directEnabled, groupEnabled], guidance) => ({
directEnabled, groupEnabled, fields: ['channel', 'botId'], guidance,
});
let config = configFor(scenario.before, 'before cursor write');
const update = (id, kind) => ({
update_id: id,
message: {
message_id: id + 100,
chat: { id: kind === 'private' ? 42 : -1001, type: kind },
from: { id: 7, is_bot: false, first_name: 'Ada' },
text: kind === 'private' ? `message ${id}` : `@HarnessBot message ${id}`,
entities: kind === 'private' ? [] : [{ type: 'mention', offset: 0, length: 11 }],
},
});
const firstBatch = scenario.kinds.map((kind, index) => update(10 + index, kind));
const nextBatch = ['private', 'supergroup'].map((kind, index) => update(12 + index, kind));
const runtime = new TelegramRuntime({
config: { botId: 'telegram_poll', platformId: '123456789', username: 'HarnessBot' },
token: TOKEN,
contextEnhancement: {
botId: 'telegram_poll',
getSettings: () => { settingsReads += 1; return config; },
},
state: {
cursor: () => cursor,
setCursor: async (value) => {
cursorWrites.push(value);
if (value === 11) {
firstCursorStarted.resolve();
await releaseFirstCursor.promise;
}
cursor = value;
},
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: () => 'session-existing',
},
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
ask: async (_sessionId, text) => {
asked.push(text);
if (asked.length === 4) allPrompts.resolve();
return 'done';
},
},
createApi: () => ({
getMe: async () => ({ id: 123456789, is_bot: true }),
getWebhookInfo: async () => ({ url: '' }),
setMyCommands: async () => true,
setChatMenuButton: async () => true,
getUpdates: async ({ offset, signal }) => {
pollOffsets.push(offset);
if (offset === 10) return firstBatch;
if (offset === 12) return nextBatch;
assert.equal(offset, 14);
return new Promise((_, reject) => {
if (signal.aborted) return reject(signal.reason);
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
});
},
setMessageReaction: async () => true,
sendChatAction: async () => true,
sendRichMessageDraft: async () => true,
sendRichMessage: async () => ({ message_id: nextMessageId++ }),
sendMessage: async () => ({ message_id: nextMessageId++ }),
editMessageText: async () => true,
}),
logger: { warn() {}, error() {} },
});
try {
await runtime.start();
await bounded(firstCursorStarted.promise, 'first cursor write was not reached', 5_000);
assert.equal(settingsReads, 2, 'every received update captures its settings before the first cursor await');
config = configFor(scenario.after, 'after cursor write');
releaseFirstCursor.resolve();
await bounded(allPrompts.promise, 'both poll batches did not reach Harness', 5_000);
for (const [index, event] of [...firstBatch, ...nextBatch].entries()) {
const expectedText = `message ${event.update_id}`;
const content = asked.find((text) => text.endsWith(expectedText));
assert.ok(content, `Missing prompt ${event.update_id}`);
const switches = index < 2 ? scenario.before : scenario.after;
const enabled = switches[event.message.chat.type === 'private' ? 0 : 1];
if (enabled) {
assert.match(content, index < 2 ? /before cursor write/ : /after cursor write/);
assert.deepEqual(JSON.parse(/^<dsh_im_source>(.*?)<\/dsh_im_source>/su.exec(content)[1]), {
channel: 'telegram', botId: 'telegram_poll',
});
} else {
assert.equal(content, expectedText);
}
}
assert.equal(settingsReads, 4, 'Bridge reuses the snapshot instead of reading settings again');
assert.deepEqual(pollOffsets, [10, 12, 14]);
assert.deepEqual(cursorWrites, [11, 12, 13, 14]);
assert.deepEqual([...seen].sort(), ['10', '11', '12', '13']);
} finally {
releaseFirstCursor.resolve();
await runtime.stop();
}
});
}
test('Telegram runtime keeps polling while a Harness question waits for its answer', async () => {
const directory = await mkdtemp(join(tmpdir(), 'dsh-im-telegram-interaction-'));
const state = await new TelegramStateStore(join(directory, 'state.json')).load();