mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-09 18:53:17 +08:00
feat(feishu): add configurable group response mode
This commit is contained in:
parent
1a86fa5013
commit
9950261a49
19 changed files with 615 additions and 150 deletions
|
|
@ -306,6 +306,51 @@ test('bridge maps a Feishu conversation to a persistent Harness session and repl
|
|||
assert.equal(status.messagesRejected, 1);
|
||||
});
|
||||
|
||||
test('mention response mode ignores unaddressed groups and only accepts this bot mention', async () => {
|
||||
const fixture = stateFixture([['group:oc_group_mentions', 'session-group-mentions']]);
|
||||
const asked = [];
|
||||
const status = bridgeStatus();
|
||||
const bridge = new FeishuHarnessBridge({
|
||||
client: textClient(async () => undefined),
|
||||
channel: {},
|
||||
harness: {
|
||||
sessionExists: async () => true,
|
||||
ask: async (_sessionId, text) => {
|
||||
asked.push(text);
|
||||
return '收到';
|
||||
},
|
||||
},
|
||||
state: fixture.state,
|
||||
status,
|
||||
allowedSenderOpenIds: new Set(['ou_user']),
|
||||
botOpenId: 'ou_bot',
|
||||
groupResponseMode: 'mention',
|
||||
});
|
||||
|
||||
await bridge.accept(event('group-unaddressed', '普通群消息', {
|
||||
chat_type: 'group', chat_id: 'oc_group_mentions',
|
||||
}));
|
||||
await bridge.accept(event('group-mentions-someone-else', '@_other 你好', {
|
||||
chat_type: 'group', chat_id: 'oc_group_mentions',
|
||||
mentions: [{ key: '@_other', id: { open_id: 'ou_other' } }],
|
||||
}));
|
||||
assert.deepEqual(asked, []);
|
||||
assert.equal(status.messagesReceived, 0);
|
||||
|
||||
await bridge.accept(event('group-mentions-bot', '@_bot 你好', {
|
||||
chat_type: 'group', chat_id: 'oc_group_mentions',
|
||||
mentions: [{ key: '@_bot', id: { open_id: 'ou_bot' } }],
|
||||
}));
|
||||
assert.deepEqual(asked, ['你好']);
|
||||
assert.equal(status.messagesReceived, 1);
|
||||
|
||||
bridge.setGroupResponseMode('all');
|
||||
await bridge.accept(event('group-all-mode', '无需提及', {
|
||||
chat_type: 'group', chat_id: 'oc_group_mentions',
|
||||
}));
|
||||
assert.deepEqual(asked, ['你好', '无需提及']);
|
||||
});
|
||||
|
||||
test('bridge downloads an inbound Feishu image once and submits structured Harness content', async () => {
|
||||
const fixture = stateFixture([['p2p:ou_user', 'session-image']]);
|
||||
const downloaded = [];
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ test('multi-bot endpoints are bot-scoped and keep legacy operations separate', (
|
|||
assert.equal(FEISHU_ENDPOINTS.reconnectBot, 'bot.reconnect');
|
||||
assert.equal(FEISHU_ENDPOINTS.disconnectBot, 'bot.disconnect');
|
||||
assert.equal(FEISHU_ENDPOINTS.deleteBot, 'bot.delete');
|
||||
assert.equal(FEISHU_ENDPOINTS.setGroupResponseMode, 'bot.group-response-mode.set');
|
||||
assert.equal(FEISHU_ENDPOINTS.testConnection, 'connection.test');
|
||||
});
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ test('client normalizes multiple independent bots and derives authoritative tota
|
|||
state: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
groupResponseMode: 'all',
|
||||
bot: {
|
||||
name: '销售助手',
|
||||
appIdMasked: 'cli_aaaa••••1111',
|
||||
|
|
@ -58,6 +60,8 @@ test('client normalizes multiple independent bots and derives authoritative tota
|
|||
assert.equal(snapshot.revision, 9);
|
||||
assert.deepEqual(snapshot.totals, { configured: 2, connected: 1 });
|
||||
assert.equal(snapshot.bots[0].state, 'connected');
|
||||
assert.equal(snapshot.bots[0].groupResponseMode, 'all');
|
||||
assert.equal(snapshot.bots[1].groupResponseMode, 'mention');
|
||||
assert.equal(snapshot.bots[1].state, 'connecting');
|
||||
assert.equal(snapshot.bots[1].bot.domain, 'lark');
|
||||
assert.equal(snapshot.bots[1].error.message, '连接失败');
|
||||
|
|
|
|||
|
|
@ -51,6 +51,44 @@ test('Feishu connection check requests and displays test-message feedback', asyn
|
|||
assert.match(markup, /role="status"[^>]*>测试消息已发送/);
|
||||
assert.match(markup, /修复卡片按钮/);
|
||||
assert.match(markup, /aria-label="修复飞书测试机器人的卡片按钮"/);
|
||||
assert.match(markup, /<select[^>]*aria-label="群聊响应方式"/);
|
||||
assert.match(markup, /仅在 @机器人时响应(推荐)/);
|
||||
assert.match(markup, /响应所有群消息(需飞书敏感权限)/);
|
||||
});
|
||||
|
||||
test('Feishu bot card saves group response mode from a dropdown', async () => {
|
||||
const saved = [];
|
||||
let renderer;
|
||||
await act(async () => {
|
||||
renderer = create(React.createElement(BotCard, {
|
||||
connection: {
|
||||
botId: 'bot-mode-test',
|
||||
state: 'connected',
|
||||
connected: true,
|
||||
groupResponseMode: 'mention',
|
||||
bot: { name: '响应模式机器人', appIdMasked: 'cli_mode••••test' },
|
||||
health: { summary: '长连接运行正常', lastCheckedAt: Date.now() },
|
||||
},
|
||||
onGroupResponseModeSave: async (value) => saved.push(value),
|
||||
onReconnect() {},
|
||||
onRequestRemove() {},
|
||||
onConfirmRemove() {},
|
||||
onCancelRemove() {},
|
||||
}));
|
||||
});
|
||||
const select = renderer.root.findByProps({ 'aria-label': '群聊响应方式' });
|
||||
assert.equal(select.type, 'select');
|
||||
assert.equal(select.props.value, 'mention');
|
||||
assert.deepEqual(select.findAllByType('option').map((option) => option.props.value), [
|
||||
'mention', 'all',
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
select.props.onChange({ target: { value: 'all' } });
|
||||
await flushMicrotasks();
|
||||
});
|
||||
assert.deepEqual(saved, ['all']);
|
||||
await act(async () => renderer.unmount());
|
||||
});
|
||||
|
||||
test('Feishu callback repair keeps a Host-submitted attempt when a stale QR cancel races saving', async (t) => {
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ function fixture({
|
|||
stops: 0,
|
||||
sentTests: [],
|
||||
probes: [],
|
||||
responseModes: [],
|
||||
repair,
|
||||
get status() { return structuredClone(status); },
|
||||
async start() {
|
||||
|
|
@ -126,6 +127,10 @@ function fixture({
|
|||
runtime.sentTests.push(text);
|
||||
return { sent: true };
|
||||
},
|
||||
setGroupResponseMode(mode) {
|
||||
runtime.responseModes.push(mode);
|
||||
runtime.config.groupResponseMode = mode;
|
||||
},
|
||||
async beginCardActionProbe(options) {
|
||||
runtime.probes.push(structuredClone(options));
|
||||
if (callbackProbe) return callbackProbe({ botId, runtime, options });
|
||||
|
|
@ -184,6 +189,29 @@ test('QR registration separates events from card callbacks', async () => {
|
|||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('group response mode defaults to mention and updates the live runtime without reconnecting', async () => {
|
||||
const existing = bot('bot_response_mode', 'response_mode');
|
||||
const fx = fixture({
|
||||
bots: [existing],
|
||||
secrets: { [existing.secretRef]: 'stable-secret' },
|
||||
});
|
||||
await fx.controller.initialize();
|
||||
|
||||
assert.equal(fx.controller.status().bots[0].groupResponseMode, 'mention');
|
||||
const runtime = fx.runtimes.get(existing.id)[0];
|
||||
const updated = await fx.controller.updateGroupResponseMode(existing.id, 'all');
|
||||
|
||||
assert.equal(updated.bots[0].groupResponseMode, 'all');
|
||||
assert.equal(fx.configStore.getBot(existing.id).groupResponseMode, 'all');
|
||||
assert.deepEqual(runtime.responseModes, ['all']);
|
||||
assert.equal(fx.runtimes.get(existing.id).length, 1);
|
||||
await assert.rejects(
|
||||
fx.controller.updateGroupResponseMode(existing.id, 'sometimes'),
|
||||
/Invalid Feishu group response mode/,
|
||||
);
|
||||
await fx.controller.close();
|
||||
});
|
||||
|
||||
test('callback repair is deduplicated per bot, updates only its secret, and proves the callback', async () => {
|
||||
const existing = bot('bot_existing', 'existing');
|
||||
const fx = fixture({
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ test('PluginConfigStore persists non-secret onboarding facts', async () => {
|
|||
assert.doesNotMatch(raw, /must-not-be-written/);
|
||||
assert.equal((await stat(path)).mode & 0o777, 0o600);
|
||||
assert.equal((await new PluginConfigStore(path).load()).get().appId, 'cli_test');
|
||||
assert.equal(store.get().groupResponseMode, 'mention');
|
||||
|
||||
await store.save({ ...store.get(), groupResponseMode: 'all' });
|
||||
assert.equal((await new PluginConfigStore(path).load()).get().groupResponseMode, 'all');
|
||||
|
||||
await store.clear();
|
||||
assert.equal(store.get(), null);
|
||||
|
|
@ -66,6 +70,7 @@ test('PluginConfigStore migrates a v1 bot atomically without moving its credenti
|
|||
assert.deepEqual(migrated.bots[0].ownerOpenIds, ['ou_legacy']);
|
||||
assert.equal(store.get().ownerOpenId, 'ou_legacy');
|
||||
assert.equal(store.list()[0].appId, 'cli_legacy');
|
||||
assert.equal(store.list()[0].groupResponseMode, 'mention');
|
||||
});
|
||||
|
||||
test('PluginConfigStore rejects an invalid or duplicate v2 document without dropping entries', async () => {
|
||||
|
|
|
|||
|
|
@ -198,6 +198,57 @@ test('Host exposes only browser-safe Agent Preset fields in status and update re
|
|||
await fx.dispose();
|
||||
});
|
||||
|
||||
test('Host validates and updates the Feishu group response mode', async () => {
|
||||
let mode = 'mention';
|
||||
const current = () => status({
|
||||
schemaVersion: 2,
|
||||
revision: 4,
|
||||
configured: true,
|
||||
bots: [{
|
||||
botId: 'bot_mode',
|
||||
phase: 'connected',
|
||||
connected: true,
|
||||
configured: true,
|
||||
groupResponseMode: mode,
|
||||
bot: { name: '模式机器人', domain: 'feishu' },
|
||||
connection: {
|
||||
ready: true,
|
||||
feishuLongConnectionState: 'connected',
|
||||
harnessReachable: true,
|
||||
},
|
||||
}],
|
||||
});
|
||||
const controller = {
|
||||
status: async () => current(),
|
||||
startRegistration: async () => current(),
|
||||
cancelRegistration: async () => current(),
|
||||
disconnect: async () => status(),
|
||||
updateGroupResponseMode: async (botId, value) => {
|
||||
assert.equal(botId, 'bot_mode');
|
||||
mode = value;
|
||||
return current();
|
||||
},
|
||||
};
|
||||
const fx = await rpcFixture(controller);
|
||||
|
||||
const updated = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.setGroupResponseMode,
|
||||
{ botId: 'bot_mode', groupResponseMode: 'all' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(updated.ok, true);
|
||||
assert.equal(updated.value.bots[0].groupResponseMode, 'all');
|
||||
|
||||
const invalid = await fx.registration.handler(
|
||||
FEISHU_ENDPOINTS.setGroupResponseMode,
|
||||
{ botId: 'bot_mode', groupResponseMode: 'sometimes' },
|
||||
signal(),
|
||||
);
|
||||
assert.equal(invalid.ok, false);
|
||||
assert.equal(invalid.error.code, 'bad-request');
|
||||
await fx.dispose();
|
||||
});
|
||||
|
||||
test('RPC dispatch matches every endpoint in client/api.js', async () => {
|
||||
const calls = [];
|
||||
let current = status({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue