@openclaw/feishu 2026.2.21 → 2026.2.22

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.22",
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
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
package/src/dedup.ts CHANGED
@@ -1,33 +1,54 @@
1
- // Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
2
- const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
3
- const DEDUP_MAX_SIZE = 1_000;
4
- const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
5
- const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
6
- let lastCleanupTime = Date.now();
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk";
7
4
 
8
- export function tryRecordMessage(messageId: string): boolean {
9
- const now = Date.now();
5
+ // Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
6
+ const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
7
+ const MEMORY_MAX_SIZE = 1_000;
8
+ const FILE_MAX_ENTRIES = 10_000;
10
9
 
11
- // Throttled cleanup: evict expired entries at most once per interval.
12
- if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
13
- for (const [id, ts] of processedMessageIds) {
14
- if (now - ts > DEDUP_TTL_MS) {
15
- processedMessageIds.delete(id);
16
- }
17
- }
18
- lastCleanupTime = now;
19
- }
10
+ const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
20
11
 
21
- if (processedMessageIds.has(messageId)) {
22
- return false;
12
+ function resolveStateDirFromEnv(env: NodeJS.ProcessEnv = process.env): string {
13
+ const stateOverride = env.OPENCLAW_STATE_DIR?.trim() || env.CLAWDBOT_STATE_DIR?.trim();
14
+ if (stateOverride) {
15
+ return stateOverride;
23
16
  }
24
-
25
- // Evict oldest entries if cache is full.
26
- if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
27
- const first = processedMessageIds.keys().next().value!;
28
- processedMessageIds.delete(first);
17
+ if (env.VITEST || env.NODE_ENV === "test") {
18
+ return path.join(os.tmpdir(), ["openclaw-vitest", String(process.pid)].join("-"));
29
19
  }
20
+ return path.join(os.homedir(), ".openclaw");
21
+ }
22
+
23
+ function resolveNamespaceFilePath(namespace: string): string {
24
+ const safe = namespace.replace(/[^a-zA-Z0-9_-]/g, "_");
25
+ return path.join(resolveStateDirFromEnv(), "feishu", "dedup", `${safe}.json`);
26
+ }
27
+
28
+ const persistentDedupe = createPersistentDedupe({
29
+ ttlMs: DEDUP_TTL_MS,
30
+ memoryMaxSize: MEMORY_MAX_SIZE,
31
+ fileMaxEntries: FILE_MAX_ENTRIES,
32
+ resolveFilePath: resolveNamespaceFilePath,
33
+ });
34
+
35
+ /**
36
+ * Synchronous dedup — memory only.
37
+ * Kept for backward compatibility; prefer {@link tryRecordMessagePersistent}.
38
+ */
39
+ export function tryRecordMessage(messageId: string): boolean {
40
+ return !memoryDedupe.check(messageId);
41
+ }
30
42
 
31
- processedMessageIds.set(messageId, now);
32
- return true;
43
+ export async function tryRecordMessagePersistent(
44
+ messageId: string,
45
+ namespace = "global",
46
+ log?: (...args: unknown[]) => void,
47
+ ): Promise<boolean> {
48
+ return persistentDedupe.checkAndRecord(messageId, {
49
+ namespace,
50
+ onDiskError: (error) => {
51
+ log?.(`feishu-dedup: disk error, falling back to memory: ${String(error)}`);
52
+ },
53
+ });
33
54
  }
package/src/media.test.ts CHANGED
@@ -38,6 +38,16 @@ vi.mock("./runtime.js", () => ({
38
38
 
39
39
  import { downloadImageFeishu, downloadMessageResourceFeishu, sendMediaFeishu } from "./media.js";
40
40
 
41
+ function expectPathIsolatedToTmpRoot(pathValue: string, key: string): void {
42
+ expect(pathValue).not.toContain(key);
43
+ expect(pathValue).not.toContain("..");
44
+
45
+ const tmpRoot = path.resolve(os.tmpdir());
46
+ const resolved = path.resolve(pathValue);
47
+ const rel = path.relative(tmpRoot, resolved);
48
+ expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
49
+ }
50
+
41
51
  describe("sendMediaFeishu msg_type routing", () => {
42
52
  beforeEach(() => {
43
53
  vi.clearAllMocks();
@@ -217,13 +227,7 @@ describe("sendMediaFeishu msg_type routing", () => {
217
227
 
218
228
  expect(result.buffer).toEqual(Buffer.from("image-data"));
219
229
  expect(capturedPath).toBeDefined();
220
- expect(capturedPath).not.toContain(imageKey);
221
- expect(capturedPath).not.toContain("..");
222
-
223
- const tmpRoot = path.resolve(os.tmpdir());
224
- const resolved = path.resolve(capturedPath as string);
225
- const rel = path.relative(tmpRoot, resolved);
226
- expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
230
+ expectPathIsolatedToTmpRoot(capturedPath as string, imageKey);
227
231
  });
228
232
 
229
233
  it("uses isolated temp paths for message resource downloads", async () => {
@@ -246,13 +250,7 @@ describe("sendMediaFeishu msg_type routing", () => {
246
250
 
247
251
  expect(result.buffer).toEqual(Buffer.from("resource-data"));
248
252
  expect(capturedPath).toBeDefined();
249
- expect(capturedPath).not.toContain(fileKey);
250
- expect(capturedPath).not.toContain("..");
251
-
252
- const tmpRoot = path.resolve(os.tmpdir());
253
- const resolved = path.resolve(capturedPath as string);
254
- const rel = path.relative(tmpRoot, resolved);
255
- expect(rel === ".." || rel.startsWith(`..${path.sep}`)).toBe(false);
253
+ expectPathIsolatedToTmpRoot(capturedPath as string, fileKey);
256
254
  });
257
255
 
258
256
  it("rejects invalid image keys before calling feishu api", async () => {
@@ -78,6 +78,41 @@ function buildConfig(params: {
78
78
  } as ClawdbotConfig;
79
79
  }
80
80
 
81
+ async function withRunningWebhookMonitor(
82
+ params: {
83
+ accountId: string;
84
+ path: string;
85
+ verificationToken: string;
86
+ },
87
+ run: (url: string) => Promise<void>,
88
+ ) {
89
+ const port = await getFreePort();
90
+ const cfg = buildConfig({
91
+ accountId: params.accountId,
92
+ path: params.path,
93
+ port,
94
+ verificationToken: params.verificationToken,
95
+ });
96
+
97
+ const abortController = new AbortController();
98
+ const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
99
+ const monitorPromise = monitorFeishuProvider({
100
+ config: cfg,
101
+ runtime,
102
+ abortSignal: abortController.signal,
103
+ });
104
+
105
+ const url = `http://127.0.0.1:${port}${params.path}`;
106
+ await waitUntilServerReady(url);
107
+
108
+ try {
109
+ await run(url);
110
+ } finally {
111
+ abortController.abort();
112
+ await monitorPromise;
113
+ }
114
+ }
115
+
81
116
  afterEach(() => {
82
117
  stopFeishuMonitor();
83
118
  });
@@ -99,76 +134,50 @@ describe("Feishu webhook security hardening", () => {
99
134
 
100
135
  it("returns 415 for POST requests without json content type", async () => {
101
136
  probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
102
- const port = await getFreePort();
103
- const path = "/hook-content-type";
104
- const cfg = buildConfig({
105
- accountId: "content-type",
106
- path,
107
- port,
108
- verificationToken: "verify_token",
109
- });
110
-
111
- const abortController = new AbortController();
112
- const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
113
- const monitorPromise = monitorFeishuProvider({
114
- config: cfg,
115
- runtime,
116
- abortSignal: abortController.signal,
117
- });
118
-
119
- await waitUntilServerReady(`http://127.0.0.1:${port}${path}`);
120
-
121
- const response = await fetch(`http://127.0.0.1:${port}${path}`, {
122
- method: "POST",
123
- headers: { "content-type": "text/plain" },
124
- body: "{}",
125
- });
126
-
127
- expect(response.status).toBe(415);
128
- expect(await response.text()).toBe("Unsupported Media Type");
129
-
130
- abortController.abort();
131
- await monitorPromise;
137
+ await withRunningWebhookMonitor(
138
+ {
139
+ accountId: "content-type",
140
+ path: "/hook-content-type",
141
+ verificationToken: "verify_token",
142
+ },
143
+ async (url) => {
144
+ const response = await fetch(url, {
145
+ method: "POST",
146
+ headers: { "content-type": "text/plain" },
147
+ body: "{}",
148
+ });
149
+
150
+ expect(response.status).toBe(415);
151
+ expect(await response.text()).toBe("Unsupported Media Type");
152
+ },
153
+ );
132
154
  });
133
155
 
134
156
  it("rate limits webhook burst traffic with 429", async () => {
135
157
  probeFeishuMock.mockResolvedValue({ ok: true, botOpenId: "bot_open_id" });
136
- const port = await getFreePort();
137
- const path = "/hook-rate-limit";
138
- const cfg = buildConfig({
139
- accountId: "rate-limit",
140
- path,
141
- port,
142
- verificationToken: "verify_token",
143
- });
144
-
145
- const abortController = new AbortController();
146
- const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
147
- const monitorPromise = monitorFeishuProvider({
148
- config: cfg,
149
- runtime,
150
- abortSignal: abortController.signal,
151
- });
152
-
153
- await waitUntilServerReady(`http://127.0.0.1:${port}${path}`);
154
-
155
- let saw429 = false;
156
- for (let i = 0; i < 130; i += 1) {
157
- const response = await fetch(`http://127.0.0.1:${port}${path}`, {
158
- method: "POST",
159
- headers: { "content-type": "text/plain" },
160
- body: "{}",
161
- });
162
- if (response.status === 429) {
163
- saw429 = true;
164
- expect(await response.text()).toBe("Too Many Requests");
165
- break;
166
- }
167
- }
168
-
169
- expect(saw429).toBe(true);
170
-
171
- abortController.abort();
172
- await monitorPromise;
158
+ await withRunningWebhookMonitor(
159
+ {
160
+ accountId: "rate-limit",
161
+ path: "/hook-rate-limit",
162
+ verificationToken: "verify_token",
163
+ },
164
+ async (url) => {
165
+ let saw429 = false;
166
+ for (let i = 0; i < 130; i += 1) {
167
+ const response = await fetch(url, {
168
+ method: "POST",
169
+ headers: { "content-type": "text/plain" },
170
+ body: "{}",
171
+ });
172
+ if (response.status === 429) {
173
+ saw429 = true;
174
+ expect(await response.text()).toBe("Too Many Requests");
175
+ break;
176
+ }
177
+ }
178
+
179
+ expect(saw429).toBe(true);
180
+ },
181
+ );
173
182
  });
174
183
  });
package/src/onboarding.ts CHANGED
@@ -104,6 +104,25 @@ async function noteFeishuCredentialHelp(prompter: WizardPrompter): Promise<void>
104
104
  );
105
105
  }
106
106
 
107
+ async function promptFeishuCredentials(prompter: WizardPrompter): Promise<{
108
+ appId: string;
109
+ appSecret: string;
110
+ }> {
111
+ const appId = String(
112
+ await prompter.text({
113
+ message: "Enter Feishu App ID",
114
+ validate: (value) => (value?.trim() ? undefined : "Required"),
115
+ }),
116
+ ).trim();
117
+ const appSecret = String(
118
+ await prompter.text({
119
+ message: "Enter Feishu App Secret",
120
+ validate: (value) => (value?.trim() ? undefined : "Required"),
121
+ }),
122
+ ).trim();
123
+ return { appId, appSecret };
124
+ }
125
+
107
126
  function setFeishuGroupPolicy(
108
127
  cfg: ClawdbotConfig,
109
128
  groupPolicy: "open" | "allowlist" | "disabled",
@@ -210,18 +229,9 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
210
229
  },
211
230
  };
212
231
  } else {
213
- appId = String(
214
- await prompter.text({
215
- message: "Enter Feishu App ID",
216
- validate: (value) => (value?.trim() ? undefined : "Required"),
217
- }),
218
- ).trim();
219
- appSecret = String(
220
- await prompter.text({
221
- message: "Enter Feishu App Secret",
222
- validate: (value) => (value?.trim() ? undefined : "Required"),
223
- }),
224
- ).trim();
232
+ const entered = await promptFeishuCredentials(prompter);
233
+ appId = entered.appId;
234
+ appSecret = entered.appSecret;
225
235
  }
226
236
  } else if (hasConfigCreds) {
227
237
  const keep = await prompter.confirm({
@@ -229,32 +239,14 @@ export const feishuOnboardingAdapter: ChannelOnboardingAdapter = {
229
239
  initialValue: true,
230
240
  });
231
241
  if (!keep) {
232
- appId = String(
233
- await prompter.text({
234
- message: "Enter Feishu App ID",
235
- validate: (value) => (value?.trim() ? undefined : "Required"),
236
- }),
237
- ).trim();
238
- appSecret = String(
239
- await prompter.text({
240
- message: "Enter Feishu App Secret",
241
- validate: (value) => (value?.trim() ? undefined : "Required"),
242
- }),
243
- ).trim();
242
+ const entered = await promptFeishuCredentials(prompter);
243
+ appId = entered.appId;
244
+ appSecret = entered.appSecret;
244
245
  }
245
246
  } else {
246
- appId = String(
247
- await prompter.text({
248
- message: "Enter Feishu App ID",
249
- validate: (value) => (value?.trim() ? undefined : "Required"),
250
- }),
251
- ).trim();
252
- appSecret = String(
253
- await prompter.text({
254
- message: "Enter Feishu App Secret",
255
- validate: (value) => (value?.trim() ? undefined : "Required"),
256
- }),
257
- ).trim();
247
+ const entered = await promptFeishuCredentials(prompter);
248
+ appId = entered.appId;
249
+ appSecret = entered.appSecret;
258
250
  }
259
251
 
260
252
  if (appId && appSecret) {
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isFeishuGroupAllowed, resolveFeishuAllowlistMatch } from "./policy.js";
3
+
4
+ describe("feishu policy", () => {
5
+ describe("resolveFeishuAllowlistMatch", () => {
6
+ it("allows wildcard", () => {
7
+ expect(
8
+ resolveFeishuAllowlistMatch({
9
+ allowFrom: ["*"],
10
+ senderId: "ou-attacker",
11
+ }),
12
+ ).toEqual({ allowed: true, matchKey: "*", matchSource: "wildcard" });
13
+ });
14
+
15
+ it("matches normalized ID entries", () => {
16
+ expect(
17
+ resolveFeishuAllowlistMatch({
18
+ allowFrom: ["feishu:user:OU_ALLOWED"],
19
+ senderId: "ou_allowed",
20
+ }),
21
+ ).toEqual({ allowed: true, matchKey: "ou_allowed", matchSource: "id" });
22
+ });
23
+
24
+ it("supports user_id as an additional immutable sender candidate", () => {
25
+ expect(
26
+ resolveFeishuAllowlistMatch({
27
+ allowFrom: ["on_user_123"],
28
+ senderId: "ou_other",
29
+ senderIds: ["on_user_123"],
30
+ }),
31
+ ).toEqual({ allowed: true, matchKey: "on_user_123", matchSource: "id" });
32
+ });
33
+
34
+ it("does not authorize based on display-name collision", () => {
35
+ const victimOpenId = "ou_4f4ec5aa111122223333444455556666";
36
+
37
+ expect(
38
+ resolveFeishuAllowlistMatch({
39
+ allowFrom: [victimOpenId],
40
+ senderId: "ou_attacker_real_open_id",
41
+ senderIds: ["on_attacker_user_id"],
42
+ senderName: victimOpenId,
43
+ }),
44
+ ).toEqual({ allowed: false });
45
+ });
46
+ });
47
+
48
+ describe("isFeishuGroupAllowed", () => {
49
+ it("matches group IDs with chat: prefix", () => {
50
+ expect(
51
+ isFeishuGroupAllowed({
52
+ groupPolicy: "allowlist",
53
+ allowFrom: ["chat:oc_group_123"],
54
+ senderId: "oc_group_123",
55
+ }),
56
+ ).toBe(true);
57
+ });
58
+ });
59
+ });
package/src/policy.ts CHANGED
@@ -3,17 +3,52 @@ import type {
3
3
  ChannelGroupContext,
4
4
  GroupToolPolicyConfig,
5
5
  } from "openclaw/plugin-sdk";
6
- import { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk";
6
+ import { normalizeFeishuTarget } from "./targets.js";
7
7
  import type { FeishuConfig, FeishuGroupConfig } from "./types.js";
8
8
 
9
- export type FeishuAllowlistMatch = AllowlistMatch<"wildcard" | "id" | "name">;
9
+ export type FeishuAllowlistMatch = AllowlistMatch<"wildcard" | "id">;
10
+
11
+ function normalizeFeishuAllowEntry(raw: string): string {
12
+ const trimmed = raw.trim();
13
+ if (!trimmed) {
14
+ return "";
15
+ }
16
+ if (trimmed === "*") {
17
+ return "*";
18
+ }
19
+ const withoutProviderPrefix = trimmed.replace(/^feishu:/i, "");
20
+ const normalized = normalizeFeishuTarget(withoutProviderPrefix) ?? withoutProviderPrefix;
21
+ return normalized.trim().toLowerCase();
22
+ }
10
23
 
11
24
  export function resolveFeishuAllowlistMatch(params: {
12
25
  allowFrom: Array<string | number>;
13
26
  senderId: string;
27
+ senderIds?: Array<string | null | undefined>;
14
28
  senderName?: string | null;
15
29
  }): FeishuAllowlistMatch {
16
- return resolveAllowlistMatchSimple(params);
30
+ const allowFrom = params.allowFrom
31
+ .map((entry) => normalizeFeishuAllowEntry(String(entry)))
32
+ .filter(Boolean);
33
+ if (allowFrom.length === 0) {
34
+ return { allowed: false };
35
+ }
36
+ if (allowFrom.includes("*")) {
37
+ return { allowed: true, matchKey: "*", matchSource: "wildcard" };
38
+ }
39
+
40
+ // Feishu allowlists are ID-based; mutable display names must never grant access.
41
+ const senderCandidates = [params.senderId, ...(params.senderIds ?? [])]
42
+ .map((entry) => normalizeFeishuAllowEntry(String(entry ?? "")))
43
+ .filter(Boolean);
44
+
45
+ for (const senderId of senderCandidates) {
46
+ if (allowFrom.includes(senderId)) {
47
+ return { allowed: true, matchKey: senderId, matchSource: "id" };
48
+ }
49
+ }
50
+
51
+ return { allowed: false };
17
52
  }
18
53
 
19
54
  export function resolveFeishuGroupConfig(params: {
@@ -56,6 +91,7 @@ export function isFeishuGroupAllowed(params: {
56
91
  groupPolicy: "open" | "allowlist" | "disabled";
57
92
  allowFrom: Array<string | number>;
58
93
  senderId: string;
94
+ senderIds?: Array<string | null | undefined>;
59
95
  senderName?: string | null;
60
96
  }): boolean {
61
97
  const { groupPolicy } = params;