@openclaw/feishu 2026.2.6 → 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.6",
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"
package/src/bitable.ts CHANGED
@@ -212,7 +212,8 @@ async function createRecord(
212
212
  ) {
213
213
  const res = await client.bitable.appTableRecord.create({
214
214
  path: { app_token: appToken, table_id: tableId },
215
- data: { fields },
215
+ // oxlint-disable-next-line typescript/no-explicit-any
216
+ data: { fields: fields as any },
216
217
  });
217
218
  if (res.code !== 0) {
218
219
  throw new Error(res.msg);
@@ -232,7 +233,8 @@ async function updateRecord(
232
233
  ) {
233
234
  const res = await client.bitable.appTableRecord.update({
234
235
  path: { app_token: appToken, table_id: tableId, record_id: recordId },
235
- data: { fields },
236
+ // oxlint-disable-next-line typescript/no-explicit-any
237
+ data: { fields: fields as any },
236
238
  });
237
239
  if (res.code !== 0) {
238
240
  throw new Error(res.msg);
@@ -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
- kind: isGroup ? "group" : "dm",
656
- id: isGroup ? ctx.chatId : ctx.senderOpenId,
688
+ kind: isGroup ? "group" : "direct",
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
@@ -1,8 +1,9 @@
1
- import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
1
+ import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
2
2
  import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
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,9 +18,9 @@ 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
- const meta = {
23
+ const meta: ChannelMeta = {
23
24
  id: "feishu",
24
25
  label: "Feishu",
25
26
  selectionLabel: "Feishu/Lark (飞书)",
@@ -28,7 +29,7 @@ const meta = {
28
29
  blurb: "飞书/Lark enterprise messaging.",
29
30
  aliases: ["lark"],
30
31
  order: 70,
31
- } as const;
32
+ };
32
33
 
33
34
  export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
34
35
  id: "feishu",
@@ -38,23 +39,22 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
38
39
  pairing: {
39
40
  idLabel: "feishuUserId",
40
41
  normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""),
41
- notifyApproval: async ({ cfg, id, accountId }) => {
42
+ notifyApproval: async ({ cfg, id }) => {
42
43
  await sendMessageFeishu({
43
44
  cfg,
44
45
  to: id,
45
46
  text: PAIRING_APPROVED_MESSAGE,
46
- accountId,
47
47
  });
48
48
  },
49
49
  },
50
50
  capabilities: {
51
- chatTypes: ["direct", "group"],
51
+ chatTypes: ["direct", "channel"],
52
+ polls: false,
53
+ threads: true,
52
54
  media: true,
53
55
  reactions: true,
54
- threads: false,
55
- polls: false,
56
- nativeCommands: true,
57
- blockStreaming: true,
56
+ edit: true,
57
+ reply: true,
58
58
  },
59
59
  agentPrompt: {
60
60
  messageToolHints: () => [
@@ -93,6 +93,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
93
93
  items: { oneOf: [{ type: "string" }, { type: "number" }] },
94
94
  },
95
95
  requireMention: { type: "boolean" },
96
+ topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
96
97
  historyLimit: { type: "integer", minimum: 0 },
97
98
  dmHistoryLimit: { type: "integer", minimum: 0 },
98
99
  textChunkLimit: { type: "integer", minimum: 1 },
@@ -123,7 +124,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
123
124
  resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
124
125
  defaultAccountId: (cfg) => resolveDefaultFeishuAccountId(cfg),
125
126
  setAccountEnabled: ({ cfg, accountId, enabled }) => {
126
- const _account = resolveFeishuAccount({ cfg, accountId });
127
+ const account = resolveFeishuAccount({ cfg, accountId });
127
128
  const isDefault = accountId === DEFAULT_ACCOUNT_ID;
128
129
 
129
130
  if (isDefault) {
@@ -202,7 +203,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
202
203
  }),
203
204
  resolveAllowFrom: ({ cfg, accountId }) => {
204
205
  const account = resolveFeishuAccount({ cfg, accountId });
205
- return account.config?.allowFrom ?? [];
206
+ return (account.config?.allowFrom ?? []).map((entry) => String(entry));
206
207
  },
207
208
  formatAllowFrom: ({ allowFrom }) =>
208
209
  allowFrom
@@ -218,9 +219,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
218
219
  cfg.channels as Record<string, { groupPolicy?: string }> | undefined
219
220
  )?.defaults?.groupPolicy;
220
221
  const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
221
- if (groupPolicy !== "open") {
222
- return [];
223
- }
222
+ if (groupPolicy !== "open") return [];
224
223
  return [
225
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.`,
226
225
  ];
@@ -265,7 +264,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
265
264
  },
266
265
  onboarding: feishuOnboardingAdapter,
267
266
  messaging: {
268
- normalizeTarget: normalizeFeishuTarget,
267
+ normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
269
268
  targetResolver: {
270
269
  looksLikeId: looksLikeFeishuId,
271
270
  hint: "<chatId|user:openId|chat:chatId>",
@@ -274,13 +273,33 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
274
273
  directory: {
275
274
  self: async () => null,
276
275
  listPeers: async ({ cfg, query, limit, accountId }) =>
277
- listFeishuDirectoryPeers({ cfg, query, limit, accountId }),
276
+ listFeishuDirectoryPeers({
277
+ cfg,
278
+ query: query ?? undefined,
279
+ limit: limit ?? undefined,
280
+ accountId: accountId ?? undefined,
281
+ }),
278
282
  listGroups: async ({ cfg, query, limit, accountId }) =>
279
- listFeishuDirectoryGroups({ cfg, query, limit, accountId }),
283
+ listFeishuDirectoryGroups({
284
+ cfg,
285
+ query: query ?? undefined,
286
+ limit: limit ?? undefined,
287
+ accountId: accountId ?? undefined,
288
+ }),
280
289
  listPeersLive: async ({ cfg, query, limit, accountId }) =>
281
- listFeishuDirectoryPeersLive({ cfg, query, limit, accountId }),
290
+ listFeishuDirectoryPeersLive({
291
+ cfg,
292
+ query: query ?? undefined,
293
+ limit: limit ?? undefined,
294
+ accountId: accountId ?? undefined,
295
+ }),
282
296
  listGroupsLive: async ({ cfg, query, limit, accountId }) =>
283
- listFeishuDirectoryGroupsLive({ cfg, query, limit, accountId }),
297
+ listFeishuDirectoryGroupsLive({
298
+ cfg,
299
+ query: query ?? undefined,
300
+ limit: limit ?? undefined,
301
+ accountId: accountId ?? undefined,
302
+ }),
284
303
  },
285
304
  outbound: feishuOutbound,
286
305
  status: {
@@ -302,10 +321,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
302
321
  probe: snapshot.probe,
303
322
  lastProbeAt: snapshot.lastProbeAt ?? null,
304
323
  }),
305
- probeAccount: async ({ cfg, accountId }) => {
306
- const account = resolveFeishuAccount({ cfg, accountId });
307
- return await probeFeishu(account);
308
- },
324
+ probeAccount: async ({ account }) => await probeFeishu(account),
309
325
  buildAccountSnapshot: ({ account, runtime, probe }) => ({
310
326
  accountId: account.accountId,
311
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 {