@openclaw/feishu 2026.2.19 → 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 +1 -1
- package/src/bot.checkBotMentioned.test.ts +17 -36
- package/src/bot.test.ts +150 -54
- package/src/bot.ts +30 -8
- package/src/channel.ts +8 -4
- package/src/config-schema.test.ts +22 -0
- package/src/config-schema.ts +27 -41
- package/src/dedup.ts +47 -26
- package/src/media.test.ts +12 -14
- package/src/monitor.webhook-security.test.ts +76 -67
- package/src/onboarding.ts +28 -36
- package/src/policy.test.ts +59 -0
- package/src/policy.ts +39 -3
package/package.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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.
|
|
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 (!
|
|
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
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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",
|
package/src/config-schema.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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 {
|
|
6
|
+
import { normalizeFeishuTarget } from "./targets.js";
|
|
7
7
|
import type { FeishuConfig, FeishuGroupConfig } from "./types.js";
|
|
8
8
|
|
|
9
|
-
export type FeishuAllowlistMatch = AllowlistMatch<"wildcard" | "id"
|
|
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
|
-
|
|
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;
|