@openclaw/feishu 2026.2.12 → 2026.2.14
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 +4 -1
- package/src/accounts.ts +1 -1
- package/src/bot.test.ts +265 -0
- package/src/bot.ts +73 -48
- package/src/config-schema.ts +6 -0
- package/src/dedup.ts +33 -0
- package/src/docx.test.ts +123 -0
- package/src/docx.ts +18 -13
- package/src/media.test.ts +36 -0
- package/src/media.ts +82 -147
- package/src/monitor.ts +28 -2
- package/src/reply-dispatcher.test.ts +116 -0
- package/src/reply-dispatcher.ts +127 -67
- package/src/streaming-card.ts +223 -0
- package/src/targets.test.ts +16 -0
- package/src/targets.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/feishu",
|
|
3
|
-
"version": "2026.2.
|
|
3
|
+
"version": "2026.2.14",
|
|
4
4
|
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"dependencies": {
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
"@sinclair/typebox": "0.34.48",
|
|
9
9
|
"zod": "^4.3.6"
|
|
10
10
|
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"openclaw": "workspace:*"
|
|
13
|
+
},
|
|
11
14
|
"openclaw": {
|
|
12
15
|
"extensions": [
|
|
13
16
|
"./index.ts"
|
package/src/accounts.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
-
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
|
3
3
|
import type {
|
|
4
4
|
FeishuConfig,
|
|
5
5
|
FeishuAccountConfig,
|
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
|
@@ -10,6 +10,7 @@ import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } fro
|
|
|
10
10
|
import type { DynamicAgentCreationConfig } from "./types.js";
|
|
11
11
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
12
12
|
import { createFeishuClient } from "./client.js";
|
|
13
|
+
import { tryRecordMessage } from "./dedup.js";
|
|
13
14
|
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
|
14
15
|
import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
|
|
15
16
|
import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
|
|
@@ -21,38 +22,7 @@ import {
|
|
|
21
22
|
} from "./policy.js";
|
|
22
23
|
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|
23
24
|
import { getFeishuRuntime } from "./runtime.js";
|
|
24
|
-
import { getMessageFeishu } from "./send.js";
|
|
25
|
-
|
|
26
|
-
// --- Message deduplication ---
|
|
27
|
-
// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
|
|
28
|
-
const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
29
|
-
const DEDUP_MAX_SIZE = 1_000;
|
|
30
|
-
const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
|
|
31
|
-
const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
|
|
32
|
-
let lastCleanupTime = Date.now();
|
|
33
|
-
|
|
34
|
-
function tryRecordMessage(messageId: string): boolean {
|
|
35
|
-
const now = Date.now();
|
|
36
|
-
|
|
37
|
-
// Throttled cleanup: evict expired entries at most once per interval
|
|
38
|
-
if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
|
|
39
|
-
for (const [id, ts] of processedMessageIds) {
|
|
40
|
-
if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);
|
|
41
|
-
}
|
|
42
|
-
lastCleanupTime = now;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
if (processedMessageIds.has(messageId)) return false;
|
|
46
|
-
|
|
47
|
-
// Evict oldest entries if cache is full
|
|
48
|
-
if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
|
|
49
|
-
const first = processedMessageIds.keys().next().value!;
|
|
50
|
-
processedMessageIds.delete(first);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
processedMessageIds.set(messageId, now);
|
|
54
|
-
return true;
|
|
55
|
-
}
|
|
25
|
+
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
|
|
56
26
|
|
|
57
27
|
// --- Permission error extraction ---
|
|
58
28
|
// Extract permission grant URL from Feishu API error response.
|
|
@@ -581,12 +551,17 @@ export async function handleFeishuMessage(params: {
|
|
|
581
551
|
0,
|
|
582
552
|
feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
|
583
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;
|
|
584
560
|
|
|
585
561
|
if (isGroup) {
|
|
586
562
|
const groupPolicy = feishuCfg?.groupPolicy ?? "open";
|
|
587
563
|
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
|
|
588
564
|
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
|
|
589
|
-
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
|
590
565
|
|
|
591
566
|
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
|
|
592
567
|
const groupAllowed = isFeishuGroupAllowed({
|
|
@@ -642,23 +617,73 @@ export async function handleFeishuMessage(params: {
|
|
|
642
617
|
return;
|
|
643
618
|
}
|
|
644
619
|
} else {
|
|
645
|
-
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
|
646
|
-
const allowFrom = feishuCfg?.allowFrom ?? [];
|
|
647
|
-
|
|
648
|
-
if (dmPolicy === "allowlist") {
|
|
649
|
-
const match = resolveFeishuAllowlistMatch({
|
|
650
|
-
allowFrom,
|
|
651
|
-
senderId: ctx.senderOpenId,
|
|
652
|
-
});
|
|
653
|
-
if (!match.allowed) {
|
|
654
|
-
log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in DM allowlist`);
|
|
655
|
-
return;
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
620
|
}
|
|
659
621
|
|
|
660
622
|
try {
|
|
661
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;
|
|
662
687
|
|
|
663
688
|
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
|
|
664
689
|
// Using a group-scoped From causes the agent to treat different users as the same person.
|
|
@@ -815,7 +840,7 @@ export async function handleFeishuMessage(params: {
|
|
|
815
840
|
MessageSid: `${ctx.messageId}:permission-error`,
|
|
816
841
|
Timestamp: Date.now(),
|
|
817
842
|
WasMentioned: false,
|
|
818
|
-
CommandAuthorized:
|
|
843
|
+
CommandAuthorized: commandAuthorized,
|
|
819
844
|
OriginatingChannel: "feishu" as const,
|
|
820
845
|
OriginatingTo: feishuTo,
|
|
821
846
|
});
|
|
@@ -903,7 +928,7 @@ export async function handleFeishuMessage(params: {
|
|
|
903
928
|
ReplyToBody: quotedContent ?? undefined,
|
|
904
929
|
Timestamp: Date.now(),
|
|
905
930
|
WasMentioned: ctx.mentionedBot,
|
|
906
|
-
CommandAuthorized:
|
|
931
|
+
CommandAuthorized: commandAuthorized,
|
|
907
932
|
OriginatingChannel: "feishu" as const,
|
|
908
933
|
OriginatingTo: feishuTo,
|
|
909
934
|
...mediaPayload,
|
package/src/config-schema.ts
CHANGED
|
@@ -36,6 +36,10 @@ const MarkdownConfigSchema = z
|
|
|
36
36
|
// Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
|
|
37
37
|
const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
|
|
38
38
|
|
|
39
|
+
// Streaming card mode: when enabled, card replies use Feishu's Card Kit streaming API
|
|
40
|
+
// for incremental text display with a "Thinking..." placeholder
|
|
41
|
+
const StreamingModeSchema = z.boolean().optional();
|
|
42
|
+
|
|
39
43
|
const BlockStreamingCoalesceSchema = z
|
|
40
44
|
.object({
|
|
41
45
|
enabled: z.boolean().optional(),
|
|
@@ -142,6 +146,7 @@ export const FeishuAccountConfigSchema = z
|
|
|
142
146
|
mediaMaxMb: z.number().positive().optional(),
|
|
143
147
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
144
148
|
renderMode: RenderModeSchema,
|
|
149
|
+
streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
|
|
145
150
|
tools: FeishuToolsConfigSchema,
|
|
146
151
|
})
|
|
147
152
|
.strict();
|
|
@@ -177,6 +182,7 @@ export const FeishuConfigSchema = z
|
|
|
177
182
|
mediaMaxMb: z.number().positive().optional(),
|
|
178
183
|
heartbeat: ChannelHeartbeatVisibilitySchema,
|
|
179
184
|
renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
|
|
185
|
+
streaming: StreamingModeSchema, // Enable streaming card mode (default: true)
|
|
180
186
|
tools: FeishuToolsConfigSchema,
|
|
181
187
|
// Dynamic agent creation for DM users
|
|
182
188
|
dynamicAgentCreation: DynamicAgentCreationSchema,
|
package/src/dedup.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
|
|
2
|
+
const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
3
|
+
const DEDUP_MAX_SIZE = 1_000;
|
|
4
|
+
const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
|
|
5
|
+
const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
|
|
6
|
+
let lastCleanupTime = Date.now();
|
|
7
|
+
|
|
8
|
+
export function tryRecordMessage(messageId: string): boolean {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
|
|
11
|
+
// Throttled cleanup: evict expired entries at most once per interval.
|
|
12
|
+
if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
|
|
13
|
+
for (const [id, ts] of processedMessageIds) {
|
|
14
|
+
if (now - ts > DEDUP_TTL_MS) {
|
|
15
|
+
processedMessageIds.delete(id);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
lastCleanupTime = now;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (processedMessageIds.has(messageId)) {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Evict oldest entries if cache is full.
|
|
26
|
+
if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
|
|
27
|
+
const first = processedMessageIds.keys().next().value!;
|
|
28
|
+
processedMessageIds.delete(first);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
processedMessageIds.set(messageId, now);
|
|
32
|
+
return true;
|
|
33
|
+
}
|
package/src/docx.test.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
const createFeishuClientMock = vi.hoisted(() => vi.fn());
|
|
4
|
+
const fetchRemoteMediaMock = vi.hoisted(() => vi.fn());
|
|
5
|
+
|
|
6
|
+
vi.mock("./client.js", () => ({
|
|
7
|
+
createFeishuClient: createFeishuClientMock,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("./runtime.js", () => ({
|
|
11
|
+
getFeishuRuntime: () => ({
|
|
12
|
+
channel: {
|
|
13
|
+
media: {
|
|
14
|
+
fetchRemoteMedia: fetchRemoteMediaMock,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
}),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { registerFeishuDocTools } from "./docx.js";
|
|
21
|
+
|
|
22
|
+
describe("feishu_doc image fetch hardening", () => {
|
|
23
|
+
const convertMock = vi.hoisted(() => vi.fn());
|
|
24
|
+
const blockListMock = vi.hoisted(() => vi.fn());
|
|
25
|
+
const blockChildrenCreateMock = vi.hoisted(() => vi.fn());
|
|
26
|
+
const driveUploadAllMock = vi.hoisted(() => vi.fn());
|
|
27
|
+
const blockPatchMock = vi.hoisted(() => vi.fn());
|
|
28
|
+
const scopeListMock = vi.hoisted(() => vi.fn());
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
vi.clearAllMocks();
|
|
32
|
+
|
|
33
|
+
createFeishuClientMock.mockReturnValue({
|
|
34
|
+
docx: {
|
|
35
|
+
document: {
|
|
36
|
+
convert: convertMock,
|
|
37
|
+
},
|
|
38
|
+
documentBlock: {
|
|
39
|
+
list: blockListMock,
|
|
40
|
+
patch: blockPatchMock,
|
|
41
|
+
},
|
|
42
|
+
documentBlockChildren: {
|
|
43
|
+
create: blockChildrenCreateMock,
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
drive: {
|
|
47
|
+
media: {
|
|
48
|
+
uploadAll: driveUploadAllMock,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
application: {
|
|
52
|
+
scope: {
|
|
53
|
+
list: scopeListMock,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
convertMock.mockResolvedValue({
|
|
59
|
+
code: 0,
|
|
60
|
+
data: {
|
|
61
|
+
blocks: [{ block_type: 27 }],
|
|
62
|
+
first_level_block_ids: [],
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
blockListMock.mockResolvedValue({
|
|
67
|
+
code: 0,
|
|
68
|
+
data: {
|
|
69
|
+
items: [],
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
blockChildrenCreateMock.mockResolvedValue({
|
|
74
|
+
code: 0,
|
|
75
|
+
data: {
|
|
76
|
+
children: [{ block_type: 27, block_id: "img_block_1" }],
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
driveUploadAllMock.mockResolvedValue({ file_token: "token_1" });
|
|
81
|
+
blockPatchMock.mockResolvedValue({ code: 0 });
|
|
82
|
+
scopeListMock.mockResolvedValue({ code: 0, data: { scopes: [] } });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("skips image upload when markdown image URL is blocked", async () => {
|
|
86
|
+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
87
|
+
fetchRemoteMediaMock.mockRejectedValueOnce(
|
|
88
|
+
new Error("Blocked: resolves to private/internal IP address"),
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const registerTool = vi.fn();
|
|
92
|
+
registerFeishuDocTools({
|
|
93
|
+
config: {
|
|
94
|
+
channels: {
|
|
95
|
+
feishu: {
|
|
96
|
+
appId: "app_id",
|
|
97
|
+
appSecret: "app_secret",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
} as any,
|
|
101
|
+
logger: { debug: vi.fn(), info: vi.fn() } as any,
|
|
102
|
+
registerTool,
|
|
103
|
+
} as any);
|
|
104
|
+
|
|
105
|
+
const feishuDocTool = registerTool.mock.calls
|
|
106
|
+
.map((call) => call[0])
|
|
107
|
+
.find((tool) => tool.name === "feishu_doc");
|
|
108
|
+
expect(feishuDocTool).toBeDefined();
|
|
109
|
+
|
|
110
|
+
const result = await feishuDocTool.execute("tool-call", {
|
|
111
|
+
action: "write",
|
|
112
|
+
doc_token: "doc_1",
|
|
113
|
+
content: "",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
expect(fetchRemoteMediaMock).toHaveBeenCalled();
|
|
117
|
+
expect(driveUploadAllMock).not.toHaveBeenCalled();
|
|
118
|
+
expect(blockPatchMock).not.toHaveBeenCalled();
|
|
119
|
+
expect(result.details.images_processed).toBe(0);
|
|
120
|
+
expect(consoleErrorSpy).toHaveBeenCalled();
|
|
121
|
+
consoleErrorSpy.mockRestore();
|
|
122
|
+
});
|
|
123
|
+
});
|