@openclaw/feishu 2026.2.21 → 2026.2.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/bot.checkBotMentioned.test.ts +17 -36
- package/src/bot.test.ts +150 -54
- package/src/bot.ts +40 -10
- 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/media.ts +11 -25
- 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/src/send-target.ts +25 -0
- package/src/send.ts +4 -26
- package/src/streaming-card.ts +22 -27
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
|
|
@@ -698,10 +720,10 @@ export async function handleFeishuMessage(params: {
|
|
|
698
720
|
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
|
|
699
721
|
// get a separate session from the main group chat.
|
|
700
722
|
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
|
|
723
|
+
let topicSessionMode: "enabled" | "disabled" = "disabled";
|
|
701
724
|
if (isGroup && ctx.rootId) {
|
|
702
725
|
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
|
703
|
-
|
|
704
|
-
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
|
726
|
+
topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
|
705
727
|
if (topicSessionMode === "enabled") {
|
|
706
728
|
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
|
|
707
729
|
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
|
|
@@ -717,6 +739,14 @@ export async function handleFeishuMessage(params: {
|
|
|
717
739
|
kind: isGroup ? "group" : "direct",
|
|
718
740
|
id: peerId,
|
|
719
741
|
},
|
|
742
|
+
// Add parentPeer for binding inheritance in topic mode
|
|
743
|
+
parentPeer:
|
|
744
|
+
isGroup && ctx.rootId && topicSessionMode === "enabled"
|
|
745
|
+
? {
|
|
746
|
+
kind: "group",
|
|
747
|
+
id: ctx.chatId,
|
|
748
|
+
}
|
|
749
|
+
: null,
|
|
720
750
|
});
|
|
721
751
|
|
|
722
752
|
// Dynamic agent creation for DM users
|
package/src/channel.ts
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
createDefaultChannelRuntimeState,
|
|
5
5
|
DEFAULT_ACCOUNT_ID,
|
|
6
6
|
PAIRING_APPROVED_MESSAGE,
|
|
7
|
+
resolveAllowlistProviderRuntimeGroupPolicy,
|
|
8
|
+
resolveDefaultGroupPolicy,
|
|
7
9
|
} from "openclaw/plugin-sdk";
|
|
8
10
|
import {
|
|
9
11
|
resolveFeishuAccount,
|
|
@@ -224,10 +226,12 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
224
226
|
collectWarnings: ({ cfg, accountId }) => {
|
|
225
227
|
const account = resolveFeishuAccount({ cfg, accountId });
|
|
226
228
|
const feishuCfg = account.config;
|
|
227
|
-
const defaultGroupPolicy = (
|
|
228
|
-
|
|
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
|