@openclaw/feishu 2026.2.9 → 2026.2.13

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.
@@ -0,0 +1,48 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ const probeFeishuMock = vi.hoisted(() => vi.fn());
5
+
6
+ vi.mock("./probe.js", () => ({
7
+ probeFeishu: probeFeishuMock,
8
+ }));
9
+
10
+ import { feishuPlugin } from "./channel.js";
11
+
12
+ describe("feishuPlugin.status.probeAccount", () => {
13
+ it("uses current account credentials for multi-account config", async () => {
14
+ const cfg = {
15
+ channels: {
16
+ feishu: {
17
+ enabled: true,
18
+ accounts: {
19
+ main: {
20
+ appId: "cli_main",
21
+ appSecret: "secret_main",
22
+ enabled: true,
23
+ },
24
+ },
25
+ },
26
+ },
27
+ } as OpenClawConfig;
28
+
29
+ const account = feishuPlugin.config.resolveAccount(cfg, "main");
30
+ probeFeishuMock.mockResolvedValueOnce({ ok: true, appId: "cli_main" });
31
+
32
+ const result = await feishuPlugin.status?.probeAccount?.({
33
+ account,
34
+ timeoutMs: 1_000,
35
+ cfg,
36
+ });
37
+
38
+ expect(probeFeishuMock).toHaveBeenCalledTimes(1);
39
+ expect(probeFeishuMock).toHaveBeenCalledWith(
40
+ expect.objectContaining({
41
+ accountId: "main",
42
+ appId: "cli_main",
43
+ appSecret: "secret_main",
44
+ }),
45
+ );
46
+ expect(result).toMatchObject({ ok: true, appId: "cli_main" });
47
+ });
48
+ });
package/src/channel.ts CHANGED
@@ -3,6 +3,7 @@ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sd
3
3
  import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
4
4
  import {
5
5
  resolveFeishuAccount,
6
+ resolveFeishuCredentials,
6
7
  listFeishuAccountIds,
7
8
  resolveDefaultFeishuAccountId,
8
9
  } from "./accounts.js";
@@ -17,7 +18,7 @@ import { feishuOutbound } from "./outbound.js";
17
18
  import { resolveFeishuGroupToolPolicy } from "./policy.js";
18
19
  import { probeFeishu } from "./probe.js";
19
20
  import { sendMessageFeishu } from "./send.js";
20
- import { normalizeFeishuTarget, looksLikeFeishuId } from "./targets.js";
21
+ import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
21
22
 
22
23
  const meta: ChannelMeta = {
23
24
  id: "feishu",
@@ -47,13 +48,13 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
47
48
  },
48
49
  },
49
50
  capabilities: {
50
- chatTypes: ["direct", "group"],
51
+ chatTypes: ["direct", "channel"],
52
+ polls: false,
53
+ threads: true,
51
54
  media: true,
52
55
  reactions: true,
53
- threads: false,
54
- polls: false,
55
- nativeCommands: true,
56
- blockStreaming: true,
56
+ edit: true,
57
+ reply: true,
57
58
  },
58
59
  agentPrompt: {
59
60
  messageToolHints: () => [
@@ -92,6 +93,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
92
93
  items: { oneOf: [{ type: "string" }, { type: "number" }] },
93
94
  },
94
95
  requireMention: { type: "boolean" },
96
+ topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
95
97
  historyLimit: { type: "integer", minimum: 0 },
96
98
  dmHistoryLimit: { type: "integer", minimum: 0 },
97
99
  textChunkLimit: { type: "integer", minimum: 1 },
@@ -122,7 +124,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
122
124
  resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
123
125
  defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
124
126
  setAccountEnabled: ({ cfg, accountId, enabled }) => {
125
- const _account = resolveFeishuAccount({ cfg, accountId });
127
+ const account = resolveFeishuAccount({ cfg, accountId });
126
128
  const isDefault = accountId === DEFAULT_ACCOUNT_ID;
127
129
 
128
130
  if (isDefault) {
@@ -217,9 +219,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
217
219
  cfg.channels as Record<string, { groupPolicy?: string }> | undefined
218
220
  )?.defaults?.groupPolicy;
219
221
  const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
220
- if (groupPolicy !== "open") {
221
- return [];
222
- }
222
+ if (groupPolicy !== "open") return [];
223
223
  return [
224
224
  `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
225
225
  ];
@@ -321,9 +321,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
321
321
  probe: snapshot.probe,
322
322
  lastProbeAt: snapshot.lastProbeAt ?? null,
323
323
  }),
324
- probeAccount: async ({ account }) => {
325
- return await probeFeishu(account);
326
- },
324
+ probeAccount: async ({ account }) => await probeFeishu(account),
327
325
  buildAccountSnapshot: ({ account, runtime, probe }) => ({
328
326
  accountId: account.accountId,
329
327
  enabled: account.enabled,
@@ -36,6 +36,10 @@ const MarkdownConfigSchema = z
36
36
  // Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
37
37
  const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
38
38
 
39
+ // Streaming card mode: when enabled, card replies use Feishu's Card Kit streaming API
40
+ // for incremental text display with a "Thinking..." placeholder
41
+ const StreamingModeSchema = z.boolean().optional();
42
+
39
43
  const BlockStreamingCoalesceSchema = z
40
44
  .object({
41
45
  enabled: z.boolean().optional(),
@@ -53,6 +57,20 @@ const ChannelHeartbeatVisibilitySchema = z
53
57
  .strict()
54
58
  .optional();
55
59
 
60
+ /**
61
+ * Dynamic agent creation configuration.
62
+ * When enabled, a new agent is created for each unique DM user.
63
+ */
64
+ const DynamicAgentCreationSchema = z
65
+ .object({
66
+ enabled: z.boolean().optional(),
67
+ workspaceTemplate: z.string().optional(),
68
+ agentDirTemplate: z.string().optional(),
69
+ maxAgents: z.number().int().positive().optional(),
70
+ })
71
+ .strict()
72
+ .optional();
73
+
56
74
  /**
57
75
  * Feishu tools configuration.
58
76
  * Controls which tool categories are enabled.
@@ -72,6 +90,16 @@ const FeishuToolsConfigSchema = z
72
90
  .strict()
73
91
  .optional();
74
92
 
93
+ /**
94
+ * Topic session isolation mode for group chats.
95
+ * - "disabled" (default): All messages in a group share one session
96
+ * - "enabled": Messages in different topics get separate sessions
97
+ *
98
+ * When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
99
+ * for messages within a topic thread, allowing isolated conversations.
100
+ */
101
+ const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
102
+
75
103
  export const FeishuGroupSchema = z
76
104
  .object({
77
105
  requireMention: z.boolean().optional(),
@@ -80,6 +108,7 @@ export const FeishuGroupSchema = z
80
108
  enabled: z.boolean().optional(),
81
109
  allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
82
110
  systemPrompt: z.string().optional(),
111
+ topicSessionMode: TopicSessionModeSchema,
83
112
  })
84
113
  .strict();
85
114
 
@@ -117,6 +146,7 @@ export const FeishuAccountConfigSchema = z
117
146
  mediaMaxMb: z.number().positive().optional(),
118
147
  heartbeat: ChannelHeartbeatVisibilitySchema,
119
148
  renderMode: RenderModeSchema,
149
+ streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
120
150
  tools: FeishuToolsConfigSchema,
121
151
  })
122
152
  .strict();
@@ -142,6 +172,7 @@ export const FeishuConfigSchema = z
142
172
  groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
143
173
  requireMention: z.boolean().optional().default(true),
144
174
  groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
175
+ topicSessionMode: TopicSessionModeSchema,
145
176
  historyLimit: z.number().int().min(0).optional(),
146
177
  dmHistoryLimit: z.number().int().min(0).optional(),
147
178
  dms: z.record(z.string(), DmConfigSchema).optional(),
@@ -151,7 +182,10 @@ export const FeishuConfigSchema = z
151
182
  mediaMaxMb: z.number().positive().optional(),
152
183
  heartbeat: ChannelHeartbeatVisibilitySchema,
153
184
  renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
185
+ streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
154
186
  tools: FeishuToolsConfigSchema,
187
+ // Dynamic agent creation for DM users
188
+ dynamicAgentCreation: DynamicAgentCreationSchema,
155
189
  // Multi-account configuration
156
190
  accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
157
191
  })
package/src/dedup.ts ADDED
@@ -0,0 +1,33 @@
1
+ // Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
2
+ const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
3
+ const DEDUP_MAX_SIZE = 1_000;
4
+ const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
5
+ const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
6
+ let lastCleanupTime = Date.now();
7
+
8
+ export function tryRecordMessage(messageId: string): boolean {
9
+ const now = Date.now();
10
+
11
+ // Throttled cleanup: evict expired entries at most once per interval.
12
+ if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
13
+ for (const [id, ts] of processedMessageIds) {
14
+ if (now - ts > DEDUP_TTL_MS) {
15
+ processedMessageIds.delete(id);
16
+ }
17
+ }
18
+ lastCleanupTime = now;
19
+ }
20
+
21
+ if (processedMessageIds.has(messageId)) {
22
+ return false;
23
+ }
24
+
25
+ // Evict oldest entries if cache is full.
26
+ if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
27
+ const first = processedMessageIds.keys().next().value!;
28
+ processedMessageIds.delete(first);
29
+ }
30
+
31
+ processedMessageIds.set(messageId, now);
32
+ return true;
33
+ }
package/src/docx.ts CHANGED
@@ -92,6 +92,14 @@ async function convertMarkdown(client: Lark.Client, markdown: string) {
92
92
  };
93
93
  }
94
94
 
95
+ function sortBlocksByFirstLevel(blocks: any[], firstLevelIds: string[]): any[] {
96
+ if (!firstLevelIds || firstLevelIds.length === 0) return blocks;
97
+ const sorted = firstLevelIds.map((id) => blocks.find((b) => b.block_id === id)).filter(Boolean);
98
+ const sortedIds = new Set(firstLevelIds);
99
+ const remaining = blocks.filter((b) => !sortedIds.has(b.block_id));
100
+ return [...sorted, ...remaining];
101
+ }
102
+
95
103
  /* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
96
104
  async function insertBlocks(
97
105
  client: Lark.Client,
@@ -279,12 +287,13 @@ async function createDoc(client: Lark.Client, title: string, folderToken?: strin
279
287
  async function writeDoc(client: Lark.Client, docToken: string, markdown: string) {
280
288
  const deleted = await clearDocumentContent(client, docToken);
281
289
 
282
- const { blocks } = await convertMarkdown(client, markdown);
290
+ const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
283
291
  if (blocks.length === 0) {
284
292
  return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
285
293
  }
294
+ const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
286
295
 
287
- const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks);
296
+ const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
288
297
  const imagesProcessed = await processImages(client, docToken, markdown, inserted);
289
298
 
290
299
  return {
@@ -299,12 +308,13 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
299
308
  }
300
309
 
301
310
  async function appendDoc(client: Lark.Client, docToken: string, markdown: string) {
302
- const { blocks } = await convertMarkdown(client, markdown);
311
+ const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
303
312
  if (blocks.length === 0) {
304
313
  throw new Error("Content is empty");
305
314
  }
315
+ const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
306
316
 
307
- const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks);
317
+ const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
308
318
  const imagesProcessed = await processImages(client, docToken, markdown, inserted);
309
319
 
310
320
  return {
@@ -0,0 +1,131 @@
1
+ import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type { DynamicAgentCreationConfig } from "./types.js";
6
+
7
+ export type MaybeCreateDynamicAgentResult = {
8
+ created: boolean;
9
+ updatedCfg: OpenClawConfig;
10
+ agentId?: string;
11
+ };
12
+
13
+ /**
14
+ * Check if a dynamic agent should be created for a DM user and create it if needed.
15
+ * This creates a unique agent instance with its own workspace for each DM user.
16
+ */
17
+ export async function maybeCreateDynamicAgent(params: {
18
+ cfg: OpenClawConfig;
19
+ runtime: PluginRuntime;
20
+ senderOpenId: string;
21
+ dynamicCfg: DynamicAgentCreationConfig;
22
+ log: (msg: string) => void;
23
+ }): Promise<MaybeCreateDynamicAgentResult> {
24
+ const { cfg, runtime, senderOpenId, dynamicCfg, log } = params;
25
+
26
+ // Check if there's already a binding for this user
27
+ const existingBindings = cfg.bindings ?? [];
28
+ const hasBinding = existingBindings.some(
29
+ (b) =>
30
+ b.match?.channel === "feishu" &&
31
+ b.match?.peer?.kind === "direct" &&
32
+ b.match?.peer?.id === senderOpenId,
33
+ );
34
+
35
+ if (hasBinding) {
36
+ return { created: false, updatedCfg: cfg };
37
+ }
38
+
39
+ // Check maxAgents limit if configured
40
+ if (dynamicCfg.maxAgents !== undefined) {
41
+ const feishuAgentCount = (cfg.agents?.list ?? []).filter((a) =>
42
+ a.id.startsWith("feishu-"),
43
+ ).length;
44
+ if (feishuAgentCount >= dynamicCfg.maxAgents) {
45
+ log(
46
+ `feishu: maxAgents limit (${dynamicCfg.maxAgents}) reached, not creating agent for ${senderOpenId}`,
47
+ );
48
+ return { created: false, updatedCfg: cfg };
49
+ }
50
+ }
51
+
52
+ // Use full OpenID as agent ID suffix (OpenID format: ou_xxx is already filesystem-safe)
53
+ const agentId = `feishu-${senderOpenId}`;
54
+
55
+ // Check if agent already exists (but binding was missing)
56
+ const existingAgent = (cfg.agents?.list ?? []).find((a) => a.id === agentId);
57
+ if (existingAgent) {
58
+ // Agent exists but binding doesn't - just add the binding
59
+ log(`feishu: agent "${agentId}" exists, adding missing binding for ${senderOpenId}`);
60
+
61
+ const updatedCfg: OpenClawConfig = {
62
+ ...cfg,
63
+ bindings: [
64
+ ...existingBindings,
65
+ {
66
+ agentId,
67
+ match: {
68
+ channel: "feishu",
69
+ peer: { kind: "direct", id: senderOpenId },
70
+ },
71
+ },
72
+ ],
73
+ };
74
+
75
+ await runtime.config.writeConfigFile(updatedCfg);
76
+ return { created: true, updatedCfg, agentId };
77
+ }
78
+
79
+ // Resolve path templates with substitutions
80
+ const workspaceTemplate = dynamicCfg.workspaceTemplate ?? "~/.openclaw/workspace-{agentId}";
81
+ const agentDirTemplate = dynamicCfg.agentDirTemplate ?? "~/.openclaw/agents/{agentId}/agent";
82
+
83
+ const workspace = resolveUserPath(
84
+ workspaceTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
85
+ );
86
+ const agentDir = resolveUserPath(
87
+ agentDirTemplate.replace("{userId}", senderOpenId).replace("{agentId}", agentId),
88
+ );
89
+
90
+ log(`feishu: creating dynamic agent "${agentId}" for user ${senderOpenId}`);
91
+ log(` workspace: ${workspace}`);
92
+ log(` agentDir: ${agentDir}`);
93
+
94
+ // Create directories
95
+ await fs.promises.mkdir(workspace, { recursive: true });
96
+ await fs.promises.mkdir(agentDir, { recursive: true });
97
+
98
+ // Update configuration with new agent and binding
99
+ const updatedCfg: OpenClawConfig = {
100
+ ...cfg,
101
+ agents: {
102
+ ...cfg.agents,
103
+ list: [...(cfg.agents?.list ?? []), { id: agentId, workspace, agentDir }],
104
+ },
105
+ bindings: [
106
+ ...existingBindings,
107
+ {
108
+ agentId,
109
+ match: {
110
+ channel: "feishu",
111
+ peer: { kind: "direct", id: senderOpenId },
112
+ },
113
+ },
114
+ ],
115
+ };
116
+
117
+ // Write updated config using PluginRuntime API
118
+ await runtime.config.writeConfigFile(updatedCfg);
119
+
120
+ return { created: true, updatedCfg, agentId };
121
+ }
122
+
123
+ /**
124
+ * Resolve a path that may start with ~ to the user's home directory.
125
+ */
126
+ function resolveUserPath(p: string): string {
127
+ if (p.startsWith("~/")) {
128
+ return path.join(os.homedir(), p.slice(2));
129
+ }
130
+ return p;
131
+ }
@@ -0,0 +1,151 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const createFeishuClientMock = vi.hoisted(() => vi.fn());
4
+ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
5
+ const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn());
6
+ const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
7
+
8
+ const fileCreateMock = vi.hoisted(() => vi.fn());
9
+ const messageCreateMock = vi.hoisted(() => vi.fn());
10
+ const messageReplyMock = vi.hoisted(() => vi.fn());
11
+
12
+ vi.mock("./client.js", () => ({
13
+ createFeishuClient: createFeishuClientMock,
14
+ }));
15
+
16
+ vi.mock("./accounts.js", () => ({
17
+ resolveFeishuAccount: resolveFeishuAccountMock,
18
+ }));
19
+
20
+ vi.mock("./targets.js", () => ({
21
+ normalizeFeishuTarget: normalizeFeishuTargetMock,
22
+ resolveReceiveIdType: resolveReceiveIdTypeMock,
23
+ }));
24
+
25
+ import { sendMediaFeishu } from "./media.js";
26
+
27
+ describe("sendMediaFeishu msg_type routing", () => {
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+
31
+ resolveFeishuAccountMock.mockReturnValue({
32
+ configured: true,
33
+ accountId: "main",
34
+ appId: "app_id",
35
+ appSecret: "app_secret",
36
+ domain: "feishu",
37
+ });
38
+
39
+ normalizeFeishuTargetMock.mockReturnValue("ou_target");
40
+ resolveReceiveIdTypeMock.mockReturnValue("open_id");
41
+
42
+ createFeishuClientMock.mockReturnValue({
43
+ im: {
44
+ file: {
45
+ create: fileCreateMock,
46
+ },
47
+ message: {
48
+ create: messageCreateMock,
49
+ reply: messageReplyMock,
50
+ },
51
+ },
52
+ });
53
+
54
+ fileCreateMock.mockResolvedValue({
55
+ code: 0,
56
+ data: { file_key: "file_key_1" },
57
+ });
58
+
59
+ messageCreateMock.mockResolvedValue({
60
+ code: 0,
61
+ data: { message_id: "msg_1" },
62
+ });
63
+
64
+ messageReplyMock.mockResolvedValue({
65
+ code: 0,
66
+ data: { message_id: "reply_1" },
67
+ });
68
+ });
69
+
70
+ it("uses msg_type=media for mp4", async () => {
71
+ await sendMediaFeishu({
72
+ cfg: {} as any,
73
+ to: "user:ou_target",
74
+ mediaBuffer: Buffer.from("video"),
75
+ fileName: "clip.mp4",
76
+ });
77
+
78
+ expect(fileCreateMock).toHaveBeenCalledWith(
79
+ expect.objectContaining({
80
+ data: expect.objectContaining({ file_type: "mp4" }),
81
+ }),
82
+ );
83
+
84
+ expect(messageCreateMock).toHaveBeenCalledWith(
85
+ expect.objectContaining({
86
+ data: expect.objectContaining({ msg_type: "media" }),
87
+ }),
88
+ );
89
+ });
90
+
91
+ it("uses msg_type=media for opus", async () => {
92
+ await sendMediaFeishu({
93
+ cfg: {} as any,
94
+ to: "user:ou_target",
95
+ mediaBuffer: Buffer.from("audio"),
96
+ fileName: "voice.opus",
97
+ });
98
+
99
+ expect(fileCreateMock).toHaveBeenCalledWith(
100
+ expect.objectContaining({
101
+ data: expect.objectContaining({ file_type: "opus" }),
102
+ }),
103
+ );
104
+
105
+ expect(messageCreateMock).toHaveBeenCalledWith(
106
+ expect.objectContaining({
107
+ data: expect.objectContaining({ msg_type: "media" }),
108
+ }),
109
+ );
110
+ });
111
+
112
+ it("uses msg_type=file for documents", async () => {
113
+ await sendMediaFeishu({
114
+ cfg: {} as any,
115
+ to: "user:ou_target",
116
+ mediaBuffer: Buffer.from("doc"),
117
+ fileName: "paper.pdf",
118
+ });
119
+
120
+ expect(fileCreateMock).toHaveBeenCalledWith(
121
+ expect.objectContaining({
122
+ data: expect.objectContaining({ file_type: "pdf" }),
123
+ }),
124
+ );
125
+
126
+ expect(messageCreateMock).toHaveBeenCalledWith(
127
+ expect.objectContaining({
128
+ data: expect.objectContaining({ msg_type: "file" }),
129
+ }),
130
+ );
131
+ });
132
+
133
+ it("uses msg_type=media when replying with mp4", async () => {
134
+ await sendMediaFeishu({
135
+ cfg: {} as any,
136
+ to: "user:ou_target",
137
+ mediaBuffer: Buffer.from("video"),
138
+ fileName: "reply.mp4",
139
+ replyToMessageId: "om_parent",
140
+ });
141
+
142
+ expect(messageReplyMock).toHaveBeenCalledWith(
143
+ expect.objectContaining({
144
+ path: { message_id: "om_parent" },
145
+ data: expect.objectContaining({ msg_type: "media" }),
146
+ }),
147
+ );
148
+
149
+ expect(messageCreateMock).not.toHaveBeenCalled();
150
+ });
151
+ });
package/src/media.ts CHANGED
@@ -210,15 +210,16 @@ export async function uploadImageFeishu(params: {
210
210
 
211
211
  const client = createFeishuClient(account);
212
212
 
213
- // SDK expects a Readable stream, not a Buffer
214
- // Use type assertion since SDK actually accepts any Readable at runtime
215
- const imageStream = typeof image === "string" ? fs.createReadStream(image) : Readable.from(image);
213
+ // SDK accepts Buffer directly or fs.ReadStream for file paths
214
+ // Using Readable.from(buffer) causes issues with form-data library
215
+ // See: https://github.com/larksuite/node-sdk/issues/121
216
+ const imageData = typeof image === "string" ? fs.createReadStream(image) : image;
216
217
 
217
218
  const response = await client.im.image.create({
218
219
  data: {
219
220
  image_type: imageType,
220
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type
221
- image: imageStream as any,
221
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
222
+ image: imageData as any,
222
223
  },
223
224
  });
224
225
 
@@ -258,16 +259,17 @@ export async function uploadFileFeishu(params: {
258
259
 
259
260
  const client = createFeishuClient(account);
260
261
 
261
- // SDK expects a Readable stream, not a Buffer
262
- // Use type assertion since SDK actually accepts any Readable at runtime
263
- const fileStream = typeof file === "string" ? fs.createReadStream(file) : Readable.from(file);
262
+ // SDK accepts Buffer directly or fs.ReadStream for file paths
263
+ // Using Readable.from(buffer) causes issues with form-data library
264
+ // See: https://github.com/larksuite/node-sdk/issues/121
265
+ const fileData = typeof file === "string" ? fs.createReadStream(file) : file;
264
266
 
265
267
  const response = await client.im.file.create({
266
268
  data: {
267
269
  file_type: fileType,
268
270
  file_name: fileName,
269
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type
270
- file: fileStream as any,
271
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
272
+ file: fileData as any,
271
273
  ...(duration !== undefined && { duration }),
272
274
  },
273
275
  });
@@ -357,10 +359,13 @@ export async function sendFileFeishu(params: {
357
359
  cfg: ClawdbotConfig;
358
360
  to: string;
359
361
  fileKey: string;
362
+ /** Use "media" for audio/video files, "file" for documents */
363
+ msgType?: "file" | "media";
360
364
  replyToMessageId?: string;
361
365
  accountId?: string;
362
366
  }): Promise<SendMediaResult> {
363
367
  const { cfg, to, fileKey, replyToMessageId, accountId } = params;
368
+ const msgType = params.msgType ?? "file";
364
369
  const account = resolveFeishuAccount({ cfg, accountId });
365
370
  if (!account.configured) {
366
371
  throw new Error(`Feishu account "${account.accountId}" not configured`);
@@ -380,7 +385,7 @@ export async function sendFileFeishu(params: {
380
385
  path: { message_id: replyToMessageId },
381
386
  data: {
382
387
  content,
383
- msg_type: "file",
388
+ msg_type: msgType,
384
389
  },
385
390
  });
386
391
 
@@ -399,7 +404,7 @@ export async function sendFileFeishu(params: {
399
404
  data: {
400
405
  receive_id: receiveId,
401
406
  content,
402
- msg_type: "file",
407
+ msg_type: msgType,
403
408
  },
404
409
  });
405
410
 
@@ -522,6 +527,15 @@ export async function sendMediaFeishu(params: {
522
527
  fileType,
523
528
  accountId,
524
529
  });
525
- return sendFileFeishu({ cfg, to, fileKey, replyToMessageId, accountId });
530
+ // Feishu requires msg_type "media" for audio/video, "file" for documents
531
+ const isMedia = fileType === "mp4" || fileType === "opus";
532
+ return sendFileFeishu({
533
+ cfg,
534
+ to,
535
+ fileKey,
536
+ msgType: isMedia ? "media" : "file",
537
+ replyToMessageId,
538
+ accountId,
539
+ });
526
540
  }
527
541
  }