mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-10 07:50:47 +08:00
Merge main into feat/feishu-interaction-cards
# Conflicts: # lib/index.js
This commit is contained in:
commit
d4e1bd3cab
83 changed files with 5516 additions and 597 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue