@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 +2 -5
- package/src/bot.checkBotMentioned.test.ts +64 -0
- package/src/bot.test.ts +265 -0
- package/src/bot.ts +153 -52
- package/src/channel.test.ts +48 -0
- package/src/channel.ts +11 -13
- package/src/config-schema.ts +34 -0
- package/src/dedup.ts +33 -0
- package/src/docx.ts +14 -4
- package/src/dynamic-agent.ts +131 -0
- package/src/media.test.ts +151 -0
- package/src/media.ts +27 -13
- package/src/monitor.ts +173 -33
- package/src/reply-dispatcher.test.ts +116 -0
- package/src/reply-dispatcher.ts +124 -67
- package/src/send.ts +11 -7
- package/src/streaming-card.ts +223 -0
- package/src/targets.test.ts +16 -0
- package/src/targets.ts +1 -1
- package/src/types.ts +7 -0
package/package.json
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/feishu",
|
|
3
|
-
"version": "2026.2.
|
|
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.
|
|
7
|
+
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
8
8
|
"@sinclair/typebox": "0.34.48",
|
|
9
9
|
"zod": "^4.3.6"
|
|
10
10
|
},
|
|
11
|
-
"devDependencies": {
|
|
12
|
-
"openclaw": "workspace:*"
|
|
13
|
-
},
|
|
14
11
|
"openclaw": {
|
|
15
12
|
"extensions": [
|
|
16
13
|
"./index.ts"
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseFeishuMessageEvent } from "./bot.js";
|
|
3
|
+
|
|
4
|
+
// Helper to build a minimal FeishuMessageEvent for testing
|
|
5
|
+
function makeEvent(
|
|
6
|
+
chatType: "p2p" | "group",
|
|
7
|
+
mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
|
|
8
|
+
) {
|
|
9
|
+
return {
|
|
10
|
+
sender: {
|
|
11
|
+
sender_id: { user_id: "u1", open_id: "ou_sender" },
|
|
12
|
+
},
|
|
13
|
+
message: {
|
|
14
|
+
message_id: "msg_1",
|
|
15
|
+
chat_id: "oc_chat1",
|
|
16
|
+
chat_type: chatType,
|
|
17
|
+
message_type: "text",
|
|
18
|
+
content: JSON.stringify({ text: "hello" }),
|
|
19
|
+
mentions,
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("parseFeishuMessageEvent – mentionedBot", () => {
|
|
25
|
+
const BOT_OPEN_ID = "ou_bot_123";
|
|
26
|
+
|
|
27
|
+
it("returns mentionedBot=false when there are no mentions", () => {
|
|
28
|
+
const event = makeEvent("group", []);
|
|
29
|
+
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
|
|
30
|
+
expect(ctx.mentionedBot).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns mentionedBot=true when bot is mentioned", () => {
|
|
34
|
+
const event = makeEvent("group", [
|
|
35
|
+
{ key: "@_user_1", name: "Bot", id: { open_id: BOT_OPEN_ID } },
|
|
36
|
+
]);
|
|
37
|
+
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
|
|
38
|
+
expect(ctx.mentionedBot).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("returns mentionedBot=false when only other users are mentioned", () => {
|
|
42
|
+
const event = makeEvent("group", [
|
|
43
|
+
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
|
|
44
|
+
]);
|
|
45
|
+
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
|
|
46
|
+
expect(ctx.mentionedBot).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns mentionedBot=false when botOpenId is undefined (unknown bot)", () => {
|
|
50
|
+
const event = makeEvent("group", [
|
|
51
|
+
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
|
|
52
|
+
]);
|
|
53
|
+
const ctx = parseFeishuMessageEvent(event as any, undefined);
|
|
54
|
+
expect(ctx.mentionedBot).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns mentionedBot=false when botOpenId is empty string (probe failed)", () => {
|
|
58
|
+
const event = makeEvent("group", [
|
|
59
|
+
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
|
|
60
|
+
]);
|
|
61
|
+
const ctx = parseFeishuMessageEvent(event as any, "");
|
|
62
|
+
expect(ctx.mentionedBot).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/src/bot.test.ts
ADDED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
931
|
+
CommandAuthorized: commandAuthorized,
|
|
831
932
|
OriginatingChannel: "feishu" as const,
|
|
832
933
|
OriginatingTo: feishuTo,
|
|
833
934
|
...mediaPayload,
|