@openclaw/feishu 2026.3.2 → 2026.3.8-beta.1

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.
Files changed (70) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +199 -13
  4. package/src/accounts.ts +45 -17
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +8 -0
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +516 -9
  9. package/src/bot.ts +366 -109
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +52 -64
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +207 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +14 -6
  18. package/src/config-schema.ts +5 -1
  19. package/src/dedup.ts +1 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/docx-batch-insert.test.ts +90 -0
  23. package/src/docx-batch-insert.ts +8 -11
  24. package/src/docx.account-selection.test.ts +3 -3
  25. package/src/docx.ts +1 -1
  26. package/src/drive.ts +13 -17
  27. package/src/dynamic-agent.ts +1 -1
  28. package/src/feishu-command-handler.ts +59 -0
  29. package/src/media.test.ts +60 -13
  30. package/src/media.ts +23 -9
  31. package/src/monitor.account.ts +19 -8
  32. package/src/monitor.reaction.test.ts +111 -105
  33. package/src/monitor.startup.test.ts +11 -10
  34. package/src/monitor.startup.ts +20 -7
  35. package/src/monitor.state.ts +4 -1
  36. package/src/monitor.test-mocks.ts +42 -9
  37. package/src/monitor.transport.ts +4 -1
  38. package/src/monitor.ts +4 -4
  39. package/src/monitor.webhook-security.test.ts +8 -23
  40. package/src/onboarding.status.test.ts +1 -1
  41. package/src/onboarding.test.ts +143 -0
  42. package/src/onboarding.ts +86 -71
  43. package/src/outbound.test.ts +178 -0
  44. package/src/outbound.ts +39 -6
  45. package/src/perm.ts +11 -15
  46. package/src/policy.test.ts +40 -0
  47. package/src/policy.ts +9 -10
  48. package/src/probe.test.ts +18 -18
  49. package/src/reactions.ts +1 -1
  50. package/src/reply-dispatcher.test.ts +175 -0
  51. package/src/reply-dispatcher.ts +69 -21
  52. package/src/runtime.ts +5 -13
  53. package/src/secret-input.ts +8 -14
  54. package/src/send-message.ts +71 -0
  55. package/src/send-target.test.ts +1 -1
  56. package/src/send-target.ts +1 -1
  57. package/src/send.reply-fallback.test.ts +74 -0
  58. package/src/send.test.ts +1 -1
  59. package/src/send.ts +88 -49
  60. package/src/streaming-card.test.ts +54 -0
  61. package/src/streaming-card.ts +96 -28
  62. package/src/targets.ts +5 -1
  63. package/src/tool-account-routing.test.ts +3 -3
  64. package/src/tool-account.ts +1 -1
  65. package/src/tool-factory-test-harness.ts +1 -1
  66. package/src/tool-result.test.ts +32 -0
  67. package/src/tool-result.ts +14 -0
  68. package/src/types.ts +2 -3
  69. package/src/typing.ts +1 -1
  70. package/src/wiki.ts +15 -19
@@ -26,6 +26,23 @@ vi.mock("./typing.js", () => ({
26
26
  removeTypingIndicator: removeTypingIndicatorMock,
27
27
  }));
28
28
  vi.mock("./streaming-card.js", () => ({
29
+ mergeStreamingText: (previousText: string | undefined, nextText: string | undefined) => {
30
+ const previous = typeof previousText === "string" ? previousText : "";
31
+ const next = typeof nextText === "string" ? nextText : "";
32
+ if (!next) {
33
+ return previous;
34
+ }
35
+ if (!previous || next === previous) {
36
+ return next;
37
+ }
38
+ if (next.startsWith(previous)) {
39
+ return next;
40
+ }
41
+ if (previous.startsWith(next)) {
42
+ return previous;
43
+ }
44
+ return `${previous}${next}`;
45
+ },
29
46
  FeishuStreamingSession: class {
30
47
  active = false;
31
48
  start = vi.fn(async () => {
@@ -89,6 +106,28 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
89
106
  });
90
107
  });
91
108
 
109
+ function setupNonStreamingAutoDispatcher() {
110
+ resolveFeishuAccountMock.mockReturnValue({
111
+ accountId: "main",
112
+ appId: "app_id",
113
+ appSecret: "app_secret",
114
+ domain: "feishu",
115
+ config: {
116
+ renderMode: "auto",
117
+ streaming: false,
118
+ },
119
+ });
120
+
121
+ createFeishuReplyDispatcher({
122
+ cfg: {} as never,
123
+ agentId: "agent",
124
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
125
+ chatId: "oc_chat",
126
+ });
127
+
128
+ return createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
129
+ }
130
+
92
131
  it("skips typing indicator when account typingIndicator is disabled", async () => {
93
132
  resolveFeishuAccountMock.mockReturnValue({
94
133
  accountId: "main",
@@ -202,6 +241,17 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
202
241
  expect(sendMediaFeishuMock).not.toHaveBeenCalled();
203
242
  });
204
243
 
244
+ it("sets disableBlockStreaming in replyOptions to prevent silent reply drops", async () => {
245
+ const result = createFeishuReplyDispatcher({
246
+ cfg: {} as never,
247
+ agentId: "agent",
248
+ runtime: {} as never,
249
+ chatId: "oc_chat",
250
+ });
251
+
252
+ expect(result.replyOptions).toHaveProperty("disableBlockStreaming", true);
253
+ });
254
+
205
255
  it("uses streaming session for auto mode markdown payloads", async () => {
206
256
  createFeishuReplyDispatcher({
207
257
  cfg: {} as never,
@@ -226,6 +276,131 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
226
276
  expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
227
277
  });
228
278
 
279
+ it("closes streaming with block text when final reply is missing", async () => {
280
+ createFeishuReplyDispatcher({
281
+ cfg: {} as never,
282
+ agentId: "agent",
283
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
284
+ chatId: "oc_chat",
285
+ });
286
+
287
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
288
+ await options.deliver({ text: "```md\npartial answer\n```" }, { kind: "block" });
289
+ await options.onIdle?.();
290
+
291
+ expect(streamingInstances).toHaveLength(1);
292
+ expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
293
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
294
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```");
295
+ });
296
+
297
+ it("delivers distinct final payloads after streaming close", async () => {
298
+ createFeishuReplyDispatcher({
299
+ cfg: {} as never,
300
+ agentId: "agent",
301
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
302
+ chatId: "oc_chat",
303
+ });
304
+
305
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
306
+ await options.deliver({ text: "```md\n完整回复第一段\n```" }, { kind: "final" });
307
+ await options.deliver({ text: "```md\n完整回复第一段 + 第二段\n```" }, { kind: "final" });
308
+
309
+ expect(streamingInstances).toHaveLength(2);
310
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
311
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```");
312
+ expect(streamingInstances[1].close).toHaveBeenCalledTimes(1);
313
+ expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```");
314
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
315
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
316
+ });
317
+
318
+ it("skips exact duplicate final text after streaming close", async () => {
319
+ createFeishuReplyDispatcher({
320
+ cfg: {} as never,
321
+ agentId: "agent",
322
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
323
+ chatId: "oc_chat",
324
+ });
325
+
326
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
327
+ await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
328
+ await options.deliver({ text: "```md\n同一条回复\n```" }, { kind: "final" });
329
+
330
+ expect(streamingInstances).toHaveLength(1);
331
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
332
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```");
333
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
334
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
335
+ });
336
+ it("suppresses duplicate final text while still sending media", async () => {
337
+ const options = setupNonStreamingAutoDispatcher();
338
+ await options.deliver({ text: "plain final" }, { kind: "final" });
339
+ await options.deliver(
340
+ { text: "plain final", mediaUrl: "https://example.com/a.png" },
341
+ { kind: "final" },
342
+ );
343
+
344
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
345
+ expect(sendMessageFeishuMock).toHaveBeenLastCalledWith(
346
+ expect.objectContaining({
347
+ text: "plain final",
348
+ }),
349
+ );
350
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
351
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
352
+ expect.objectContaining({
353
+ mediaUrl: "https://example.com/a.png",
354
+ }),
355
+ );
356
+ });
357
+
358
+ it("keeps distinct non-streaming final payloads", async () => {
359
+ const options = setupNonStreamingAutoDispatcher();
360
+ await options.deliver({ text: "notice header" }, { kind: "final" });
361
+ await options.deliver({ text: "actual answer body" }, { kind: "final" });
362
+
363
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(2);
364
+ expect(sendMessageFeishuMock).toHaveBeenNthCalledWith(
365
+ 1,
366
+ expect.objectContaining({ text: "notice header" }),
367
+ );
368
+ expect(sendMessageFeishuMock).toHaveBeenNthCalledWith(
369
+ 2,
370
+ expect.objectContaining({ text: "actual answer body" }),
371
+ );
372
+ });
373
+
374
+ it("treats block updates as delta chunks", async () => {
375
+ resolveFeishuAccountMock.mockReturnValue({
376
+ accountId: "main",
377
+ appId: "app_id",
378
+ appSecret: "app_secret",
379
+ domain: "feishu",
380
+ config: {
381
+ renderMode: "card",
382
+ streaming: true,
383
+ },
384
+ });
385
+
386
+ const result = createFeishuReplyDispatcher({
387
+ cfg: {} as never,
388
+ agentId: "agent",
389
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
390
+ chatId: "oc_chat",
391
+ });
392
+
393
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
394
+ await options.onReplyStart?.();
395
+ await result.replyOptions.onPartialReply?.({ text: "hello" });
396
+ await options.deliver({ text: "lo world" }, { kind: "block" });
397
+ await options.onIdle?.();
398
+
399
+ expect(streamingInstances).toHaveLength(1);
400
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
401
+ expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world");
402
+ });
403
+
229
404
  it("sends media-only payloads as attachments", async () => {
230
405
  createFeishuReplyDispatcher({
231
406
  cfg: {} as never,
@@ -5,7 +5,7 @@ import {
5
5
  type ClawdbotConfig,
6
6
  type ReplyPayload,
7
7
  type RuntimeEnv,
8
- } from "openclaw/plugin-sdk";
8
+ } from "openclaw/plugin-sdk/feishu";
9
9
  import { resolveFeishuAccount } from "./accounts.js";
10
10
  import { createFeishuClient } from "./client.js";
11
11
  import { sendMediaFeishu } from "./media.js";
@@ -13,7 +13,7 @@ import type { MentionTarget } from "./mention.js";
13
13
  import { buildMentionedCardContent } from "./mention.js";
14
14
  import { getFeishuRuntime } from "./runtime.js";
15
15
  import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js";
16
- import { FeishuStreamingSession } from "./streaming-card.js";
16
+ import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
17
17
  import { resolveReceiveIdType } from "./targets.js";
18
18
  import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
19
19
 
@@ -143,8 +143,39 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
143
143
  let streaming: FeishuStreamingSession | null = null;
144
144
  let streamText = "";
145
145
  let lastPartial = "";
146
+ const deliveredFinalTexts = new Set<string>();
146
147
  let partialUpdateQueue: Promise<void> = Promise.resolve();
147
148
  let streamingStartPromise: Promise<void> | null = null;
149
+ type StreamTextUpdateMode = "snapshot" | "delta";
150
+
151
+ const queueStreamingUpdate = (
152
+ nextText: string,
153
+ options?: {
154
+ dedupeWithLastPartial?: boolean;
155
+ mode?: StreamTextUpdateMode;
156
+ },
157
+ ) => {
158
+ if (!nextText) {
159
+ return;
160
+ }
161
+ if (options?.dedupeWithLastPartial && nextText === lastPartial) {
162
+ return;
163
+ }
164
+ if (options?.dedupeWithLastPartial) {
165
+ lastPartial = nextText;
166
+ }
167
+ const mode = options?.mode ?? "snapshot";
168
+ streamText =
169
+ mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText);
170
+ partialUpdateQueue = partialUpdateQueue.then(async () => {
171
+ if (streamingStartPromise) {
172
+ await streamingStartPromise;
173
+ }
174
+ if (streaming?.isActive()) {
175
+ await streaming.update(streamText);
176
+ }
177
+ });
178
+ };
148
179
 
149
180
  const startStreaming = () => {
150
181
  if (!streamingEnabled || streamingStartPromise || streaming) {
@@ -199,18 +230,13 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
199
230
  responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
200
231
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
201
232
  onReplyStart: () => {
233
+ deliveredFinalTexts.clear();
202
234
  if (streamingEnabled && renderMode === "card") {
203
235
  startStreaming();
204
236
  }
205
237
  void typingCallbacks.onReplyStart?.();
206
238
  },
207
239
  deliver: async (payload: ReplyPayload, info) => {
208
- // FIX: Filter out internal 'block' reasoning chunks immediately to prevent
209
- // data leak and race conditions with streaming state initialization.
210
- if (info?.kind === "block") {
211
- return;
212
- }
213
-
214
240
  const text = payload.text ?? "";
215
241
  const mediaList =
216
242
  payload.mediaUrls && payload.mediaUrls.length > 0
@@ -220,14 +246,29 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
220
246
  : [];
221
247
  const hasText = Boolean(text.trim());
222
248
  const hasMedia = mediaList.length > 0;
249
+ const skipTextForDuplicateFinal =
250
+ info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
251
+ const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
223
252
 
224
- if (!hasText && !hasMedia) {
253
+ if (!shouldDeliverText && !hasMedia) {
225
254
  return;
226
255
  }
227
256
 
228
- if (hasText) {
257
+ if (shouldDeliverText) {
229
258
  const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
230
259
 
260
+ if (info?.kind === "block") {
261
+ // Drop internal block chunks unless we can safely consume them as
262
+ // streaming-card fallback content.
263
+ if (!(streamingEnabled && useCard)) {
264
+ return;
265
+ }
266
+ startStreaming();
267
+ if (streamingStartPromise) {
268
+ await streamingStartPromise;
269
+ }
270
+ }
271
+
231
272
  if (info?.kind === "final" && streamingEnabled && useCard) {
232
273
  startStreaming();
233
274
  if (streamingStartPromise) {
@@ -236,9 +277,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
236
277
  }
237
278
 
238
279
  if (streaming?.isActive()) {
280
+ if (info?.kind === "block") {
281
+ // Some runtimes emit block payloads without onPartial/final callbacks.
282
+ // Mirror block text into streamText so onIdle close still sends content.
283
+ queueStreamingUpdate(text, { mode: "delta" });
284
+ }
239
285
  if (info?.kind === "final") {
240
- streamText = text;
286
+ streamText = mergeStreamingText(streamText, text);
241
287
  await closeStreaming();
288
+ deliveredFinalTexts.add(text);
242
289
  }
243
290
  // Send media even when streaming handled the text
244
291
  if (hasMedia) {
@@ -274,6 +321,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
274
321
  });
275
322
  first = false;
276
323
  }
324
+ if (info?.kind === "final") {
325
+ deliveredFinalTexts.add(text);
326
+ }
277
327
  } else {
278
328
  const converted = core.channel.text.convertMarkdownTables(text, tableMode);
279
329
  for (const chunk of core.channel.text.chunkTextWithMode(
@@ -292,6 +342,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
292
342
  });
293
343
  first = false;
294
344
  }
345
+ if (info?.kind === "final") {
346
+ deliveredFinalTexts.add(text);
347
+ }
295
348
  }
296
349
  }
297
350
 
@@ -329,20 +382,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
329
382
  replyOptions: {
330
383
  ...replyOptions,
331
384
  onModelSelected: prefixContext.onModelSelected,
385
+ disableBlockStreaming: true,
332
386
  onPartialReply: streamingEnabled
333
387
  ? (payload: ReplyPayload) => {
334
- if (!payload.text || payload.text === lastPartial) {
388
+ if (!payload.text) {
335
389
  return;
336
390
  }
337
- lastPartial = payload.text;
338
- streamText = payload.text;
339
- partialUpdateQueue = partialUpdateQueue.then(async () => {
340
- if (streamingStartPromise) {
341
- await streamingStartPromise;
342
- }
343
- if (streaming?.isActive()) {
344
- await streaming.update(streamText);
345
- }
391
+ queueStreamingUpdate(payload.text, {
392
+ dedupeWithLastPartial: true,
393
+ mode: "snapshot",
346
394
  });
347
395
  }
348
396
  : undefined,
package/src/runtime.ts CHANGED
@@ -1,14 +1,6 @@
1
- import type { PluginRuntime } from "openclaw/plugin-sdk";
1
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
2
+ import type { PluginRuntime } from "openclaw/plugin-sdk/feishu";
2
3
 
3
- let runtime: PluginRuntime | null = null;
4
-
5
- export function setFeishuRuntime(next: PluginRuntime) {
6
- runtime = next;
7
- }
8
-
9
- export function getFeishuRuntime(): PluginRuntime {
10
- if (!runtime) {
11
- throw new Error("Feishu runtime not initialized");
12
- }
13
- return runtime;
14
- }
4
+ const { setRuntime: setFeishuRuntime, getRuntime: getFeishuRuntime } =
5
+ createPluginRuntimeStore<PluginRuntime>("Feishu runtime not initialized");
6
+ export { getFeishuRuntime, setFeishuRuntime };
@@ -1,19 +1,13 @@
1
1
  import {
2
+ buildSecretInputSchema,
2
3
  hasConfiguredSecretInput,
3
4
  normalizeResolvedSecretInputString,
4
5
  normalizeSecretInputString,
5
- } from "openclaw/plugin-sdk";
6
- import { z } from "zod";
6
+ } from "openclaw/plugin-sdk/feishu";
7
7
 
8
- export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
9
-
10
- export function buildSecretInputSchema() {
11
- return z.union([
12
- z.string(),
13
- z.object({
14
- source: z.enum(["env", "file", "exec"]),
15
- provider: z.string().min(1),
16
- id: z.string().min(1),
17
- }),
18
- ]);
19
- }
8
+ export {
9
+ buildSecretInputSchema,
10
+ hasConfiguredSecretInput,
11
+ normalizeResolvedSecretInputString,
12
+ normalizeSecretInputString,
13
+ };
@@ -0,0 +1,71 @@
1
+ import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
2
+
3
+ type FeishuMessageClient = {
4
+ im: {
5
+ message: {
6
+ reply: (params: {
7
+ path: { message_id: string };
8
+ data: Record<string, unknown>;
9
+ }) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
10
+ create: (params: {
11
+ params: { receive_id_type: string };
12
+ data: Record<string, unknown>;
13
+ }) => Promise<{ code?: number; msg?: string; data?: { message_id?: string } }>;
14
+ };
15
+ };
16
+ };
17
+
18
+ export async function sendFeishuMessageWithOptionalReply(params: {
19
+ client: FeishuMessageClient;
20
+ receiveId: string;
21
+ receiveIdType: string;
22
+ content: string;
23
+ msgType: string;
24
+ replyToMessageId?: string;
25
+ replyInThread?: boolean;
26
+ sendErrorPrefix: string;
27
+ replyErrorPrefix: string;
28
+ fallbackSendErrorPrefix?: string;
29
+ shouldFallbackFromReply?: (response: { code?: number; msg?: string }) => boolean;
30
+ }): Promise<{ messageId: string; chatId: string }> {
31
+ const data = {
32
+ content: params.content,
33
+ msg_type: params.msgType,
34
+ };
35
+
36
+ if (params.replyToMessageId) {
37
+ const response = await params.client.im.message.reply({
38
+ path: { message_id: params.replyToMessageId },
39
+ data: {
40
+ ...data,
41
+ ...(params.replyInThread ? { reply_in_thread: true } : {}),
42
+ },
43
+ });
44
+ if (params.shouldFallbackFromReply?.(response)) {
45
+ const fallback = await params.client.im.message.create({
46
+ params: { receive_id_type: params.receiveIdType },
47
+ data: {
48
+ receive_id: params.receiveId,
49
+ ...data,
50
+ },
51
+ });
52
+ assertFeishuMessageApiSuccess(
53
+ fallback,
54
+ params.fallbackSendErrorPrefix ?? params.sendErrorPrefix,
55
+ );
56
+ return toFeishuSendResult(fallback, params.receiveId);
57
+ }
58
+ assertFeishuMessageApiSuccess(response, params.replyErrorPrefix);
59
+ return toFeishuSendResult(response, params.receiveId);
60
+ }
61
+
62
+ const response = await params.client.im.message.create({
63
+ params: { receive_id_type: params.receiveIdType },
64
+ data: {
65
+ receive_id: params.receiveId,
66
+ ...data,
67
+ },
68
+ });
69
+ assertFeishuMessageApiSuccess(response, params.sendErrorPrefix);
70
+ return toFeishuSendResult(response, params.receiveId);
71
+ }
@@ -1,4 +1,4 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { resolveFeishuSendTarget } from "./send-target.js";
4
4
 
@@ -1,4 +1,4 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
2
2
  import { resolveFeishuAccount } from "./accounts.js";
3
3
  import { createFeishuClient } from "./client.js";
4
4
  import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
@@ -102,4 +102,78 @@ describe("Feishu reply fallback for withdrawn/deleted targets", () => {
102
102
 
103
103
  expect(createMock).not.toHaveBeenCalled();
104
104
  });
105
+
106
+ it("falls back to create when reply throws a withdrawn SDK error", async () => {
107
+ const sdkError = Object.assign(new Error("request failed"), { code: 230011 });
108
+ replyMock.mockRejectedValue(sdkError);
109
+ createMock.mockResolvedValue({
110
+ code: 0,
111
+ data: { message_id: "om_thrown_fallback" },
112
+ });
113
+
114
+ const result = await sendMessageFeishu({
115
+ cfg: {} as never,
116
+ to: "user:ou_target",
117
+ text: "hello",
118
+ replyToMessageId: "om_parent",
119
+ });
120
+
121
+ expect(replyMock).toHaveBeenCalledTimes(1);
122
+ expect(createMock).toHaveBeenCalledTimes(1);
123
+ expect(result.messageId).toBe("om_thrown_fallback");
124
+ });
125
+
126
+ it("falls back to create when card reply throws a not-found AxiosError", async () => {
127
+ const axiosError = Object.assign(new Error("Request failed"), {
128
+ response: { status: 200, data: { code: 231003, msg: "The message is not found" } },
129
+ });
130
+ replyMock.mockRejectedValue(axiosError);
131
+ createMock.mockResolvedValue({
132
+ code: 0,
133
+ data: { message_id: "om_axios_fallback" },
134
+ });
135
+
136
+ const result = await sendCardFeishu({
137
+ cfg: {} as never,
138
+ to: "user:ou_target",
139
+ card: { schema: "2.0" },
140
+ replyToMessageId: "om_parent",
141
+ });
142
+
143
+ expect(replyMock).toHaveBeenCalledTimes(1);
144
+ expect(createMock).toHaveBeenCalledTimes(1);
145
+ expect(result.messageId).toBe("om_axios_fallback");
146
+ });
147
+
148
+ it("re-throws non-withdrawn thrown errors for text messages", async () => {
149
+ const sdkError = Object.assign(new Error("rate limited"), { code: 99991400 });
150
+ replyMock.mockRejectedValue(sdkError);
151
+
152
+ await expect(
153
+ sendMessageFeishu({
154
+ cfg: {} as never,
155
+ to: "user:ou_target",
156
+ text: "hello",
157
+ replyToMessageId: "om_parent",
158
+ }),
159
+ ).rejects.toThrow("rate limited");
160
+
161
+ expect(createMock).not.toHaveBeenCalled();
162
+ });
163
+
164
+ it("re-throws non-withdrawn thrown errors for card messages", async () => {
165
+ const sdkError = Object.assign(new Error("permission denied"), { code: 99991401 });
166
+ replyMock.mockRejectedValue(sdkError);
167
+
168
+ await expect(
169
+ sendCardFeishu({
170
+ cfg: {} as never,
171
+ to: "user:ou_target",
172
+ card: { schema: "2.0" },
173
+ replyToMessageId: "om_parent",
174
+ }),
175
+ ).rejects.toThrow("permission denied");
176
+
177
+ expect(createMock).not.toHaveBeenCalled();
178
+ });
105
179
  });
package/src/send.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ClawdbotConfig } from "openclaw/plugin-sdk";
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
2
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import { getMessageFeishu } from "./send.js";
4
4