mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 11:43:19 +08:00
feat: bind bot chats to existing harness sessions
This commit is contained in:
parent
76d5430f1f
commit
1af8610cd5
21 changed files with 1807 additions and 130 deletions
175
test/session-bind-command.test.mjs
Normal file
175
test/session-bind-command.test.mjs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
|
||||
import { TextHarnessBridge } from '../src/channels/shared/text-harness-bridge.mjs';
|
||||
import { runWorkspaceCommand } from '../src/channels/shared/workspace-command.mjs';
|
||||
|
||||
test('/session binds exactly one safe Session ID to the current conversation', async () => {
|
||||
const calls = [];
|
||||
const result = await runWorkspaceCommand('/SESSION session-123', {
|
||||
async bindWorkspaceSession(key, sessionId) {
|
||||
calls.push({ key, sessionId });
|
||||
return {
|
||||
sessionId,
|
||||
workspace: '/workspace/project',
|
||||
title: '安全标题\u202e伪造\n下一行',
|
||||
archived: true,
|
||||
};
|
||||
},
|
||||
}, 'direct:conversation-1');
|
||||
|
||||
assert.deepEqual(calls, [{ key: 'direct:conversation-1', sessionId: 'session-123' }]);
|
||||
assert.match(result.message, /^当前聊天已绑定会话:/);
|
||||
assert.match(result.message, /工作区:\/workspace\/project/);
|
||||
assert.match(result.message, /标题:安全标题 伪造 下一行/);
|
||||
assert.doesNotMatch(result.message, /\u202e|\n下一行/);
|
||||
assert.match(result.message, /ID:session-123/);
|
||||
assert.match(result.message, /归档:是/);
|
||||
assert.equal(result.messages.join(''), result.message);
|
||||
});
|
||||
test('/session strictly rejects missing, multiple, oversized, and unsafe IDs', async () => {
|
||||
let bindCalls = 0;
|
||||
const harness = {
|
||||
async bindWorkspaceSession() {
|
||||
bindCalls += 1;
|
||||
throw new Error('must not be called');
|
||||
},
|
||||
};
|
||||
const invalid = [
|
||||
'/session',
|
||||
'/session first second',
|
||||
'/session\nsecond-line',
|
||||
`/session ${'x'.repeat(257)}`,
|
||||
'/session unsafe\u202eid',
|
||||
'/session unsafe\u0000id',
|
||||
];
|
||||
|
||||
for (const command of invalid) {
|
||||
const result = await runWorkspaceCommand(command, harness, 'direct:conversation-1');
|
||||
assert.match(result.message, /用法:\/session Session ID/);
|
||||
}
|
||||
assert.equal(bindCalls, 0);
|
||||
});
|
||||
|
||||
test('/session requires binding support and a conversation key', async () => {
|
||||
assert.match(
|
||||
(await runWorkspaceCommand('/session session-1', {}, 'direct:conversation-1')).message,
|
||||
/暂不支持绑定已有会话/,
|
||||
);
|
||||
assert.match(
|
||||
(await runWorkspaceCommand('/session session-1', {
|
||||
async bindWorkspaceSession() { throw new Error('must not be called'); },
|
||||
})).message,
|
||||
/缺少可绑定的会话上下文/,
|
||||
);
|
||||
});
|
||||
|
||||
test('/session maps adoption, lifecycle, and concurrent failures to safe messages', async () => {
|
||||
const cases = [
|
||||
['session-id-invalid', /Session ID 格式无效/],
|
||||
['session-not-registered', /未找到该会话/],
|
||||
['session-workspace-ambiguous', /工作区归属不明确/],
|
||||
['session-summary-unavailable', /暂时无法读取该会话的信息/],
|
||||
['session-subagent-unsupported', /子代理会话不能绑定/],
|
||||
['workspace-bot-not-found', /正在移除或已重新接入/],
|
||||
['workspace-session-stale', /状态已发生变化/],
|
||||
['agent-busy', /状态已发生变化/],
|
||||
['session-conflict', /状态已发生变化/],
|
||||
['internal', /暂时无法绑定会话/],
|
||||
];
|
||||
|
||||
for (const [code, expected] of cases) {
|
||||
const internal = new Error(`private detail for ${code}`);
|
||||
internal.code = code;
|
||||
const result = await runWorkspaceCommand('/session session-1', {
|
||||
async bindWorkspaceSession() { throw internal; },
|
||||
}, 'direct:conversation-1');
|
||||
assert.match(result.message, expected);
|
||||
assert.doesNotMatch(result.message, /private detail/);
|
||||
}
|
||||
|
||||
const stale = new Error('old bot lifecycle');
|
||||
stale.code = 'workspace-bot-not-found';
|
||||
const staleAfterBinding = await runWorkspaceCommand('/session session-1', {
|
||||
async bindWorkspaceSession(sessionKey, sessionId) {
|
||||
return { sessionId, workspace: '/workspace/project', sessionKey };
|
||||
},
|
||||
assertWorkspaceScope() { throw stale; },
|
||||
}, 'direct:conversation-1');
|
||||
assert.match(staleAfterBinding.message, /正在移除或已重新接入/);
|
||||
});
|
||||
|
||||
test('the shared bridge binds locally with its conversation key and never prompts or creates', async () => {
|
||||
const sent = [];
|
||||
const seen = new Set();
|
||||
const calls = [];
|
||||
let forbiddenCalls = 0;
|
||||
const bridge = new TextHarnessBridge({
|
||||
descriptor: { key: 'test', label: 'Test' },
|
||||
bot: { async sendText(_target, text) { sent.push(text); } },
|
||||
harness: {
|
||||
async bindWorkspaceSession(key, sessionId) {
|
||||
calls.push({ key, sessionId });
|
||||
return {
|
||||
sessionId,
|
||||
workspace: '/workspace/project',
|
||||
title: null,
|
||||
archived: false,
|
||||
};
|
||||
},
|
||||
async createSession() { forbiddenCalls += 1; },
|
||||
async ask() { forbiddenCalls += 1; },
|
||||
},
|
||||
state: {
|
||||
hasSeen(messageId) { return seen.has(messageId); },
|
||||
async markSeen(messageId) { seen.add(messageId); },
|
||||
},
|
||||
});
|
||||
|
||||
await bridge.accept({
|
||||
messageId: 'message-session-bind',
|
||||
senderId: 'sender',
|
||||
conversationId: 'conversation-1',
|
||||
kind: 'direct',
|
||||
content: '/session session-123',
|
||||
replyTarget: 'target',
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [{ key: 'direct:conversation-1', sessionId: 'session-123' }]);
|
||||
assert.equal(forbiddenCalls, 0);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.match(sent[0], /当前聊天已绑定会话/);
|
||||
assert.match(sent[0], /标题:暂无标题/);
|
||||
assert.match(sent[0], /归档:否/);
|
||||
assert.equal(seen.has('message-session-bind'), true);
|
||||
});
|
||||
|
||||
test('all nine channel bridges advertise /session and pass their current conversation key', async () => {
|
||||
const bridgeFamilies = [
|
||||
['../src/channels/shared/text-harness-bridge.mjs', 'conversationKey'],
|
||||
['../src/channels/weixin/weixin-bridge.mjs', 'key'],
|
||||
['../src/channels/feishu/bridge.mjs', 'key'],
|
||||
['../src/channels/dingtalk/dingtalk-bridge.mjs', 'key'],
|
||||
['../src/channels/wecom/wecom-bridge.mjs', 'key'],
|
||||
['../src/channels/qq/qq-bridge.mjs', 'key'],
|
||||
];
|
||||
for (const [file, key] of bridgeFamilies) {
|
||||
const source = await readFile(new URL(file, import.meta.url), 'utf8');
|
||||
assert.match(source, /\/session Session ID 将当前聊天绑定到指定会话/);
|
||||
assert.ok(
|
||||
source.includes(`runWorkspaceCommand(text, this.#harness, ${key})`),
|
||||
`${file} must pass ${key} to the shared command`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of [
|
||||
'../src/channels/discord/discord-bridge.mjs',
|
||||
'../src/channels/slack/slack-bridge.mjs',
|
||||
'../src/channels/telegram/telegram-bridge.mjs',
|
||||
'../src/channels/whatsapp/whatsapp-bridge.mjs',
|
||||
]) {
|
||||
const source = await readFile(new URL(file, import.meta.url), 'utf8');
|
||||
assert.match(source, /extends TextHarnessBridge/);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue