@openclaw/feishu 2026.2.25 → 2026.3.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 (64) 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 +90 -0
  5. package/src/accounts.ts +11 -2
  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 +55 -0
  10. package/src/bot.test.ts +863 -9
  11. package/src/bot.ts +414 -200
  12. package/src/card-action.ts +79 -0
  13. package/src/channel.ts +6 -0
  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 +107 -0
  18. package/src/client.ts +13 -0
  19. package/src/config-schema.test.ts +82 -1
  20. package/src/config-schema.ts +54 -3
  21. package/src/doc-schema.ts +141 -0
  22. package/src/docx-batch-insert.ts +190 -0
  23. package/src/docx-color-text.ts +149 -0
  24. package/src/docx-table-ops.ts +298 -0
  25. package/src/docx.account-selection.test.ts +76 -0
  26. package/src/docx.test.ts +470 -0
  27. package/src/docx.ts +996 -72
  28. package/src/drive.ts +38 -33
  29. package/src/media.test.ts +123 -6
  30. package/src/media.ts +31 -10
  31. package/src/monitor.account.ts +286 -0
  32. package/src/monitor.reaction.test.ts +235 -0
  33. package/src/monitor.startup.test.ts +187 -0
  34. package/src/monitor.startup.ts +51 -0
  35. package/src/monitor.state.ts +76 -0
  36. package/src/monitor.transport.ts +163 -0
  37. package/src/monitor.ts +44 -346
  38. package/src/monitor.webhook-security.test.ts +27 -1
  39. package/src/outbound.test.ts +181 -0
  40. package/src/outbound.ts +94 -7
  41. package/src/perm.ts +37 -30
  42. package/src/policy.test.ts +56 -1
  43. package/src/policy.ts +5 -1
  44. package/src/post.test.ts +105 -0
  45. package/src/post.ts +274 -0
  46. package/src/probe.test.ts +253 -0
  47. package/src/probe.ts +99 -7
  48. package/src/reply-dispatcher.test.ts +259 -0
  49. package/src/reply-dispatcher.ts +139 -45
  50. package/src/send.reply-fallback.test.ts +105 -0
  51. package/src/send.test.ts +168 -0
  52. package/src/send.ts +143 -18
  53. package/src/streaming-card.ts +131 -43
  54. package/src/targets.test.ts +26 -1
  55. package/src/targets.ts +11 -6
  56. package/src/tool-account-routing.test.ts +129 -0
  57. package/src/tool-account.ts +70 -0
  58. package/src/tool-factory-test-harness.ts +76 -0
  59. package/src/tools-config.test.ts +21 -0
  60. package/src/tools-config.ts +2 -1
  61. package/src/types.ts +1 -0
  62. package/src/typing.test.ts +144 -0
  63. package/src/typing.ts +140 -10
  64. package/src/wiki.ts +55 -50
package/src/monitor.ts CHANGED
@@ -1,16 +1,17 @@
1
- import * as http from "http";
2
- import * as Lark from "@larksuiteoapi/node-sdk";
1
+ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { listEnabledFeishuAccounts, resolveFeishuAccount } from "./accounts.js";
3
3
  import {
4
- type ClawdbotConfig,
5
- type RuntimeEnv,
6
- type HistoryEntry,
7
- installRequestBodyLimitGuard,
8
- } from "openclaw/plugin-sdk";
9
- import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
10
- import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
11
- import { createFeishuWSClient, createEventDispatcher } from "./client.js";
12
- import { probeFeishu } from "./probe.js";
13
- import type { ResolvedFeishuAccount } from "./types.js";
4
+ monitorSingleAccount,
5
+ resolveReactionSyntheticEvent,
6
+ type FeishuReactionCreatedEvent,
7
+ } from "./monitor.account.js";
8
+ import { fetchBotOpenIdForMonitor } from "./monitor.startup.js";
9
+ import {
10
+ clearFeishuWebhookRateLimitStateForTest,
11
+ getFeishuWebhookRateLimitStateSizeForTest,
12
+ isWebhookRateLimitedForTest,
13
+ stopFeishuMonitorState,
14
+ } from "./monitor.state.js";
14
15
 
15
16
  export type MonitorFeishuOpts = {
16
17
  config?: ClawdbotConfig;
@@ -19,316 +20,14 @@ export type MonitorFeishuOpts = {
19
20
  accountId?: string;
20
21
  };
21
22
 
22
- // Per-account WebSocket clients, HTTP servers, and bot info
23
- const wsClients = new Map<string, Lark.WSClient>();
24
- const httpServers = new Map<string, http.Server>();
25
- const botOpenIds = new Map<string, string>();
26
- const FEISHU_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
27
- const FEISHU_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
28
- const FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
29
- const FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 120;
30
- const FEISHU_WEBHOOK_COUNTER_LOG_EVERY = 25;
31
- const feishuWebhookRateLimits = new Map<string, { count: number; windowStartMs: number }>();
32
- const feishuWebhookStatusCounters = new Map<string, number>();
33
-
34
- function isJsonContentType(value: string | string[] | undefined): boolean {
35
- const first = Array.isArray(value) ? value[0] : value;
36
- if (!first) {
37
- return false;
38
- }
39
- const mediaType = first.split(";", 1)[0]?.trim().toLowerCase();
40
- return mediaType === "application/json" || Boolean(mediaType?.endsWith("+json"));
41
- }
42
-
43
- function isWebhookRateLimited(key: string, nowMs: number): boolean {
44
- const state = feishuWebhookRateLimits.get(key);
45
- if (!state || nowMs - state.windowStartMs >= FEISHU_WEBHOOK_RATE_LIMIT_WINDOW_MS) {
46
- feishuWebhookRateLimits.set(key, { count: 1, windowStartMs: nowMs });
47
- return false;
48
- }
49
-
50
- state.count += 1;
51
- if (state.count > FEISHU_WEBHOOK_RATE_LIMIT_MAX_REQUESTS) {
52
- return true;
53
- }
54
- return false;
55
- }
56
-
57
- function recordWebhookStatus(
58
- runtime: RuntimeEnv | undefined,
59
- accountId: string,
60
- path: string,
61
- statusCode: number,
62
- ): void {
63
- if (![400, 401, 408, 413, 415, 429].includes(statusCode)) {
64
- return;
65
- }
66
- const key = `${accountId}:${path}:${statusCode}`;
67
- const next = (feishuWebhookStatusCounters.get(key) ?? 0) + 1;
68
- feishuWebhookStatusCounters.set(key, next);
69
- if (next === 1 || next % FEISHU_WEBHOOK_COUNTER_LOG_EVERY === 0) {
70
- const log = runtime?.log ?? console.log;
71
- log(`feishu[${accountId}]: webhook anomaly path=${path} status=${statusCode} count=${next}`);
72
- }
73
- }
74
-
75
- async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
76
- try {
77
- const result = await probeFeishu(account);
78
- return result.ok ? result.botOpenId : undefined;
79
- } catch {
80
- return undefined;
81
- }
82
- }
83
-
84
- /**
85
- * Register common event handlers on an EventDispatcher.
86
- * When fireAndForget is true (webhook mode), message handling is not awaited
87
- * to avoid blocking the HTTP response (Lark requires <3s response).
88
- */
89
- function registerEventHandlers(
90
- eventDispatcher: Lark.EventDispatcher,
91
- context: {
92
- cfg: ClawdbotConfig;
93
- accountId: string;
94
- runtime?: RuntimeEnv;
95
- chatHistories: Map<string, HistoryEntry[]>;
96
- fireAndForget?: boolean;
97
- },
98
- ) {
99
- const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
100
- const log = runtime?.log ?? console.log;
101
- const error = runtime?.error ?? console.error;
102
-
103
- eventDispatcher.register({
104
- "im.message.receive_v1": async (data) => {
105
- try {
106
- const event = data as unknown as FeishuMessageEvent;
107
- const promise = handleFeishuMessage({
108
- cfg,
109
- event,
110
- botOpenId: botOpenIds.get(accountId),
111
- runtime,
112
- chatHistories,
113
- accountId,
114
- });
115
- if (fireAndForget) {
116
- promise.catch((err) => {
117
- error(`feishu[${accountId}]: error handling message: ${String(err)}`);
118
- });
119
- } else {
120
- await promise;
121
- }
122
- } catch (err) {
123
- error(`feishu[${accountId}]: error handling message: ${String(err)}`);
124
- }
125
- },
126
- "im.message.message_read_v1": async () => {
127
- // Ignore read receipts
128
- },
129
- "im.chat.member.bot.added_v1": async (data) => {
130
- try {
131
- const event = data as unknown as FeishuBotAddedEvent;
132
- log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
133
- } catch (err) {
134
- error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
135
- }
136
- },
137
- "im.chat.member.bot.deleted_v1": async (data) => {
138
- try {
139
- const event = data as unknown as { chat_id: string };
140
- log(`feishu[${accountId}]: bot removed from chat ${event.chat_id}`);
141
- } catch (err) {
142
- error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
143
- }
144
- },
145
- });
146
- }
147
-
148
- type MonitorAccountParams = {
149
- cfg: ClawdbotConfig;
150
- account: ResolvedFeishuAccount;
151
- runtime?: RuntimeEnv;
152
- abortSignal?: AbortSignal;
153
- };
154
-
155
- /**
156
- * Monitor a single Feishu account.
157
- */
158
- async function monitorSingleAccount(params: MonitorAccountParams): Promise<void> {
159
- const { cfg, account, runtime, abortSignal } = params;
160
- const { accountId } = account;
161
- const log = runtime?.log ?? console.log;
162
-
163
- // Fetch bot open_id
164
- const botOpenId = await fetchBotOpenId(account);
165
- botOpenIds.set(accountId, botOpenId ?? "");
166
- log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
167
-
168
- const connectionMode = account.config.connectionMode ?? "websocket";
169
- if (connectionMode === "webhook" && !account.verificationToken?.trim()) {
170
- throw new Error(`Feishu account "${accountId}" webhook mode requires verificationToken`);
171
- }
172
- const eventDispatcher = createEventDispatcher(account);
173
- const chatHistories = new Map<string, HistoryEntry[]>();
174
-
175
- registerEventHandlers(eventDispatcher, {
176
- cfg,
177
- accountId,
178
- runtime,
179
- chatHistories,
180
- fireAndForget: connectionMode === "webhook",
181
- });
182
-
183
- if (connectionMode === "webhook") {
184
- return monitorWebhook({ params, accountId, eventDispatcher });
185
- }
186
-
187
- return monitorWebSocket({ params, accountId, eventDispatcher });
188
- }
189
-
190
- type ConnectionParams = {
191
- params: MonitorAccountParams;
192
- accountId: string;
193
- eventDispatcher: Lark.EventDispatcher;
23
+ export {
24
+ clearFeishuWebhookRateLimitStateForTest,
25
+ getFeishuWebhookRateLimitStateSizeForTest,
26
+ isWebhookRateLimitedForTest,
27
+ resolveReactionSyntheticEvent,
194
28
  };
29
+ export type { FeishuReactionCreatedEvent };
195
30
 
196
- async function monitorWebSocket({
197
- params,
198
- accountId,
199
- eventDispatcher,
200
- }: ConnectionParams): Promise<void> {
201
- const { account, runtime, abortSignal } = params;
202
- const log = runtime?.log ?? console.log;
203
- const error = runtime?.error ?? console.error;
204
-
205
- log(`feishu[${accountId}]: starting WebSocket connection...`);
206
-
207
- const wsClient = createFeishuWSClient(account);
208
- wsClients.set(accountId, wsClient);
209
-
210
- return new Promise((resolve, reject) => {
211
- const cleanup = () => {
212
- wsClients.delete(accountId);
213
- botOpenIds.delete(accountId);
214
- };
215
-
216
- const handleAbort = () => {
217
- log(`feishu[${accountId}]: abort signal received, stopping`);
218
- cleanup();
219
- resolve();
220
- };
221
-
222
- if (abortSignal?.aborted) {
223
- cleanup();
224
- resolve();
225
- return;
226
- }
227
-
228
- abortSignal?.addEventListener("abort", handleAbort, { once: true });
229
-
230
- try {
231
- wsClient.start({ eventDispatcher });
232
- log(`feishu[${accountId}]: WebSocket client started`);
233
- } catch (err) {
234
- cleanup();
235
- abortSignal?.removeEventListener("abort", handleAbort);
236
- reject(err);
237
- }
238
- });
239
- }
240
-
241
- async function monitorWebhook({
242
- params,
243
- accountId,
244
- eventDispatcher,
245
- }: ConnectionParams): Promise<void> {
246
- const { account, runtime, abortSignal } = params;
247
- const log = runtime?.log ?? console.log;
248
- const error = runtime?.error ?? console.error;
249
-
250
- const port = account.config.webhookPort ?? 3000;
251
- const path = account.config.webhookPath ?? "/feishu/events";
252
- const host = account.config.webhookHost ?? "127.0.0.1";
253
-
254
- log(`feishu[${accountId}]: starting Webhook server on ${host}:${port}, path ${path}...`);
255
-
256
- const server = http.createServer();
257
- const webhookHandler = Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true });
258
- server.on("request", (req, res) => {
259
- res.on("finish", () => {
260
- recordWebhookStatus(runtime, accountId, path, res.statusCode);
261
- });
262
-
263
- const rateLimitKey = `${accountId}:${path}:${req.socket.remoteAddress ?? "unknown"}`;
264
- if (isWebhookRateLimited(rateLimitKey, Date.now())) {
265
- res.statusCode = 429;
266
- res.end("Too Many Requests");
267
- return;
268
- }
269
-
270
- if (req.method === "POST" && !isJsonContentType(req.headers["content-type"])) {
271
- res.statusCode = 415;
272
- res.end("Unsupported Media Type");
273
- return;
274
- }
275
-
276
- const guard = installRequestBodyLimitGuard(req, res, {
277
- maxBytes: FEISHU_WEBHOOK_MAX_BODY_BYTES,
278
- timeoutMs: FEISHU_WEBHOOK_BODY_TIMEOUT_MS,
279
- responseFormat: "text",
280
- });
281
- if (guard.isTripped()) {
282
- return;
283
- }
284
- void Promise.resolve(webhookHandler(req, res))
285
- .catch((err) => {
286
- if (!guard.isTripped()) {
287
- error(`feishu[${accountId}]: webhook handler error: ${String(err)}`);
288
- }
289
- })
290
- .finally(() => {
291
- guard.dispose();
292
- });
293
- });
294
- httpServers.set(accountId, server);
295
-
296
- return new Promise((resolve, reject) => {
297
- const cleanup = () => {
298
- server.close();
299
- httpServers.delete(accountId);
300
- botOpenIds.delete(accountId);
301
- };
302
-
303
- const handleAbort = () => {
304
- log(`feishu[${accountId}]: abort signal received, stopping Webhook server`);
305
- cleanup();
306
- resolve();
307
- };
308
-
309
- if (abortSignal?.aborted) {
310
- cleanup();
311
- resolve();
312
- return;
313
- }
314
-
315
- abortSignal?.addEventListener("abort", handleAbort, { once: true });
316
-
317
- server.listen(port, host, () => {
318
- log(`feishu[${accountId}]: Webhook server listening on ${host}:${port}`);
319
- });
320
-
321
- server.on("error", (err) => {
322
- error(`feishu[${accountId}]: Webhook server error: ${err}`);
323
- abortSignal?.removeEventListener("abort", handleAbort);
324
- reject(err);
325
- });
326
- });
327
- }
328
-
329
- /**
330
- * Main entry: start monitoring for all enabled accounts.
331
- */
332
31
  export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promise<void> {
333
32
  const cfg = opts.config;
334
33
  if (!cfg) {
@@ -337,7 +36,6 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
337
36
 
338
37
  const log = opts.runtime?.log ?? console.log;
339
38
 
340
- // If accountId is specified, only monitor that account
341
39
  if (opts.accountId) {
342
40
  const account = resolveFeishuAccount({ cfg, accountId: opts.accountId });
343
41
  if (!account.enabled || !account.configured) {
@@ -351,7 +49,6 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
351
49
  });
352
50
  }
353
51
 
354
- // Otherwise, start all enabled accounts
355
52
  const accounts = listEnabledFeishuAccounts(cfg);
356
53
  if (accounts.length === 0) {
357
54
  throw new Error("No enabled Feishu accounts configured");
@@ -361,37 +58,38 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
361
58
  `feishu: starting ${accounts.length} account(s): ${accounts.map((a) => a.accountId).join(", ")}`,
362
59
  );
363
60
 
364
- // Start all accounts in parallel
365
- await Promise.all(
366
- accounts.map((account) =>
61
+ const monitorPromises: Promise<void>[] = [];
62
+ for (const account of accounts) {
63
+ if (opts.abortSignal?.aborted) {
64
+ log("feishu: abort signal received during startup preflight; stopping startup");
65
+ break;
66
+ }
67
+
68
+ // Probe sequentially so large multi-account startups do not burst Feishu's bot-info endpoint.
69
+ const botOpenId = await fetchBotOpenIdForMonitor(account, {
70
+ runtime: opts.runtime,
71
+ abortSignal: opts.abortSignal,
72
+ });
73
+
74
+ if (opts.abortSignal?.aborted) {
75
+ log("feishu: abort signal received during startup preflight; stopping startup");
76
+ break;
77
+ }
78
+
79
+ monitorPromises.push(
367
80
  monitorSingleAccount({
368
81
  cfg,
369
82
  account,
370
83
  runtime: opts.runtime,
371
84
  abortSignal: opts.abortSignal,
85
+ botOpenIdSource: { kind: "prefetched", botOpenId },
372
86
  }),
373
- ),
374
- );
87
+ );
88
+ }
89
+
90
+ await Promise.all(monitorPromises);
375
91
  }
376
92
 
377
- /**
378
- * Stop monitoring for a specific account or all accounts.
379
- */
380
93
  export function stopFeishuMonitor(accountId?: string): void {
381
- if (accountId) {
382
- wsClients.delete(accountId);
383
- const server = httpServers.get(accountId);
384
- if (server) {
385
- server.close();
386
- httpServers.delete(accountId);
387
- }
388
- botOpenIds.delete(accountId);
389
- } else {
390
- wsClients.clear();
391
- for (const server of httpServers.values()) {
392
- server.close();
393
- }
394
- httpServers.clear();
395
- botOpenIds.clear();
396
- }
94
+ stopFeishuMonitorState(accountId);
397
95
  }
@@ -23,7 +23,13 @@ vi.mock("./client.js", () => ({
23
23
  createEventDispatcher: vi.fn(() => ({ register: vi.fn() })),
24
24
  }));
25
25
 
26
- import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
26
+ import {
27
+ clearFeishuWebhookRateLimitStateForTest,
28
+ getFeishuWebhookRateLimitStateSizeForTest,
29
+ isWebhookRateLimitedForTest,
30
+ monitorFeishuProvider,
31
+ stopFeishuMonitor,
32
+ } from "./monitor.js";
27
33
 
28
34
  async function getFreePort(): Promise<number> {
29
35
  const server = createServer();
@@ -114,6 +120,7 @@ async function withRunningWebhookMonitor(
114
120
  }
115
121
 
116
122
  afterEach(() => {
123
+ clearFeishuWebhookRateLimitStateForTest();
117
124
  stopFeishuMonitor();
118
125
  });
119
126
 
@@ -180,4 +187,23 @@ describe("Feishu webhook security hardening", () => {
180
187
  },
181
188
  );
182
189
  });
190
+
191
+ it("caps tracked webhook rate-limit keys to prevent unbounded growth", () => {
192
+ const now = 1_000_000;
193
+ for (let i = 0; i < 4_500; i += 1) {
194
+ isWebhookRateLimitedForTest(`/feishu-rate-limit:key-${i}`, now);
195
+ }
196
+ expect(getFeishuWebhookRateLimitStateSizeForTest()).toBeLessThanOrEqual(4_096);
197
+ });
198
+
199
+ it("prunes stale webhook rate-limit state after window elapses", () => {
200
+ const now = 2_000_000;
201
+ for (let i = 0; i < 100; i += 1) {
202
+ isWebhookRateLimitedForTest(`/feishu-rate-limit-stale:key-${i}`, now);
203
+ }
204
+ expect(getFeishuWebhookRateLimitStateSizeForTest()).toBe(100);
205
+
206
+ isWebhookRateLimitedForTest("/feishu-rate-limit-stale:fresh", now + 60_001);
207
+ expect(getFeishuWebhookRateLimitStateSizeForTest()).toBe(1);
208
+ });
183
209
  });
@@ -0,0 +1,181 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
7
+ const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
8
+ const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
9
+
10
+ vi.mock("./media.js", () => ({
11
+ sendMediaFeishu: sendMediaFeishuMock,
12
+ }));
13
+
14
+ vi.mock("./send.js", () => ({
15
+ sendMessageFeishu: sendMessageFeishuMock,
16
+ sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
17
+ }));
18
+
19
+ vi.mock("./runtime.js", () => ({
20
+ getFeishuRuntime: () => ({
21
+ channel: {
22
+ text: {
23
+ chunkMarkdownText: (text: string) => [text],
24
+ },
25
+ },
26
+ }),
27
+ }));
28
+
29
+ import { feishuOutbound } from "./outbound.js";
30
+ const sendText = feishuOutbound.sendText!;
31
+
32
+ describe("feishuOutbound.sendText local-image auto-convert", () => {
33
+ beforeEach(() => {
34
+ vi.clearAllMocks();
35
+ sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
36
+ sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
37
+ sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
38
+ });
39
+
40
+ async function createTmpImage(ext = ".png"): Promise<{ dir: string; file: string }> {
41
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-feishu-outbound-"));
42
+ const file = path.join(dir, `sample${ext}`);
43
+ await fs.writeFile(file, "image-data");
44
+ return { dir, file };
45
+ }
46
+
47
+ it("sends an absolute existing local image path as media", async () => {
48
+ const { dir, file } = await createTmpImage();
49
+ try {
50
+ const result = await sendText({
51
+ cfg: {} as any,
52
+ to: "chat_1",
53
+ text: file,
54
+ accountId: "main",
55
+ });
56
+
57
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
58
+ expect.objectContaining({
59
+ to: "chat_1",
60
+ mediaUrl: file,
61
+ accountId: "main",
62
+ }),
63
+ );
64
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
65
+ expect(result).toEqual(
66
+ expect.objectContaining({ channel: "feishu", messageId: "media_msg" }),
67
+ );
68
+ } finally {
69
+ await fs.rm(dir, { recursive: true, force: true });
70
+ }
71
+ });
72
+
73
+ it("keeps non-path text on the text-send path", async () => {
74
+ await sendText({
75
+ cfg: {} as any,
76
+ to: "chat_1",
77
+ text: "please upload /tmp/example.png",
78
+ accountId: "main",
79
+ });
80
+
81
+ expect(sendMediaFeishuMock).not.toHaveBeenCalled();
82
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
83
+ expect.objectContaining({
84
+ to: "chat_1",
85
+ text: "please upload /tmp/example.png",
86
+ accountId: "main",
87
+ }),
88
+ );
89
+ });
90
+
91
+ it("falls back to plain text if local-image media send fails", async () => {
92
+ const { dir, file } = await createTmpImage();
93
+ sendMediaFeishuMock.mockRejectedValueOnce(new Error("upload failed"));
94
+ try {
95
+ await sendText({
96
+ cfg: {} as any,
97
+ to: "chat_1",
98
+ text: file,
99
+ accountId: "main",
100
+ });
101
+
102
+ expect(sendMediaFeishuMock).toHaveBeenCalledTimes(1);
103
+ expect(sendMessageFeishuMock).toHaveBeenCalledWith(
104
+ expect.objectContaining({
105
+ to: "chat_1",
106
+ text: file,
107
+ accountId: "main",
108
+ }),
109
+ );
110
+ } finally {
111
+ await fs.rm(dir, { recursive: true, force: true });
112
+ }
113
+ });
114
+
115
+ it("uses markdown cards when renderMode=card", async () => {
116
+ const result = await sendText({
117
+ cfg: {
118
+ channels: {
119
+ feishu: {
120
+ renderMode: "card",
121
+ },
122
+ },
123
+ } as any,
124
+ to: "chat_1",
125
+ text: "| a | b |\n| - | - |",
126
+ accountId: "main",
127
+ });
128
+
129
+ expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
130
+ expect.objectContaining({
131
+ to: "chat_1",
132
+ text: "| a | b |\n| - | - |",
133
+ accountId: "main",
134
+ }),
135
+ );
136
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
137
+ expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "card_msg" }));
138
+ });
139
+ });
140
+
141
+ describe("feishuOutbound.sendMedia renderMode", () => {
142
+ beforeEach(() => {
143
+ vi.clearAllMocks();
144
+ sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
145
+ sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
146
+ sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
147
+ });
148
+
149
+ it("uses markdown cards for captions when renderMode=card", async () => {
150
+ const result = await feishuOutbound.sendMedia?.({
151
+ cfg: {
152
+ channels: {
153
+ feishu: {
154
+ renderMode: "card",
155
+ },
156
+ },
157
+ } as any,
158
+ to: "chat_1",
159
+ text: "| a | b |\n| - | - |",
160
+ mediaUrl: "https://example.com/image.png",
161
+ accountId: "main",
162
+ });
163
+
164
+ expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith(
165
+ expect.objectContaining({
166
+ to: "chat_1",
167
+ text: "| a | b |\n| - | - |",
168
+ accountId: "main",
169
+ }),
170
+ );
171
+ expect(sendMediaFeishuMock).toHaveBeenCalledWith(
172
+ expect.objectContaining({
173
+ to: "chat_1",
174
+ mediaUrl: "https://example.com/image.png",
175
+ accountId: "main",
176
+ }),
177
+ );
178
+ expect(sendMessageFeishuMock).not.toHaveBeenCalled();
179
+ expect(result).toEqual(expect.objectContaining({ channel: "feishu", messageId: "media_msg" }));
180
+ });
181
+ });