@openclaw/feishu 2026.2.9 → 2026.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,16 +1,13 @@
1
1
  {
2
2
  "name": "@openclaw/feishu",
3
- "version": "2026.2.9",
3
+ "version": "2026.2.13",
4
4
  "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
5
5
  "type": "module",
6
6
  "dependencies": {
7
- "@larksuiteoapi/node-sdk": "^1.58.0",
7
+ "@larksuiteoapi/node-sdk": "^1.59.0",
8
8
  "@sinclair/typebox": "0.34.48",
9
9
  "zod": "^4.3.6"
10
10
  },
11
- "devDependencies": {
12
- "openclaw": "workspace:*"
13
- },
14
11
  "openclaw": {
15
12
  "extensions": [
16
13
  "./index.ts"
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseFeishuMessageEvent } from "./bot.js";
3
+
4
+ // Helper to build a minimal FeishuMessageEvent for testing
5
+ function makeEvent(
6
+ chatType: "p2p" | "group",
7
+ mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
8
+ ) {
9
+ return {
10
+ sender: {
11
+ sender_id: { user_id: "u1", open_id: "ou_sender" },
12
+ },
13
+ message: {
14
+ message_id: "msg_1",
15
+ chat_id: "oc_chat1",
16
+ chat_type: chatType,
17
+ message_type: "text",
18
+ content: JSON.stringify({ text: "hello" }),
19
+ mentions,
20
+ },
21
+ };
22
+ }
23
+
24
+ describe("parseFeishuMessageEvent – mentionedBot", () => {
25
+ const BOT_OPEN_ID = "ou_bot_123";
26
+
27
+ it("returns mentionedBot=false when there are no mentions", () => {
28
+ const event = makeEvent("group", []);
29
+ const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
30
+ expect(ctx.mentionedBot).toBe(false);
31
+ });
32
+
33
+ it("returns mentionedBot=true when bot is mentioned", () => {
34
+ const event = makeEvent("group", [
35
+ { key: "@_user_1", name: "Bot", id: { open_id: BOT_OPEN_ID } },
36
+ ]);
37
+ const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
38
+ expect(ctx.mentionedBot).toBe(true);
39
+ });
40
+
41
+ it("returns mentionedBot=false when only other users are mentioned", () => {
42
+ const event = makeEvent("group", [
43
+ { key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
44
+ ]);
45
+ const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
46
+ expect(ctx.mentionedBot).toBe(false);
47
+ });
48
+
49
+ it("returns mentionedBot=false when botOpenId is undefined (unknown bot)", () => {
50
+ const event = makeEvent("group", [
51
+ { key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
52
+ ]);
53
+ const ctx = parseFeishuMessageEvent(event as any, undefined);
54
+ expect(ctx.mentionedBot).toBe(false);
55
+ });
56
+
57
+ it("returns mentionedBot=false when botOpenId is empty string (probe failed)", () => {
58
+ const event = makeEvent("group", [
59
+ { key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
60
+ ]);
61
+ const ctx = parseFeishuMessageEvent(event as any, "");
62
+ expect(ctx.mentionedBot).toBe(false);
63
+ });
64
+ });
@@ -0,0 +1,265 @@
1
+ import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import type { FeishuMessageEvent } from "./bot.js";
4
+ import { handleFeishuMessage } from "./bot.js";
5
+ import { setFeishuRuntime } from "./runtime.js";
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),
16
+ }),
17
+ );
18
+
19
+ vi.mock("./reply-dispatcher.js", () => ({
20
+ createFeishuReplyDispatcher: mockCreateFeishuReplyDispatcher,
21
+ }));
22
+
23
+ vi.mock("./send.js", () => ({
24
+ sendMessageFeishu: mockSendMessageFeishu,
25
+ getMessageFeishu: mockGetMessageFeishu,
26
+ }));
27
+
28
+ describe("handleFeishuMessage command authorization", () => {
29
+ const mockFinalizeInboundContext = vi.fn((ctx: unknown) => ctx);
30
+ const mockDispatchReplyFromConfig = vi
31
+ .fn()
32
+ .mockResolvedValue({ queuedFinal: false, counts: { final: 1 } });
33
+ const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
34
+ const mockShouldComputeCommandAuthorized = vi.fn(() => true);
35
+ const mockReadAllowFromStore = vi.fn().mockResolvedValue([]);
36
+ const mockUpsertPairingRequest = vi.fn().mockResolvedValue({ code: "ABCDEFGH", created: false });
37
+ const mockBuildPairingReply = vi.fn(() => "Pairing response");
38
+
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ setFeishuRuntime({
42
+ system: {
43
+ enqueueSystemEvent: vi.fn(),
44
+ },
45
+ channel: {
46
+ routing: {
47
+ resolveAgentRoute: vi.fn(() => ({
48
+ agentId: "main",
49
+ accountId: "default",
50
+ sessionKey: "agent:main:feishu:dm:ou-attacker",
51
+ matchedBy: "default",
52
+ })),
53
+ },
54
+ reply: {
55
+ resolveEnvelopeFormatOptions: vi.fn(() => ({ template: "channel+name+time" })),
56
+ formatAgentEnvelope: vi.fn((params: { body: string }) => params.body),
57
+ finalizeInboundContext: mockFinalizeInboundContext,
58
+ dispatchReplyFromConfig: mockDispatchReplyFromConfig,
59
+ },
60
+ commands: {
61
+ shouldComputeCommandAuthorized: mockShouldComputeCommandAuthorized,
62
+ resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
63
+ },
64
+ pairing: {
65
+ readAllowFromStore: mockReadAllowFromStore,
66
+ upsertPairingRequest: mockUpsertPairingRequest,
67
+ buildPairingReply: mockBuildPairingReply,
68
+ },
69
+ },
70
+ } as unknown as PluginRuntime);
71
+ });
72
+
73
+ it("uses authorizer resolution instead of hardcoded CommandAuthorized=true", async () => {
74
+ const cfg: ClawdbotConfig = {
75
+ commands: { useAccessGroups: true },
76
+ channels: {
77
+ feishu: {
78
+ dmPolicy: "open",
79
+ allowFrom: ["ou-admin"],
80
+ },
81
+ },
82
+ } as ClawdbotConfig;
83
+
84
+ const event: FeishuMessageEvent = {
85
+ sender: {
86
+ sender_id: {
87
+ open_id: "ou-attacker",
88
+ },
89
+ },
90
+ message: {
91
+ message_id: "msg-auth-bypass-regression",
92
+ chat_id: "oc-dm",
93
+ chat_type: "p2p",
94
+ message_type: "text",
95
+ content: JSON.stringify({ text: "/status" }),
96
+ },
97
+ };
98
+
99
+ await handleFeishuMessage({
100
+ cfg,
101
+ event,
102
+ runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
103
+ });
104
+
105
+ expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
106
+ useAccessGroups: true,
107
+ authorizers: [{ configured: true, allowed: false }],
108
+ });
109
+ expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
110
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ CommandAuthorized: false,
113
+ SenderId: "ou-attacker",
114
+ Surface: "feishu",
115
+ }),
116
+ );
117
+ });
118
+
119
+ it("reads pairing allow store for non-command DMs when dmPolicy is pairing", async () => {
120
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
121
+ mockReadAllowFromStore.mockResolvedValue(["ou-attacker"]);
122
+
123
+ const cfg: ClawdbotConfig = {
124
+ commands: { useAccessGroups: true },
125
+ channels: {
126
+ feishu: {
127
+ dmPolicy: "pairing",
128
+ allowFrom: [],
129
+ },
130
+ },
131
+ } as ClawdbotConfig;
132
+
133
+ const event: FeishuMessageEvent = {
134
+ sender: {
135
+ sender_id: {
136
+ open_id: "ou-attacker",
137
+ },
138
+ },
139
+ message: {
140
+ message_id: "msg-read-store-non-command",
141
+ chat_id: "oc-dm",
142
+ chat_type: "p2p",
143
+ message_type: "text",
144
+ content: JSON.stringify({ text: "hello there" }),
145
+ },
146
+ };
147
+
148
+ await handleFeishuMessage({
149
+ cfg,
150
+ event,
151
+ runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
152
+ });
153
+
154
+ expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu");
155
+ expect(mockResolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
156
+ expect(mockFinalizeInboundContext).toHaveBeenCalledTimes(1);
157
+ expect(mockDispatchReplyFromConfig).toHaveBeenCalledTimes(1);
158
+ });
159
+
160
+ it("creates pairing request and drops unauthorized DMs in pairing mode", async () => {
161
+ mockShouldComputeCommandAuthorized.mockReturnValue(false);
162
+ mockReadAllowFromStore.mockResolvedValue([]);
163
+ mockUpsertPairingRequest.mockResolvedValue({ code: "ABCDEFGH", created: true });
164
+
165
+ const cfg: ClawdbotConfig = {
166
+ channels: {
167
+ feishu: {
168
+ dmPolicy: "pairing",
169
+ allowFrom: [],
170
+ },
171
+ },
172
+ } as ClawdbotConfig;
173
+
174
+ const event: FeishuMessageEvent = {
175
+ sender: {
176
+ sender_id: {
177
+ open_id: "ou-unapproved",
178
+ },
179
+ },
180
+ message: {
181
+ message_id: "msg-pairing-flow",
182
+ chat_id: "oc-dm",
183
+ chat_type: "p2p",
184
+ message_type: "text",
185
+ content: JSON.stringify({ text: "hello" }),
186
+ },
187
+ };
188
+
189
+ await handleFeishuMessage({
190
+ cfg,
191
+ event,
192
+ runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
193
+ });
194
+
195
+ expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
196
+ channel: "feishu",
197
+ id: "ou-unapproved",
198
+ meta: { name: undefined },
199
+ });
200
+ expect(mockBuildPairingReply).toHaveBeenCalledWith({
201
+ channel: "feishu",
202
+ idLine: "Your Feishu user id: ou-unapproved",
203
+ code: "ABCDEFGH",
204
+ });
205
+ expect(mockSendMessageFeishu).toHaveBeenCalledWith(
206
+ expect.objectContaining({
207
+ to: "user:ou-unapproved",
208
+ accountId: "default",
209
+ }),
210
+ );
211
+ expect(mockFinalizeInboundContext).not.toHaveBeenCalled();
212
+ expect(mockDispatchReplyFromConfig).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it("computes group command authorization from group allowFrom", async () => {
216
+ mockShouldComputeCommandAuthorized.mockReturnValue(true);
217
+ mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
218
+
219
+ const cfg: ClawdbotConfig = {
220
+ commands: { useAccessGroups: true },
221
+ channels: {
222
+ feishu: {
223
+ groups: {
224
+ "oc-group": {
225
+ requireMention: false,
226
+ },
227
+ },
228
+ },
229
+ },
230
+ } as ClawdbotConfig;
231
+
232
+ const event: FeishuMessageEvent = {
233
+ sender: {
234
+ sender_id: {
235
+ open_id: "ou-attacker",
236
+ },
237
+ },
238
+ message: {
239
+ message_id: "msg-group-command-auth",
240
+ chat_id: "oc-group",
241
+ chat_type: "group",
242
+ message_type: "text",
243
+ content: JSON.stringify({ text: "/status" }),
244
+ },
245
+ };
246
+
247
+ await handleFeishuMessage({
248
+ cfg,
249
+ event,
250
+ runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
251
+ });
252
+
253
+ expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
254
+ useAccessGroups: true,
255
+ authorizers: [{ configured: false, allowed: false }],
256
+ });
257
+ expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
258
+ expect.objectContaining({
259
+ ChatType: "group",
260
+ CommandAuthorized: false,
261
+ SenderId: "ou-attacker",
262
+ }),
263
+ );
264
+ });
265
+ });
package/src/bot.ts CHANGED
@@ -7,9 +7,12 @@ 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 { tryRecordMessage } from "./dedup.js";
14
+ import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
15
+ import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
13
16
  import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
14
17
  import {
15
18
  resolveFeishuGroupConfig,
@@ -19,7 +22,7 @@ import {
19
22
  } from "./policy.js";
20
23
  import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
21
24
  import { getFeishuRuntime } from "./runtime.js";
22
- import { getMessageFeishu } from "./send.js";
25
+ import { getMessageFeishu, sendMessageFeishu } from "./send.js";
23
26
 
24
27
  // --- Permission error extraction ---
25
28
  // Extract permission grant URL from Feishu API error response.
@@ -30,16 +33,12 @@ type PermissionError = {
30
33
  };
31
34
 
32
35
  function extractPermissionError(err: unknown): PermissionError | null {
33
- if (!err || typeof err !== "object") {
34
- return null;
35
- }
36
+ if (!err || typeof err !== "object") return null;
36
37
 
37
38
  // Axios error structure: err.response.data contains the Feishu error
38
39
  const axiosErr = err as { response?: { data?: unknown } };
39
40
  const data = axiosErr.response?.data;
40
- if (!data || typeof data !== "object") {
41
- return null;
42
- }
41
+ if (!data || typeof data !== "object") return null;
43
42
 
44
43
  const feishuErr = data as {
45
44
  code?: number;
@@ -48,9 +47,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
48
47
  };
49
48
 
50
49
  // Feishu permission error code: 99991672
51
- if (feishuErr.code !== 99991672) {
52
- return null;
53
- }
50
+ if (feishuErr.code !== 99991672) return null;
54
51
 
55
52
  // Extract the grant URL from the error message (contains the direct link)
56
53
  const msg = feishuErr.msg ?? "";
@@ -82,28 +79,20 @@ type SenderNameResult = {
82
79
  async function resolveFeishuSenderName(params: {
83
80
  account: ResolvedFeishuAccount;
84
81
  senderOpenId: string;
85
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic log function
86
82
  log: (...args: any[]) => void;
87
83
  }): Promise<SenderNameResult> {
88
84
  const { account, senderOpenId, log } = params;
89
- if (!account.configured) {
90
- return {};
91
- }
92
- if (!senderOpenId) {
93
- return {};
94
- }
85
+ if (!account.configured) return {};
86
+ if (!senderOpenId) return {};
95
87
 
96
88
  const cached = senderNameCache.get(senderOpenId);
97
89
  const now = Date.now();
98
- if (cached && cached.expireAt > now) {
99
- return { name: cached.name };
100
- }
90
+ if (cached && cached.expireAt > now) return { name: cached.name };
101
91
 
102
92
  try {
103
93
  const client = createFeishuClient(account);
104
94
 
105
95
  // contact/v3/users/:user_id?user_id_type=open_id
106
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
107
96
  const res: any = await client.contact.user.get({
108
97
  path: { user_id: senderOpenId },
109
98
  params: { user_id_type: "open_id" },
@@ -196,12 +185,8 @@ function parseMessageContent(content: string, messageType: string): string {
196
185
 
197
186
  function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
198
187
  const mentions = event.message.mentions ?? [];
199
- if (mentions.length === 0) {
200
- return false;
201
- }
202
- if (!botOpenId) {
203
- return mentions.length > 0;
204
- }
188
+ if (mentions.length === 0) return false;
189
+ if (!botOpenId) return false;
205
190
  return mentions.some((m) => m.id.open_id === botOpenId);
206
191
  }
207
192
 
@@ -209,9 +194,7 @@ function stripBotMention(
209
194
  text: string,
210
195
  mentions?: FeishuMessageEvent["message"]["mentions"],
211
196
  ): string {
212
- if (!mentions || mentions.length === 0) {
213
- return text;
214
- }
197
+ if (!mentions || mentions.length === 0) return text;
215
198
  let result = text;
216
199
  for (const mention of mentions) {
217
200
  result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
@@ -523,6 +506,13 @@ export async function handleFeishuMessage(params: {
523
506
  const log = runtime?.log ?? console.log;
524
507
  const error = runtime?.error ?? console.error;
525
508
 
509
+ // Dedup check: skip if this message was already processed
510
+ const messageId = event.message.message_id;
511
+ if (!tryRecordMessage(messageId)) {
512
+ log(`feishu: skipping duplicate message ${messageId}`);
513
+ return;
514
+ }
515
+
526
516
  let ctx = parseFeishuMessageEvent(event, botOpenId);
527
517
  const isGroup = ctx.chatType === "group";
528
518
 
@@ -532,9 +522,7 @@ export async function handleFeishuMessage(params: {
532
522
  senderOpenId: ctx.senderOpenId,
533
523
  log,
534
524
  });
535
- if (senderResult.name) {
536
- ctx = { ...ctx, senderName: senderResult.name };
537
- }
525
+ if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
538
526
 
539
527
  // Track permission error to inform agent later (with cooldown to avoid repetition)
540
528
  let permissionErrorForAgent: PermissionError | undefined;
@@ -563,12 +551,17 @@ export async function handleFeishuMessage(params: {
563
551
  0,
564
552
  feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
565
553
  );
554
+ const groupConfig = isGroup
555
+ ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
556
+ : undefined;
557
+ const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
558
+ const configAllowFrom = feishuCfg?.allowFrom ?? [];
559
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
566
560
 
567
561
  if (isGroup) {
568
562
  const groupPolicy = feishuCfg?.groupPolicy ?? "open";
569
563
  const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
570
564
  // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
571
- const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
572
565
 
573
566
  // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
574
567
  const groupAllowed = isFeishuGroupAllowed({
@@ -624,39 +617,134 @@ export async function handleFeishuMessage(params: {
624
617
  return;
625
618
  }
626
619
  } else {
627
- const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
628
- const allowFrom = feishuCfg?.allowFrom ?? [];
629
-
630
- if (dmPolicy === "allowlist") {
631
- const match = resolveFeishuAllowlistMatch({
632
- allowFrom,
633
- senderId: ctx.senderOpenId,
634
- });
635
- if (!match.allowed) {
636
- log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in DM allowlist`);
637
- return;
638
- }
639
- }
640
620
  }
641
621
 
642
622
  try {
643
623
  const core = getFeishuRuntime();
624
+ const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
625
+ ctx.content,
626
+ cfg,
627
+ );
628
+ const storeAllowFrom =
629
+ !isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
630
+ ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
631
+ : [];
632
+ const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
633
+ const dmAllowed = resolveFeishuAllowlistMatch({
634
+ allowFrom: effectiveDmAllowFrom,
635
+ senderId: ctx.senderOpenId,
636
+ senderName: ctx.senderName,
637
+ }).allowed;
638
+
639
+ if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
640
+ if (dmPolicy === "pairing") {
641
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
642
+ channel: "feishu",
643
+ id: ctx.senderOpenId,
644
+ meta: { name: ctx.senderName },
645
+ });
646
+ if (created) {
647
+ log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
648
+ try {
649
+ await sendMessageFeishu({
650
+ cfg,
651
+ to: `user:${ctx.senderOpenId}`,
652
+ text: core.channel.pairing.buildPairingReply({
653
+ channel: "feishu",
654
+ idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
655
+ code,
656
+ }),
657
+ accountId: account.accountId,
658
+ });
659
+ } catch (err) {
660
+ log(
661
+ `feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
662
+ );
663
+ }
664
+ }
665
+ } else {
666
+ log(
667
+ `feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
668
+ );
669
+ }
670
+ return;
671
+ }
672
+
673
+ const commandAllowFrom = isGroup ? (groupConfig?.allowFrom ?? []) : effectiveDmAllowFrom;
674
+ const senderAllowedForCommands = resolveFeishuAllowlistMatch({
675
+ allowFrom: commandAllowFrom,
676
+ senderId: ctx.senderOpenId,
677
+ senderName: ctx.senderName,
678
+ }).allowed;
679
+ const commandAuthorized = shouldComputeCommandAuthorized
680
+ ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
681
+ useAccessGroups,
682
+ authorizers: [
683
+ { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
684
+ ],
685
+ })
686
+ : undefined;
644
687
 
645
688
  // In group chats, the session is scoped to the group, but the *speaker* is the sender.
646
689
  // Using a group-scoped From causes the agent to treat different users as the same person.
647
690
  const feishuFrom = `feishu:${ctx.senderOpenId}`;
648
691
  const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
649
692
 
650
- const route = core.channel.routing.resolveAgentRoute({
693
+ // Resolve peer ID for session routing
694
+ // When topicSessionMode is enabled, messages within a topic (identified by root_id)
695
+ // get a separate session from the main group chat.
696
+ let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
697
+ if (isGroup && ctx.rootId) {
698
+ const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
699
+ const topicSessionMode =
700
+ groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
701
+ if (topicSessionMode === "enabled") {
702
+ // Use chatId:topic:rootId as peer ID for topic-scoped sessions
703
+ peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
704
+ log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
705
+ }
706
+ }
707
+
708
+ let route = core.channel.routing.resolveAgentRoute({
651
709
  cfg,
652
710
  channel: "feishu",
653
711
  accountId: account.accountId,
654
712
  peer: {
655
713
  kind: isGroup ? "group" : "direct",
656
- id: isGroup ? ctx.chatId : ctx.senderOpenId,
714
+ id: peerId,
657
715
  },
658
716
  });
659
717
 
718
+ // Dynamic agent creation for DM users
719
+ // When enabled, creates a unique agent instance with its own workspace for each DM user.
720
+ let effectiveCfg = cfg;
721
+ if (!isGroup && route.matchedBy === "default") {
722
+ const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
723
+ if (dynamicCfg?.enabled) {
724
+ const runtime = getFeishuRuntime();
725
+ const result = await maybeCreateDynamicAgent({
726
+ cfg,
727
+ runtime,
728
+ senderOpenId: ctx.senderOpenId,
729
+ dynamicCfg,
730
+ log: (msg) => log(msg),
731
+ });
732
+ if (result.created) {
733
+ effectiveCfg = result.updatedCfg;
734
+ // Re-resolve route with updated config
735
+ route = core.channel.routing.resolveAgentRoute({
736
+ cfg: result.updatedCfg,
737
+ channel: "feishu",
738
+ accountId: account.accountId,
739
+ peer: { kind: "direct", id: ctx.senderOpenId },
740
+ });
741
+ log(
742
+ `feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`,
743
+ );
744
+ }
745
+ }
746
+ }
747
+
660
748
  const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
661
749
  const inboundLabel = isGroup
662
750
  ? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
@@ -736,6 +824,7 @@ export async function handleFeishuMessage(params: {
736
824
 
737
825
  const permissionCtx = core.channel.reply.finalizeInboundContext({
738
826
  Body: permissionBody,
827
+ BodyForAgent: permissionNotifyBody,
739
828
  RawBody: permissionNotifyBody,
740
829
  CommandBody: permissionNotifyBody,
741
830
  From: feishuFrom,
@@ -751,7 +840,7 @@ export async function handleFeishuMessage(params: {
751
840
  MessageSid: `${ctx.messageId}:permission-error`,
752
841
  Timestamp: Date.now(),
753
842
  WasMentioned: false,
754
- CommandAuthorized: true,
843
+ CommandAuthorized: commandAuthorized,
755
844
  OriginatingChannel: "feishu" as const,
756
845
  OriginatingTo: feishuTo,
757
846
  });
@@ -810,8 +899,19 @@ export async function handleFeishuMessage(params: {
810
899
  });
811
900
  }
812
901
 
902
+ const inboundHistory =
903
+ isGroup && historyKey && historyLimit > 0 && chatHistories
904
+ ? (chatHistories.get(historyKey) ?? []).map((entry) => ({
905
+ sender: entry.sender,
906
+ body: entry.body,
907
+ timestamp: entry.timestamp,
908
+ }))
909
+ : undefined;
910
+
813
911
  const ctxPayload = core.channel.reply.finalizeInboundContext({
814
912
  Body: combinedBody,
913
+ BodyForAgent: ctx.content,
914
+ InboundHistory: inboundHistory,
815
915
  RawBody: ctx.content,
816
916
  CommandBody: ctx.content,
817
917
  From: feishuFrom,
@@ -825,9 +925,10 @@ export async function handleFeishuMessage(params: {
825
925
  Provider: "feishu" as const,
826
926
  Surface: "feishu" as const,
827
927
  MessageSid: ctx.messageId,
928
+ ReplyToBody: quotedContent ?? undefined,
828
929
  Timestamp: Date.now(),
829
930
  WasMentioned: ctx.mentionedBot,
830
- CommandAuthorized: true,
931
+ CommandAuthorized: commandAuthorized,
831
932
  OriginatingChannel: "feishu" as const,
832
933
  OriginatingTo: feishuTo,
833
934
  ...mediaPayload,