Skip to content

Commit f22fc17

Browse files
feat(feishu): prefer thread_id for topic session routing (#29788) thanks @songyaolun
Verified: - pnpm test -- extensions/feishu/src/bot.test.ts extensions/feishu/src/reply-dispatcher.test.ts - pnpm build Co-authored-by: songyaolun <[email protected]> Co-authored-by: Tak Hoffman <[email protected]>
1 parent 28c88e9 commit f22fc17

File tree

7 files changed

+368
-62
lines changed

7 files changed

+368
-62
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai
2828

2929
### Fixes
3030

31+
- Feishu/topic session routing: use `thread_id` as topic session scope fallback when `root_id` is absent, keep first-turn topic keys stable across thread creation, and force thread replies when inbound events already carry topic/thread context. (#29788) Thanks @songyaolun.
3132
- Feishu/DM pairing reply target: send pairing challenge replies to `chat:<chat_id>` instead of `user:<sender_open_id>` so Lark/Feishu private chats with user-id-only sender payloads receive pairing messages reliably. (#31403) Thanks @stakeswky.
3233
- Feishu/Lark private DM routing: treat inbound `chat_type: "private"` as direct-message context for pairing/mention-forward/reaction synthetic handling so Lark private chats behave like Feishu p2p DMs. (#31400) Thanks @stakeswky.
3334
- Sandbox/workspace mount permissions: make primary `/workspace` bind mounts read-only whenever `workspaceAccess` is not `rw` (including `none`) across both core sandbox container and sandbox browser create flows. (#32227) Thanks @guanyu-zhang.

extensions/feishu/src/bot.test.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,6 +1148,83 @@ describe("handleFeishuMessage command authorization", () => {
11481148
);
11491149
});
11501150

1151+
it("keeps root_id as topic key when root_id and thread_id both exist", async () => {
1152+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1153+
1154+
const cfg: ClawdbotConfig = {
1155+
channels: {
1156+
feishu: {
1157+
groups: {
1158+
"oc-group": {
1159+
requireMention: false,
1160+
groupSessionScope: "group_topic_sender",
1161+
},
1162+
},
1163+
},
1164+
},
1165+
} as ClawdbotConfig;
1166+
1167+
const event: FeishuMessageEvent = {
1168+
sender: { sender_id: { open_id: "ou-topic-user" } },
1169+
message: {
1170+
message_id: "msg-scope-topic-thread-id",
1171+
chat_id: "oc-group",
1172+
chat_type: "group",
1173+
root_id: "om_root_topic",
1174+
thread_id: "omt_topic_1",
1175+
message_type: "text",
1176+
content: JSON.stringify({ text: "topic sender scope" }),
1177+
},
1178+
};
1179+
1180+
await dispatchMessage({ cfg, event });
1181+
1182+
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1183+
expect.objectContaining({
1184+
peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
1185+
parentPeer: { kind: "group", id: "oc-group" },
1186+
}),
1187+
);
1188+
});
1189+
1190+
it("uses thread_id as topic key when root_id is missing", async () => {
1191+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1192+
1193+
const cfg: ClawdbotConfig = {
1194+
channels: {
1195+
feishu: {
1196+
groups: {
1197+
"oc-group": {
1198+
requireMention: false,
1199+
groupSessionScope: "group_topic_sender",
1200+
},
1201+
},
1202+
},
1203+
},
1204+
} as ClawdbotConfig;
1205+
1206+
const event: FeishuMessageEvent = {
1207+
sender: { sender_id: { open_id: "ou-topic-user" } },
1208+
message: {
1209+
message_id: "msg-scope-topic-thread-only",
1210+
chat_id: "oc-group",
1211+
chat_type: "group",
1212+
thread_id: "omt_topic_1",
1213+
message_type: "text",
1214+
content: JSON.stringify({ text: "topic sender scope" }),
1215+
},
1216+
};
1217+
1218+
await dispatchMessage({ cfg, event });
1219+
1220+
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1221+
expect.objectContaining({
1222+
peer: { kind: "group", id: "oc-group:topic:omt_topic_1:sender:ou-topic-user" },
1223+
parentPeer: { kind: "group", id: "oc-group" },
1224+
}),
1225+
);
1226+
});
1227+
11511228
it("maps legacy topicSessionMode=enabled to group_topic routing", async () => {
11521229
mockShouldComputeCommandAuthorized.mockReturnValue(false);
11531230

@@ -1186,6 +1263,45 @@ describe("handleFeishuMessage command authorization", () => {
11861263
);
11871264
});
11881265

1266+
it("maps legacy topicSessionMode=enabled to root_id when both root_id and thread_id exist", async () => {
1267+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1268+
1269+
const cfg: ClawdbotConfig = {
1270+
channels: {
1271+
feishu: {
1272+
topicSessionMode: "enabled",
1273+
groups: {
1274+
"oc-group": {
1275+
requireMention: false,
1276+
},
1277+
},
1278+
},
1279+
},
1280+
} as ClawdbotConfig;
1281+
1282+
const event: FeishuMessageEvent = {
1283+
sender: { sender_id: { open_id: "ou-legacy-thread-id" } },
1284+
message: {
1285+
message_id: "msg-legacy-topic-thread-id",
1286+
chat_id: "oc-group",
1287+
chat_type: "group",
1288+
root_id: "om_root_legacy",
1289+
thread_id: "omt_topic_legacy",
1290+
message_type: "text",
1291+
content: JSON.stringify({ text: "legacy topic mode" }),
1292+
},
1293+
};
1294+
1295+
await dispatchMessage({ cfg, event });
1296+
1297+
expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1298+
expect.objectContaining({
1299+
peer: { kind: "group", id: "oc-group:topic:om_root_legacy" },
1300+
parentPeer: { kind: "group", id: "oc-group" },
1301+
}),
1302+
);
1303+
});
1304+
11891305
it("uses message_id as topic root when group_topic + replyInThread and no root_id", async () => {
11901306
mockShouldComputeCommandAuthorized.mockReturnValue(false);
11911307

@@ -1224,6 +1340,102 @@ describe("handleFeishuMessage command authorization", () => {
12241340
);
12251341
});
12261342

1343+
it("keeps topic session key stable after first turn creates a thread", async () => {
1344+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1345+
1346+
const cfg: ClawdbotConfig = {
1347+
channels: {
1348+
feishu: {
1349+
groups: {
1350+
"oc-group": {
1351+
requireMention: false,
1352+
groupSessionScope: "group_topic",
1353+
replyInThread: "enabled",
1354+
},
1355+
},
1356+
},
1357+
},
1358+
} as ClawdbotConfig;
1359+
1360+
const firstTurn: FeishuMessageEvent = {
1361+
sender: { sender_id: { open_id: "ou-topic-init" } },
1362+
message: {
1363+
message_id: "msg-topic-first",
1364+
chat_id: "oc-group",
1365+
chat_type: "group",
1366+
message_type: "text",
1367+
content: JSON.stringify({ text: "create topic" }),
1368+
},
1369+
};
1370+
const secondTurn: FeishuMessageEvent = {
1371+
sender: { sender_id: { open_id: "ou-topic-init" } },
1372+
message: {
1373+
message_id: "msg-topic-second",
1374+
chat_id: "oc-group",
1375+
chat_type: "group",
1376+
root_id: "msg-topic-first",
1377+
thread_id: "omt_topic_created",
1378+
message_type: "text",
1379+
content: JSON.stringify({ text: "follow up in same topic" }),
1380+
},
1381+
};
1382+
1383+
await dispatchMessage({ cfg, event: firstTurn });
1384+
await dispatchMessage({ cfg, event: secondTurn });
1385+
1386+
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
1387+
1,
1388+
expect.objectContaining({
1389+
peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
1390+
}),
1391+
);
1392+
expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
1393+
2,
1394+
expect.objectContaining({
1395+
peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
1396+
}),
1397+
);
1398+
});
1399+
1400+
it("forces thread replies when inbound message contains thread_id", async () => {
1401+
mockShouldComputeCommandAuthorized.mockReturnValue(false);
1402+
1403+
const cfg: ClawdbotConfig = {
1404+
channels: {
1405+
feishu: {
1406+
groups: {
1407+
"oc-group": {
1408+
requireMention: false,
1409+
groupSessionScope: "group",
1410+
replyInThread: "disabled",
1411+
},
1412+
},
1413+
},
1414+
},
1415+
} as ClawdbotConfig;
1416+
1417+
const event: FeishuMessageEvent = {
1418+
sender: { sender_id: { open_id: "ou-thread-reply" } },
1419+
message: {
1420+
message_id: "msg-thread-reply",
1421+
chat_id: "oc-group",
1422+
chat_type: "group",
1423+
thread_id: "omt_topic_thread_reply",
1424+
message_type: "text",
1425+
content: JSON.stringify({ text: "thread content" }),
1426+
},
1427+
};
1428+
1429+
await dispatchMessage({ cfg, event });
1430+
1431+
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
1432+
expect.objectContaining({
1433+
replyInThread: true,
1434+
threadReply: true,
1435+
}),
1436+
);
1437+
});
1438+
12271439
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
12281440
mockShouldComputeCommandAuthorized.mockReturnValue(false);
12291441

0 commit comments

Comments
 (0)