@openclaw/feishu 2026.5.1-beta.1 → 2026.5.2-beta.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.
@@ -6,6 +6,180 @@
6
6
  "channels": [
7
7
  "feishu"
8
8
  ],
9
+ "contracts": {
10
+ "tools": [
11
+ "feishu_app_scopes",
12
+ "feishu_bitable_create_app",
13
+ "feishu_bitable_create_field",
14
+ "feishu_bitable_create_record",
15
+ "feishu_bitable_get_meta",
16
+ "feishu_bitable_get_record",
17
+ "feishu_bitable_list_fields",
18
+ "feishu_bitable_list_records",
19
+ "feishu_bitable_update_record",
20
+ "feishu_chat",
21
+ "feishu_doc",
22
+ "feishu_drive",
23
+ "feishu_perm",
24
+ "feishu_wiki"
25
+ ]
26
+ },
27
+ "toolMetadata": {
28
+ "feishu_app_scopes": {
29
+ "configSignals": [
30
+ {
31
+ "rootPath": "channels.feishu",
32
+ "required": [
33
+ "appId",
34
+ "appSecret"
35
+ ]
36
+ }
37
+ ]
38
+ },
39
+ "feishu_bitable_create_app": {
40
+ "configSignals": [
41
+ {
42
+ "rootPath": "channels.feishu",
43
+ "required": [
44
+ "appId",
45
+ "appSecret"
46
+ ]
47
+ }
48
+ ]
49
+ },
50
+ "feishu_bitable_create_field": {
51
+ "configSignals": [
52
+ {
53
+ "rootPath": "channels.feishu",
54
+ "required": [
55
+ "appId",
56
+ "appSecret"
57
+ ]
58
+ }
59
+ ]
60
+ },
61
+ "feishu_bitable_create_record": {
62
+ "configSignals": [
63
+ {
64
+ "rootPath": "channels.feishu",
65
+ "required": [
66
+ "appId",
67
+ "appSecret"
68
+ ]
69
+ }
70
+ ]
71
+ },
72
+ "feishu_bitable_get_meta": {
73
+ "configSignals": [
74
+ {
75
+ "rootPath": "channels.feishu",
76
+ "required": [
77
+ "appId",
78
+ "appSecret"
79
+ ]
80
+ }
81
+ ]
82
+ },
83
+ "feishu_bitable_get_record": {
84
+ "configSignals": [
85
+ {
86
+ "rootPath": "channels.feishu",
87
+ "required": [
88
+ "appId",
89
+ "appSecret"
90
+ ]
91
+ }
92
+ ]
93
+ },
94
+ "feishu_bitable_list_fields": {
95
+ "configSignals": [
96
+ {
97
+ "rootPath": "channels.feishu",
98
+ "required": [
99
+ "appId",
100
+ "appSecret"
101
+ ]
102
+ }
103
+ ]
104
+ },
105
+ "feishu_bitable_list_records": {
106
+ "configSignals": [
107
+ {
108
+ "rootPath": "channels.feishu",
109
+ "required": [
110
+ "appId",
111
+ "appSecret"
112
+ ]
113
+ }
114
+ ]
115
+ },
116
+ "feishu_bitable_update_record": {
117
+ "configSignals": [
118
+ {
119
+ "rootPath": "channels.feishu",
120
+ "required": [
121
+ "appId",
122
+ "appSecret"
123
+ ]
124
+ }
125
+ ]
126
+ },
127
+ "feishu_chat": {
128
+ "configSignals": [
129
+ {
130
+ "rootPath": "channels.feishu",
131
+ "required": [
132
+ "appId",
133
+ "appSecret"
134
+ ]
135
+ }
136
+ ]
137
+ },
138
+ "feishu_doc": {
139
+ "configSignals": [
140
+ {
141
+ "rootPath": "channels.feishu",
142
+ "required": [
143
+ "appId",
144
+ "appSecret"
145
+ ]
146
+ }
147
+ ]
148
+ },
149
+ "feishu_drive": {
150
+ "configSignals": [
151
+ {
152
+ "rootPath": "channels.feishu",
153
+ "required": [
154
+ "appId",
155
+ "appSecret"
156
+ ]
157
+ }
158
+ ]
159
+ },
160
+ "feishu_perm": {
161
+ "configSignals": [
162
+ {
163
+ "rootPath": "channels.feishu",
164
+ "required": [
165
+ "appId",
166
+ "appSecret"
167
+ ]
168
+ }
169
+ ]
170
+ },
171
+ "feishu_wiki": {
172
+ "configSignals": [
173
+ {
174
+ "rootPath": "channels.feishu",
175
+ "required": [
176
+ "appId",
177
+ "appSecret"
178
+ ]
179
+ }
180
+ ]
181
+ }
182
+ },
9
183
  "channelEnvVars": {
10
184
  "feishu": [
11
185
  "FEISHU_APP_ID",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/feishu",
3
- "version": "2026.5.1-beta.1",
3
+ "version": "2026.5.2-beta.2",
4
4
  "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,7 +16,7 @@
16
16
  "openclaw": "workspace:*"
17
17
  },
18
18
  "peerDependencies": {
19
- "openclaw": ">=2026.4.25"
19
+ "openclaw": ">=2026.5.2-beta.2"
20
20
  },
21
21
  "peerDependenciesMeta": {
22
22
  "openclaw": {
@@ -47,10 +47,10 @@
47
47
  "minHostVersion": ">=2026.4.25"
48
48
  },
49
49
  "compat": {
50
- "pluginApi": ">=2026.4.25"
50
+ "pluginApi": ">=2026.5.2-beta.2"
51
51
  },
52
52
  "build": {
53
- "openclawVersion": "2026.5.1-beta.1"
53
+ "openclawVersion": "2026.5.2-beta.2"
54
54
  },
55
55
  "release": {
56
56
  "publishToClawHub": true,
package/src/channel.ts CHANGED
@@ -1154,6 +1154,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount, FeishuProbeResul
1154
1154
  setup: feishuSetupAdapter,
1155
1155
  setupWizard: feishuSetupWizard,
1156
1156
  messaging: {
1157
+ targetPrefixes: ["feishu", "lark"],
1157
1158
  normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
1158
1159
  resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {
1159
1160
  const directId = parseFeishuDirectConversationId(conversationId);
package/src/chat.test.ts CHANGED
@@ -142,4 +142,55 @@ describe("registerFeishuChatTools", () => {
142
142
  );
143
143
  expect(registerTool).not.toHaveBeenCalled();
144
144
  });
145
+
146
+ it("preserves Feishu diagnostics from rejected member lookups", async () => {
147
+ const registerTool = vi.fn();
148
+ registerFeishuChatTools(
149
+ createChatToolApi({
150
+ config: {
151
+ channels: {
152
+ feishu: {
153
+ enabled: true,
154
+ appId: "app_id",
155
+ appSecret: "app_secret", // pragma: allowlist secret
156
+ tools: { chat: true },
157
+ },
158
+ },
159
+ },
160
+ registerTool,
161
+ }),
162
+ );
163
+
164
+ const tool = registerTool.mock.calls[0]?.[0];
165
+ contactUserGetMock.mockRejectedValueOnce(
166
+ Object.assign(new Error("Request failed with status code 400"), {
167
+ response: {
168
+ status: 400,
169
+ data: {
170
+ code: 99992360,
171
+ msg: "The request you send is not a valid {user_id} or not exists",
172
+ error: {
173
+ log_id: "20260429124800CHAT",
174
+ troubleshooter: "https://open.feishu.cn/search?log_id=20260429124800CHAT",
175
+ },
176
+ },
177
+ },
178
+ }),
179
+ );
180
+
181
+ const result = await tool.execute("tc_4", {
182
+ action: "member_info",
183
+ member_id: "ou_1",
184
+ });
185
+
186
+ expect(result.details.error).toContain('"http_status":400');
187
+ expect(result.details.error).toContain('"feishu_code":99992360');
188
+ expect(result.details.error).toContain(
189
+ '"feishu_msg":"The request you send is not a valid {user_id} or not exists"',
190
+ );
191
+ expect(result.details.error).toContain('"feishu_log_id":"20260429124800CHAT"');
192
+ expect(result.details.error).toContain(
193
+ '"feishu_troubleshooter":"https://open.feishu.cn/search?log_id=20260429124800CHAT"',
194
+ );
195
+ });
145
196
  });
package/src/chat.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type * as Lark from "@larksuiteoapi/node-sdk";
2
- import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3
2
  import type { OpenClawPluginApi } from "../runtime-api.js";
4
3
  import { listEnabledFeishuAccounts } from "./accounts.js";
5
4
  import { FeishuChatSchema, type FeishuChatParams } from "./chat-schema.js";
6
5
  import { createFeishuClient } from "./client.js";
6
+ import { formatFeishuApiError } from "./comment-shared.js";
7
7
  import { resolveToolsConfig } from "./tools-config.js";
8
8
 
9
9
  function json(data: unknown) {
@@ -179,7 +179,7 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) {
179
179
  return json({ error: `Unknown action: ${String(p.action)}` });
180
180
  }
181
181
  } catch (err) {
182
- return json({ error: formatErrorMessage(err) });
182
+ return json({ error: formatFeishuApiError(err, { includeNestedErrorLogId: true }) });
183
183
  }
184
184
  },
185
185
  },
@@ -41,6 +41,7 @@ export function formatFeishuApiError(
41
41
  (options.includeNestedErrorLogId
42
42
  ? readString(isRecord(responseData?.error) ? responseData.error.log_id : undefined)
43
43
  : undefined);
44
+ const nestedError = isRecord(responseData?.error) ? responseData.error : undefined;
44
45
 
45
46
  return JSON.stringify({
46
47
  message:
@@ -58,9 +59,49 @@ export function formatFeishuApiError(
58
59
  typeof responseData?.code === "number" ? responseData.code : readString(responseData?.code),
59
60
  feishu_msg: readString(responseData?.msg),
60
61
  feishu_log_id: feishuLogId,
62
+ feishu_troubleshooter:
63
+ readString(responseData?.troubleshooter) || readString(nestedError?.troubleshooter),
61
64
  });
62
65
  }
63
66
 
67
+ function formatFeishuApiFailure(
68
+ error: unknown,
69
+ errorPrefix: string,
70
+ options: {
71
+ includeConfigParams?: boolean;
72
+ includeNestedErrorLogId?: boolean;
73
+ } = {},
74
+ ): string {
75
+ const details = formatFeishuApiError(error, options);
76
+ return `${errorPrefix}: ${details || "unknown error"}`;
77
+ }
78
+
79
+ export function createFeishuApiError(
80
+ error: unknown,
81
+ errorPrefix: string,
82
+ options: {
83
+ includeConfigParams?: boolean;
84
+ includeNestedErrorLogId?: boolean;
85
+ } = {},
86
+ ): Error {
87
+ return new Error(formatFeishuApiFailure(error, errorPrefix, options), { cause: error });
88
+ }
89
+
90
+ export async function requestFeishuApi<T>(
91
+ request: () => Promise<T>,
92
+ errorPrefix: string,
93
+ options: {
94
+ includeConfigParams?: boolean;
95
+ includeNestedErrorLogId?: boolean;
96
+ } = {},
97
+ ): Promise<T> {
98
+ try {
99
+ return await request();
100
+ } catch (error) {
101
+ throw createFeishuApiError(error, errorPrefix, options);
102
+ }
103
+ }
104
+
64
105
  type ParsedCommentDocumentRef = {
65
106
  fileType?: CommentFileType;
66
107
  fileToken?: string;
package/src/dedup.ts CHANGED
@@ -118,7 +118,7 @@ export async function tryRecordMessagePersistent(
118
118
  });
119
119
  }
120
120
 
121
- export async function hasRecordedMessagePersistent(
121
+ async function hasRecordedMessagePersistent(
122
122
  messageId: string,
123
123
  namespace = "global",
124
124
  log?: (...args: unknown[]) => void,
@@ -8,15 +8,12 @@ vi.mock("./client.js", () => ({
8
8
  createFeishuClient: createFeishuClientMock,
9
9
  }));
10
10
 
11
- const {
12
- listFeishuDirectoryGroups,
13
- listFeishuDirectoryGroupsLive,
14
- listFeishuDirectoryPeers,
15
- listFeishuDirectoryPeersLive,
16
- } = await importFreshModule<typeof import("./directory.js")>(
17
- import.meta.url,
18
- "./directory.js?directory-test",
19
- );
11
+ const { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive } = await importFreshModule<
12
+ typeof import("./directory.js")
13
+ >(import.meta.url, "./directory.js?directory-test");
14
+ const { listFeishuDirectoryGroups, listFeishuDirectoryPeers } = await importFreshModule<
15
+ typeof import("./directory.static.js")
16
+ >(import.meta.url, "./directory.static.js?directory-test");
20
17
 
21
18
  function makeStaticCfg(): ClawdbotConfig {
22
19
  return {
package/src/directory.ts CHANGED
@@ -9,8 +9,6 @@ import {
9
9
  type FeishuDirectoryPeer,
10
10
  } from "./directory.static.js";
11
11
 
12
- export { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.static.js";
13
-
14
12
  export async function listFeishuDirectoryPeersLive(params: {
15
13
  cfg: ClawdbotConfig;
16
14
  query?: string;
package/src/media.test.ts CHANGED
@@ -384,6 +384,34 @@ describe("sendMediaFeishu msg_type routing", () => {
384
384
  );
385
385
  });
386
386
 
387
+ it("preserves Feishu diagnostics when media sends reject before response checks", async () => {
388
+ messageCreateMock.mockRejectedValueOnce(
389
+ Object.assign(new Error("Request failed with status code 400"), {
390
+ response: {
391
+ status: 400,
392
+ data: {
393
+ code: 9499,
394
+ msg: "Bad Request",
395
+ error: {
396
+ log_id: "20260429124731MEDIA",
397
+ troubleshooter: "https://open.feishu.cn/search?log_id=20260429124731MEDIA",
398
+ },
399
+ },
400
+ },
401
+ }),
402
+ );
403
+
404
+ const send = sendMediaFeishu({
405
+ cfg: emptyConfig,
406
+ to: "user:ou_target",
407
+ mediaBuffer: Buffer.from("image"),
408
+ fileName: "photo.png",
409
+ });
410
+
411
+ await expect(send).rejects.toThrow(/Feishu image send failed: .*"feishu_code":9499/);
412
+ await expect(send).rejects.toThrow(/"feishu_log_id":"20260429124731MEDIA"/);
413
+ });
414
+
387
415
  it("uses msg_type=media when replying with mp4", async () => {
388
416
  await sendMediaFeishu({
389
417
  cfg: emptyConfig,
package/src/media.ts CHANGED
@@ -12,6 +12,7 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtim
12
12
  import type { ClawdbotConfig } from "../runtime-api.js";
13
13
  import { resolveFeishuRuntimeAccount } from "./accounts.js";
14
14
  import { createFeishuClient } from "./client.js";
15
+ import { requestFeishuApi } from "./comment-shared.js";
15
16
  import { normalizeFeishuExternalKey } from "./external-keys.js";
16
17
  import { getFeishuRuntime } from "./runtime.js";
17
18
  import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
@@ -418,12 +419,17 @@ export async function uploadImageFeishu(params: {
418
419
  // See: https://github.com/larksuite/node-sdk/issues/121
419
420
  const imageData = typeof image === "string" ? fs.createReadStream(image) : image;
420
421
 
421
- const response = await client.im.image.create({
422
- data: {
423
- image_type: imageType,
424
- image: imageData,
425
- },
426
- });
422
+ const response = await requestFeishuApi(
423
+ () =>
424
+ client.im.image.create({
425
+ data: {
426
+ image_type: imageType,
427
+ image: imageData,
428
+ },
429
+ }),
430
+ "Feishu image upload failed",
431
+ { includeNestedErrorLogId: true },
432
+ );
427
433
 
428
434
  return {
429
435
  imageKey: extractFeishuUploadKey(response, {
@@ -469,14 +475,19 @@ export async function uploadFileFeishu(params: {
469
475
 
470
476
  const safeFileName = sanitizeFileNameForUpload(fileName);
471
477
 
472
- const response = await client.im.file.create({
473
- data: {
474
- file_type: fileType,
475
- file_name: safeFileName,
476
- file: fileData,
477
- ...(duration !== undefined && { duration }),
478
- },
479
- });
478
+ const response = await requestFeishuApi(
479
+ () =>
480
+ client.im.file.create({
481
+ data: {
482
+ file_type: fileType,
483
+ file_name: safeFileName,
484
+ file: fileData,
485
+ ...(duration !== undefined && { duration }),
486
+ },
487
+ }),
488
+ "Feishu file upload failed",
489
+ { includeNestedErrorLogId: true },
490
+ );
480
491
 
481
492
  return {
482
493
  fileKey: extractFeishuUploadKey(response, {
@@ -506,26 +517,36 @@ export async function sendImageFeishu(params: {
506
517
  const content = JSON.stringify({ image_key: imageKey });
507
518
 
508
519
  if (replyToMessageId) {
509
- const response = await client.im.message.reply({
510
- path: { message_id: replyToMessageId },
511
- data: {
512
- content,
513
- msg_type: "image",
514
- ...(replyInThread ? { reply_in_thread: true } : {}),
515
- },
516
- });
520
+ const response = await requestFeishuApi(
521
+ () =>
522
+ client.im.message.reply({
523
+ path: { message_id: replyToMessageId },
524
+ data: {
525
+ content,
526
+ msg_type: "image",
527
+ ...(replyInThread ? { reply_in_thread: true } : {}),
528
+ },
529
+ }),
530
+ "Feishu image reply failed",
531
+ { includeNestedErrorLogId: true },
532
+ );
517
533
  assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
518
534
  return toFeishuSendResult(response, receiveId);
519
535
  }
520
536
 
521
- const response = await client.im.message.create({
522
- params: { receive_id_type: receiveIdType },
523
- data: {
524
- receive_id: receiveId,
525
- content,
526
- msg_type: "image",
527
- },
528
- });
537
+ const response = await requestFeishuApi(
538
+ () =>
539
+ client.im.message.create({
540
+ params: { receive_id_type: receiveIdType },
541
+ data: {
542
+ receive_id: receiveId,
543
+ content,
544
+ msg_type: "image",
545
+ },
546
+ }),
547
+ "Feishu image send failed",
548
+ { includeNestedErrorLogId: true },
549
+ );
529
550
  assertFeishuMessageApiSuccess(response, "Feishu image send failed");
530
551
  return toFeishuSendResult(response, receiveId);
531
552
  }
@@ -553,26 +574,36 @@ export async function sendFileFeishu(params: {
553
574
  const content = JSON.stringify({ file_key: fileKey });
554
575
 
555
576
  if (replyToMessageId) {
556
- const response = await client.im.message.reply({
557
- path: { message_id: replyToMessageId },
558
- data: {
559
- content,
560
- msg_type: msgType,
561
- ...(replyInThread ? { reply_in_thread: true } : {}),
562
- },
563
- });
577
+ const response = await requestFeishuApi(
578
+ () =>
579
+ client.im.message.reply({
580
+ path: { message_id: replyToMessageId },
581
+ data: {
582
+ content,
583
+ msg_type: msgType,
584
+ ...(replyInThread ? { reply_in_thread: true } : {}),
585
+ },
586
+ }),
587
+ "Feishu file reply failed",
588
+ { includeNestedErrorLogId: true },
589
+ );
564
590
  assertFeishuMessageApiSuccess(response, "Feishu file reply failed");
565
591
  return toFeishuSendResult(response, receiveId);
566
592
  }
567
593
 
568
- const response = await client.im.message.create({
569
- params: { receive_id_type: receiveIdType },
570
- data: {
571
- receive_id: receiveId,
572
- content,
573
- msg_type: msgType,
574
- },
575
- });
594
+ const response = await requestFeishuApi(
595
+ () =>
596
+ client.im.message.create({
597
+ params: { receive_id_type: receiveIdType },
598
+ data: {
599
+ receive_id: receiveId,
600
+ content,
601
+ msg_type: msgType,
602
+ },
603
+ }),
604
+ "Feishu file send failed",
605
+ { includeNestedErrorLogId: true },
606
+ );
576
607
  assertFeishuMessageApiSuccess(response, "Feishu file send failed");
577
608
  return toFeishuSendResult(response, receiveId);
578
609
  }
@@ -57,6 +57,34 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
57
57
  });
58
58
  });
59
59
 
60
+ it("preserves Feishu diagnostics when direct sends reject before response checks", async () => {
61
+ const apiError = Object.assign(new Error("Request failed with status code 400"), {
62
+ response: {
63
+ status: 400,
64
+ data: {
65
+ code: 9499,
66
+ msg: "Bad Request",
67
+ error: {
68
+ log_id: "202604291247104BEF4C42D2420A9AD569",
69
+ troubleshooter:
70
+ "https://open.feishu.cn/search?log_id=202604291247104BEF4C42D2420A9AD569",
71
+ },
72
+ },
73
+ },
74
+ });
75
+ createMock.mockRejectedValue(apiError);
76
+
77
+ await expect(
78
+ sendMessageFeishu({
79
+ cfg: {} as never,
80
+ to: "user:ou_target",
81
+ text: "hello",
82
+ }),
83
+ ).rejects.toThrow(
84
+ /Feishu send failed: .*"http_status":400.*"feishu_code":9499.*"feishu_msg":"Bad Request".*"feishu_log_id":"202604291247104BEF4C42D2420A9AD569".*"feishu_troubleshooter":"https:\/\/open\.feishu\.cn\/search\?log_id=202604291247104BEF4C42D2420A9AD569"/,
85
+ );
86
+ });
87
+
60
88
  it("falls back to create for withdrawn post replies", async () => {
61
89
  replyMock.mockResolvedValue({
62
90
  code: 230011,
package/src/send.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  import type { ClawdbotConfig } from "../runtime-api.js";
8
8
  import { resolveFeishuRuntimeAccount } from "./accounts.js";
9
9
  import { createFeishuClient } from "./client.js";
10
+ import { createFeishuApiError, requestFeishuApi } from "./comment-shared.js";
10
11
  import type { MentionTarget } from "./mention-target.types.js";
11
12
  import { buildMentionedCardContent, buildMentionedMessage } from "./mention.js";
12
13
  import { parsePostContent } from "./post.js";
@@ -117,14 +118,19 @@ async function sendFallbackDirect(
117
118
  },
118
119
  errorPrefix: string,
119
120
  ): Promise<FeishuSendResult> {
120
- const response = await client.im.message.create({
121
- params: { receive_id_type: params.receiveIdType },
122
- data: {
123
- receive_id: params.receiveId,
124
- content: params.content,
125
- msg_type: params.msgType,
126
- },
127
- });
121
+ const response = await requestFeishuApi(
122
+ () =>
123
+ client.im.message.create({
124
+ params: { receive_id_type: params.receiveIdType },
125
+ data: {
126
+ receive_id: params.receiveId,
127
+ content: params.content,
128
+ msg_type: params.msgType,
129
+ },
130
+ }),
131
+ errorPrefix,
132
+ { includeNestedErrorLogId: true },
133
+ );
128
134
  assertFeishuMessageApiSuccess(response, errorPrefix);
129
135
  return toFeishuSendResult(response, params.receiveId);
130
136
  }
@@ -168,7 +174,7 @@ async function sendReplyOrFallbackDirect(
168
174
  });
169
175
  } catch (err) {
170
176
  if (!isWithdrawnReplyError(err)) {
171
- throw err;
177
+ throw createFeishuApiError(err, params.replyErrorPrefix, { includeNestedErrorLogId: true });
172
178
  }
173
179
  if (threadReplyFallbackError) {
174
180
  throw threadReplyFallbackError;
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
2
2
  import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
3
3
  import { expect, vi, type Mock } from "vitest";
4
4
  import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../../runtime-api.js";
5
- import { createFeishuMessageReceiveHandler } from "../monitor.message-handler.js";
6
5
  import { setFeishuRuntime } from "../runtime.js";
7
6
  import type { ResolvedFeishuAccount } from "../types.js";
8
7
 
@@ -411,31 +410,6 @@ async function loadMonitorSingleAccount() {
411
410
  return module.monitorSingleAccount;
412
411
  }
413
412
 
414
- export async function setupFeishuMessageReceiveLifecycleHandler(params: {
415
- runtime: RuntimeEnv;
416
- core: PluginRuntime;
417
- cfg: ClawdbotConfig;
418
- accountId: string;
419
- fireAndForget?: boolean;
420
- handleMessage: Parameters<typeof createFeishuMessageReceiveHandler>[0]["handleMessage"];
421
- resolveDebounceText: Parameters<
422
- typeof createFeishuMessageReceiveHandler
423
- >[0]["resolveDebounceText"];
424
- }): Promise<(data: unknown) => Promise<void>> {
425
- return createFeishuMessageReceiveHandler({
426
- cfg: params.cfg,
427
- core: params.core,
428
- accountId: params.accountId,
429
- runtime: params.runtime,
430
- chatHistories: new Map(),
431
- fireAndForget: params.fireAndForget,
432
- handleMessage: params.handleMessage,
433
- resolveDebounceText: params.resolveDebounceText,
434
- hasProcessedMessage: vi.fn(async () => false),
435
- recordProcessedMessage: vi.fn(async () => true),
436
- });
437
- }
438
-
439
413
  export async function setupFeishuLifecycleHandler(params: {
440
414
  createEventDispatcherMock: {
441
415
  mockReturnValue: (value: unknown) => unknown;
@@ -1,190 +0,0 @@
1
- import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
2
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
- import "./lifecycle.test-support.js";
4
- import {
5
- getFeishuLifecycleTestMocks,
6
- resetFeishuLifecycleTestMocks,
7
- } from "./lifecycle.test-support.js";
8
- import {
9
- createFeishuLifecycleConfig,
10
- createFeishuLifecycleReplyDispatcher,
11
- createFeishuTextMessageEvent,
12
- expectFeishuReplyDispatcherSentFinalReplyOnce,
13
- expectFeishuReplyPipelineDedupedAcrossReplay,
14
- expectFeishuReplyPipelineDedupedAfterPostSendFailure,
15
- installFeishuLifecycleReplyRuntime,
16
- mockFeishuReplyOnceDispatch,
17
- restoreFeishuLifecycleStateDir,
18
- setFeishuLifecycleStateDir,
19
- setupFeishuMessageReceiveLifecycleHandler,
20
- } from "./test-support/lifecycle-test-support.js";
21
-
22
- const {
23
- createFeishuReplyDispatcherMock,
24
- dispatchReplyFromConfigMock,
25
- finalizeInboundContextMock,
26
- resolveAgentRouteMock,
27
- withReplyDispatcherMock,
28
- } = getFeishuLifecycleTestMocks();
29
-
30
- let lastRuntime = createRuntimeEnv();
31
- let lifecycleCore: ReturnType<typeof installFeishuLifecycleReplyRuntime>;
32
- const handleMessageMock = vi.fn();
33
- const originalStateDir = process.env.OPENCLAW_STATE_DIR;
34
- const lifecycleConfig = createFeishuLifecycleConfig({
35
- accountId: "acct-lifecycle",
36
- appId: "cli_test",
37
- appSecret: "secret_test",
38
- accountConfig: {
39
- groupPolicy: "open",
40
- groups: {
41
- oc_group_1: {
42
- requireMention: false,
43
- groupSessionScope: "group_topic_sender",
44
- replyInThread: "enabled",
45
- },
46
- },
47
- },
48
- });
49
-
50
- async function setupLifecycleMonitor() {
51
- lastRuntime = createRuntimeEnv();
52
- return setupFeishuMessageReceiveLifecycleHandler({
53
- runtime: lastRuntime,
54
- core: lifecycleCore,
55
- cfg: lifecycleConfig,
56
- accountId: "acct-lifecycle",
57
- handleMessage: handleMessageMock,
58
- resolveDebounceText: ({ event }) => {
59
- const parsed = JSON.parse(event.message.content) as { text?: string };
60
- return parsed.text ?? "";
61
- },
62
- });
63
- }
64
-
65
- describe("Feishu reply-once lifecycle", () => {
66
- beforeEach(() => {
67
- vi.useRealTimers();
68
- resetFeishuLifecycleTestMocks();
69
- handleMessageMock.mockReset();
70
- lastRuntime = createRuntimeEnv();
71
- setFeishuLifecycleStateDir("openclaw-feishu-lifecycle");
72
-
73
- createFeishuReplyDispatcherMock.mockReturnValue(createFeishuLifecycleReplyDispatcher());
74
-
75
- resolveAgentRouteMock.mockReturnValue({
76
- agentId: "main",
77
- channel: "feishu",
78
- accountId: "acct-lifecycle",
79
- sessionKey: "agent:main:feishu:group:oc_group_1",
80
- mainSessionKey: "agent:main:main",
81
- matchedBy: "default",
82
- });
83
-
84
- mockFeishuReplyOnceDispatch({
85
- dispatchReplyFromConfigMock,
86
- replyText: "reply once",
87
- });
88
-
89
- withReplyDispatcherMock.mockImplementation(async ({ run }) => await run());
90
- handleMessageMock.mockImplementation(async ({ event }) => {
91
- const reply = createFeishuReplyDispatcherMock({
92
- accountId: "acct-lifecycle",
93
- chatId: event.message.chat_id,
94
- replyToMessageId: event.message.root_id ?? event.message.message_id,
95
- replyInThread: true,
96
- rootId: event.message.root_id,
97
- });
98
- try {
99
- await withReplyDispatcherMock({
100
- dispatcher: reply.dispatcher,
101
- onSettled: () => reply.markDispatchIdle(),
102
- run: () =>
103
- dispatchReplyFromConfigMock({
104
- ctx: {
105
- AccountId: "acct-lifecycle",
106
- MessageSid: event.message.message_id,
107
- },
108
- dispatcher: reply.dispatcher,
109
- }),
110
- });
111
- } catch (err) {
112
- lastRuntime?.error(`feishu[acct-lifecycle]: failed to dispatch message: ${String(err)}`);
113
- }
114
- });
115
-
116
- lifecycleCore = installFeishuLifecycleReplyRuntime({
117
- resolveAgentRouteMock,
118
- finalizeInboundContextMock,
119
- dispatchReplyFromConfigMock,
120
- withReplyDispatcherMock,
121
- storePath: "/tmp/feishu-lifecycle-sessions.json",
122
- });
123
- });
124
-
125
- afterEach(() => {
126
- vi.useRealTimers();
127
- restoreFeishuLifecycleStateDir(originalStateDir);
128
- });
129
-
130
- it("routes a topic-bound inbound event and emits one reply across duplicate replay", async () => {
131
- const onMessage = await setupLifecycleMonitor();
132
- const event = createFeishuTextMessageEvent({
133
- messageId: "om_lifecycle_once",
134
- chatId: "oc_group_1",
135
- rootId: "om_root_topic_1",
136
- threadId: "omt_topic_1",
137
- text: "hello from topic",
138
- });
139
-
140
- await expectFeishuReplyPipelineDedupedAcrossReplay({
141
- handler: onMessage,
142
- event,
143
- dispatchReplyFromConfigMock,
144
- createFeishuReplyDispatcherMock,
145
- });
146
-
147
- expect(lastRuntime?.error).not.toHaveBeenCalled();
148
- expect(handleMessageMock).toHaveBeenCalledTimes(1);
149
- expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
150
- expect(createFeishuReplyDispatcherMock).toHaveBeenCalledTimes(1);
151
- expect(createFeishuReplyDispatcherMock).toHaveBeenCalledWith(
152
- expect.objectContaining({
153
- accountId: "acct-lifecycle",
154
- chatId: "oc_group_1",
155
- replyToMessageId: "om_root_topic_1",
156
- replyInThread: true,
157
- rootId: "om_root_topic_1",
158
- }),
159
- );
160
- expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
161
- });
162
-
163
- it("does not duplicate delivery when the first attempt fails after sending the reply", async () => {
164
- const onMessage = await setupLifecycleMonitor();
165
- const event = createFeishuTextMessageEvent({
166
- messageId: "om_lifecycle_retry",
167
- chatId: "oc_group_1",
168
- rootId: "om_root_topic_1",
169
- threadId: "omt_topic_1",
170
- text: "hello from topic",
171
- });
172
-
173
- dispatchReplyFromConfigMock.mockImplementationOnce(async ({ dispatcher }) => {
174
- await dispatcher.sendFinalReply({ text: "reply once" });
175
- throw new Error("post-send failure");
176
- });
177
-
178
- await expectFeishuReplyPipelineDedupedAfterPostSendFailure({
179
- handler: onMessage,
180
- event,
181
- dispatchReplyFromConfigMock,
182
- runtimeErrorMock: lastRuntime?.error as ReturnType<typeof vi.fn>,
183
- });
184
-
185
- expect(lastRuntime?.error).toHaveBeenCalledTimes(1);
186
- expect(handleMessageMock).toHaveBeenCalledTimes(1);
187
- expect(dispatchReplyFromConfigMock).toHaveBeenCalledTimes(1);
188
- expectFeishuReplyDispatcherSentFinalReplyOnce({ createFeishuReplyDispatcherMock });
189
- });
190
- });