Merge main into feat/feishu-interaction-cards

# Conflicts:
#	lib/index.js
This commit is contained in:
xmanrui 2026-09-03 01:00:04 +08:00
commit d4e1bd3cab
83 changed files with 5516 additions and 597 deletions

View file

@ -66,6 +66,12 @@ function sourceOf(content) {
return JSON.parse(match[1]);
}
function textOf(content) {
return Array.isArray(content)
? content.filter((item) => item?.type === 'text').map((item) => item.text).join('\n\n')
: content;
}
function withoutPrompt(calls) {
return calls.map(([operation, ...args]) => operation === 'ask'
? [operation, args[0], '(prompt)', args[2]] : [operation, ...args]);
@ -306,6 +312,10 @@ for (const channel of CHANNELS) {
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 chatId = channel === 'telegram' ? '100'
: channel === 'whatsapp' ? (kind === 'group' ? 'chat@g.us' : 'actor@s.whatsapp.net')
: channel === 'wecom' || channel === 'qq' || channel === 'weixin'
? (kind === 'group' ? 'chat' : 'actor') : 'chat';
const expected = {
channel, conversationType: kind,
senderId: channel === 'telegram' ? '42' : channel === 'whatsapp' ? 'actor@s.whatsapp.net' : 'actor',
@ -313,11 +323,13 @@ for (const channel of CHANNELS) {
? { senderName: 'Ada' } : {}),
...(channel === 'dingtalk' && kind === 'group' ? { conversationTitle: '钉钉测试群' } : {}),
...(channel === 'telegram' && kind === 'group' ? { conversationTitle: 'Telegram群' } : {}),
chatId,
...(channel === 'slack' && kind === 'group' ? { threadId: 'thread-existing' } : {}),
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/);
assert.ok(textOf(current.prompts[0]).endsWith('\n\nhello'));
assert.doesNotMatch(textOf(current.prompts[0]), /source_guidance|private-token|private-secret/);
assert.deepEqual(
current.calls.filter(([operation]) => operation === 'renameSession'),
[['renameSession', 'session-existing', 'hello']],
@ -345,11 +357,11 @@ for (const channel of CHANNELS) {
await current.bridge.accept(current.event(1, 'direct message', { kind: 'direct' }));
await current.bridge.accept(current.event(2, 'group message', { kind: 'group' }));
assert.deepEqual(sourceOf(current.prompts[0]), { botId: `${channel}_internal` });
assert.match(current.prompts[0], /DIRECT-ONLY-TOKEN/);
assert.doesNotMatch(current.prompts[0], /GROUP-ONLY-TOKEN|"channel"/);
assert.match(textOf(current.prompts[0]), /DIRECT-ONLY-TOKEN/);
assert.doesNotMatch(textOf(current.prompts[0]), /GROUP-ONLY-TOKEN|"channel"/);
assert.deepEqual(sourceOf(current.prompts[1]), { channel });
assert.match(current.prompts[1], /GROUP-ONLY-TOKEN/);
assert.doesNotMatch(current.prompts[1], /DIRECT-ONLY-TOKEN|"botId"/);
assert.match(textOf(current.prompts[1]), /GROUP-ONLY-TOKEN/);
assert.doesNotMatch(textOf(current.prompts[1]), /DIRECT-ONLY-TOKEN|"botId"/);
});
}
@ -553,3 +565,25 @@ test('Discord prefers event member nickname, global name, then username without
assert.deepEqual(current.prompts.map((content) => sourceOf(content).senderName),
['Group Nick', 'Global Name', 'username', undefined]);
});
test('Feishu topics expose chatId always and threadId only when the event carries a thread', async () => {
const current = fixture('feishu', { contextEnhancement: provider('feishu', settings()) });
await current.bridge.accept(current.event(1, 'main channel', { kind: 'group' }));
const main = sourceOf(current.prompts[0]);
assert.equal(main.chatId, 'chat');
assert.equal(Object.hasOwn(main, 'threadId'), false);
assert.deepEqual(main, {
channel: 'feishu', conversationType: 'group', senderId: 'actor',
chatId: 'chat', botId: 'feishu_internal',
});
const topic = current.event(2, 'inside a topic', { kind: 'group' });
topic.message.thread_id = 'omt_topic';
await current.bridge.accept(topic);
const topicSource = sourceOf(current.prompts[1]);
assert.equal(topicSource.chatId, 'chat');
assert.equal(topicSource.threadId, 'omt_topic');
assert.equal(current.sessions.size, 2, 'topic messages keep their own thread-scoped Session');
await current.bridge.accept(current.event(3, 'direct chat', { kind: 'direct' }));
assert.equal(sourceOf(current.prompts[2]).chatId, 'chat');
assert.equal(Object.hasOwn(sourceOf(current.prompts[2]), 'threadId'), false);
});

View file

@ -371,6 +371,62 @@ test('all four shared text channels enforce fail-closed live access before side
}
});
test('all four shared text channels resolve replies only after trigger and command gates', async () => {
for (const [name, Bridge] of [
['slack', SlackHarnessBridge],
['telegram', TelegramHarnessBridge],
['discord', DiscordHarnessBridge],
['whatsapp', WhatsappHarnessBridge],
]) {
const fixture = stateFixture();
const sent = [];
const asks = [];
let replyLoads = 0;
const replyTo = () => ({
messageId: `quoted-${name}`,
load: async () => {
replyLoads += 1;
return {
messageId: `quoted-${name}`,
authorName: 'Original author',
content: '/new 只是被引用的原文',
attachments: [{ kind: 'file', name: 'brief.pdf' }],
};
},
});
const bridge = new Bridge({
bot: { sendText: async (_target, text) => sent.push(text) },
state: fixture.state,
harness: {
createSession: async () => `session-reply-${name}`,
sessionExists: async () => true,
ask: async (_sessionId, content) => {
asks.push(content);
return `${name} reply`;
},
},
});
await bridge.accept(message(`reply-unaddressed-${name}`, 'ignored', {
kind: 'group',
conversationId: `group-${name}`,
addressed: false,
replyTo: replyTo(),
}));
await bridge.accept(message(`reply-help-${name}`, '/help', { replyTo: replyTo() }));
assert.equal(replyLoads, 0, `${name} does not fetch before trigger and command gates`);
assert.equal(asks.length, 0, name);
await bridge.accept(message(`reply-question-${name}`, '当前问题', { replyTo: replyTo() }));
assert.equal(replyLoads, 1, `${name} resolves once at the model prompt stage`);
assert.equal(asks.length, 1, name);
assert.equal(Array.isArray(asks[0]), true, name);
assert.match(asks[0][0].text, /^<dsh_im_reply_to>/, name);
assert.match(asks[0][0].text, /\/new 只是被引用的原文/, name);
assert.deepEqual(asks[0][1], { type: 'text', text: '当前问题' }, name);
}
});
test('runtime abort clears a queued interaction reply reaction instead of marking success', async () => {
const fixture = stateFixture();
const controller = new AbortController();
@ -714,6 +770,13 @@ test('private batch input enforces text-only collection, command blocking, the l
await bridge.accept(message('limited-file', 'file caption', {
files: [{ name: 'data.txt', data: Buffer.from('data') }],
}));
let replyLoads = 0;
await bridge.accept(message('limited-reply', 'quoted caption', {
replyTo: {
content: 'quoted text',
load: async () => { replyLoads += 1; return { content: 'must not load' }; },
},
}));
await bridge.accept(message('limited-unsupported-media', 'video caption', {
plainText: false,
}));
@ -728,7 +791,8 @@ test('private batch input enforces text-only collection, command blocking, the l
await bridge.accept(message('limited-cancel', '/cancel'));
assert.equal(asks, 0, 'collection and cancellation never call Harness');
assert.equal(sent.filter((text) => text.includes('目前仅支持文字')).length, 3);
assert.equal(replyLoads, 0, 'batch collection never resolves quoted content');
assert.equal(sent.filter((text) => text.includes('目前仅支持文字')).length, 4);
assert.equal(sent.some((text) => text.includes('先发送 /send 提交或 /cancel 取消')), true);
assert.equal(sent.some((text) => /10\/10.*已满/.test(text)), true);
assert.equal(sent.some((text) => text.includes('这条消息未收录')), true);