@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
package/src/onboarding.ts CHANGED
@@ -3,51 +3,49 @@ import type {
3
3
  ChannelOnboardingDmPolicy,
4
4
  ClawdbotConfig,
5
5
  DmPolicy,
6
+ SecretInput,
6
7
  WizardPrompter,
7
- } from "openclaw/plugin-sdk";
8
- import { addWildcardAllowFrom, DEFAULT_ACCOUNT_ID, formatDocsLink } from "openclaw/plugin-sdk";
8
+ } from "openclaw/plugin-sdk/feishu";
9
+ import {
10
+ buildSingleChannelSecretPromptState,
11
+ DEFAULT_ACCOUNT_ID,
12
+ formatDocsLink,
13
+ hasConfiguredSecretInput,
14
+ mergeAllowFromEntries,
15
+ promptSingleChannelSecretInput,
16
+ setTopLevelChannelAllowFrom,
17
+ setTopLevelChannelDmPolicyWithAllowFrom,
18
+ setTopLevelChannelGroupPolicy,
19
+ splitOnboardingEntries,
20
+ } from "openclaw/plugin-sdk/feishu";
9
21
  import { resolveFeishuCredentials } from "./accounts.js";
10
22
  import { probeFeishu } from "./probe.js";
11
23
  import type { FeishuConfig } from "./types.js";
12
24
 
13
25
  const channel = "feishu" as const;
14
26
 
15
- function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
16
- const allowFrom =
17
- dmPolicy === "open"
18
- ? addWildcardAllowFrom(cfg.channels?.feishu?.allowFrom)?.map((entry) => String(entry))
19
- : undefined;
20
- return {
21
- ...cfg,
22
- channels: {
23
- ...cfg.channels,
24
- feishu: {
25
- ...cfg.channels?.feishu,
26
- dmPolicy,
27
- ...(allowFrom ? { allowFrom } : {}),
28
- },
29
- },
30
- };
27
+ function normalizeString(value: unknown): string | undefined {
28
+ if (typeof value !== "string") {
29
+ return undefined;
30
+ }
31
+ const trimmed = value.trim();
32
+ return trimmed || undefined;
31
33
  }
32
34
 
33
- function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
34
- return {
35
- ...cfg,
36
- channels: {
37
- ...cfg.channels,
38
- feishu: {
39
- ...cfg.channels?.feishu,
40
- allowFrom,
41
- },
42
- },
43
- };
35
+ function setFeishuDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy): ClawdbotConfig {
36
+ return setTopLevelChannelDmPolicyWithAllowFrom({
37
+ cfg,
38
+ channel: "feishu",
39
+ dmPolicy,
40
+ }) as ClawdbotConfig;
44
41
  }
45
42
 
46
- function parseAllowFromInput(raw: string): string[] {
47
- return raw
48
- .split(/[\n,;]+/g)
49
- .map((entry) => entry.trim())
50
- .filter(Boolean);
43
+ function setFeishuAllowFrom(cfg: ClawdbotConfig, allowFrom: string[]): ClawdbotConfig {
44
+ return setTopLevelChannelAllowFrom({
45
+ cfg,
46
+ channel: "feishu",
47
+ allowFrom,
48
+ }) as ClawdbotConfig;
51
49
  }
52
50
 
53
51
  async function promptFeishuAllowFrom(params: {
@@ -73,18 +71,13 @@ async function promptFeishuAllowFrom(params: {
73
71
  initialValue: existing[0] ? String(existing[0]) : undefined,
74
72
  validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
75
73
  });
76
- const parts = parseAllowFromInput(String(entry));
74
+ const parts = splitOnboardingEntries(String(entry));
77
75
  if (parts.length === 0) {
78
76
  await params.prompter.note("Enter at least one user.", "Feishu allowlist");
79
77
  continue;
80
78
  }
81
79
 
82
- const unique = [
83
- ...new Set([
84
- ...existing.map((v: string | number) => String(v).trim()).filter(Boolean),
85
- ...parts,
86
- ]),
87
- ];
80
+ const unique = mergeAllowFromEntries(existing, parts);
88
81
  return setFeishuAllowFrom(params.cfg, unique);
89
82
  }
90
83
  }
@@ -104,40 +97,30 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void>
104
97
  );
105
98
  }
106
99
 
107
- async function promptFeishuCredentials(prompter: WizardPrompter): Promise<{
108
- appId: string;
109
- appSecret: string;
110
- }> {
100
+ async function promptFeishuAppId(params: {
101
+ prompter: WizardPrompter;
102
+ initialValue?: string;
103
+ }): Promise<string> {
111
104
  const appId = String(
112
- await prompter.text({
105
+ await params.prompter.text({
113
106
  message: "Enter Feishu App ID",
107
+ initialValue: params.initialValue,
114
108
  validate: (value) => (value?.trim() ? undefined : "Required"),
115
109
  }),
116
110
  ).trim();
117
- const appSecret = String(
118
- await prompter.text({
119
- message: "Enter Feishu App Secret",
120
- validate: (value) => (value?.trim() ? undefined : "Required"),
121
- }),
122
- ).trim();
123
- return { appId, appSecret };
111
+ return appId;
124
112
  }
125
113
 
126
114
  function setFeishuGroupPolicy(
127
115
  cfg: ClawdbotConfig,
128
116
  groupPolicy: "open" | "allowlist" | "disabled",
129
117
  ): ClawdbotConfig {
130
- return {
131
- ...cfg,
132
- channels: {
133
- ...cfg.channels,
134
- feishu: {
135
- ...cfg.channels?.feishu,
136
- enabled: true,
137
- groupPolicy,
138
- },
139
- },
140
- };
118
+ return setTopLevelChannelGroupPolicy({
119
+ cfg,
120
+ channel: "feishu",
121
+ groupPolicy,
122
+ enabled: true,
123
+ }) as ClawdbotConfig;
141
124
  }
142
125
 
143
126
  function setFeishuGroupAllowFrom(cfg: ClawdbotConfig, groupAllowFrom: string[]): ClawdbotConfig {
@@ -167,13 +150,53 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
167
150
  channel,
168
151
  getStatus: async ({ cfg }) => {
169
152
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
170
- const configured = Boolean(resolveFeishuCredentials(feishuCfg));
153
+
154
+ const isAppIdConfigured = (value: unknown): boolean => {
155
+ const asString = normalizeString(value);
156
+ if (asString) {
157
+ return true;
158
+ }
159
+ if (!value || typeof value !== "object") {
160
+ return false;
161
+ }
162
+ const rec = value as Record<string, unknown>;
163
+ const source = normalizeString(rec.source)?.toLowerCase();
164
+ const id = normalizeString(rec.id);
165
+ if (source === "env" && id) {
166
+ return Boolean(normalizeString(process.env[id]));
167
+ }
168
+ return hasConfiguredSecretInput(value);
169
+ };
170
+
171
+ const topLevelConfigured = Boolean(
172
+ isAppIdConfigured(feishuCfg?.appId) && hasConfiguredSecretInput(feishuCfg?.appSecret),
173
+ );
174
+
175
+ const accountConfigured = Object.values(feishuCfg?.accounts ?? {}).some((account) => {
176
+ if (!account || typeof account !== "object") {
177
+ return false;
178
+ }
179
+ const hasOwnAppId = Object.prototype.hasOwnProperty.call(account, "appId");
180
+ const hasOwnAppSecret = Object.prototype.hasOwnProperty.call(account, "appSecret");
181
+ const accountAppIdConfigured = hasOwnAppId
182
+ ? isAppIdConfigured((account as Record<string, unknown>).appId)
183
+ : isAppIdConfigured(feishuCfg?.appId);
184
+ const accountSecretConfigured = hasOwnAppSecret
185
+ ? hasConfiguredSecretInput((account as Record<string, unknown>).appSecret)
186
+ : hasConfiguredSecretInput(feishuCfg?.appSecret);
187
+ return Boolean(accountAppIdConfigured && accountSecretConfigured);
188
+ });
189
+
190
+ const configured = topLevelConfigured || accountConfigured;
191
+ const resolvedCredentials = resolveFeishuCredentials(feishuCfg, {
192
+ allowUnresolvedSecretRef: true,
193
+ });
171
194
 
172
195
  // Try to probe if configured
173
196
  let probeResult = null;
174
- if (configured && feishuCfg) {
197
+ if (configured && resolvedCredentials) {
175
198
  try {
176
- probeResult = await probeFeishu(feishuCfg);
199
+ probeResult = await probeFeishu(resolvedCredentials);
177
200
  } catch {
178
201
  // Ignore probe errors
179
202
  }
@@ -201,52 +224,59 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
201
224
 
202
225
  configure: async ({ cfg, prompter }) => {
203
226
  const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
204
- const resolved = resolveFeishuCredentials(feishuCfg);
205
- const hasConfigCreds = Boolean(feishuCfg?.appId?.trim() && feishuCfg?.appSecret?.trim());
206
- const canUseEnv = Boolean(
207
- !hasConfigCreds && process.env.FEISHU_APP_ID?.trim() && process.env.FEISHU_APP_SECRET?.trim(),
227
+ const resolved = resolveFeishuCredentials(feishuCfg, {
228
+ allowUnresolvedSecretRef: true,
229
+ });
230
+ const hasConfigSecret = hasConfiguredSecretInput(feishuCfg?.appSecret);
231
+ const hasConfigCreds = Boolean(
232
+ typeof feishuCfg?.appId === "string" && feishuCfg.appId.trim() && hasConfigSecret,
208
233
  );
234
+ const appSecretPromptState = buildSingleChannelSecretPromptState({
235
+ accountConfigured: Boolean(resolved),
236
+ hasConfigToken: hasConfigSecret,
237
+ allowEnv: !hasConfigCreds && Boolean(process.env.FEISHU_APP_ID?.trim()),
238
+ envValue: process.env.FEISHU_APP_SECRET,
239
+ });
209
240
 
210
241
  let next = cfg;
211
242
  let appId: string | null = null;
212
- let appSecret: string | null = null;
243
+ let appSecret: SecretInput | null = null;
244
+ let appSecretProbeValue: string | null = null;
213
245
 
214
246
  if (!resolved) {
215
247
  await noteFeishuCredentialHelp(prompter);
216
248
  }
217
249
 
218
- if (canUseEnv) {
219
- const keepEnv = await prompter.confirm({
220
- message: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
221
- initialValue: true,
222
- });
223
- if (keepEnv) {
224
- next = {
225
- ...next,
226
- channels: {
227
- ...next.channels,
228
- feishu: { ...next.channels?.feishu, enabled: true },
229
- },
230
- };
231
- } else {
232
- const entered = await promptFeishuCredentials(prompter);
233
- appId = entered.appId;
234
- appSecret = entered.appSecret;
235
- }
236
- } else if (hasConfigCreds) {
237
- const keep = await prompter.confirm({
238
- message: "Feishu credentials already configured. Keep them?",
239
- initialValue: true,
250
+ const appSecretResult = await promptSingleChannelSecretInput({
251
+ cfg: next,
252
+ prompter,
253
+ providerHint: "feishu",
254
+ credentialLabel: "App Secret",
255
+ accountConfigured: appSecretPromptState.accountConfigured,
256
+ canUseEnv: appSecretPromptState.canUseEnv,
257
+ hasConfigToken: appSecretPromptState.hasConfigToken,
258
+ envPrompt: "FEISHU_APP_ID + FEISHU_APP_SECRET detected. Use env vars?",
259
+ keepPrompt: "Feishu App Secret already configured. Keep it?",
260
+ inputPrompt: "Enter Feishu App Secret",
261
+ preferredEnvVar: "FEISHU_APP_SECRET",
262
+ });
263
+
264
+ if (appSecretResult.action === "use-env") {
265
+ next = {
266
+ ...next,
267
+ channels: {
268
+ ...next.channels,
269
+ feishu: { ...next.channels?.feishu, enabled: true },
270
+ },
271
+ };
272
+ } else if (appSecretResult.action === "set") {
273
+ appSecret = appSecretResult.value;
274
+ appSecretProbeValue = appSecretResult.resolvedValue;
275
+ appId = await promptFeishuAppId({
276
+ prompter,
277
+ initialValue:
278
+ normalizeString(feishuCfg?.appId) ?? normalizeString(process.env.FEISHU_APP_ID),
240
279
  });
241
- if (!keep) {
242
- const entered = await promptFeishuCredentials(prompter);
243
- appId = entered.appId;
244
- appSecret = entered.appSecret;
245
- }
246
- } else {
247
- const entered = await promptFeishuCredentials(prompter);
248
- appId = entered.appId;
249
- appSecret = entered.appSecret;
250
280
  }
251
281
 
252
282
  if (appId && appSecret) {
@@ -264,9 +294,12 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
264
294
  };
265
295
 
266
296
  // Test connection
267
- const testCfg = next.channels?.feishu as FeishuConfig;
268
297
  try {
269
- const probe = await probeFeishu(testCfg);
298
+ const probe = await probeFeishu({
299
+ appId,
300
+ appSecret: appSecretProbeValue ?? undefined,
301
+ domain: (next.channels?.feishu as FeishuConfig | undefined)?.domain,
302
+ });
270
303
  if (probe.ok) {
271
304
  await prompter.note(
272
305
  `Connected as ${probe.botName ?? probe.botOpenId ?? "bot"}`,
@@ -283,6 +316,80 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
283
316
  }
284
317
  }
285
318
 
319
+ const currentMode =
320
+ (next.channels?.feishu as FeishuConfig | undefined)?.connectionMode ?? "websocket";
321
+ const connectionMode = (await prompter.select({
322
+ message: "Feishu connection mode",
323
+ options: [
324
+ { value: "websocket", label: "WebSocket (default)" },
325
+ { value: "webhook", label: "Webhook" },
326
+ ],
327
+ initialValue: currentMode,
328
+ })) as "websocket" | "webhook";
329
+ next = {
330
+ ...next,
331
+ channels: {
332
+ ...next.channels,
333
+ feishu: {
334
+ ...next.channels?.feishu,
335
+ connectionMode,
336
+ },
337
+ },
338
+ };
339
+
340
+ if (connectionMode === "webhook") {
341
+ const currentVerificationToken = (next.channels?.feishu as FeishuConfig | undefined)
342
+ ?.verificationToken;
343
+ const verificationTokenPromptState = buildSingleChannelSecretPromptState({
344
+ accountConfigured: hasConfiguredSecretInput(currentVerificationToken),
345
+ hasConfigToken: hasConfiguredSecretInput(currentVerificationToken),
346
+ allowEnv: false,
347
+ });
348
+ const verificationTokenResult = await promptSingleChannelSecretInput({
349
+ cfg: next,
350
+ prompter,
351
+ providerHint: "feishu-webhook",
352
+ credentialLabel: "verification token",
353
+ accountConfigured: verificationTokenPromptState.accountConfigured,
354
+ canUseEnv: verificationTokenPromptState.canUseEnv,
355
+ hasConfigToken: verificationTokenPromptState.hasConfigToken,
356
+ envPrompt: "",
357
+ keepPrompt: "Feishu verification token already configured. Keep it?",
358
+ inputPrompt: "Enter Feishu verification token",
359
+ preferredEnvVar: "FEISHU_VERIFICATION_TOKEN",
360
+ });
361
+ if (verificationTokenResult.action === "set") {
362
+ next = {
363
+ ...next,
364
+ channels: {
365
+ ...next.channels,
366
+ feishu: {
367
+ ...next.channels?.feishu,
368
+ verificationToken: verificationTokenResult.value,
369
+ },
370
+ },
371
+ };
372
+ }
373
+ const currentWebhookPath = (next.channels?.feishu as FeishuConfig | undefined)?.webhookPath;
374
+ const webhookPath = String(
375
+ await prompter.text({
376
+ message: "Feishu webhook path",
377
+ initialValue: currentWebhookPath ?? "/feishu/events",
378
+ validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
379
+ }),
380
+ ).trim();
381
+ next = {
382
+ ...next,
383
+ channels: {
384
+ ...next.channels,
385
+ feishu: {
386
+ ...next.channels?.feishu,
387
+ webhookPath,
388
+ },
389
+ },
390
+ };
391
+ }
392
+
286
393
  // Domain selection
287
394
  const currentDomain = (next.channels?.feishu as FeishuConfig | undefined)?.domain ?? "feishu";
288
395
  const domain = await prompter.select({
@@ -329,7 +436,7 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
329
436
  initialValue: existing.length > 0 ? existing.map(String).join(", ") : undefined,
330
437
  });
331
438
  if (entry) {
332
- const parts = parseAllowFromInput(String(entry));
439
+ const parts = splitOnboardingEntries(String(entry));
333
440
  if (parts.length > 0) {
334
441
  next = setFeishuGroupAllowFrom(next, parts);
335
442
  }
@@ -136,6 +136,156 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
136
136
  expect(sendMessageFeishuMock).not.toHaveBeenCalled();
137
137
  expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "card_msg" }));
138
138
  });
139
+
140
+ it("forwards replyToId as replyToMessageId on sendText", async () => {
141
+ await sendText({
142
+ cfg: {} as any,
143
+ to: "chat_1",
144
+ text: "hello",
145
+ replyToId: "om_reply_1",
146
+ accountId: "main",
147
+ } as any);
148
+
149
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
150
+ expect.objectContaining({
151
+ to: "chat_1",
152
+ text: "hello",
153
+ replyToMessageId: "om_reply_1",
154
+ accountId: "main",
155
+ }),
156
+ );
157
+ });
158
+
159
+ it("falls back to threadId when replyToId is empty on sendText", async () => {
160
+ await sendText({
161
+ cfg: {} as any,
162
+ to: "chat_1",
163
+ text: "hello",
164
+ replyToId: " ",
165
+ threadId: "om_thread_2",
166
+ accountId: "main",
167
+ } as any);
168
+
169
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
170
+ expect.objectContaining({
171
+ to: "chat_1",
172
+ text: "hello",
173
+ replyToMessageId: "om_thread_2",
174
+ accountId: "main",
175
+ }),
176
+ );
177
+ });
178
+ });
179
+
180
+ describe("feishuOutbound.sendText replyToId forwarding", () => {
181
+ beforeEach(() => {
182
+ vi.clearAllMocks();
183
+ sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
184
+ sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
185
+ sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
186
+ });
187
+
188
+ it("forwards replyToId as replyToMessageId to sendMessageFeishu", async () => {
189
+ await sendText({
190
+ cfg: {} as any,
191
+ to: "chat_1",
192
+ text: "hello",
193
+ replyToId: "om_reply_target",
194
+ accountId: "main",
195
+ });
196
+
197
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
198
+ expect.objectContaining({
199
+ to: "chat_1",
200
+ text: "hello",
201
+ replyToMessageId: "om_reply_target",
202
+ accountId: "main",
203
+ }),
204
+ );
205
+ });
206
+
207
+ it("forwards replyToId to sendMarkdownCardFeishu when renderMode=card", async () => {
208
+ await sendText({
209
+ cfg: {
210
+ channels: {
211
+ feishu: {
212
+ renderMode: "card",
213
+ },
214
+ },
215
+ } as any,
216
+ to: "chat_1",
217
+ text: "```code```",
218
+ replyToId: "om_reply_target",
219
+ accountId: "main",
220
+ });
221
+
222
+ expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
223
+ expect.objectContaining({
224
+ replyToMessageId: "om_reply_target",
225
+ }),
226
+ );
227
+ });
228
+
229
+ it("does not pass replyToMessageId when replyToId is absent", async () => {
230
+ await sendText({
231
+ cfg: {} as any,
232
+ to: "chat_1",
233
+ text: "hello",
234
+ accountId: "main",
235
+ });
236
+
237
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
238
+ expect.objectContaining({
239
+ to: "chat_1",
240
+ text: "hello",
241
+ accountId: "main",
242
+ }),
243
+ );
244
+ expect(sendMessageFeishuMock.mock.calls[0][0].replyToMessageId).toBeUndefined();
245
+ });
246
+ });
247
+
248
+ describe("feishuOutbound.sendMedia replyToId forwarding", () => {
249
+ beforeEach(() => {
250
+ vi.clearAllMocks();
251
+ sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
252
+ sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
253
+ sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
254
+ });
255
+
256
+ it("forwards replyToId to sendMediaFeishu", async () => {
257
+ await feishuOutbound.sendMedia?.({
258
+ cfg: {} as any,
259
+ to: "chat_1",
260
+ text: "",
261
+ mediaUrl: "https://example.com/image.png",
262
+ replyToId: "om_reply_target",
263
+ accountId: "main",
264
+ });
265
+
266
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
267
+ expect.objectContaining({
268
+ replyToMessageId: "om_reply_target",
269
+ }),
270
+ );
271
+ });
272
+
273
+ it("forwards replyToId to text caption send", async () => {
274
+ await feishuOutbound.sendMedia?.({
275
+ cfg: {} as any,
276
+ to: "chat_1",
277
+ text: "caption text",
278
+ mediaUrl: "https://example.com/image.png",
279
+ replyToId: "om_reply_target",
280
+ accountId: "main",
281
+ });
282
+
283
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
284
+ expect.objectContaining({
285
+ replyToMessageId: "om_reply_target",
286
+ }),
287
+ );
288
+ });
139
289
  });
140
290
 
141
291
  describe("feishuOutbound.sendMedia renderMode", () => {
@@ -178,4 +328,32 @@ describe("feishuOutbound.sendMedia renderMode", () => {
178
328
  expect(sendMessageFeishuMock).not.toHaveBeenCalled();
179
329
  expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "media_msg" }));
180
330
  });
331
+
332
+ it("uses threadId fallback as replyToMessageId on sendMedia", async () => {
333
+ await feishuOutbound.sendMedia?.({
334
+ cfg: {} as any,
335
+ to: "chat_1",
336
+ text: "caption",
337
+ mediaUrl: "https://example.com/image.png",
338
+ threadId: "om_thread_1",
339
+ accountId: "main",
340
+ } as any);
341
+
342
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
343
+ expect.objectContaining({
344
+ to: "chat_1",
345
+ mediaUrl: "https://example.com/image.png",
346
+ replyToMessageId: "om_thread_1",
347
+ accountId: "main",
348
+ }),
349
+ );
350
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
351
+ expect.objectContaining({
352
+ to: "chat_1",
353
+ text: "caption",
354
+ replyToMessageId: "om_thread_1",
355
+ accountId: "main",
356
+ }),
357
+ );
358
+ });
181
359
  });