@openclaw/feishu 2026.2.21 → 2026.2.23

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/feishu",
3
- "version": "2026.2.21",
3
+ "version": "2026.2.23",
4
4
  "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
5
5
  "type": "module",
6
6
  "dependencies": {
@@ -22,6 +22,20 @@ function makeEvent(
22
22
  };
23
23
  }
24
24
 
25
+ function makePostEvent(content: unknown) {
26
+ return {
27
+ sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
28
+ message: {
29
+ message_id: "msg_1",
30
+ chat_id: "oc_chat1",
31
+ chat_type: "group",
32
+ message_type: "post",
33
+ content: JSON.stringify(content),
34
+ mentions: [],
35
+ },
36
+ };
37
+ }
38
+
25
39
  describe("parseFeishuMessageEvent – mentionedBot", () => {
26
40
  const BOT_OPEN_ID = "ou_bot_123";
27
41
 
@@ -85,64 +99,31 @@ describe("parseFeishuMessageEvent – mentionedBot", () => {
85
99
 
86
100
  it("returns mentionedBot=true for post message with at (no top-level mentions)", () => {
87
101
  const BOT_OPEN_ID = "ou_bot_123";
88
- const postContent = JSON.stringify({
102
+ const event = makePostEvent({
89
103
  content: [
90
104
  [{ tag: "at", user_id: BOT_OPEN_ID, user_name: "claw" }],
91
105
  [{ tag: "text", text: "What does this document say" }],
92
106
  ],
93
107
  });
94
- const event = {
95
- sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
96
- message: {
97
- message_id: "msg_1",
98
- chat_id: "oc_chat1",
99
- chat_type: "group",
100
- message_type: "post",
101
- content: postContent,
102
- mentions: [],
103
- },
104
- };
105
108
  const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
106
109
  expect(ctx.mentionedBot).toBe(true);
107
110
  });
108
111
 
109
112
  it("returns mentionedBot=false for post message with no at", () => {
110
- const postContent = JSON.stringify({
113
+ const event = makePostEvent({
111
114
  content: [[{ tag: "text", text: "hello" }]],
112
115
  });
113
- const event = {
114
- sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
115
- message: {
116
- message_id: "msg_1",
117
- chat_id: "oc_chat1",
118
- chat_type: "group",
119
- message_type: "post",
120
- content: postContent,
121
- mentions: [],
122
- },
123
- };
124
116
  const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123");
125
117
  expect(ctx.mentionedBot).toBe(false);
126
118
  });
127
119
 
128
120
  it("returns mentionedBot=false for post message with at for another user", () => {
129
- const postContent = JSON.stringify({
121
+ const event = makePostEvent({
130
122
  content: [
131
123
  [{ tag: "at", user_id: "ou_other", user_name: "other" }],
132
124
  [{ tag: "text", text: "hello" }],
133
125
  ],
134
126
  });
135
- const event = {
136
- sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
137
- message: {
138
- message_id: "msg_1",
139
- chat_id: "oc_chat1",
140
- chat_type: "group",
141
- message_type: "post",
142
- content: postContent,
143
- mentions: [],
144
- },
145
- };
146
127
  const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123");
147
128
  expect(ctx.mentionedBot).toBe(false);
148
129
  });
package/src/bot.test.ts CHANGED
@@ -4,17 +4,25 @@ import type { FeishuMessageEvent } from "./bot.js";
4
4
  import { handleFeishuMessage } from "./bot.js";
5
5
  import { setFeishuRuntime } from "./runtime.js";
6
6
 
7
- const { mockCreateFeishuReplyDispatcher, mockSendMessageFeishu, mockGetMessageFeishu } = vi.hoisted(
8
- () => ({
9
- mockCreateFeishuReplyDispatcher: vi.fn(() => ({
10
- dispatcher: vi.fn(),
11
- replyOptions: {},
12
- markDispatchIdle: vi.fn(),
13
- })),
14
- mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
15
- mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
7
+ const {
8
+ mockCreateFeishuReplyDispatcher,
9
+ mockSendMessageFeishu,
10
+ mockGetMessageFeishu,
11
+ mockDownloadMessageResourceFeishu,
12
+ } = vi.hoisted(() => ({
13
+ mockCreateFeishuReplyDispatcher: vi.fn(() => ({
14
+ dispatcher: vi.fn(),
15
+ replyOptions: {},
16
+ markDispatchIdle: vi.fn(),
17
+ })),
18
+ mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
19
+ mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
20
+ mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
21
+ buffer: Buffer.from("video"),
22
+ contentType: "video/mp4",
23
+ fileName: "clip.mp4",
16
24
  }),
17
- );
25
+ }));
18
26
 
19
27
  vi.mock("./reply-dispatcher.js", () => ({
20
28
  createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
@@ -25,6 +33,28 @@ vi.mock("./send.js", () => ({
25
33
  getMessageFeishu: mockGetMessageFeishu,
26
34
  }));
27
35
 
36
+ vi.mock("./media.js", () => ({
37
+ downloadMessageResourceFeishu: mockDownloadMessageResourceFeishu,
38
+ }));
39
+
40
+ function createRuntimeEnv(): RuntimeEnv {
41
+ return {
42
+ log: vi.fn(),
43
+ error: vi.fn(),
44
+ exit: vi.fn((code: number): never => {
45
+ throw new Error(`exit ${code}`);
46
+ }),
47
+ } as RuntimeEnv;
48
+ }
49
+
50
+ async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
51
+ await handleFeishuMessage({
52
+ cfg: params.cfg,
53
+ event: params.event,
54
+ runtime: createRuntimeEnv(),
55
+ });
56
+ }
57
+
28
58
  describe("handleFeishuMessage command authorization", () => {
29
59
  const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
30
60
  const mockDispatchReplyFromConfig = vi
@@ -35,6 +65,10 @@ describe("handleFeishuMessage command authorization", () => {
35
65
  const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
36
66
  const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false });
37
67
  const mockBuildPairingReply = vi.fn(() => "Pairing response");
68
+ const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
69
+ path: "/tmp/inbound-clip.mp4",
70
+ contentType: "video/mp4",
71
+ });
38
72
 
39
73
  beforeEach(() => {
40
74
  vi.clearAllMocks();
@@ -61,12 +95,18 @@ describe("handleFeishuMessage command authorization", () => {
61
95
  shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
62
96
  resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
63
97
  },
98
+ media: {
99
+ saveMediaBuffer: mockSaveMediaBuffer,
100
+ },
64
101
  pairing: {
65
102
  readAllowFromStore: mockReadAllowFromStore,
66
103
  upsertPairingRequest: mockUpsertPairingRequest,
67
104
  buildPairingReply: mockBuildPairingReply,
68
105
  },
69
106
  },
107
+ media: {
108
+ detectMime: vi.fn(async () => "application/octet-stream"),
109
+ },
70
110
  } as unknown as PluginRuntime);
71
111
  });
72
112
 
@@ -96,17 +136,7 @@ describe("handleFeishuMessage command authorization", () => {
96
136
  },
97
137
  };
98
138
 
99
- await handleFeishuMessage({
100
- cfg,
101
- event,
102
- runtime: {
103
- log: vi.fn(),
104
- error: vi.fn(),
105
- exit: vi.fn((code: number): never => {
106
- throw new Error(`exit ${code}`);
107
- }),
108
- } as RuntimeEnv,
109
- });
139
+ await dispatchMessage({ cfg, event });
110
140
 
111
141
  expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
112
142
  useAccessGroups: true,
@@ -151,17 +181,7 @@ describe("handleFeishuMessage command authorization", () => {
151
181
  },
152
182
  };
153
183
 
154
- await handleFeishuMessage({
155
- cfg,
156
- event,
157
- runtime: {
158
- log: vi.fn(),
159
- error: vi.fn(),
160
- exit: vi.fn((code: number): never => {
161
- throw new Error(`exit ${code}`);
162
- }),
163
- } as RuntimeEnv,
164
- });
184
+ await dispatchMessage({ cfg, event });
165
185
 
166
186
  expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu");
167
187
  expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
@@ -198,17 +218,7 @@ describe("handleFeishuMessage command authorization", () => {
198
218
  },
199
219
  };
200
220
 
201
- await handleFeishuMessage({
202
- cfg,
203
- event,
204
- runtime: {
205
- log: vi.fn(),
206
- error: vi.fn(),
207
- exit: vi.fn((code: number): never => {
208
- throw new Error(`exit ${code}`);
209
- }),
210
- } as RuntimeEnv,
211
- });
221
+ await dispatchMessage({ cfg, event });
212
222
 
213
223
  expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
214
224
  channel: "feishu",
@@ -262,17 +272,7 @@ describe("handleFeishuMessage command authorization", () => {
262
272
  },
263
273
  };
264
274
 
265
- await handleFeishuMessage({
266
- cfg,
267
- event,
268
- runtime: {
269
- log: vi.fn(),
270
- error: vi.fn(),
271
- exit: vi.fn((code: number): never => {
272
- throw new Error(`exit ${code}`);
273
- }),
274
- } as RuntimeEnv,
275
- });
275
+ await dispatchMessage({ cfg, event });
276
276
 
277
277
  expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
278
278
  useAccessGroups: true,
@@ -286,4 +286,100 @@ describe("handleFeishuMessage command authorization", () => {
286
286
  }),
287
287
  );
288
288
  });
289
+
290
+ it("falls back to top-level allowFrom for group command authorization", async () => {
291
+ mockShouldComputeCommandAuthorized.mockReturnValue(true);
292
+ mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(true);
293
+
294
+ const cfg: ClawdbotConfig = {
295
+ commands: { useAccessGroups: true },
296
+ channels: {
297
+ feishu: {
298
+ allowFrom: ["ou-admin"],
299
+ groups: {
300
+ "oc-group": {
301
+ requireMention: false,
302
+ },
303
+ },
304
+ },
305
+ },
306
+ } as ClawdbotConfig;
307
+
308
+ const event: FeishuMessageEvent = {
309
+ sender: {
310
+ sender_id: {
311
+ open_id: "ou-admin",
312
+ },
313
+ },
314
+ message: {
315
+ message_id: "msg-group-command-fallback",
316
+ chat_id: "oc-group",
317
+ chat_type: "group",
318
+ message_type: "text",
319
+ content: JSON.stringify({ text: "/status" }),
320
+ },
321
+ };
322
+
323
+ await dispatchMessage({ cfg, event });
324
+
325
+ expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
326
+ useAccessGroups: true,
327
+ authorizers: [{ configured: true, allowed: true }],
328
+ });
329
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
330
+ expect.objectContaining({
331
+ ChatType: "group",
332
+ CommandAuthorized: true,
333
+ SenderId: "ou-admin",
334
+ }),
335
+ );
336
+ });
337
+
338
+ it("uses video file_key (not thumbnail image_key) for inbound video download", async () => {
339
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
340
+
341
+ const cfg: ClawdbotConfig = {
342
+ channels: {
343
+ feishu: {
344
+ dmPolicy: "open",
345
+ },
346
+ },
347
+ } as ClawdbotConfig;
348
+
349
+ const event: FeishuMessageEvent = {
350
+ sender: {
351
+ sender_id: {
352
+ open_id: "ou-sender",
353
+ },
354
+ },
355
+ message: {
356
+ message_id: "msg-video-inbound",
357
+ chat_id: "oc-dm",
358
+ chat_type: "p2p",
359
+ message_type: "video",
360
+ content: JSON.stringify({
361
+ file_key: "file_video_payload",
362
+ image_key: "img_thumb_payload",
363
+ file_name: "clip.mp4",
364
+ }),
365
+ },
366
+ };
367
+
368
+ await dispatchMessage({ cfg, event });
369
+
370
+ expect(mockDownloadMessageResourceFeishu).toHaveBeenCalledWith(
371
+ expect.objectContaining({
372
+ messageId: "msg-video-inbound",
373
+ fileKey: "file_video_payload",
374
+ type: "file",
375
+ }),
376
+ );
377
+ expect(mockSaveMediaBuffer).toHaveBeenCalledWith(
378
+ expect.any(Buffer),
379
+ "video/mp4",
380
+ "inbound",
381
+ expect.any(Number),
382
+ "clip.mp4",
383
+ );
384
+ });
289
385
  });
package/src/bot.ts CHANGED
@@ -2,14 +2,17 @@ import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
2
2
  import {
3
3
  buildAgentMediaPayload,
4
4
  buildPendingHistoryContextFromMap,
5
- recordPendingHistoryEntryIfEnabled,
6
5
  clearHistoryEntriesIfEnabled,
7
6
  DEFAULT_GROUP_HISTORY_LIMIT,
8
7
  type HistoryEntry,
8
+ recordPendingHistoryEntryIfEnabled,
9
+ resolveOpenProviderRuntimeGroupPolicy,
10
+ resolveDefaultGroupPolicy,
11
+ warnMissingProviderGroupPolicyFallbackOnce,
9
12
  } from "openclaw/plugin-sdk";
10
13
  import { resolveFeishuAccount } from "./accounts.js";
11
14
  import { createFeishuClient } from "./client.js";
12
- import { tryRecordMessage } from "./dedup.js";
15
+ import { tryRecordMessagePersistent } from "./dedup.js";
13
16
  import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
14
17
  import { normalizeFeishuExternalKey } from "./external-keys.js";
15
18
  import { downloadMessageResourceFeishu } from "./media.js";
@@ -409,7 +412,7 @@ async function resolveFeishuMediaList(params: {
409
412
 
410
413
  // For message media, always use messageResource API
411
414
  // The image.get API is only for images uploaded via im/v1/images, not for message attachments
412
- const fileKey = mediaKeys.imageKey || mediaKeys.fileKey;
415
+ const fileKey = mediaKeys.fileKey || mediaKeys.imageKey;
413
416
  if (!fileKey) {
414
417
  return [];
415
418
  }
@@ -510,15 +513,16 @@ export async function handleFeishuMessage(params: {
510
513
  const log = runtime?.log ?? console.log;
511
514
  const error = runtime?.error ?? console.error;
512
515
 
513
- // Dedup check: skip if this message was already processed
516
+ // Dedup check: skip if this message was already processed (memory + disk).
514
517
  const messageId = event.message.message_id;
515
- if (!tryRecordMessage(messageId)) {
518
+ if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
516
519
  log(`feishu: skipping duplicate message ${messageId}`);
517
520
  return;
518
521
  }
519
522
 
520
523
  let ctx = parseFeishuMessageEvent(event, botOpenId);
521
524
  const isGroup = ctx.chatType === "group";
525
+ const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
522
526
 
523
527
  // Resolve sender display name (best-effort) so the agent can attribute messages correctly.
524
528
  const senderResult = await resolveFeishuSenderName({
@@ -563,7 +567,18 @@ export async function handleFeishuMessage(params: {
563
567
  const useAccessGroups = cfg.commands?.useAccessGroups !== false;
564
568
 
565
569
  if (isGroup) {
566
- const groupPolicy = feishuCfg?.groupPolicy ?? "open";
570
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
571
+ const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
572
+ providerConfigPresent: cfg.channels?.feishu !== undefined,
573
+ groupPolicy: feishuCfg?.groupPolicy,
574
+ defaultGroupPolicy,
575
+ });
576
+ warnMissingProviderGroupPolicyFallbackOnce({
577
+ providerMissingFallbackApplied,
578
+ providerKey: "feishu",
579
+ accountId: account.accountId,
580
+ log,
581
+ });
567
582
  const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
568
583
  // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
569
584
 
@@ -587,6 +602,7 @@ export async function handleFeishuMessage(params: {
587
602
  groupPolicy: "allowlist",
588
603
  allowFrom: senderAllowFrom,
589
604
  senderId: ctx.senderOpenId,
605
+ senderIds: [senderUserId],
590
606
  senderName: ctx.senderName,
591
607
  });
592
608
  if (!senderAllowed) {
@@ -630,13 +646,16 @@ export async function handleFeishuMessage(params: {
630
646
  cfg,
631
647
  );
632
648
  const storeAllowFrom =
633
- !isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
649
+ !isGroup &&
650
+ dmPolicy !== "allowlist" &&
651
+ (dmPolicy !== "open" || shouldComputeCommandAuthorized)
634
652
  ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
635
653
  : [];
636
654
  const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
637
655
  const dmAllowed = resolveFeishuAllowlistMatch({
638
656
  allowFrom: effectiveDmAllowFrom,
639
657
  senderId: ctx.senderOpenId,
658
+ senderIds: [senderUserId],
640
659
  senderName: ctx.senderName,
641
660
  }).allowed;
642
661
 
@@ -674,10 +693,13 @@ export async function handleFeishuMessage(params: {
674
693
  return;
675
694
  }
676
695
 
677
- const commandAllowFrom = isGroup ? (groupConfig?.allowFrom ?? []) : effectiveDmAllowFrom;
696
+ const commandAllowFrom = isGroup
697
+ ? (groupConfig?.allowFrom ?? configAllowFrom)
698
+ : effectiveDmAllowFrom;
678
699
  const senderAllowedForCommands = resolveFeishuAllowlistMatch({
679
700
  allowFrom: commandAllowFrom,
680
701
  senderId: ctx.senderOpenId,
702
+ senderIds: [senderUserId],
681
703
  senderName: ctx.senderName,
682
704
  }).allowed;
683
705
  const commandAuthorized = shouldComputeCommandAuthorized
@@ -698,10 +720,10 @@ export async function handleFeishuMessage(params: {
698
720
  // When topicSessionMode is enabled, messages within a topic (identified by root_id)
699
721
  // get a separate session from the main group chat.
700
722
  let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
723
+ let topicSessionMode: "enabled" | "disabled" = "disabled";
701
724
  if (isGroup && ctx.rootId) {
702
725
  const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
703
- const topicSessionMode =
704
- groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
726
+ topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
705
727
  if (topicSessionMode === "enabled") {
706
728
  // Use chatId:topic:rootId as peer ID for topic-scoped sessions
707
729
  peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
@@ -717,6 +739,14 @@ export async function handleFeishuMessage(params: {
717
739
  kind: isGroup ? "group" : "direct",
718
740
  id: peerId,
719
741
  },
742
+ // Add parentPeer for binding inheritance in topic mode
743
+ parentPeer:
744
+ isGroup && ctx.rootId && topicSessionMode === "enabled"
745
+ ? {
746
+ kind: "group",
747
+ id: ctx.chatId,
748
+ }
749
+ : null,
720
750
  });
721
751
 
722
752
  // Dynamic agent creation for DM users
package/src/channel.ts CHANGED
@@ -4,6 +4,8 @@ import {
4
4
  createDefaultChannelRuntimeState,
5
5
  DEFAULT_ACCOUNT_ID,
6
6
  PAIRING_APPROVED_MESSAGE,
7
+ resolveAllowlistProviderRuntimeGroupPolicy,
8
+ resolveDefaultGroupPolicy,
7
9
  } from "openclaw/plugin-sdk";
8
10
  import {
9
11
  resolveFeishuAccount,
@@ -224,10 +226,12 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
224
226
  collectWarnings: ({ cfg, accountId }) => {
225
227
  const account = resolveFeishuAccount({ cfg, accountId });
226
228
  const feishuCfg = account.config;
227
- const defaultGroupPolicy = (
228
- cfg.channels as Record<string, { groupPolicy?: string }> | undefined
229
- )?.defaults?.groupPolicy;
230
- const groupPolicy = feishuCfg?.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
229
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
230
+ const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
231
+ providerConfigPresent: cfg.channels?.feishu !== undefined,
232
+ groupPolicy: feishuCfg?.groupPolicy,
233
+ defaultGroupPolicy,
234
+ });
231
235
  if (groupPolicy !== "open") return [];
232
236
  return [
233
237
  `- Feishu[${account.accountId}] groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.feishu.groupPolicy="allowlist" + channels.feishu.groupAllowFrom to restrict senders.`,
@@ -2,6 +2,28 @@ import { describe, expect, it } from "vitest";
2
2
  import { FeishuConfigSchema } from "./config-schema.js";
3
3
 
4
4
  describe("FeishuConfigSchema webhook validation", () => {
5
+ it("applies top-level defaults", () => {
6
+ const result = FeishuConfigSchema.parse({});
7
+ expect(result.domain).toBe("feishu");
8
+ expect(result.connectionMode).toBe("websocket");
9
+ expect(result.webhookPath).toBe("/feishu/events");
10
+ expect(result.dmPolicy).toBe("pairing");
11
+ expect(result.groupPolicy).toBe("allowlist");
12
+ expect(result.requireMention).toBe(true);
13
+ });
14
+
15
+ it("does not force top-level policy defaults into account config", () => {
16
+ const result = FeishuConfigSchema.parse({
17
+ accounts: {
18
+ main: {},
19
+ },
20
+ });
21
+
22
+ expect(result.accounts?.main?.dmPolicy).toBeUndefined();
23
+ expect(result.accounts?.main?.groupPolicy).toBeUndefined();
24
+ expect(result.accounts?.main?.requireMention).toBeUndefined();
25
+ });
26
+
5
27
  it("rejects top-level webhook mode without verificationToken", () => {
6
28
  const result = FeishuConfigSchema.safeParse({
7
29
  connectionMode: "webhook",
@@ -112,6 +112,31 @@ export const FeishuGroupSchema = z
112
112
  })
113
113
  .strict();
114
114
 
115
+ const FeishuSharedConfigShape = {
116
+ webhookHost: z.string().optional(),
117
+ webhookPort: z.number().int().positive().optional(),
118
+ capabilities: z.array(z.string()).optional(),
119
+ markdown: MarkdownConfigSchema,
120
+ configWrites: z.boolean().optional(),
121
+ dmPolicy: DmPolicySchema.optional(),
122
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
123
+ groupPolicy: GroupPolicySchema.optional(),
124
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
125
+ requireMention: z.boolean().optional(),
126
+ groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
127
+ historyLimit: z.number().int().min(0).optional(),
128
+ dmHistoryLimit: z.number().int().min(0).optional(),
129
+ dms: z.record(z.string(), DmConfigSchema).optional(),
130
+ textChunkLimit: z.number().int().positive().optional(),
131
+ chunkMode: z.enum(["length", "newline"]).optional(),
132
+ blockStreamingCoalesce: BlockStreamingCoalesceSchema,
133
+ mediaMaxMb: z.number().positive().optional(),
134
+ heartbeat: ChannelHeartbeatVisibilitySchema,
135
+ renderMode: RenderModeSchema,
136
+ streaming: StreamingModeSchema,
137
+ tools: FeishuToolsConfigSchema,
138
+ };
139
+
115
140
  /**
116
141
  * Per-account configuration.
117
142
  * All fields are optional - missing fields inherit from top-level config.
@@ -127,28 +152,7 @@ export const FeishuAccountConfigSchema = z
127
152
  domain: FeishuDomainSchema.optional(),
128
153
  connectionMode: FeishuConnectionModeSchema.optional(),
129
154
  webhookPath: z.string().optional(),
130
- webhookHost: z.string().optional(),
131
- webhookPort: z.number().int().positive().optional(),
132
- capabilities: z.array(z.string()).optional(),
133
- markdown: MarkdownConfigSchema,
134
- configWrites: z.boolean().optional(),
135
- dmPolicy: DmPolicySchema.optional(),
136
- allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
137
- groupPolicy: GroupPolicySchema.optional(),
138
- groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
139
- requireMention: z.boolean().optional(),
140
- groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
141
- historyLimit: z.number().int().min(0).optional(),
142
- dmHistoryLimit: z.number().int().min(0).optional(),
143
- dms: z.record(z.string(), DmConfigSchema).optional(),
144
- textChunkLimit: z.number().int().positive().optional(),
145
- chunkMode: z.enum(["length", "newline"]).optional(),
146
- blockStreamingCoalesce: BlockStreamingCoalesceSchema,
147
- mediaMaxMb: z.number().positive().optional(),
148
- heartbeat: ChannelHeartbeatVisibilitySchema,
149
- renderMode: RenderModeSchema,
150
- streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
151
- tools: FeishuToolsConfigSchema,
155
+ ...FeishuSharedConfigShape,
152
156
  })
153
157
  .strict();
154
158
 
@@ -163,29 +167,11 @@ export const FeishuConfigSchema = z
163
167
  domain: FeishuDomainSchema.optional().default("feishu"),
164
168
  connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
165
169
  webhookPath: z.string().optional().default("/feishu/events"),
166
- webhookHost: z.string().optional(),
167
- webhookPort: z.number().int().positive().optional(),
168
- capabilities: z.array(z.string()).optional(),
169
- markdown: MarkdownConfigSchema,
170
- configWrites: z.boolean().optional(),
170
+ ...FeishuSharedConfigShape,
171
171
  dmPolicy: DmPolicySchema.optional().default("pairing"),
172
- allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
173
172
  groupPolicy: GroupPolicySchema.optional().default("allowlist"),
174
- groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
175
173
  requireMention: z.boolean().optional().default(true),
176
- groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
177
174
  topicSessionMode: TopicSessionModeSchema,
178
- historyLimit: z.number().int().min(0).optional(),
179
- dmHistoryLimit: z.number().int().min(0).optional(),
180
- dms: z.record(z.string(), DmConfigSchema).optional(),
181
- textChunkLimit: z.number().int().positive().optional(),
182
- chunkMode: z.enum(["length", "newline"]).optional(),
183
- blockStreamingCoalesce: BlockStreamingCoalesceSchema,
184
- mediaMaxMb: z.number().positive().optional(),
185
- heartbeat: ChannelHeartbeatVisibilitySchema,
186
- renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
187
- streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
188
- tools: FeishuToolsConfigSchema,
189
175
  // Dynamic agent creation for DM users
190
176
  dynamicAgentCreation: DynamicAgentCreationSchema,
191
177
  // Multi-account configuration