feat(feishu): thread command replies, cards, and approvals to the triggering message

Plain command replies (menu cards, session/workspace/watch lists, /new,
/status, /compact, /archived, model and preset commands) and Harness
approval prompts were sent as fresh messages keyed only by chat_id, so
in topic groups they landed in the group's default area instead of the
topic the conversation belongs to.

Deliver them as replies to the triggering message, mirroring how stream
answers already thread:

- #sendCard accepts { replyTo } and replies interactively, falling back
  to a plain card when the referenced message is gone
- command replies, status text, menu/session/workspace/watch cards,
  approval prompts and resolved-question notices all carry the
  triggering message id
- batch-input failure notices follow the same rule

- bridge.test.mjs: /new in a topic group must thread the confirmation
  text and the menu card to the command message
- lib/index.js: regenerated host bundle
This commit is contained in:
evanfang0054 2026-08-31 18:55:38 +08:00
parent 3b810fea6b
commit fd6e0af81b
3 changed files with 209 additions and 101 deletions

View file

@ -1335,6 +1335,83 @@ test('a Harness question is presented as a threaded reply inside a topic group',
assert.equal(streamed.at(-1), '已完成');
});
test('command replies in a topic group are threaded to the triggering message', async () => {
const created = [];
const replied = [];
const seen = new Set();
const sessions = new Map([['group:oc_topic:thread:omt_cmd', 'session-cmd']]);
const bridge = new FeishuHarnessBridge({
client: {
im: { v1: { message: {
create: async (request) => {
created.push({
type: request.data.msg_type,
text: request.data.msg_type === 'text'
? JSON.parse(request.data.content).text
: null,
});
return { code: 0, data: { message_id: `om_created_${created.length}` } };
},
reply: async (request) => {
replied.push({
to: request.path.message_id,
type: request.data.msg_type,
text: request.data.msg_type === 'text'
? JSON.parse(request.data.content).text
: null,
});
return { code: 0, data: { message_id: `om_replied_${replied.length}` } };
},
} } },
},
harness: {
ensureRunning: async () => true,
sessionExists: async () => true,
},
state: {
hasSeen: (id) => seen.has(id),
markSeen: async (id) => seen.add(id),
sessionFor: (key) => sessions.get(key) ?? null,
setSession: async (key, sessionId) => sessions.set(key, sessionId),
clearSession: async (key) => sessions.delete(key),
},
status: {
messagesReceived: 0,
messagesReplied: 0,
messagesRejected: 0,
lastMessageAt: null,
lastReplyAt: null,
lastRejectedAt: null,
lastError: null,
},
allowedSenderOpenIds: new Set(['ou_user']),
});
bridge.accept(event('om_cmd', '/new', {
chat_type: 'group',
chat_id: 'oc_topic',
thread_id: 'omt_cmd',
}));
await eventually(
() => replied.length >= 2,
'the /new replies were not presented as threaded replies',
);
assert.ok(
replied.some(({ to, text }) => to === 'om_cmd' && text?.includes('已开启全新')),
'the /new confirmation must be delivered through the reply API targeting the command message',
);
assert.ok(
replied.some(({ to, type }) => to === 'om_cmd' && type === 'interactive'),
'the menu card must also be threaded to the command message',
);
assert.equal(
created.some(({ text }) => text?.includes('已开启全新')),
false,
'the /new confirmation must not be sent as a plain chat message outside the topic',
);
});
test('pending Harness questions are isolated by Feishu conversation', async () => {
const fixture = stateFixture([
['p2p:ou_a', 'session-a'],