@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/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 () => {
|
package/src/media.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { createFeishuClient } from "./client.js";
|
|
|
7
7
|
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
8
8
|
import { getFeishuRuntime } from "./runtime.js";
|
|
9
9
|
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
|
10
|
-
import {
|
|
10
|
+
import { resolveFeishuSendTarget } from "./send-target.js";
|
|
11
11
|
|
|
12
12
|
export type DownloadImageResult = {
|
|
13
13
|
buffer: Buffer;
|
|
@@ -268,18 +268,11 @@ export async function sendImageFeishu(params: {
|
|
|
268
268
|
accountId?: string;
|
|
269
269
|
}): Promise<SendMediaResult> {
|
|
270
270
|
const { cfg, to, imageKey, replyToMessageId, accountId } = params;
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
const client = createFeishuClient(account);
|
|
277
|
-
const receiveId = normalizeFeishuTarget(to);
|
|
278
|
-
if (!receiveId) {
|
|
279
|
-
throw new Error(`Invalid Feishu target: ${to}`);
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
const receiveIdType = resolveReceiveIdType(receiveId);
|
|
271
|
+
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
|
|
272
|
+
cfg,
|
|
273
|
+
to,
|
|
274
|
+
accountId,
|
|
275
|
+
});
|
|
283
276
|
const content = JSON.stringify({ image_key: imageKey });
|
|
284
277
|
|
|
285
278
|
if (replyToMessageId) {
|
|
@@ -320,18 +313,11 @@ export async function sendFileFeishu(params: {
|
|
|
320
313
|
}): Promise<SendMediaResult> {
|
|
321
314
|
const { cfg, to, fileKey, replyToMessageId, accountId } = params;
|
|
322
315
|
const msgType = params.msgType ?? "file";
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const client = createFeishuClient(account);
|
|
329
|
-
const receiveId = normalizeFeishuTarget(to);
|
|
330
|
-
if (!receiveId) {
|
|
331
|
-
throw new Error(`Invalid Feishu target: ${to}`);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
const receiveIdType = resolveReceiveIdType(receiveId);
|
|
316
|
+
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
|
|
317
|
+
cfg,
|
|
318
|
+
to,
|
|
319
|
+
accountId,
|
|
320
|
+
});
|
|
335
321
|
const content = JSON.stringify({ file_key: fileKey });
|
|
336
322
|
|
|
337
323
|
if (replyToMessageId) {
|
|
@@ -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;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ClawdbotConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
3
|
+
import { createFeishuClient } from "./client.js";
|
|
4
|
+
import { resolveReceiveIdType, normalizeFeishuTarget } from "./targets.js";
|
|
5
|
+
|
|
6
|
+
export function resolveFeishuSendTarget(params: {
|
|
7
|
+
cfg: ClawdbotConfig;
|
|
8
|
+
to: string;
|
|
9
|
+
accountId?: string;
|
|
10
|
+
}) {
|
|
11
|
+
const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
|
|
12
|
+
if (!account.configured) {
|
|
13
|
+
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
14
|
+
}
|
|
15
|
+
const client = createFeishuClient(account);
|
|
16
|
+
const receiveId = normalizeFeishuTarget(params.to);
|
|
17
|
+
if (!receiveId) {
|
|
18
|
+
throw new Error(`Invalid Feishu target: ${params.to}`);
|
|
19
|
+
}
|
|
20
|
+
return {
|
|
21
|
+
client,
|
|
22
|
+
receiveId,
|
|
23
|
+
receiveIdType: resolveReceiveIdType(receiveId),
|
|
24
|
+
};
|
|
25
|
+
}
|
package/src/send.ts
CHANGED
|
@@ -5,8 +5,8 @@ import type { MentionTarget } from "./mention.js";
|
|
|
5
5
|
import { buildMentionedMessage, buildMentionedCardContent } from "./mention.js";
|
|
6
6
|
import { getFeishuRuntime } from "./runtime.js";
|
|
7
7
|
import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result.js";
|
|
8
|
-
import {
|
|
9
|
-
import type { FeishuSendResult
|
|
8
|
+
import { resolveFeishuSendTarget } from "./send-target.js";
|
|
9
|
+
import type { FeishuSendResult } from "./types.js";
|
|
10
10
|
|
|
11
11
|
export type FeishuMessageInfo = {
|
|
12
12
|
messageId: string;
|
|
@@ -128,18 +128,7 @@ export async function sendMessageFeishu(
|
|
|
128
128
|
params: SendFeishuMessageParams,
|
|
129
129
|
): Promise<FeishuSendResult> {
|
|
130
130
|
const { cfg, to, text, replyToMessageId, mentions, accountId } = params;
|
|
131
|
-
const
|
|
132
|
-
if (!account.configured) {
|
|
133
|
-
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const client = createFeishuClient(account);
|
|
137
|
-
const receiveId = normalizeFeishuTarget(to);
|
|
138
|
-
if (!receiveId) {
|
|
139
|
-
throw new Error(`Invalid Feishu target: ${to}`);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const receiveIdType = resolveReceiveIdType(receiveId);
|
|
131
|
+
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
|
|
143
132
|
const tableMode = getFeishuRuntime().channel.text.resolveMarkdownTableMode({
|
|
144
133
|
cfg,
|
|
145
134
|
channel: "feishu",
|
|
@@ -188,18 +177,7 @@ export type SendFeishuCardParams = {
|
|
|
188
177
|
|
|
189
178
|
export async function sendCardFeishu(params: SendFeishuCardParams): Promise<FeishuSendResult> {
|
|
190
179
|
const { cfg, to, card, replyToMessageId, accountId } = params;
|
|
191
|
-
const
|
|
192
|
-
if (!account.configured) {
|
|
193
|
-
throw new Error(`Feishu account "${account.accountId}" not configured`);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const client = createFeishuClient(account);
|
|
197
|
-
const receiveId = normalizeFeishuTarget(to);
|
|
198
|
-
if (!receiveId) {
|
|
199
|
-
throw new Error(`Invalid Feishu target: ${to}`);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const receiveIdType = resolveReceiveIdType(receiveId);
|
|
180
|
+
const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({ cfg, to, accountId });
|
|
203
181
|
const content = JSON.stringify(card);
|
|
204
182
|
|
|
205
183
|
if (replyToMessageId) {
|