@openclaw/feishu 2026.2.25 → 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.
Files changed (73) hide show
  1. package/index.ts +2 -0
  2. package/package.json +2 -1
  3. package/skills/feishu-doc/SKILL.md +109 -3
  4. package/src/accounts.test.ts +161 -0
  5. package/src/accounts.ts +76 -8
  6. package/src/async.ts +62 -0
  7. package/src/bitable.ts +189 -215
  8. package/src/bot.card-action.test.ts +63 -0
  9. package/src/bot.checkBotMentioned.test.ts +56 -1
  10. package/src/bot.test.ts +1271 -56
  11. package/src/bot.ts +499 -215
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +26 -4
  14. package/src/chat-schema.ts +24 -0
  15. package/src/chat.test.ts +89 -0
  16. package/src/chat.ts +130 -0
  17. package/src/client.test.ts +121 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +101 -1
  20. package/src/config-schema.ts +66 -11
  21. package/src/dedup.ts +47 -1
  22. package/src/doc-schema.ts +135 -0
  23. package/src/docx-batch-insert.ts +190 -0
  24. package/src/docx-color-text.ts +149 -0
  25. package/src/docx-table-ops.ts +298 -0
  26. package/src/docx.account-selection.test.ts +70 -0
  27. package/src/docx.test.ts +331 -9
  28. package/src/docx.ts +996 -72
  29. package/src/drive.ts +38 -33
  30. package/src/media.test.ts +227 -7
  31. package/src/media.ts +52 -11
  32. package/src/mention.ts +1 -1
  33. package/src/monitor.account.ts +534 -0
  34. package/src/monitor.reaction.test.ts +578 -0
  35. package/src/monitor.startup.test.ts +203 -0
  36. package/src/monitor.startup.ts +51 -0
  37. package/src/monitor.state.defaults.test.ts +46 -0
  38. package/src/monitor.state.ts +152 -0
  39. package/src/monitor.test-mocks.ts +12 -0
  40. package/src/monitor.transport.ts +163 -0
  41. package/src/monitor.ts +44 -346
  42. package/src/monitor.webhook-security.test.ts +53 -10
  43. package/src/onboarding.status.test.ts +25 -0
  44. package/src/onboarding.ts +144 -52
  45. package/src/outbound.test.ts +181 -0
  46. package/src/outbound.ts +94 -7
  47. package/src/perm.ts +37 -30
  48. package/src/policy.test.ts +56 -1
  49. package/src/policy.ts +5 -1
  50. package/src/post.test.ts +105 -0
  51. package/src/post.ts +274 -0
  52. package/src/probe.test.ts +271 -0
  53. package/src/probe.ts +131 -19
  54. package/src/reply-dispatcher.test.ts +300 -0
  55. package/src/reply-dispatcher.ts +159 -46
  56. package/src/secret-input.ts +19 -0
  57. package/src/send-target.test.ts +74 -0
  58. package/src/send-target.ts +6 -2
  59. package/src/send.reply-fallback.test.ts +105 -0
  60. package/src/send.test.ts +168 -0
  61. package/src/send.ts +143 -18
  62. package/src/streaming-card.ts +131 -43
  63. package/src/targets.test.ts +55 -1
  64. package/src/targets.ts +32 -7
  65. package/src/tool-account-routing.test.ts +129 -0
  66. package/src/tool-account.ts +70 -0
  67. package/src/tool-factory-test-harness.ts +76 -0
  68. package/src/tools-config.test.ts +21 -0
  69. package/src/tools-config.ts +2 -1
  70. package/src/types.ts +10 -1
  71. package/src/typing.test.ts +144 -0
  72. package/src/typing.ts +140 -10
  73. package/src/wiki.ts +55 -50
@@ -4,9 +4,12 @@ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
4
4
  const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
5
5
  const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
6
6
  const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
7
+ const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
7
8
  const createFeishuClientMock = vi.hoisted(() => vi.fn());
8
9
  const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
9
10
  const createReplyDispatcherWithTypingMock = vi.hoisted(() => vi.fn());
11
+ const addTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "om_msg" })));
12
+ const removeTypingIndicatorMock = vi.hoisted(() => vi.fn(async () => {}));
10
13
  const streamingInstances = vi.hoisted(() => [] as any[]);
11
14
 
12
15
  vi.mock("./accounts.js", () => ({ resolveFeishuAccount: resolveFeishuAccountMock }));
@@ -15,8 +18,13 @@ vi.mock("./send.js", () => ({
15
18
  sendMessageFeishu: sendMessageFeishuMock,
16
19
  sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
17
20
  }));
21
+ vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
18
22
  vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
19
23
  vi.mock("./targets.js", () => ({ resolveReceiveIdType: resolveReceiveIdTypeMock }));
24
+ vi.mock("./typing.js", () => ({
25
+ addTypingIndicator: addTypingIndicatorMock,
26
+ removeTypingIndicator: removeTypingIndicatorMock,
27
+ }));
20
28
  vi.mock("./streaming-card.js", () => ({
21
29
  FeishuStreamingSession: class {
22
30
  active = false;
@@ -41,6 +49,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
41
49
  beforeEach(() => {
42
50
  vi.clearAllMocks();
43
51
  streamingInstances.length = 0;
52
+ sendMediaFeishuMock.mockResolvedValue(undefined);
44
53
 
45
54
  resolveFeishuAccountMock.mockReturnValue({
46
55
  accountId: "main",
@@ -80,6 +89,86 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
80
89
  });
81
90
  });
82
91
 
92
+ it("skips typing indicator when account typingIndicator is disabled", async () => {
93
+ resolveFeishuAccountMock.mockReturnValue({
94
+ accountId: "main",
95
+ appId: "app_id",
96
+ appSecret: "app_secret",
97
+ domain: "feishu",
98
+ config: {
99
+ renderMode: "auto",
100
+ streaming: true,
101
+ typingIndicator: false,
102
+ },
103
+ });
104
+
105
+ createFeishuReplyDispatcher({
106
+ cfg: {} as never,
107
+ agentId: "agent",
108
+ runtime: {} as never,
109
+ chatId: "oc_chat",
110
+ replyToMessageId: "om_parent",
111
+ });
112
+
113
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
114
+ await options.onReplyStart?.();
115
+
116
+ expect(addTypingIndicatorMock).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it("skips typing indicator for stale replayed messages", async () => {
120
+ createFeishuReplyDispatcher({
121
+ cfg: {} as never,
122
+ agentId: "agent",
123
+ runtime: {} as never,
124
+ chatId: "oc_chat",
125
+ replyToMessageId: "om_parent",
126
+ messageCreateTimeMs: Date.now() - 3 * 60_000,
127
+ });
128
+
129
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
130
+ await options.onReplyStart?.();
131
+
132
+ expect(addTypingIndicatorMock).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it("treats second-based timestamps as stale for typing suppression", async () => {
136
+ createFeishuReplyDispatcher({
137
+ cfg: {} as never,
138
+ agentId: "agent",
139
+ runtime: {} as never,
140
+ chatId: "oc_chat",
141
+ replyToMessageId: "om_parent",
142
+ messageCreateTimeMs: Math.floor((Date.now() - 3 * 60_000) / 1000),
143
+ });
144
+
145
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
146
+ await options.onReplyStart?.();
147
+
148
+ expect(addTypingIndicatorMock).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it("keeps typing indicator for fresh messages", async () => {
152
+ createFeishuReplyDispatcher({
153
+ cfg: {} as never,
154
+ agentId: "agent",
155
+ runtime: {} as never,
156
+ chatId: "oc_chat",
157
+ replyToMessageId: "om_parent",
158
+ messageCreateTimeMs: Date.now() - 30_000,
159
+ });
160
+
161
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
162
+ await options.onReplyStart?.();
163
+
164
+ expect(addTypingIndicatorMock).toHaveBeenCalledTimes(1);
165
+ expect(addTypingIndicatorMock).toHaveBeenCalledWith(
166
+ expect.objectContaining({
167
+ messageId: "om_parent",
168
+ }),
169
+ );
170
+ });
171
+
83
172
  it("keeps auto mode plain text on non-streaming send path", async () => {
84
173
  createFeishuReplyDispatcher({
85
174
  cfg: {} as never,
@@ -96,12 +185,30 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
96
185
  expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
97
186
  });
98
187
 
188
+ it("suppresses internal block payload delivery", async () => {
189
+ createFeishuReplyDispatcher({
190
+ cfg: {} as never,
191
+ agentId: "agent",
192
+ runtime: {} as never,
193
+ chatId: "oc_chat",
194
+ });
195
+
196
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
197
+ await options.deliver({ text: "internal reasoning chunk" }, { kind: "block" });
198
+
199
+ expect(streamingInstances).toHaveLength(0);
200
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
201
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
202
+ expect(sendMediaFeishuMock).not.toHaveBeenCalled();
203
+ });
204
+
99
205
  it("uses streaming session for auto mode markdown payloads", async () => {
100
206
  createFeishuReplyDispatcher({
101
207
  cfg: {} as never,
102
208
  agentId: "agent",
103
209
  runtime: { log: vi.fn(), error: vi.fn() } as never,
104
210
  chatId: "oc_chat",
211
+ rootId: "om_root_topic",
105
212
  });
106
213
 
107
214
  const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
@@ -109,8 +216,201 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
109
216
 
110
217
  expect(streamingInstances).toHaveLength(1);
111
218
  expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
219
+ expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
220
+ replyToMessageId: undefined,
221
+ replyInThread: undefined,
222
+ rootId: "om_root_topic",
223
+ });
112
224
  expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
113
225
  expect(sendMessageFeishuMock).not.toHaveBeenCalled();
114
226
  expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
115
227
  });
228
+
229
+ it("sends media-only payloads as attachments", async () => {
230
+ createFeishuReplyDispatcher({
231
+ cfg: {} as never,
232
+ agentId: "agent",
233
+ runtime: {} as never,
234
+ chatId: "oc_chat",
235
+ });
236
+
237
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
238
+ await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
239
+
240
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
241
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
242
+ expect.objectContaining({
243
+ to: "oc_chat",
244
+ mediaUrl: "https://example.com/a.png",
245
+ }),
246
+ );
247
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
248
+ expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
249
+ });
250
+
251
+ it("falls back to legacy mediaUrl when mediaUrls is an empty array", async () => {
252
+ createFeishuReplyDispatcher({
253
+ cfg: {} as never,
254
+ agentId: "agent",
255
+ runtime: {} as never,
256
+ chatId: "oc_chat",
257
+ });
258
+
259
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
260
+ await options.deliver(
261
+ { text: "caption", mediaUrl: "https://example.com/a.png", mediaUrls: [] },
262
+ { kind: "final" },
263
+ );
264
+
265
+ expect(sendMessageFeishuMock).toHaveBeenCalledTimes(1);
266
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
267
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
268
+ expect.objectContaining({
269
+ mediaUrl: "https://example.com/a.png",
270
+ }),
271
+ );
272
+ });
273
+
274
+ it("sends attachments after streaming final markdown replies", async () => {
275
+ createFeishuReplyDispatcher({
276
+ cfg: {} as never,
277
+ agentId: "agent",
278
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
279
+ chatId: "oc_chat",
280
+ });
281
+
282
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
283
+ await options.deliver(
284
+ { text: "```ts\nconst x = 1\n```", mediaUrls: ["https://example.com/a.png"] },
285
+ { kind: "final" },
286
+ );
287
+
288
+ expect(streamingInstances).toHaveLength(1);
289
+ expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
290
+ expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
291
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
292
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
293
+ expect.objectContaining({
294
+ mediaUrl: "https://example.com/a.png",
295
+ }),
296
+ );
297
+ });
298
+
299
+ it("passes replyInThread to sendMessageFeishu for plain text", async () => {
300
+ createFeishuReplyDispatcher({
301
+ cfg: {} as never,
302
+ agentId: "agent",
303
+ runtime: {} as never,
304
+ chatId: "oc_chat",
305
+ replyToMessageId: "om_msg",
306
+ replyInThread: true,
307
+ });
308
+
309
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
310
+ await options.deliver({ text: "plain text" }, { kind: "final" });
311
+
312
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
313
+ expect.objectContaining({
314
+ replyToMessageId: "om_msg",
315
+ replyInThread: true,
316
+ }),
317
+ );
318
+ });
319
+
320
+ it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => {
321
+ resolveFeishuAccountMock.mockReturnValue({
322
+ accountId: "main",
323
+ appId: "app_id",
324
+ appSecret: "app_secret",
325
+ domain: "feishu",
326
+ config: {
327
+ renderMode: "card",
328
+ streaming: false,
329
+ },
330
+ });
331
+
332
+ createFeishuReplyDispatcher({
333
+ cfg: {} as never,
334
+ agentId: "agent",
335
+ runtime: {} as never,
336
+ chatId: "oc_chat",
337
+ replyToMessageId: "om_msg",
338
+ replyInThread: true,
339
+ });
340
+
341
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
342
+ await options.deliver({ text: "card text" }, { kind: "final" });
343
+
344
+ expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
345
+ expect.objectContaining({
346
+ replyToMessageId: "om_msg",
347
+ replyInThread: true,
348
+ }),
349
+ );
350
+ });
351
+
352
+ it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
353
+ createFeishuReplyDispatcher({
354
+ cfg: {} as never,
355
+ agentId: "agent",
356
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
357
+ chatId: "oc_chat",
358
+ replyToMessageId: "om_msg",
359
+ replyInThread: true,
360
+ });
361
+
362
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
363
+ await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
364
+
365
+ expect(streamingInstances).toHaveLength(1);
366
+ expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", {
367
+ replyToMessageId: "om_msg",
368
+ replyInThread: true,
369
+ });
370
+ });
371
+
372
+ it("disables streaming for thread replies and keeps reply metadata", async () => {
373
+ createFeishuReplyDispatcher({
374
+ cfg: {} as never,
375
+ agentId: "agent",
376
+ runtime: { log: vi.fn(), error: vi.fn() } as never,
377
+ chatId: "oc_chat",
378
+ replyToMessageId: "om_msg",
379
+ replyInThread: false,
380
+ threadReply: true,
381
+ rootId: "om_root_topic",
382
+ });
383
+
384
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
385
+ await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
386
+
387
+ expect(streamingInstances).toHaveLength(0);
388
+ expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
389
+ expect.objectContaining({
390
+ replyToMessageId: "om_msg",
391
+ replyInThread: true,
392
+ }),
393
+ );
394
+ });
395
+
396
+ it("passes replyInThread to media attachments", async () => {
397
+ createFeishuReplyDispatcher({
398
+ cfg: {} as never,
399
+ agentId: "agent",
400
+ runtime: {} as never,
401
+ chatId: "oc_chat",
402
+ replyToMessageId: "om_msg",
403
+ replyInThread: true,
404
+ });
405
+
406
+ const options = createReplyDispatcherWithTypingMock.mock.calls[0]?.[0];
407
+ await options.deliver({ mediaUrl: "https://example.com/a.png" }, { kind: "final" });
408
+
409
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
410
+ expect.objectContaining({
411
+ replyToMessageId: "om_msg",
412
+ replyInThread: true,
413
+ }),
414
+ );
415
+ });
116
416
  });
@@ -8,6 +8,7 @@ import {
8
8
  } from "openclaw/plugin-sdk";
9
9
  import { resolveFeishuAccount } from "./accounts.js";
10
10
  import { createFeishuClient } from "./client.js";
11
+ import { sendMediaFeishu } from "./media.js";
11
12
  import type { MentionTarget } from "./mention.js";
12
13
  import { buildMentionedCardContent } from "./mention.js";
13
14
  import { getFeishuRuntime } from "./runtime.js";
@@ -21,35 +22,96 @@ function shouldUseCard(text: string): boolean {
21
22
  return /```[\s\S]*?```/.test(text) || /\|.+\|[\r\n]+\|[-:| ]+\|/.test(text);
22
23
  }
23
24
 
25
+ /** Maximum age (ms) for a message to receive a typing indicator reaction.
26
+ * Messages older than this are likely replays after context compaction (#30418). */
27
+ const TYPING_INDICATOR_MAX_AGE_MS = 2 * 60_000;
28
+ const MS_EPOCH_MIN = 1_000_000_000_000;
29
+
30
+ function normalizeEpochMs(timestamp: number | undefined): number | undefined {
31
+ if (!Number.isFinite(timestamp) || timestamp === undefined || timestamp <= 0) {
32
+ return undefined;
33
+ }
34
+ // Defensive normalization: some payloads use seconds, others milliseconds.
35
+ // Values below 1e12 are treated as epoch-seconds.
36
+ return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp;
37
+ }
38
+
24
39
  export type CreateFeishuReplyDispatcherParams = {
25
40
  cfg: ClawdbotConfig;
26
41
  agentId: string;
27
42
  runtime: RuntimeEnv;
28
43
  chatId: string;
29
44
  replyToMessageId?: string;
45
+ /** When true, preserve typing indicator on reply target but send messages without reply metadata */
46
+ skipReplyToInMessages?: boolean;
47
+ replyInThread?: boolean;
48
+ /** True when inbound message is already inside a thread/topic context */
49
+ threadReply?: boolean;
50
+ rootId?: string;
30
51
  mentionTargets?: MentionTarget[];
31
52
  accountId?: string;
53
+ /** Epoch ms when the inbound message was created. Used to suppress typing
54
+ * indicators on old/replayed messages after context compaction (#30418). */
55
+ messageCreateTimeMs?: number;
32
56
  };
33
57
 
34
58
  export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherParams) {
35
59
  const core = getFeishuRuntime();
36
- const { cfg, agentId, chatId, replyToMessageId, mentionTargets, accountId } = params;
60
+ const {
61
+ cfg,
62
+ agentId,
63
+ chatId,
64
+ replyToMessageId,
65
+ skipReplyToInMessages,
66
+ replyInThread,
67
+ threadReply,
68
+ rootId,
69
+ mentionTargets,
70
+ accountId,
71
+ } = params;
72
+ const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
73
+ const threadReplyMode = threadReply === true;
74
+ const effectiveReplyInThread = threadReplyMode ? true : replyInThread;
37
75
  const account = resolveFeishuAccount({ cfg, accountId });
38
76
  const prefixContext = createReplyPrefixContext({ cfg, agentId });
39
77
 
40
78
  let typingState: TypingIndicatorState | null = null;
41
79
  const typingCallbacks = createTypingCallbacks({
42
80
  start: async () => {
81
+ // Check if typing indicator is enabled (default: true)
82
+ if (!(account.config.typingIndicator ?? true)) {
83
+ return;
84
+ }
43
85
  if (!replyToMessageId) {
44
86
  return;
45
87
  }
46
- typingState = await addTypingIndicator({ cfg, messageId: replyToMessageId, accountId });
88
+ // Skip typing indicator for old messages likely replays after context
89
+ // compaction that would flood users with stale notifications (#30418).
90
+ const messageCreateTimeMs = normalizeEpochMs(params.messageCreateTimeMs);
91
+ if (
92
+ messageCreateTimeMs !== undefined &&
93
+ Date.now() - messageCreateTimeMs > TYPING_INDICATOR_MAX_AGE_MS
94
+ ) {
95
+ return;
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
+ }
103
+ typingState = await addTypingIndicator({
104
+ cfg,
105
+ messageId: replyToMessageId,
106
+ accountId,
107
+ runtime: params.runtime,
108
+ });
47
109
  },
48
110
  stop: async () => {
49
111
  if (!typingState) {
50
112
  return;
51
113
  }
52
- await removeTypingIndicator({ cfg, state: typingState, accountId });
114
+ await removeTypingIndicator({ cfg, state: typingState, accountId, runtime: params.runtime });
53
115
  typingState = null;
54
116
  },
55
117
  onStartError: (err) =>
@@ -74,7 +136,9 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
74
136
  const chunkMode = core.channel.text.resolveChunkMode(cfg, "feishu");
75
137
  const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg, channel: "feishu" });
76
138
  const renderMode = account.config?.renderMode ?? "auto";
77
- 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";
78
142
 
79
143
  let streaming: FeishuStreamingSession | null = null;
80
144
  let streamText = "";
@@ -99,7 +163,11 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
99
163
  params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
100
164
  );
101
165
  try {
102
- await streaming.start(chatId, resolveReceiveIdType(chatId));
166
+ await streaming.start(chatId, resolveReceiveIdType(chatId), {
167
+ replyToMessageId,
168
+ replyInThread: effectiveReplyInThread,
169
+ rootId,
170
+ });
103
171
  } catch (error) {
104
172
  params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
105
173
  streaming = null;
@@ -137,61 +205,106 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
137
205
  void typingCallbacks.onReplyStart?.();
138
206
  },
139
207
  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
+
140
214
  const text = payload.text ?? "";
141
- if (!text.trim()) {
215
+ const mediaList =
216
+ payload.mediaUrls && payload.mediaUrls.length > 0
217
+ ? payload.mediaUrls
218
+ : payload.mediaUrl
219
+ ? [payload.mediaUrl]
220
+ : [];
221
+ const hasText = Boolean(text.trim());
222
+ const hasMedia = mediaList.length > 0;
223
+
224
+ if (!hasText && !hasMedia) {
142
225
  return;
143
226
  }
144
227
 
145
- const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
228
+ if (hasText) {
229
+ const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
146
230
 
147
- if ((info?.kind === "block" || info?.kind === "final") && streamingEnabled && useCard) {
148
- startStreaming();
149
- if (streamingStartPromise) {
150
- await streamingStartPromise;
231
+ if (info?.kind === "final" && streamingEnabled && useCard) {
232
+ startStreaming();
233
+ if (streamingStartPromise) {
234
+ await streamingStartPromise;
235
+ }
151
236
  }
152
- }
153
237
 
154
- if (streaming?.isActive()) {
155
- if (info?.kind === "final") {
156
- streamText = text;
157
- await closeStreaming();
238
+ if (streaming?.isActive()) {
239
+ if (info?.kind === "final") {
240
+ streamText = text;
241
+ await closeStreaming();
242
+ }
243
+ // Send media even when streaming handled the text
244
+ if (hasMedia) {
245
+ for (const mediaUrl of mediaList) {
246
+ await sendMediaFeishu({
247
+ cfg,
248
+ to: chatId,
249
+ mediaUrl,
250
+ replyToMessageId: sendReplyToMessageId,
251
+ replyInThread: effectiveReplyInThread,
252
+ accountId,
253
+ });
254
+ }
255
+ }
256
+ return;
158
257
  }
159
- return;
160
- }
161
258
 
162
- let first = true;
163
- if (useCard) {
164
- for (const chunk of core.channel.text.chunkTextWithMode(
165
- text,
166
- textChunkLimit,
167
- chunkMode,
168
- )) {
169
- await sendMarkdownCardFeishu({
170
- cfg,
171
- to: chatId,
172
- text: chunk,
173
- replyToMessageId,
174
- mentions: first ? mentionTargets : undefined,
175
- accountId,
176
- });
177
- first = false;
259
+ let first = true;
260
+ if (useCard) {
261
+ for (const chunk of core.channel.text.chunkTextWithMode(
262
+ text,
263
+ textChunkLimit,
264
+ chunkMode,
265
+ )) {
266
+ await sendMarkdownCardFeishu({
267
+ cfg,
268
+ to: chatId,
269
+ text: chunk,
270
+ replyToMessageId: sendReplyToMessageId,
271
+ replyInThread: effectiveReplyInThread,
272
+ mentions: first ? mentionTargets : undefined,
273
+ accountId,
274
+ });
275
+ first = false;
276
+ }
277
+ } else {
278
+ const converted = core.channel.text.convertMarkdownTables(text, tableMode);
279
+ for (const chunk of core.channel.text.chunkTextWithMode(
280
+ converted,
281
+ textChunkLimit,
282
+ chunkMode,
283
+ )) {
284
+ await sendMessageFeishu({
285
+ cfg,
286
+ to: chatId,
287
+ text: chunk,
288
+ replyToMessageId: sendReplyToMessageId,
289
+ replyInThread: effectiveReplyInThread,
290
+ mentions: first ? mentionTargets : undefined,
291
+ accountId,
292
+ });
293
+ first = false;
294
+ }
178
295
  }
179
- } else {
180
- const converted = core.channel.text.convertMarkdownTables(text, tableMode);
181
- for (const chunk of core.channel.text.chunkTextWithMode(
182
- converted,
183
- textChunkLimit,
184
- chunkMode,
185
- )) {
186
- await sendMessageFeishu({
296
+ }
297
+
298
+ if (hasMedia) {
299
+ for (const mediaUrl of mediaList) {
300
+ await sendMediaFeishu({
187
301
  cfg,
188
302
  to: chatId,
189
- text: chunk,
190
- replyToMessageId,
191
- mentions: first ? mentionTargets : undefined,
303
+ mediaUrl,
304
+ replyToMessageId: sendReplyToMessageId,
305
+ replyInThread: effectiveReplyInThread,
192
306
  accountId,
193
307
  });
194
- first = false;
195
308
  }
196
309
  }
197
310
  },
@@ -0,0 +1,19 @@
1
+ import {
2
+ hasConfiguredSecretInput,
3
+ normalizeResolvedSecretInputString,
4
+ normalizeSecretInputString,
5
+ } from "openclaw/plugin-sdk";
6
+ import { z } from "zod";
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
+ }