@openclaw/feishu 2026.3.1 → 2026.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bot.test.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
3
4
  import type { FeishuMessageEvent } from "./bot.js";
4
5
  import { buildFeishuAgentBody, handleFeishuMessage, toMessageResourceType } from "./bot.js";
5
6
  import { setFeishuRuntime } from "./runtime.js";
@@ -27,8 +28,10 @@ const {
27
28
  mockCreateFeishuClient: vi.fn(),
28
29
  mockResolveAgentRoute: vi.fn(() => ({
29
30
  agentId: "main",
31
+ channel: "feishu",
30
32
  accountId: "default",
31
33
  sessionKey: "agent:main:feishu:dm:ou-attacker",
34
+ mainSessionKey: "agent:main:main",
32
35
  matchedBy: "default",
33
36
  })),
34
37
  }));
@@ -122,7 +125,9 @@ describe("handleFeishuMessage command authorization", () => {
122
125
  const mockBuildPairingReply = vi.fn(() => "Pairing response");
123
126
  const mockEnqueueSystemEvent = vi.fn();
124
127
  const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
128
+ id: "inbound-clip.mp4",
125
129
  path: "/tmp/inbound-clip.mp4",
130
+ size: Buffer.byteLength("video"),
126
131
  contentType: "video/mp4",
127
132
  });
128
133
 
@@ -131,8 +136,10 @@ describe("handleFeishuMessage command authorization", () => {
131
136
  mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
132
137
  mockResolveAgentRoute.mockReturnValue({
133
138
  agentId: "main",
139
+ channel: "feishu",
134
140
  accountId: "default",
135
141
  sessionKey: "agent:main:feishu:dm:ou-attacker",
142
+ mainSessionKey: "agent:main:main",
136
143
  matchedBy: "default",
137
144
  });
138
145
  mockCreateFeishuClient.mockReturnValue({
@@ -143,38 +150,46 @@ describe("handleFeishuMessage command authorization", () => {
143
150
  },
144
151
  });
145
152
  mockEnqueueSystemEvent.mockReset();
146
- setFeishuRuntime({
147
- system: {
148
- enqueueSystemEvent: mockEnqueueSystemEvent,
149
- },
150
- channel: {
151
- routing: {
152
- resolveAgentRoute: mockResolveAgentRoute,
153
- },
154
- reply: {
155
- resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
156
- formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
157
- finalizeInboundContext: mockFinalizeInboundContext,
158
- dispatchReplyFromConfig: mockDispatchReplyFromConfig,
159
- withReplyDispatcher: mockWithReplyDispatcher,
153
+ setFeishuRuntime(
154
+ createPluginRuntimeMock({
155
+ system: {
156
+ enqueueSystemEvent: mockEnqueueSystemEvent,
160
157
  },
161
- commands: {
162
- shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
163
- resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
158
+ channel: {
159
+ routing: {
160
+ resolveAgentRoute:
161
+ mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
162
+ },
163
+ reply: {
164
+ resolveEnvelopeFormatOptions: vi.fn(
165
+ () => ({}),
166
+ ) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
167
+ formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
168
+ finalizeInboundContext:
169
+ mockFinalizeInboundContext as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
170
+ dispatchReplyFromConfig: mockDispatchReplyFromConfig,
171
+ withReplyDispatcher:
172
+ mockWithReplyDispatcher as unknown as PluginRuntime["channel"]["reply"]["withReplyDispatcher"],
173
+ },
174
+ commands: {
175
+ shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
176
+ resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
177
+ },
178
+ media: {
179
+ saveMediaBuffer:
180
+ mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
181
+ },
182
+ pairing: {
183
+ readAllowFromStore: mockReadAllowFromStore,
184
+ upsertPairingRequest: mockUpsertPairingRequest,
185
+ buildPairingReply: mockBuildPairingReply,
186
+ },
164
187
  },
165
188
  media: {
166
- saveMediaBuffer: mockSaveMediaBuffer,
189
+ detectMime: vi.fn(async () => "application/octet-stream"),
167
190
  },
168
- pairing: {
169
- readAllowFromStore: mockReadAllowFromStore,
170
- upsertPairingRequest: mockUpsertPairingRequest,
171
- buildPairingReply: mockBuildPairingReply,
172
- },
173
- },
174
- media: {
175
- detectMime: vi.fn(async () => "application/octet-stream"),
176
- },
177
- } as unknown as PluginRuntime);
191
+ }),
192
+ );
178
193
  });
179
194
 
180
195
  it("does not enqueue inbound preview text as system events", async () => {
@@ -366,6 +381,41 @@ describe("handleFeishuMessage command authorization", () => {
366
381
  );
367
382
  });
368
383
 
384
+ it("replies pairing challenge to DM chat_id instead of user:sender id", async () => {
385
+ const cfg: ClawdbotConfig = {
386
+ channels: {
387
+ feishu: {
388
+ dmPolicy: "pairing",
389
+ },
390
+ },
391
+ } as ClawdbotConfig;
392
+
393
+ const event: FeishuMessageEvent = {
394
+ sender: {
395
+ sender_id: {
396
+ user_id: "u_mobile_only",
397
+ },
398
+ },
399
+ message: {
400
+ message_id: "msg-pairing-chat-reply",
401
+ chat_id: "oc_dm_chat_1",
402
+ chat_type: "p2p",
403
+ message_type: "text",
404
+ content: JSON.stringify({ text: "hello" }),
405
+ },
406
+ };
407
+
408
+ mockReadAllowFromStore.mockResolvedValue([]);
409
+ mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
410
+
411
+ await dispatchMessage({ cfg, event });
412
+
413
+ expect(mockSendMessageFeishu).toHaveBeenCalledWith(
414
+ expect.objectContaining({
415
+ to: "chat:oc_dm_chat_1",
416
+ }),
417
+ );
418
+ });
369
419
  it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
370
420
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
371
421
  mockReadAllowFromStore.mockResolvedValue([]);
@@ -410,7 +460,7 @@ describe("handleFeishuMessage command authorization", () => {
410
460
  });
411
461
  expect(mockSendMessageFeishu).toHaveBeenCalledWith(
412
462
  expect.objectContaining({
413
- to: "user:ou-unapproved",
463
+ to: "chat:oc-dm",
414
464
  accountId: "default",
415
465
  }),
416
466
  );
@@ -1038,6 +1088,67 @@ describe("handleFeishuMessage command authorization", () => {
1038
1088
  );
1039
1089
  });
1040
1090
 
1091
+ it("ignores stale non-existent contact scope permission errors", async () => {
1092
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1093
+ mockCreateFeishuClient.mockReturnValue({
1094
+ contact: {
1095
+ user: {
1096
+ get: vi.fn().mockRejectedValue({
1097
+ response: {
1098
+ data: {
1099
+ code: 99991672,
1100
+ msg: "permission denied: contact:contact.base:readonly https://open.feishu.cn/app/cli_scope_bug",
1101
+ },
1102
+ },
1103
+ }),
1104
+ },
1105
+ },
1106
+ });
1107
+
1108
+ const cfg: ClawdbotConfig = {
1109
+ channels: {
1110
+ feishu: {
1111
+ appId: "cli_scope_bug",
1112
+ appSecret: "sec_scope_bug",
1113
+ groups: {
1114
+ "oc-group": {
1115
+ requireMention: false,
1116
+ },
1117
+ },
1118
+ },
1119
+ },
1120
+ } as ClawdbotConfig;
1121
+
1122
+ const event: FeishuMessageEvent = {
1123
+ sender: {
1124
+ sender_id: {
1125
+ open_id: "ou-perm-scope",
1126
+ },
1127
+ },
1128
+ message: {
1129
+ message_id: "msg-perm-scope-1",
1130
+ chat_id: "oc-group",
1131
+ chat_type: "group",
1132
+ message_type: "text",
1133
+ content: JSON.stringify({ text: "hello group" }),
1134
+ },
1135
+ };
1136
+
1137
+ await dispatchMessage({ cfg, event });
1138
+
1139
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
1140
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1141
+ expect.objectContaining({
1142
+ BodyForAgent: expect.not.stringContaining("Permission grant URL"),
1143
+ }),
1144
+ );
1145
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
1146
+ expect.objectContaining({
1147
+ BodyForAgent: expect.stringContaining("ou-perm-scope: hello group"),
1148
+ }),
1149
+ );
1150
+ });
1151
+
1041
1152
  it("routes group sessions by sender when groupSessionScope=group_sender", async () => {
1042
1153
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
1043
1154
 
@@ -1113,6 +1224,83 @@ describe("handleFeishuMessage command authorization", () => {
1113
1224
  );
1114
1225
  });
1115
1226
 
1227
+ it("keeps root_id as topic key when root_id and thread_id both exist", async () => {
1228
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1229
+
1230
+ const cfg: ClawdbotConfig = {
1231
+ channels: {
1232
+ feishu: {
1233
+ groups: {
1234
+ "oc-group": {
1235
+ requireMention: false,
1236
+ groupSessionScope: "group_topic_sender",
1237
+ },
1238
+ },
1239
+ },
1240
+ },
1241
+ } as ClawdbotConfig;
1242
+
1243
+ const event: FeishuMessageEvent = {
1244
+ sender: { sender_id: { open_id: "ou-topic-user" } },
1245
+ message: {
1246
+ message_id: "msg-scope-topic-thread-id",
1247
+ chat_id: "oc-group",
1248
+ chat_type: "group",
1249
+ root_id: "om_root_topic",
1250
+ thread_id: "omt_topic_1",
1251
+ message_type: "text",
1252
+ content: JSON.stringify({ text: "topic sender scope" }),
1253
+ },
1254
+ };
1255
+
1256
+ await dispatchMessage({ cfg, event });
1257
+
1258
+ expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1259
+ expect.objectContaining({
1260
+ peer: { kind: "group", id: "oc-group:topic:om_root_topic:sender:ou-topic-user" },
1261
+ parentPeer: { kind: "group", id: "oc-group" },
1262
+ }),
1263
+ );
1264
+ });
1265
+
1266
+ it("uses thread_id as topic key when root_id is missing", async () => {
1267
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1268
+
1269
+ const cfg: ClawdbotConfig = {
1270
+ channels: {
1271
+ feishu: {
1272
+ groups: {
1273
+ "oc-group": {
1274
+ requireMention: false,
1275
+ groupSessionScope: "group_topic_sender",
1276
+ },
1277
+ },
1278
+ },
1279
+ },
1280
+ } as ClawdbotConfig;
1281
+
1282
+ const event: FeishuMessageEvent = {
1283
+ sender: { sender_id: { open_id: "ou-topic-user" } },
1284
+ message: {
1285
+ message_id: "msg-scope-topic-thread-only",
1286
+ chat_id: "oc-group",
1287
+ chat_type: "group",
1288
+ thread_id: "omt_topic_1",
1289
+ message_type: "text",
1290
+ content: JSON.stringify({ text: "topic sender scope" }),
1291
+ },
1292
+ };
1293
+
1294
+ await dispatchMessage({ cfg, event });
1295
+
1296
+ expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1297
+ expect.objectContaining({
1298
+ peer: { kind: "group", id: "oc-group:topic:omt_topic_1:sender:ou-topic-user" },
1299
+ parentPeer: { kind: "group", id: "oc-group" },
1300
+ }),
1301
+ );
1302
+ });
1303
+
1116
1304
  it("maps legacy topicSessionMode=enabled to group_topic routing", async () => {
1117
1305
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
1118
1306
 
@@ -1151,6 +1339,45 @@ describe("handleFeishuMessage command authorization", () => {
1151
1339
  );
1152
1340
  });
1153
1341
 
1342
+ it("maps legacy topicSessionMode=enabled to root_id when both root_id and thread_id exist", async () => {
1343
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1344
+
1345
+ const cfg: ClawdbotConfig = {
1346
+ channels: {
1347
+ feishu: {
1348
+ topicSessionMode: "enabled",
1349
+ groups: {
1350
+ "oc-group": {
1351
+ requireMention: false,
1352
+ },
1353
+ },
1354
+ },
1355
+ },
1356
+ } as ClawdbotConfig;
1357
+
1358
+ const event: FeishuMessageEvent = {
1359
+ sender: { sender_id: { open_id: "ou-legacy-thread-id" } },
1360
+ message: {
1361
+ message_id: "msg-legacy-topic-thread-id",
1362
+ chat_id: "oc-group",
1363
+ chat_type: "group",
1364
+ root_id: "om_root_legacy",
1365
+ thread_id: "omt_topic_legacy",
1366
+ message_type: "text",
1367
+ content: JSON.stringify({ text: "legacy topic mode" }),
1368
+ },
1369
+ };
1370
+
1371
+ await dispatchMessage({ cfg, event });
1372
+
1373
+ expect(mockResolveAgentRoute).toHaveBeenCalledWith(
1374
+ expect.objectContaining({
1375
+ peer: { kind: "group", id: "oc-group:topic:om_root_legacy" },
1376
+ parentPeer: { kind: "group", id: "oc-group" },
1377
+ }),
1378
+ );
1379
+ });
1380
+
1154
1381
  it("uses message_id as topic root when group_topic + replyInThread and no root_id", async () => {
1155
1382
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
1156
1383
 
@@ -1189,6 +1416,140 @@ describe("handleFeishuMessage command authorization", () => {
1189
1416
  );
1190
1417
  });
1191
1418
 
1419
+ it("keeps topic session key stable after first turn creates a thread", async () => {
1420
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1421
+
1422
+ const cfg: ClawdbotConfig = {
1423
+ channels: {
1424
+ feishu: {
1425
+ groups: {
1426
+ "oc-group": {
1427
+ requireMention: false,
1428
+ groupSessionScope: "group_topic",
1429
+ replyInThread: "enabled",
1430
+ },
1431
+ },
1432
+ },
1433
+ },
1434
+ } as ClawdbotConfig;
1435
+
1436
+ const firstTurn: FeishuMessageEvent = {
1437
+ sender: { sender_id: { open_id: "ou-topic-init" } },
1438
+ message: {
1439
+ message_id: "msg-topic-first",
1440
+ chat_id: "oc-group",
1441
+ chat_type: "group",
1442
+ message_type: "text",
1443
+ content: JSON.stringify({ text: "create topic" }),
1444
+ },
1445
+ };
1446
+ const secondTurn: FeishuMessageEvent = {
1447
+ sender: { sender_id: { open_id: "ou-topic-init" } },
1448
+ message: {
1449
+ message_id: "msg-topic-second",
1450
+ chat_id: "oc-group",
1451
+ chat_type: "group",
1452
+ root_id: "msg-topic-first",
1453
+ thread_id: "omt_topic_created",
1454
+ message_type: "text",
1455
+ content: JSON.stringify({ text: "follow up in same topic" }),
1456
+ },
1457
+ };
1458
+
1459
+ await dispatchMessage({ cfg, event: firstTurn });
1460
+ await dispatchMessage({ cfg, event: secondTurn });
1461
+
1462
+ expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
1463
+ 1,
1464
+ expect.objectContaining({
1465
+ peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
1466
+ }),
1467
+ );
1468
+ expect(mockResolveAgentRoute).toHaveBeenNthCalledWith(
1469
+ 2,
1470
+ expect.objectContaining({
1471
+ peer: { kind: "group", id: "oc-group:topic:msg-topic-first" },
1472
+ }),
1473
+ );
1474
+ });
1475
+
1476
+ it("replies to the topic root when handling a message inside an existing topic", async () => {
1477
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1478
+
1479
+ const cfg: ClawdbotConfig = {
1480
+ channels: {
1481
+ feishu: {
1482
+ groups: {
1483
+ "oc-group": {
1484
+ requireMention: false,
1485
+ replyInThread: "enabled",
1486
+ },
1487
+ },
1488
+ },
1489
+ },
1490
+ } as ClawdbotConfig;
1491
+
1492
+ const event: FeishuMessageEvent = {
1493
+ sender: { sender_id: { open_id: "ou-topic-user" } },
1494
+ message: {
1495
+ message_id: "om_child_message",
1496
+ root_id: "om_root_topic",
1497
+ chat_id: "oc-group",
1498
+ chat_type: "group",
1499
+ message_type: "text",
1500
+ content: JSON.stringify({ text: "reply inside topic" }),
1501
+ },
1502
+ };
1503
+
1504
+ await dispatchMessage({ cfg, event });
1505
+
1506
+ expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
1507
+ expect.objectContaining({
1508
+ replyToMessageId: "om_root_topic",
1509
+ rootId: "om_root_topic",
1510
+ }),
1511
+ );
1512
+ });
1513
+
1514
+ it("forces thread replies when inbound message contains thread_id", async () => {
1515
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
1516
+
1517
+ const cfg: ClawdbotConfig = {
1518
+ channels: {
1519
+ feishu: {
1520
+ groups: {
1521
+ "oc-group": {
1522
+ requireMention: false,
1523
+ groupSessionScope: "group",
1524
+ replyInThread: "disabled",
1525
+ },
1526
+ },
1527
+ },
1528
+ },
1529
+ } as ClawdbotConfig;
1530
+
1531
+ const event: FeishuMessageEvent = {
1532
+ sender: { sender_id: { open_id: "ou-thread-reply" } },
1533
+ message: {
1534
+ message_id: "msg-thread-reply",
1535
+ chat_id: "oc-group",
1536
+ chat_type: "group",
1537
+ thread_id: "omt_topic_thread_reply",
1538
+ message_type: "text",
1539
+ content: JSON.stringify({ text: "thread content" }),
1540
+ },
1541
+ };
1542
+
1543
+ await dispatchMessage({ cfg, event });
1544
+
1545
+ expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
1546
+ expect.objectContaining({
1547
+ replyInThread: true,
1548
+ threadReply: true,
1549
+ }),
1550
+ );
1551
+ });
1552
+
1192
1553
  it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
1193
1554
  mockShouldComputeCommandAuthorized.mockReturnValue(false);
1194
1555