@openclaw/feishu 2026.2.9 → 2026.2.12

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.
package/package.json CHANGED
@@ -1,16 +1,13 @@
1
1
  {
2
2
  "name": "@openclaw/feishu",
3
- "version": "2026.2.9",
3
+ "version": "2026.2.12",
4
4
  "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
- "@larksuiteoapi/node-sdk": "^1.58.0",
7
+ "@larksuiteoapi/node-sdk": "^1.59.0",
8
8
  "@sinclair/typebox": "0.34.48",
9
9
  "zod": "^4.3.6"
10
10
  },
11
- "devDependencies": {
12
- "openclaw": "workspace:*"
13
- },
14
11
  "openclaw": {
15
12
  "extensions": [
16
13
  "./index.ts"
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseFeishuMessageEvent } from "./bot.js";
3
+
4
+ // Helper to build a minimal FeishuMessageEvent for testing
5
+ function makeEvent(
6
+ chatType: "p2p" | "group",
7
+ mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
8
+ ) {
9
+ return {
10
+ sender: {
11
+ sender_id: { user_id: "u1", open_id: "ou_sender" },
12
+ },
13
+ message: {
14
+ message_id: "msg_1",
15
+ chat_id: "oc_chat1",
16
+ chat_type: chatType,
17
+ message_type: "text",
18
+ content: JSON.stringify({ text: "hello" }),
19
+ mentions,
20
+ },
21
+ };
22
+ }
23
+
24
+ describe("parseFeishuMessageEvent – mentionedBot", () => {
25
+ const BOT_OPEN_ID = "ou_bot_123";
26
+
27
+ it("returns mentionedBot=false when there are no mentions", () => {
28
+ const event = makeEvent("group", []);
29
+ const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
30
+ expect(ctx.mentionedBot).toBe(false);
31
+ });
32
+
33
+ it("returns mentionedBot=true when bot is mentioned", () => {
34
+ const event = makeEvent("group", [
35
+ { key: "@_user_1", name: "Bot", id: { open_id: BOT_OPEN_ID } },
36
+ ]);
37
+ const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
38
+ expect(ctx.mentionedBot).toBe(true);
39
+ });
40
+
41
+ it("returns mentionedBot=false when only other users are mentioned", () => {
42
+ const event = makeEvent("group", [
43
+ { key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
44
+ ]);
45
+ const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
46
+ expect(ctx.mentionedBot).toBe(false);
47
+ });
48
+
49
+ it("returns mentionedBot=false when botOpenId is undefined (unknown bot)", () => {
50
+ const event = makeEvent("group", [
51
+ { key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
52
+ ]);
53
+ const ctx = parseFeishuMessageEvent(event as any, undefined);
54
+ expect(ctx.mentionedBot).toBe(false);
55
+ });
56
+
57
+ it("returns mentionedBot=false when botOpenId is empty string (probe failed)", () => {
58
+ const event = makeEvent("group", [
59
+ { key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
60
+ ]);
61
+ const ctx = parseFeishuMessageEvent(event as any, "");
62
+ expect(ctx.mentionedBot).toBe(false);
63
+ });
64
+ });
package/src/bot.ts CHANGED
@@ -7,9 +7,11 @@ import {
7
7
  type HistoryEntry,
8
8
  } from "openclaw/plugin-sdk";
9
9
  import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
10
+ import type { DynamicAgentCreationConfig } from "./types.js";
10
11
  import { resolveFeishuAccount } from "./accounts.js";
11
12
  import { createFeishuClient } from "./client.js";
12
- import { downloadMessageResourceFeishu } from "./media.js";
13
+ import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
14
+ import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
13
15
  import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
14
16
  import {
15
17
  resolveFeishuGroupConfig,
@@ -21,6 +23,37 @@ import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
21
23
  import { getFeishuRuntime } from "./runtime.js";
22
24
  import { getMessageFeishu } from "./send.js";
23
25
 
26
+ // --- Message deduplication ---
27
+ // Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
28
+ const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
29
+ const DEDUP_MAX_SIZE = 1_000;
30
+ const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
31
+ const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
32
+ let lastCleanupTime = Date.now();
33
+
34
+ function tryRecordMessage(messageId: string): boolean {
35
+ const now = Date.now();
36
+
37
+ // Throttled cleanup: evict expired entries at most once per interval
38
+ if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
39
+ for (const [id, ts] of processedMessageIds) {
40
+ if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);
41
+ }
42
+ lastCleanupTime = now;
43
+ }
44
+
45
+ if (processedMessageIds.has(messageId)) return false;
46
+
47
+ // Evict oldest entries if cache is full
48
+ if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
49
+ const first = processedMessageIds.keys().next().value!;
50
+ processedMessageIds.delete(first);
51
+ }
52
+
53
+ processedMessageIds.set(messageId, now);
54
+ return true;
55
+ }
56
+
24
57
  // --- Permission error extraction ---
25
58
  // Extract permission grant URL from Feishu API error response.
26
59
  type PermissionError = {
@@ -30,16 +63,12 @@ type PermissionError = {
30
63
  };
31
64
 
32
65
  function extractPermissionError(err: unknown): PermissionError | null {
33
- if (!err || typeof err !== "object") {
34
- return null;
35
- }
66
+ if (!err || typeof err !== "object") return null;
36
67
 
37
68
  // Axios error structure: err.response.data contains the Feishu error
38
69
  const axiosErr = err as { response?: { data?: unknown } };
39
70
  const data = axiosErr.response?.data;
40
- if (!data || typeof data !== "object") {
41
- return null;
42
- }
71
+ if (!data || typeof data !== "object") return null;
43
72
 
44
73
  const feishuErr = data as {
45
74
  code?: number;
@@ -48,9 +77,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
48
77
  };
49
78
 
50
79
  // Feishu permission error code: 99991672
51
- if (feishuErr.code !== 99991672) {
52
- return null;
53
- }
80
+ if (feishuErr.code !== 99991672) return null;
54
81
 
55
82
  // Extract the grant URL from the error message (contains the direct link)
56
83
  const msg = feishuErr.msg ?? "";
@@ -82,28 +109,20 @@ type SenderNameResult = {
82
109
  async function resolveFeishuSenderName(params: {
83
110
  account: ResolvedFeishuAccount;
84
111
  senderOpenId: string;
85
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic log function
86
112
  log: (...args: any[]) => void;
87
113
  }): Promise<SenderNameResult> {
88
114
  const { account, senderOpenId, log } = params;
89
- if (!account.configured) {
90
- return {};
91
- }
92
- if (!senderOpenId) {
93
- return {};
94
- }
115
+ if (!account.configured) return {};
116
+ if (!senderOpenId) return {};
95
117
 
96
118
  const cached = senderNameCache.get(senderOpenId);
97
119
  const now = Date.now();
98
- if (cached && cached.expireAt > now) {
99
- return { name: cached.name };
100
- }
120
+ if (cached && cached.expireAt > now) return { name: cached.name };
101
121
 
102
122
  try {
103
123
  const client = createFeishuClient(account);
104
124
 
105
125
  // contact/v3/users/:user_id?user_id_type=open_id
106
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
107
126
  const res: any = await client.contact.user.get({
108
127
  path: { user_id: senderOpenId },
109
128
  params: { user_id_type: "open_id" },
@@ -196,12 +215,8 @@ function parseMessageContent(content: string, messageType: string): string {
196
215
 
197
216
  function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
198
217
  const mentions = event.message.mentions ?? [];
199
- if (mentions.length === 0) {
200
- return false;
201
- }
202
- if (!botOpenId) {
203
- return mentions.length > 0;
204
- }
218
+ if (mentions.length === 0) return false;
219
+ if (!botOpenId) return false;
205
220
  return mentions.some((m) => m.id.open_id === botOpenId);
206
221
  }
207
222
 
@@ -209,9 +224,7 @@ function stripBotMention(
209
224
  text: string,
210
225
  mentions?: FeishuMessageEvent["message"]["mentions"],
211
226
  ): string {
212
- if (!mentions || mentions.length === 0) {
213
- return text;
214
- }
227
+ if (!mentions || mentions.length === 0) return text;
215
228
  let result = text;
216
229
  for (const mention of mentions) {
217
230
  result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
@@ -523,6 +536,13 @@ export async function handleFeishuMessage(params: {
523
536
  const log = runtime?.log ?? console.log;
524
537
  const error = runtime?.error ?? console.error;
525
538
 
539
+ // Dedup check: skip if this message was already processed
540
+ const messageId = event.message.message_id;
541
+ if (!tryRecordMessage(messageId)) {
542
+ log(`feishu: skipping duplicate message ${messageId}`);
543
+ return;
544
+ }
545
+
526
546
  let ctx = parseFeishuMessageEvent(event, botOpenId);
527
547
  const isGroup = ctx.chatType === "group";
528
548
 
@@ -532,9 +552,7 @@ export async function handleFeishuMessage(params: {
532
552
  senderOpenId: ctx.senderOpenId,
533
553
  log,
534
554
  });
535
- if (senderResult.name) {
536
- ctx = { ...ctx, senderName: senderResult.name };
537
- }
555
+ if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
538
556
 
539
557
  // Track permission error to inform agent later (with cooldown to avoid repetition)
540
558
  let permissionErrorForAgent: PermissionError | undefined;
@@ -647,16 +665,61 @@ export async function handleFeishuMessage(params: {
647
665
  const feishuFrom = `feishu:${ctx.senderOpenId}`;
648
666
  const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
649
667
 
650
- const route = core.channel.routing.resolveAgentRoute({
668
+ // Resolve peer ID for session routing
669
+ // When topicSessionMode is enabled, messages within a topic (identified by root_id)
670
+ // get a separate session from the main group chat.
671
+ let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
672
+ if (isGroup && ctx.rootId) {
673
+ const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
674
+ const topicSessionMode =
675
+ groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
676
+ if (topicSessionMode === "enabled") {
677
+ // Use chatId:topic:rootId as peer ID for topic-scoped sessions
678
+ peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
679
+ log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
680
+ }
681
+ }
682
+
683
+ let route = core.channel.routing.resolveAgentRoute({
651
684
  cfg,
652
685
  channel: "feishu",
653
686
  accountId: account.accountId,
654
687
  peer: {
655
688
  kind: isGroup ? "group" : "direct",
656
- id: isGroup ? ctx.chatId : ctx.senderOpenId,
689
+ id: peerId,
657
690
  },
658
691
  });
659
692
 
693
+ // Dynamic agent creation for DM users
694
+ // When enabled, creates a unique agent instance with its own workspace for each DM user.
695
+ let effectiveCfg = cfg;
696
+ if (!isGroup && route.matchedBy === "default") {
697
+ const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
698
+ if (dynamicCfg?.enabled) {
699
+ const runtime = getFeishuRuntime();
700
+ const result = await maybeCreateDynamicAgent({
701
+ cfg,
702
+ runtime,
703
+ senderOpenId: ctx.senderOpenId,
704
+ dynamicCfg,
705
+ log: (msg) => log(msg),
706
+ });
707
+ if (result.created) {
708
+ effectiveCfg = result.updatedCfg;
709
+ // Re-resolve route with updated config
710
+ route = core.channel.routing.resolveAgentRoute({
711
+ cfg: result.updatedCfg,
712
+ channel: "feishu",
713
+ accountId: account.accountId,
714
+ peer: { kind: "direct", id: ctx.senderOpenId },
715
+ });
716
+ log(
717
+ `feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`,
718
+ );
719
+ }
720
+ }
721
+ }
722
+
660
723
  const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
661
724
  const inboundLabel = isGroup
662
725
  ? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
@@ -736,6 +799,7 @@ export async function handleFeishuMessage(params: {
736
799
 
737
800
  const permissionCtx = core.channel.reply.finalizeInboundContext({
738
801
  Body: permissionBody,
802
+ BodyForAgent: permissionNotifyBody,
739
803
  RawBody: permissionNotifyBody,
740
804
  CommandBody: permissionNotifyBody,
741
805
  From: feishuFrom,
@@ -810,8 +874,19 @@ export async function handleFeishuMessage(params: {
810
874
  });
811
875
  }
812
876
 
877
+ const inboundHistory =
878
+ isGroup && historyKey && historyLimit > 0 && chatHistories
879
+ ? (chatHistories.get(historyKey) ?? []).map((entry) => ({
880
+ sender: entry.sender,
881
+ body: entry.body,
882
+ timestamp: entry.timestamp,
883
+ }))
884
+ : undefined;
885
+
813
886
  const ctxPayload = core.channel.reply.finalizeInboundContext({
814
887
  Body: combinedBody,
888
+ BodyForAgent: ctx.content,
889
+ InboundHistory: inboundHistory,
815
890
  RawBody: ctx.content,
816
891
  CommandBody: ctx.content,
817
892
  From: feishuFrom,
@@ -825,6 +900,7 @@ export async function handleFeishuMessage(params: {
825
900
  Provider: "feishu" as const,
826
901
  Surface: "feishu" as const,
827
902
  MessageSid: ctx.messageId,
903
+ ReplyToBody: quotedContent ?? undefined,
828
904
  Timestamp: Date.now(),
829
905
  WasMentioned: ctx.mentionedBot,
830
906
  CommandAuthorized: true,
@@ -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,
@@ -53,6 +53,20 @@ const ChannelHeartbeatVisibilitySchema = z
53
53
  .strict()
54
54
  .optional();
55
55
 
56
+ /**
57
+ * Dynamic agent creation configuration.
58
+ * When enabled, a new agent is created for each unique DM user.
59
+ */
60
+ const DynamicAgentCreationSchema = z
61
+ .object({
62
+ enabled: z.boolean().optional(),
63
+ workspaceTemplate: z.string().optional(),
64
+ agentDirTemplate: z.string().optional(),
65
+ maxAgents: z.number().int().positive().optional(),
66
+ })
67
+ .strict()
68
+ .optional();
69
+
56
70
  /**
57
71
  * Feishu tools configuration.
58
72
  * Controls which tool categories are enabled.
@@ -72,6 +86,16 @@ const FeishuToolsConfigSchema = z
72
86
  .strict()
73
87
  .optional();
74
88
 
89
+ /**
90
+ * Topic session isolation mode for group chats.
91
+ * - "disabled" (default): All messages in a group share one session
92
+ * - "enabled": Messages in different topics get separate sessions
93
+ *
94
+ * When enabled, the session key becomes `chat:{chatId}:topic:{rootId}`
95
+ * for messages within a topic thread, allowing isolated conversations.
96
+ */
97
+ const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
98
+
75
99
  export const FeishuGroupSchema = z
76
100
  .object({
77
101
  requireMention: z.boolean().optional(),
@@ -80,6 +104,7 @@ export const FeishuGroupSchema = z
80
104
  enabled: z.boolean().optional(),
81
105
  allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
82
106
  systemPrompt: z.string().optional(),
107
+ topicSessionMode: TopicSessionModeSchema,
83
108
  })
84
109
  .strict();
85
110
 
@@ -142,6 +167,7 @@ export const FeishuConfigSchema = z
142
167
  groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
143
168
  requireMention: z.boolean().optional().default(true),
144
169
  groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
170
+ topicSessionMode: TopicSessionModeSchema,
145
171
  historyLimit: z.number().int().min(0).optional(),
146
172
  dmHistoryLimit: z.number().int().min(0).optional(),
147
173
  dms: z.record(z.string(), DmConfigSchema).optional(),
@@ -152,6 +178,8 @@ export const FeishuConfigSchema = z
152
178
  heartbeat: ChannelHeartbeatVisibilitySchema,
153
179
  renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
154
180
  tools: FeishuToolsConfigSchema,
181
+ // Dynamic agent creation for DM users
182
+ dynamicAgentCreation: DynamicAgentCreationSchema,
155
183
  // Multi-account configuration
156
184
  accounts: z.record(z.string(), FeishuAccountConfigSchema.optional()).optional(),
157
185
  })
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
  }
package/src/monitor.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ClawdbotConfig, RuntimeEnv, HistoryEntry } from "openclaw/plugin-sdk";
2
2
  import * as Lark from "@larksuiteoapi/node-sdk";
3
+ import * as http from "http";
3
4
  import type { ResolvedFeishuAccount } from "./types.js";
4
5
  import { resolveFeishuAccount, listEnabledFeishuAccounts } from "./accounts.js";
5
6
  import { handleFeishuMessage, type FeishuMessageEvent, type FeishuBotAddedEvent } from "./bot.js";
@@ -13,8 +14,9 @@ export type MonitorFeishuOpts = {
13
14
  accountId?: string;
14
15
  };
15
16
 
16
- // Per-account WebSocket clients and bot info
17
+ // Per-account WebSocket clients, HTTP servers, and bot info
17
18
  const wsClients = new Map<string, Lark.WSClient>();
19
+ const httpServers = new Map<string, http.Server>();
18
20
  const botOpenIds = new Map<string, string>();
19
21
 
20
22
  async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string | undefined> {
@@ -27,44 +29,29 @@ async function fetchBotOpenId(account: ResolvedFeishuAccount): Promise<string |
27
29
  }
28
30
 
29
31
  /**
30
- * Monitor a single Feishu account.
32
+ * Register common event handlers on an EventDispatcher.
33
+ * When fireAndForget is true (webhook mode), message handling is not awaited
34
+ * to avoid blocking the HTTP response (Lark requires <3s response).
31
35
  */
32
- async function monitorSingleAccount(params: {
33
- cfg: ClawdbotConfig;
34
- account: ResolvedFeishuAccount;
35
- runtime?: RuntimeEnv;
36
- abortSignal?: AbortSignal;
37
- }): Promise<void> {
38
- const { cfg, account, runtime, abortSignal } = params;
39
- const { accountId } = account;
36
+ function registerEventHandlers(
37
+ eventDispatcher: Lark.EventDispatcher,
38
+ context: {
39
+ cfg: ClawdbotConfig;
40
+ accountId: string;
41
+ runtime?: RuntimeEnv;
42
+ chatHistories: Map<string, HistoryEntry[]>;
43
+ fireAndForget?: boolean;
44
+ },
45
+ ) {
46
+ const { cfg, accountId, runtime, chatHistories, fireAndForget } = context;
40
47
  const log = runtime?.log ?? console.log;
41
48
  const error = runtime?.error ?? console.error;
42
49
 
43
- // Fetch bot open_id
44
- const botOpenId = await fetchBotOpenId(account);
45
- botOpenIds.set(accountId, botOpenId ?? "");
46
- log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
47
-
48
- const connectionMode = account.config.connectionMode ?? "websocket";
49
-
50
- if (connectionMode !== "websocket") {
51
- log(`feishu[${accountId}]: webhook mode not implemented in monitor`);
52
- return;
53
- }
54
-
55
- log(`feishu[${accountId}]: starting WebSocket connection...`);
56
-
57
- const wsClient = createFeishuWSClient(account);
58
- wsClients.set(accountId, wsClient);
59
-
60
- const chatHistories = new Map<string, HistoryEntry[]>();
61
- const eventDispatcher = createEventDispatcher(account);
62
-
63
50
  eventDispatcher.register({
64
51
  "im.message.receive_v1": async (data) => {
65
52
  try {
66
53
  const event = data as unknown as FeishuMessageEvent;
67
- await handleFeishuMessage({
54
+ const promise = handleFeishuMessage({
68
55
  cfg,
69
56
  event,
70
57
  botOpenId: botOpenIds.get(accountId),
@@ -72,6 +59,13 @@ async function monitorSingleAccount(params: {
72
59
  chatHistories,
73
60
  accountId,
74
61
  });
62
+ if (fireAndForget) {
63
+ promise.catch((err) => {
64
+ error(`feishu[${accountId}]: error handling message: ${String(err)}`);
65
+ });
66
+ } else {
67
+ await promise;
68
+ }
75
69
  } catch (err) {
76
70
  error(`feishu[${accountId}]: error handling message: ${String(err)}`);
77
71
  }
@@ -96,6 +90,66 @@ async function monitorSingleAccount(params: {
96
90
  }
97
91
  },
98
92
  });
93
+ }
94
+
95
+ type MonitorAccountParams = {
96
+ cfg: ClawdbotConfig;
97
+ account: ResolvedFeishuAccount;
98
+ runtime?: RuntimeEnv;
99
+ abortSignal?: AbortSignal;
100
+ };
101
+
102
+ /**
103
+ * Monitor a single Feishu account.
104
+ */
105
+ async function monitorSingleAccount(params: MonitorAccountParams): Promise<void> {
106
+ const { cfg, account, runtime, abortSignal } = params;
107
+ const { accountId } = account;
108
+ const log = runtime?.log ?? console.log;
109
+
110
+ // Fetch bot open_id
111
+ const botOpenId = await fetchBotOpenId(account);
112
+ botOpenIds.set(accountId, botOpenId ?? "");
113
+ log(`feishu[${accountId}]: bot open_id resolved: ${botOpenId ?? "unknown"}`);
114
+
115
+ const connectionMode = account.config.connectionMode ?? "websocket";
116
+ const eventDispatcher = createEventDispatcher(account);
117
+ const chatHistories = new Map<string, HistoryEntry[]>();
118
+
119
+ registerEventHandlers(eventDispatcher, {
120
+ cfg,
121
+ accountId,
122
+ runtime,
123
+ chatHistories,
124
+ fireAndForget: connectionMode === "webhook",
125
+ });
126
+
127
+ if (connectionMode === "webhook") {
128
+ return monitorWebhook({ params, accountId, eventDispatcher });
129
+ }
130
+
131
+ return monitorWebSocket({ params, accountId, eventDispatcher });
132
+ }
133
+
134
+ type ConnectionParams = {
135
+ params: MonitorAccountParams;
136
+ accountId: string;
137
+ eventDispatcher: Lark.EventDispatcher;
138
+ };
139
+
140
+ async function monitorWebSocket({
141
+ params,
142
+ accountId,
143
+ eventDispatcher,
144
+ }: ConnectionParams): Promise<void> {
145
+ const { account, runtime, abortSignal } = params;
146
+ const log = runtime?.log ?? console.log;
147
+ const error = runtime?.error ?? console.error;
148
+
149
+ log(`feishu[${accountId}]: starting WebSocket connection...`);
150
+
151
+ const wsClient = createFeishuWSClient(account);
152
+ wsClients.set(accountId, wsClient);
99
153
 
100
154
  return new Promise((resolve, reject) => {
101
155
  const cleanup = () => {
@@ -118,7 +172,7 @@ async function monitorSingleAccount(params: {
118
172
  abortSignal?.addEventListener("abort", handleAbort, { once: true });
119
173
 
120
174
  try {
121
- void wsClient.start({ eventDispatcher });
175
+ wsClient.start({ eventDispatcher });
122
176
  log(`feishu[${accountId}]: WebSocket client started`);
123
177
  } catch (err) {
124
178
  cleanup();
@@ -128,6 +182,57 @@ async function monitorSingleAccount(params: {
128
182
  });
129
183
  }
130
184
 
185
+ async function monitorWebhook({
186
+ params,
187
+ accountId,
188
+ eventDispatcher,
189
+ }: ConnectionParams): Promise<void> {
190
+ const { account, runtime, abortSignal } = params;
191
+ const log = runtime?.log ?? console.log;
192
+ const error = runtime?.error ?? console.error;
193
+
194
+ const port = account.config.webhookPort ?? 3000;
195
+ const path = account.config.webhookPath ?? "/feishu/events";
196
+
197
+ log(`feishu[${accountId}]: starting Webhook server on port ${port}, path ${path}...`);
198
+
199
+ const server = http.createServer();
200
+ server.on("request", Lark.adaptDefault(path, eventDispatcher, { autoChallenge: true }));
201
+ httpServers.set(accountId, server);
202
+
203
+ return new Promise((resolve, reject) => {
204
+ const cleanup = () => {
205
+ server.close();
206
+ httpServers.delete(accountId);
207
+ botOpenIds.delete(accountId);
208
+ };
209
+
210
+ const handleAbort = () => {
211
+ log(`feishu[${accountId}]: abort signal received, stopping Webhook server`);
212
+ cleanup();
213
+ resolve();
214
+ };
215
+
216
+ if (abortSignal?.aborted) {
217
+ cleanup();
218
+ resolve();
219
+ return;
220
+ }
221
+
222
+ abortSignal?.addEventListener("abort", handleAbort, { once: true });
223
+
224
+ server.listen(port, () => {
225
+ log(`feishu[${accountId}]: Webhook server listening on port ${port}`);
226
+ });
227
+
228
+ server.on("error", (err) => {
229
+ error(`feishu[${accountId}]: Webhook server error: ${err}`);
230
+ abortSignal?.removeEventListener("abort", handleAbort);
231
+ reject(err);
232
+ });
233
+ });
234
+ }
235
+
131
236
  /**
132
237
  * Main entry: start monitoring for all enabled accounts.
133
238
  */
@@ -182,9 +287,18 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
182
287
  export function stopFeishuMonitor(accountId?: string): void {
183
288
  if (accountId) {
184
289
  wsClients.delete(accountId);
290
+ const server = httpServers.get(accountId);
291
+ if (server) {
292
+ server.close();
293
+ httpServers.delete(accountId);
294
+ }
185
295
  botOpenIds.delete(accountId);
186
296
  } else {
187
297
  wsClients.clear();
298
+ for (const server of httpServers.values()) {
299
+ server.close();
300
+ }
301
+ httpServers.clear();
188
302
  botOpenIds.clear();
189
303
  }
190
304
  }
package/src/send.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ClawdbotConfig } from "openclaw/plugin-sdk";
2
2
  import type { MentionTarget } from "./mention.js";
3
- import type { FeishuSendResult } from "./types.js";
3
+ import type { FeishuSendResult, ResolvedFeishuAccount } from "./types.js";
4
4
  import { resolveFeishuAccount } from "./accounts.js";
5
5
  import { createFeishuClient } from "./client.js";
6
6
  import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
@@ -281,18 +281,22 @@ export async function updateCardFeishu(params: {
281
281
  /**
282
282
  * Build a Feishu interactive card with markdown content.
283
283
  * Cards render markdown properly (code blocks, tables, links, etc.)
284
+ * Uses schema 2.0 format for proper markdown rendering.
284
285
  */
285
286
  export function buildMarkdownCard(text: string): Record<string, unknown> {
286
287
  return {
288
+ schema: "2.0",
287
289
  config: {
288
290
  wide_screen_mode: true,
289
291
  },
290
- elements: [
291
- {
292
- tag: "markdown",
293
- content: text,
294
- },
295
- ],
292
+ body: {
293
+ elements: [
294
+ {
295
+ tag: "markdown",
296
+ content: text,
297
+ },
298
+ ],
299
+ },
296
300
  };
297
301
  }
298
302
 
package/src/types.ts CHANGED
@@ -73,3 +73,10 @@ export type FeishuToolsConfig = {
73
73
  perm?: boolean;
74
74
  scopes?: boolean;
75
75
  };
76
+
77
+ export type DynamicAgentCreationConfig = {
78
+ enabled?: boolean;
79
+ workspaceTemplate?: string;
80
+ agentDirTemplate?: string;
81
+ maxAgents?: number;
82
+ };