@openclaw/feishu 2026.3.1 → 2026.3.7

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 (76) hide show
  1. package/index.ts +2 -2
  2. package/package.json +1 -1
  3. package/src/accounts.test.ts +268 -11
  4. package/src/accounts.ts +101 -14
  5. package/src/bitable.ts +40 -28
  6. package/src/bot.checkBotMentioned.test.ts +9 -1
  7. package/src/bot.stripBotMention.test.ts +118 -22
  8. package/src/bot.test.ts +945 -77
  9. package/src/bot.ts +492 -165
  10. package/src/card-action.ts +1 -1
  11. package/src/channel.test.ts +1 -1
  12. package/src/channel.ts +72 -68
  13. package/src/chat.test.ts +2 -2
  14. package/src/chat.ts +1 -1
  15. package/src/client.test.ts +221 -4
  16. package/src/client.ts +70 -5
  17. package/src/config-schema.test.ts +33 -6
  18. package/src/config-schema.ts +18 -10
  19. package/src/dedup.ts +47 -1
  20. package/src/directory.test.ts +40 -0
  21. package/src/directory.ts +29 -50
  22. package/src/doc-schema.ts +16 -22
  23. package/src/docx-batch-insert.test.ts +90 -0
  24. package/src/docx-batch-insert.ts +8 -11
  25. package/src/docx.account-selection.test.ts +10 -16
  26. package/src/docx.test.ts +41 -189
  27. package/src/docx.ts +1 -1
  28. package/src/drive.ts +13 -17
  29. package/src/dynamic-agent.ts +1 -1
  30. package/src/feishu-command-handler.ts +59 -0
  31. package/src/media.test.ts +164 -14
  32. package/src/media.ts +44 -10
  33. package/src/mention.ts +1 -1
  34. package/src/monitor.account.ts +284 -25
  35. package/src/monitor.reaction.test.ts +395 -46
  36. package/src/monitor.startup.test.ts +25 -8
  37. package/src/monitor.startup.ts +20 -7
  38. package/src/monitor.state.defaults.test.ts +46 -0
  39. package/src/monitor.state.ts +88 -9
  40. package/src/monitor.test-mocks.ts +45 -0
  41. package/src/monitor.transport.ts +4 -1
  42. package/src/monitor.ts +4 -4
  43. package/src/monitor.webhook-security.test.ts +13 -11
  44. package/src/onboarding.status.test.ts +25 -0
  45. package/src/onboarding.test.ts +143 -0
  46. package/src/onboarding.ts +213 -106
  47. package/src/outbound.test.ts +178 -0
  48. package/src/outbound.ts +39 -6
  49. package/src/perm.ts +11 -15
  50. package/src/policy.test.ts +40 -0
  51. package/src/policy.ts +9 -10
  52. package/src/probe.test.ts +54 -36
  53. package/src/probe.ts +57 -37
  54. package/src/reactions.ts +1 -1
  55. package/src/reply-dispatcher.test.ts +216 -0
  56. package/src/reply-dispatcher.ts +89 -22
  57. package/src/runtime.ts +1 -1
  58. package/src/secret-input.ts +13 -0
  59. package/src/send-message.ts +71 -0
  60. package/src/send-target.test.ts +74 -0
  61. package/src/send-target.ts +7 -3
  62. package/src/send.reply-fallback.test.ts +74 -0
  63. package/src/send.test.ts +1 -1
  64. package/src/send.ts +88 -49
  65. package/src/streaming-card.test.ts +54 -0
  66. package/src/streaming-card.ts +96 -28
  67. package/src/targets.test.ts +29 -0
  68. package/src/targets.ts +25 -1
  69. package/src/tool-account-routing.test.ts +3 -3
  70. package/src/tool-account.ts +1 -1
  71. package/src/tool-factory-test-harness.ts +1 -1
  72. package/src/tool-result.test.ts +32 -0
  73. package/src/tool-result.ts +14 -0
  74. package/src/types.ts +11 -4
  75. package/src/typing.ts +1 -1
  76. 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",
@@ -185,6 +224,34 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
185
224
  expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
186
225
  });
187
226
 
227
+ it("suppresses internal block payload delivery", async () => {
228
+ createFeishuReplyDispatcher({
229
+ cfg: {} as never,
230
+ agentId: "agent",
231
+ runtime: {} as never,
232
+ chatId: "oc_chat",
233
+ });
234
+
235
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
236
+ await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
237
+
238
+ expect(streamingInstances).toHaveLength(0);
239
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
240
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
241
+ expect(sendMediaFeishuMock).not.toHaveBeenCalled();
242
+ });
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
+
188
255
  it("uses streaming session for auto mode markdown payloads", async () => {
189
256
  createFeishuReplyDispatcher({
190
257
  cfg: {} as never,
@@ -209,6 +276,131 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
209
276
  expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
210
277
  });
211
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
+
212
404
  it("sends media-only payloads as attachments", async () => {
213
405
  createFeishuReplyDispatcher({
214
406
  cfg: {} as never,
@@ -352,6 +544,30 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
352
544
  });
353
545
  });
354
546
 
547
+ it("disables streaming for thread replies and keeps reply metadata", async () => {
548
+ createFeishuReplyDispatcher({
549
+ cfg: {} as never,
550
+ agentId: "agent",
551
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
552
+ chatId: "oc_chat",
553
+ replyToMessageId: "om_msg",
554
+ replyInThread: false,
555
+ threadReply: true,
556
+ rootId: "om_root_topic",
557
+ });
558
+
559
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
560
+ await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
561
+
562
+ expect(streamingInstances).toHaveLength(0);
563
+ expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
564
+ expect.objectContaining({
565
+ replyToMessageId: "om_msg",
566
+ replyInThread: true,
567
+ }),
568
+ );
569
+ });
570
+
355
571
  it("passes replyInThread to media attachments", async () => {
356
572
  createFeishuReplyDispatcher({
357
573
  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
 
@@ -45,6 +45,8 @@ export type CreateFeishuReplyDispatcherParams = {
45
45
  /** When true, preserve typing indicator on reply target but send messages without reply metadata */
46
46
  skipReplyToInMessages?: boolean;
47
47
  replyInThread?: boolean;
48
+ /** True when inbound message is already inside a thread/topic context */
49
+ threadReply?: boolean;
48
50
  rootId?: string;
49
51
  mentionTargets?: MentionTarget[];
50
52
  accountId?: string;
@@ -62,11 +64,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
62
64
  replyToMessageId,
63
65
  skipReplyToInMessages,
64
66
  replyInThread,
67
+ threadReply,
65
68
  rootId,
66
69
  mentionTargets,
67
70
  accountId,
68
71
  } = params;
69
72
  const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
73
+ const threadReplyMode = threadReply === true;
74
+ const effectiveReplyInThread = threadReplyMode ? true : replyInThread;
70
75
  const account = resolveFeishuAccount({ cfg, accountId });
71
76
  const prefixContext = createReplyPrefixContext({ cfg, agentId });
72
77
 
@@ -89,6 +94,12 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
89
94
  ) {
90
95
  return;
91
96
  }
97
+ // Feishu reactions persist until explicitly removed, so skip keepalive
98
+ // re-adds when a reaction already exists. Re-adding the same emoji
99
+ // triggers a new push notification for every call (#28660).
100
+ if (typingState?.reactionId) {
101
+ return;
102
+ }
92
103
  typingState = await addTypingIndicator({
93
104
  cfg,
94
105
  messageId: replyToMessageId,
@@ -125,13 +136,46 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
125
136
  const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
126
137
  const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
127
138
  const renderMode = account.config?.renderMode ?? "auto";
128
- const streamingEnabled = account.config?.streaming !== false && renderMode !== "raw";
139
+ // Card streaming may miss thread affinity in topic contexts; use direct replies there.
140
+ const streamingEnabled =
141
+ !threadReplyMode && account.config?.streaming !== false && renderMode !== "raw";
129
142
 
130
143
  let streaming: FeishuStreamingSession | null = null;
131
144
  let streamText = "";
132
145
  let lastPartial = "";
146
+ const deliveredFinalTexts = new Set<string>();
133
147
  let partialUpdateQueue: Promise<void> = Promise.resolve();
134
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
+ };
135
179
 
136
180
  const startStreaming = () => {
137
181
  if (!streamingEnabled || streamingStartPromise || streaming) {
@@ -152,7 +196,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
152
196
  try {
153
197
  await streaming.start(chatId, resolveReceiveIdType(chatId), {
154
198
  replyToMessageId,
155
- replyInThread,
199
+ replyInThread: effectiveReplyInThread,
156
200
  rootId,
157
201
  });
158
202
  } catch (error) {
@@ -186,6 +230,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
186
230
  responsePrefixContextProvider: prefixContext.responsePrefixContextProvider,
187
231
  humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
188
232
  onReplyStart: () => {
233
+ deliveredFinalTexts.clear();
189
234
  if (streamingEnabled && renderMode === "card") {
190
235
  startStreaming();
191
236
  }
@@ -201,15 +246,30 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
201
246
  : [];
202
247
  const hasText = Boolean(text.trim());
203
248
  const hasMedia = mediaList.length > 0;
249
+ const skipTextForDuplicateFinal =
250
+ info?.kind === "final" && hasText && deliveredFinalTexts.has(text);
251
+ const shouldDeliverText = hasText && !skipTextForDuplicateFinal;
204
252
 
205
- if (!hasText && !hasMedia) {
253
+ if (!shouldDeliverText && !hasMedia) {
206
254
  return;
207
255
  }
208
256
 
209
- if (hasText) {
257
+ if (shouldDeliverText) {
210
258
  const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
211
259
 
212
- if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
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
+
272
+ if (info?.kind === "final" && streamingEnabled && useCard) {
213
273
  startStreaming();
214
274
  if (streamingStartPromise) {
215
275
  await streamingStartPromise;
@@ -217,9 +277,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
217
277
  }
218
278
 
219
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
+ }
220
285
  if (info?.kind === "final") {
221
- streamText = text;
286
+ streamText = mergeStreamingText(streamText, text);
222
287
  await closeStreaming();
288
+ deliveredFinalTexts.add(text);
223
289
  }
224
290
  // Send media even when streaming handled the text
225
291
  if (hasMedia) {
@@ -229,7 +295,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
229
295
  to: chatId,
230
296
  mediaUrl,
231
297
  replyToMessageId: sendReplyToMessageId,
232
- replyInThread,
298
+ replyInThread: effectiveReplyInThread,
233
299
  accountId,
234
300
  });
235
301
  }
@@ -249,12 +315,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
249
315
  to: chatId,
250
316
  text: chunk,
251
317
  replyToMessageId: sendReplyToMessageId,
252
- replyInThread,
318
+ replyInThread: effectiveReplyInThread,
253
319
  mentions: first ? mentionTargets : undefined,
254
320
  accountId,
255
321
  });
256
322
  first = false;
257
323
  }
324
+ if (info?.kind === "final") {
325
+ deliveredFinalTexts.add(text);
326
+ }
258
327
  } else {
259
328
  const converted = core.channel.text.convertMarkdownTables(text, tableMode);
260
329
  for (const chunk of core.channel.text.chunkTextWithMode(
@@ -267,12 +336,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
267
336
  to: chatId,
268
337
  text: chunk,
269
338
  replyToMessageId: sendReplyToMessageId,
270
- replyInThread,
339
+ replyInThread: effectiveReplyInThread,
271
340
  mentions: first ? mentionTargets : undefined,
272
341
  accountId,
273
342
  });
274
343
  first = false;
275
344
  }
345
+ if (info?.kind === "final") {
346
+ deliveredFinalTexts.add(text);
347
+ }
276
348
  }
277
349
  }
278
350
 
@@ -283,7 +355,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
283
355
  to: chatId,
284
356
  mediaUrl,
285
357
  replyToMessageId: sendReplyToMessageId,
286
- replyInThread,
358
+ replyInThread: effectiveReplyInThread,
287
359
  accountId,
288
360
  });
289
361
  }
@@ -310,20 +382,15 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
310
382
  replyOptions: {
311
383
  ...replyOptions,
312
384
  onModelSelected: prefixContext.onModelSelected,
385
+ disableBlockStreaming: true,
313
386
  onPartialReply: streamingEnabled
314
387
  ? (payload: ReplyPayload) => {
315
- if (!payload.text || payload.text === lastPartial) {
388
+ if (!payload.text) {
316
389
  return;
317
390
  }
318
- lastPartial = payload.text;
319
- streamText = payload.text;
320
- partialUpdateQueue = partialUpdateQueue.then(async () => {
321
- if (streamingStartPromise) {
322
- await streamingStartPromise;
323
- }
324
- if (streaming?.isActive()) {
325
- await streaming.update(streamText);
326
- }
391
+ queueStreamingUpdate(payload.text, {
392
+ dedupeWithLastPartial: true,
393
+ mode: "snapshot",
327
394
  });
328
395
  }
329
396
  : undefined,
package/src/runtime.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginRuntime } from "openclaw/plugin-sdk";
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/feishu";
2
2
 
3
3
  let runtime: PluginRuntime | null = null;
4
4
 
@@ -0,0 +1,13 @@
1
+ import {
2
+ buildSecretInputSchema,
3
+ hasConfiguredSecretInput,
4
+ normalizeResolvedSecretInputString,
5
+ normalizeSecretInputString,
6
+ } from "openclaw/plugin-sdk/feishu";
7
+
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
+ }
@@ -0,0 +1,74 @@
1
+ import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { resolveFeishuSendTarget } from "./send-target.js";
4
+
5
+ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
6
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
7
+
8
+ vi.mock("./accounts.js", () => ({
9
+ resolveFeishuAccount: resolveFeishuAccountMock,
10
+ }));
11
+
12
+ vi.mock("./client.js", () => ({
13
+ createFeishuClient: createFeishuClientMock,
14
+ }));
15
+
16
+ describe("resolveFeishuSendTarget", () => {
17
+ const cfg = {} as ClawdbotConfig;
18
+ const client = { id: "client" };
19
+
20
+ beforeEach(() => {
21
+ resolveFeishuAccountMock.mockReset().mockReturnValue({
22
+ accountId: "default",
23
+ enabled: true,
24
+ configured: true,
25
+ });
26
+ createFeishuClientMock.mockReset().mockReturnValue(client);
27
+ });
28
+
29
+ it("keeps explicit group targets as chat_id even when ID shape is ambiguous", () => {
30
+ const result = resolveFeishuSendTarget({
31
+ cfg,
32
+ to: "feishu:group:group_room_alpha",
33
+ });
34
+
35
+ expect(result.receiveId).toBe("group_room_alpha");
36
+ expect(result.receiveIdType).toBe("chat_id");
37
+ expect(result.client).toBe(client);
38
+ });
39
+
40
+ it("maps dm-prefixed open IDs to open_id", () => {
41
+ const result = resolveFeishuSendTarget({
42
+ cfg,
43
+ to: "lark:dm:ou_123",
44
+ });
45
+
46
+ expect(result.receiveId).toBe("ou_123");
47
+ expect(result.receiveIdType).toBe("open_id");
48
+ });
49
+
50
+ it("maps dm-prefixed non-open IDs to user_id", () => {
51
+ const result = resolveFeishuSendTarget({
52
+ cfg,
53
+ to: " feishu:dm:user_123 ",
54
+ });
55
+
56
+ expect(result.receiveId).toBe("user_123");
57
+ expect(result.receiveIdType).toBe("user_id");
58
+ });
59
+
60
+ it("throws when target account is not configured", () => {
61
+ resolveFeishuAccountMock.mockReturnValue({
62
+ accountId: "default",
63
+ enabled: true,
64
+ configured: false,
65
+ });
66
+
67
+ expect(() =>
68
+ resolveFeishuSendTarget({
69
+ cfg,
70
+ to: "feishu:group:oc_123",
71
+ }),
72
+ ).toThrow('Feishu account "default" not configured');
73
+ });
74
+ });
@@ -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";
@@ -8,18 +8,22 @@ export function resolveFeishuSendTarget(params: {
8
8
  to: string;
9
9
  accountId?: string;
10
10
  }) {
11
+ const target = params.to.trim();
11
12
  const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
12
13
  if (!account.configured) {
13
14
  throw new Error(`Feishu account "${account.accountId}" not configured`);
14
15
  }
15
16
  const client = createFeishuClient(account);
16
- const receiveId = normalizeFeishuTarget(params.to);
17
+ const receiveId = normalizeFeishuTarget(target);
17
18
  if (!receiveId) {
18
19
  throw new Error(`Invalid Feishu target: ${params.to}`);
19
20
  }
21
+ // Preserve explicit routing prefixes (chat/group/user/dm/open_id) when present.
22
+ // normalizeFeishuTarget strips these prefixes, so infer type from the raw target first.
23
+ const withoutProviderPrefix = target.replace(/^(feishu|lark):/i, "");
20
24
  return {
21
25
  client,
22
26
  receiveId,
23
- receiveIdType: resolveReceiveIdType(receiveId),
27
+ receiveIdType: resolveReceiveIdType(withoutProviderPrefix),
24
28
  };
25
29
  }