@openclaw/feishu 2026.2.15 → 2026.2.19
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/bitable.ts +281 -3
- package/src/bot.checkBotMentioned.test.ts +86 -1
- package/src/bot.stripBotMention.test.ts +38 -0
- package/src/bot.test.ts +28 -4
- package/src/bot.ts +43 -19
- package/src/channel.ts +5 -1
- package/src/config-schema.test.ts +66 -0
- package/src/config-schema.ts +34 -0
- package/src/docx.ts +2 -2
- package/src/dynamic-agent.ts +1 -1
- package/src/external-keys.test.ts +20 -0
- package/src/external-keys.ts +19 -0
- package/src/media.test.ts +95 -1
- package/src/media.ts +28 -49
- package/src/mention.ts +8 -1
- package/src/monitor.ts +72 -5
- package/src/monitor.webhook-security.test.ts +174 -0
- package/src/onboarding.ts +1 -1
- package/src/probe.ts +1 -1
- package/src/reply-dispatcher.ts +1 -1
- package/src/send-result.ts +29 -0
- package/src/send.ts +11 -38
package/package.json
CHANGED
package/src/bitable.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
2
1
|
import { Type } from "@sinclair/typebox";
|
|
3
|
-
import type {
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
4
3
|
import { createFeishuClient } from "./client.js";
|
|
4
|
+
import type { FeishuConfig } from "./types.js";
|
|
5
5
|
|
|
6
6
|
// ============ Helpers ============
|
|
7
7
|
|
|
@@ -224,6 +224,198 @@ async function createRecord(
|
|
|
224
224
|
};
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
+
/** Logger interface for cleanup operations */
|
|
228
|
+
type CleanupLogger = {
|
|
229
|
+
debug: (msg: string) => void;
|
|
230
|
+
warn: (msg: string) => void;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** Default field types created for new Bitable tables (to be cleaned up) */
|
|
234
|
+
const DEFAULT_CLEANUP_FIELD_TYPES = new Set([3, 5, 17]); // SingleSelect, DateTime, Attachment
|
|
235
|
+
|
|
236
|
+
/** Clean up default placeholder rows and fields in a newly created Bitable table */
|
|
237
|
+
async function cleanupNewBitable(
|
|
238
|
+
client: ReturnType<typeof createFeishuClient>,
|
|
239
|
+
appToken: string,
|
|
240
|
+
tableId: string,
|
|
241
|
+
tableName: string,
|
|
242
|
+
logger: CleanupLogger,
|
|
243
|
+
): Promise<{ cleanedRows: number; cleanedFields: number }> {
|
|
244
|
+
let cleanedRows = 0;
|
|
245
|
+
let cleanedFields = 0;
|
|
246
|
+
|
|
247
|
+
// Step 1: Clean up default fields
|
|
248
|
+
const fieldsRes = await client.bitable.appTableField.list({
|
|
249
|
+
path: { app_token: appToken, table_id: tableId },
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (fieldsRes.code === 0 && fieldsRes.data?.items) {
|
|
253
|
+
// Step 1a: Rename primary field to the table name (works for both Feishu and Lark)
|
|
254
|
+
const primaryField = fieldsRes.data.items.find((f) => f.is_primary);
|
|
255
|
+
if (primaryField?.field_id) {
|
|
256
|
+
try {
|
|
257
|
+
const newFieldName = tableName.length <= 20 ? tableName : "Name";
|
|
258
|
+
await client.bitable.appTableField.update({
|
|
259
|
+
path: {
|
|
260
|
+
app_token: appToken,
|
|
261
|
+
table_id: tableId,
|
|
262
|
+
field_id: primaryField.field_id,
|
|
263
|
+
},
|
|
264
|
+
data: {
|
|
265
|
+
field_name: newFieldName,
|
|
266
|
+
type: 1,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
cleanedFields++;
|
|
270
|
+
} catch (err) {
|
|
271
|
+
logger.debug(`Failed to rename primary field: ${err}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Step 1b: Delete default placeholder fields by type (works for both Feishu and Lark)
|
|
276
|
+
const defaultFieldsToDelete = fieldsRes.data.items.filter(
|
|
277
|
+
(f) => !f.is_primary && DEFAULT_CLEANUP_FIELD_TYPES.has(f.type ?? 0),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
for (const field of defaultFieldsToDelete) {
|
|
281
|
+
if (field.field_id) {
|
|
282
|
+
try {
|
|
283
|
+
await client.bitable.appTableField.delete({
|
|
284
|
+
path: {
|
|
285
|
+
app_token: appToken,
|
|
286
|
+
table_id: tableId,
|
|
287
|
+
field_id: field.field_id,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
cleanedFields++;
|
|
291
|
+
} catch (err) {
|
|
292
|
+
logger.debug(`Failed to delete default field ${field.field_name}: ${err}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Step 2: Delete empty placeholder rows (batch when possible)
|
|
299
|
+
const recordsRes = await client.bitable.appTableRecord.list({
|
|
300
|
+
path: { app_token: appToken, table_id: tableId },
|
|
301
|
+
params: { page_size: 100 },
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
if (recordsRes.code === 0 && recordsRes.data?.items) {
|
|
305
|
+
const emptyRecordIds = recordsRes.data.items
|
|
306
|
+
.filter((r) => !r.fields || Object.keys(r.fields).length === 0)
|
|
307
|
+
.map((r) => r.record_id)
|
|
308
|
+
.filter((id): id is string => Boolean(id));
|
|
309
|
+
|
|
310
|
+
if (emptyRecordIds.length > 0) {
|
|
311
|
+
try {
|
|
312
|
+
await client.bitable.appTableRecord.batchDelete({
|
|
313
|
+
path: { app_token: appToken, table_id: tableId },
|
|
314
|
+
data: { records: emptyRecordIds },
|
|
315
|
+
});
|
|
316
|
+
cleanedRows = emptyRecordIds.length;
|
|
317
|
+
} catch {
|
|
318
|
+
// Fallback: delete one by one if batch API is unavailable
|
|
319
|
+
for (const recordId of emptyRecordIds) {
|
|
320
|
+
try {
|
|
321
|
+
await client.bitable.appTableRecord.delete({
|
|
322
|
+
path: { app_token: appToken, table_id: tableId, record_id: recordId },
|
|
323
|
+
});
|
|
324
|
+
cleanedRows++;
|
|
325
|
+
} catch (err) {
|
|
326
|
+
logger.debug(`Failed to delete empty row ${recordId}: ${err}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return { cleanedRows, cleanedFields };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function createApp(
|
|
337
|
+
client: ReturnType<typeof createFeishuClient>,
|
|
338
|
+
name: string,
|
|
339
|
+
folderToken?: string,
|
|
340
|
+
logger?: CleanupLogger,
|
|
341
|
+
) {
|
|
342
|
+
const res = await client.bitable.app.create({
|
|
343
|
+
data: {
|
|
344
|
+
name,
|
|
345
|
+
...(folderToken && { folder_token: folderToken }),
|
|
346
|
+
},
|
|
347
|
+
});
|
|
348
|
+
if (res.code !== 0) {
|
|
349
|
+
throw new Error(res.msg);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const appToken = res.data?.app?.app_token;
|
|
353
|
+
if (!appToken) {
|
|
354
|
+
throw new Error("Failed to create Bitable: no app_token returned");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const log: CleanupLogger = logger ?? { debug: () => {}, warn: () => {} };
|
|
358
|
+
let tableId: string | undefined;
|
|
359
|
+
let cleanedRows = 0;
|
|
360
|
+
let cleanedFields = 0;
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const tablesRes = await client.bitable.appTable.list({
|
|
364
|
+
path: { app_token: appToken },
|
|
365
|
+
});
|
|
366
|
+
if (tablesRes.code === 0 && tablesRes.data?.items && tablesRes.data.items.length > 0) {
|
|
367
|
+
tableId = tablesRes.data.items[0].table_id ?? undefined;
|
|
368
|
+
if (tableId) {
|
|
369
|
+
const cleanup = await cleanupNewBitable(client, appToken, tableId, name, log);
|
|
370
|
+
cleanedRows = cleanup.cleanedRows;
|
|
371
|
+
cleanedFields = cleanup.cleanedFields;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} catch (err) {
|
|
375
|
+
log.debug(`Cleanup failed (non-critical): ${err}`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
app_token: appToken,
|
|
380
|
+
table_id: tableId,
|
|
381
|
+
name: res.data?.app?.name,
|
|
382
|
+
url: res.data?.app?.url,
|
|
383
|
+
cleaned_placeholder_rows: cleanedRows,
|
|
384
|
+
cleaned_default_fields: cleanedFields,
|
|
385
|
+
hint: tableId
|
|
386
|
+
? `Table created. Use app_token="${appToken}" and table_id="${tableId}" for other bitable tools.`
|
|
387
|
+
: "Table created. Use feishu_bitable_get_meta to get table_id and field details.",
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function createField(
|
|
392
|
+
client: ReturnType<typeof createFeishuClient>,
|
|
393
|
+
appToken: string,
|
|
394
|
+
tableId: string,
|
|
395
|
+
fieldName: string,
|
|
396
|
+
fieldType: number,
|
|
397
|
+
property?: Record<string, unknown>,
|
|
398
|
+
) {
|
|
399
|
+
const res = await client.bitable.appTableField.create({
|
|
400
|
+
path: { app_token: appToken, table_id: tableId },
|
|
401
|
+
data: {
|
|
402
|
+
field_name: fieldName,
|
|
403
|
+
type: fieldType,
|
|
404
|
+
...(property && { property }),
|
|
405
|
+
},
|
|
406
|
+
});
|
|
407
|
+
if (res.code !== 0) {
|
|
408
|
+
throw new Error(res.msg);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
field_id: res.data?.field?.field_id,
|
|
413
|
+
field_name: res.data?.field?.field_name,
|
|
414
|
+
type: res.data?.field?.type,
|
|
415
|
+
type_name: FIELD_TYPE_NAMES[res.data?.field?.type ?? 0] || `type_${res.data?.field?.type}`,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
227
419
|
async function updateRecord(
|
|
228
420
|
client: ReturnType<typeof createFeishuClient>,
|
|
229
421
|
appToken: string,
|
|
@@ -296,6 +488,36 @@ const CreateRecordSchema = Type.Object({
|
|
|
296
488
|
}),
|
|
297
489
|
});
|
|
298
490
|
|
|
491
|
+
const CreateAppSchema = Type.Object({
|
|
492
|
+
name: Type.String({
|
|
493
|
+
description: "Name for the new Bitable application",
|
|
494
|
+
}),
|
|
495
|
+
folder_token: Type.Optional(
|
|
496
|
+
Type.String({
|
|
497
|
+
description: "Optional folder token to place the Bitable in a specific folder",
|
|
498
|
+
}),
|
|
499
|
+
),
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
const CreateFieldSchema = Type.Object({
|
|
503
|
+
app_token: Type.String({
|
|
504
|
+
description:
|
|
505
|
+
"Bitable app token (use feishu_bitable_get_meta to get from URL, or feishu_bitable_create_app to create new)",
|
|
506
|
+
}),
|
|
507
|
+
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
|
|
508
|
+
field_name: Type.String({ description: "Name for the new field" }),
|
|
509
|
+
field_type: Type.Number({
|
|
510
|
+
description:
|
|
511
|
+
"Field type ID: 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=SingleLink, 19=Lookup, 20=Formula, 21=DuplexLink, 22=Location, 23=GroupChat, 1001=CreatedTime, 1002=ModifiedTime, 1003=CreatedUser, 1004=ModifiedUser, 1005=AutoNumber",
|
|
512
|
+
minimum: 1,
|
|
513
|
+
}),
|
|
514
|
+
property: Type.Optional(
|
|
515
|
+
Type.Record(Type.String(), Type.Any(), {
|
|
516
|
+
description: "Field-specific properties (e.g., options for SingleSelect, format for Number)",
|
|
517
|
+
}),
|
|
518
|
+
),
|
|
519
|
+
});
|
|
520
|
+
|
|
299
521
|
const UpdateRecordSchema = Type.Object({
|
|
300
522
|
app_token: Type.String({
|
|
301
523
|
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
|
|
@@ -457,5 +679,61 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) {
|
|
|
457
679
|
{ name: "feishu_bitable_update_record" },
|
|
458
680
|
);
|
|
459
681
|
|
|
460
|
-
|
|
682
|
+
// Tool 6: feishu_bitable_create_app
|
|
683
|
+
api.registerTool(
|
|
684
|
+
{
|
|
685
|
+
name: "feishu_bitable_create_app",
|
|
686
|
+
label: "Feishu Bitable Create App",
|
|
687
|
+
description: "Create a new Bitable (multidimensional table) application",
|
|
688
|
+
parameters: CreateAppSchema,
|
|
689
|
+
async execute(_toolCallId, params) {
|
|
690
|
+
const { name, folder_token } = params as { name: string; folder_token?: string };
|
|
691
|
+
try {
|
|
692
|
+
const result = await createApp(getClient(), name, folder_token, {
|
|
693
|
+
debug: (msg) => api.logger.debug?.(msg),
|
|
694
|
+
warn: (msg) => api.logger.warn?.(msg),
|
|
695
|
+
});
|
|
696
|
+
return json(result);
|
|
697
|
+
} catch (err) {
|
|
698
|
+
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
699
|
+
}
|
|
700
|
+
},
|
|
701
|
+
},
|
|
702
|
+
{ name: "feishu_bitable_create_app" },
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
// Tool 7: feishu_bitable_create_field
|
|
706
|
+
api.registerTool(
|
|
707
|
+
{
|
|
708
|
+
name: "feishu_bitable_create_field",
|
|
709
|
+
label: "Feishu Bitable Create Field",
|
|
710
|
+
description: "Create a new field (column) in a Bitable table",
|
|
711
|
+
parameters: CreateFieldSchema,
|
|
712
|
+
async execute(_toolCallId, params) {
|
|
713
|
+
const { app_token, table_id, field_name, field_type, property } = params as {
|
|
714
|
+
app_token: string;
|
|
715
|
+
table_id: string;
|
|
716
|
+
field_name: string;
|
|
717
|
+
field_type: number;
|
|
718
|
+
property?: Record<string, unknown>;
|
|
719
|
+
};
|
|
720
|
+
try {
|
|
721
|
+
const result = await createField(
|
|
722
|
+
getClient(),
|
|
723
|
+
app_token,
|
|
724
|
+
table_id,
|
|
725
|
+
field_name,
|
|
726
|
+
field_type,
|
|
727
|
+
property,
|
|
728
|
+
);
|
|
729
|
+
return json(result);
|
|
730
|
+
} catch (err) {
|
|
731
|
+
return json({ error: err instanceof Error ? err.message : String(err) });
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
{ name: "feishu_bitable_create_field" },
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
api.logger.info?.("feishu_bitable: Registered bitable tools");
|
|
461
739
|
}
|
|
@@ -5,6 +5,7 @@ import { parseFeishuMessageEvent } from "./bot.js";
|
|
|
5
5
|
function makeEvent(
|
|
6
6
|
chatType: "p2p" | "group",
|
|
7
7
|
mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
|
|
8
|
+
text = "hello",
|
|
8
9
|
) {
|
|
9
10
|
return {
|
|
10
11
|
sender: {
|
|
@@ -15,7 +16,7 @@ function makeEvent(
|
|
|
15
16
|
chat_id: "oc_chat1",
|
|
16
17
|
chat_type: chatType,
|
|
17
18
|
message_type: "text",
|
|
18
|
-
content: JSON.stringify({ text
|
|
19
|
+
content: JSON.stringify({ text }),
|
|
19
20
|
mentions,
|
|
20
21
|
},
|
|
21
22
|
};
|
|
@@ -61,4 +62,88 @@ describe("parseFeishuMessageEvent – mentionedBot", () => {
|
|
|
61
62
|
const ctx = parseFeishuMessageEvent(event as any, "");
|
|
62
63
|
expect(ctx.mentionedBot).toBe(false);
|
|
63
64
|
});
|
|
65
|
+
|
|
66
|
+
it("treats mention.name regex metacharacters as literals when stripping", () => {
|
|
67
|
+
const event = makeEvent(
|
|
68
|
+
"group",
|
|
69
|
+
[{ key: "@_bot_1", name: ".*", id: { open_id: BOT_OPEN_ID } }],
|
|
70
|
+
"@NotBot hello",
|
|
71
|
+
);
|
|
72
|
+
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
|
|
73
|
+
expect(ctx.content).toBe("@NotBot hello");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("treats mention.key regex metacharacters as literals when stripping", () => {
|
|
77
|
+
const event = makeEvent(
|
|
78
|
+
"group",
|
|
79
|
+
[{ key: ".*", name: "Bot", id: { open_id: BOT_OPEN_ID } }],
|
|
80
|
+
"hello world",
|
|
81
|
+
);
|
|
82
|
+
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
|
|
83
|
+
expect(ctx.content).toBe("hello world");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns mentionedBot=true for post message with at (no top-level mentions)", () => {
|
|
87
|
+
const BOT_OPEN_ID = "ou_bot_123";
|
|
88
|
+
const postContent = JSON.stringify({
|
|
89
|
+
content: [
|
|
90
|
+
[{ tag: "at", user_id: BOT_OPEN_ID, user_name: "claw" }],
|
|
91
|
+
[{ tag: "text", text: "What does this document say" }],
|
|
92
|
+
],
|
|
93
|
+
});
|
|
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
|
+
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
|
|
106
|
+
expect(ctx.mentionedBot).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns mentionedBot=false for post message with no at", () => {
|
|
110
|
+
const postContent = JSON.stringify({
|
|
111
|
+
content: [[{ tag: "text", text: "hello" }]],
|
|
112
|
+
});
|
|
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
|
+
const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123");
|
|
125
|
+
expect(ctx.mentionedBot).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("returns mentionedBot=false for post message with at for another user", () => {
|
|
129
|
+
const postContent = JSON.stringify({
|
|
130
|
+
content: [
|
|
131
|
+
[{ tag: "at", user_id: "ou_other", user_name: "other" }],
|
|
132
|
+
[{ tag: "text", text: "hello" }],
|
|
133
|
+
],
|
|
134
|
+
});
|
|
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
|
+
const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123");
|
|
147
|
+
expect(ctx.mentionedBot).toBe(false);
|
|
148
|
+
});
|
|
64
149
|
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { stripBotMention, type FeishuMessageEvent } from "./bot.js";
|
|
3
|
+
|
|
4
|
+
type Mentions = FeishuMessageEvent["message"]["mentions"];
|
|
5
|
+
|
|
6
|
+
describe("stripBotMention", () => {
|
|
7
|
+
it("returns original text when mentions are missing", () => {
|
|
8
|
+
expect(stripBotMention("hello world", undefined)).toBe("hello world");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("strips mention name and key for normal mentions", () => {
|
|
12
|
+
const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
|
|
13
|
+
expect(stripBotMention("@Bot hello @_bot_1", mentions)).toBe("hello");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("treats mention.name regex metacharacters as literal text", () => {
|
|
17
|
+
const mentions: Mentions = [{ key: "@_bot_1", name: ".*", id: { open_id: "ou_bot" } }];
|
|
18
|
+
expect(stripBotMention("@NotBot hello", mentions)).toBe("@NotBot hello");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("treats mention.key regex metacharacters as literal text", () => {
|
|
22
|
+
const mentions: Mentions = [{ key: ".*", name: "Bot", id: { open_id: "ou_bot" } }];
|
|
23
|
+
expect(stripBotMention("hello world", mentions)).toBe("hello world");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("trims once after all mention replacements", () => {
|
|
27
|
+
const mentions: Mentions = [{ key: "@_bot_1", name: "Bot", id: { open_id: "ou_bot" } }];
|
|
28
|
+
expect(stripBotMention(" @_bot_1 hello ", mentions)).toBe("hello");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("strips multiple mentions in one pass", () => {
|
|
32
|
+
const mentions: Mentions = [
|
|
33
|
+
{ key: "@_bot_1", name: "Bot One", id: { open_id: "ou_bot_1" } },
|
|
34
|
+
{ key: "@_bot_2", name: "Bot Two", id: { open_id: "ou_bot_2" } },
|
|
35
|
+
];
|
|
36
|
+
expect(stripBotMention("@Bot One @_bot_1 hi @Bot Two @_bot_2", mentions)).toBe("hi");
|
|
37
|
+
});
|
|
38
|
+
});
|
package/src/bot.test.ts
CHANGED
|
@@ -99,7 +99,13 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
99
99
|
await handleFeishuMessage({
|
|
100
100
|
cfg,
|
|
101
101
|
event,
|
|
102
|
-
runtime: {
|
|
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,
|
|
103
109
|
});
|
|
104
110
|
|
|
105
111
|
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
|
|
@@ -148,7 +154,13 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
148
154
|
await handleFeishuMessage({
|
|
149
155
|
cfg,
|
|
150
156
|
event,
|
|
151
|
-
runtime: {
|
|
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,
|
|
152
164
|
});
|
|
153
165
|
|
|
154
166
|
expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu");
|
|
@@ -189,7 +201,13 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
189
201
|
await handleFeishuMessage({
|
|
190
202
|
cfg,
|
|
191
203
|
event,
|
|
192
|
-
runtime: {
|
|
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,
|
|
193
211
|
});
|
|
194
212
|
|
|
195
213
|
expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
|
|
@@ -247,7 +265,13 @@ describe("handleFeishuMessage command authorization", () => {
|
|
|
247
265
|
await handleFeishuMessage({
|
|
248
266
|
cfg,
|
|
249
267
|
event,
|
|
250
|
-
runtime: {
|
|
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,
|
|
251
275
|
});
|
|
252
276
|
|
|
253
277
|
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
|
package/src/bot.ts
CHANGED
|
@@ -7,14 +7,18 @@ import {
|
|
|
7
7
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
8
8
|
type HistoryEntry,
|
|
9
9
|
} from "openclaw/plugin-sdk";
|
|
10
|
-
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
|
11
|
-
import type { DynamicAgentCreationConfig } from "./types.js";
|
|
12
10
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
13
11
|
import { createFeishuClient } from "./client.js";
|
|
14
12
|
import { tryRecordMessage } from "./dedup.js";
|
|
15
13
|
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
14
|
+
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
15
|
+
import { downloadMessageResourceFeishu } from "./media.js";
|
|
16
|
+
import {
|
|
17
|
+
escapeRegExp,
|
|
18
|
+
extractMentionTargets,
|
|
19
|
+
extractMessageBody,
|
|
20
|
+
isMentionForwardRequest,
|
|
21
|
+
} from "./mention.js";
|
|
18
22
|
import {
|
|
19
23
|
resolveFeishuGroupConfig,
|
|
20
24
|
resolveFeishuReplyPolicy,
|
|
@@ -24,6 +28,8 @@ import {
|
|
|
24
28
|
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|
25
29
|
import { getFeishuRuntime } from "./runtime.js";
|
|
26
30
|
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
|
|
31
|
+
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
|
32
|
+
import type { DynamicAgentCreationConfig } from "./types.js";
|
|
27
33
|
|
|
28
34
|
// --- Permission error extraction ---
|
|
29
35
|
// Extract permission grant URL from Feishu API error response.
|
|
@@ -185,23 +191,30 @@ function parseMessageContent(content: string, messageType: string): string {
|
|
|
185
191
|
}
|
|
186
192
|
|
|
187
193
|
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
|
188
|
-
const mentions = event.message.mentions ?? [];
|
|
189
|
-
if (mentions.length === 0) return false;
|
|
190
194
|
if (!botOpenId) return false;
|
|
191
|
-
|
|
195
|
+
const mentions = event.message.mentions ?? [];
|
|
196
|
+
if (mentions.length > 0) {
|
|
197
|
+
return mentions.some((m) => m.id.open_id === botOpenId);
|
|
198
|
+
}
|
|
199
|
+
// Post (rich text) messages may have empty message.mentions when they contain docs/paste
|
|
200
|
+
if (event.message.message_type === "post") {
|
|
201
|
+
const { mentionedOpenIds } = parsePostContent(event.message.content);
|
|
202
|
+
return mentionedOpenIds.some((id) => id === botOpenId);
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
192
205
|
}
|
|
193
206
|
|
|
194
|
-
function stripBotMention(
|
|
207
|
+
export function stripBotMention(
|
|
195
208
|
text: string,
|
|
196
209
|
mentions?: FeishuMessageEvent["message"]["mentions"],
|
|
197
210
|
): string {
|
|
198
211
|
if (!mentions || mentions.length === 0) return text;
|
|
199
212
|
let result = text;
|
|
200
213
|
for (const mention of mentions) {
|
|
201
|
-
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "")
|
|
202
|
-
result = result.replace(new RegExp(mention.key, "g"), "")
|
|
214
|
+
result = result.replace(new RegExp(`@${escapeRegExp(mention.name)}\\s*`, "g"), "");
|
|
215
|
+
result = result.replace(new RegExp(escapeRegExp(mention.key), "g"), "");
|
|
203
216
|
}
|
|
204
|
-
return result;
|
|
217
|
+
return result.trim();
|
|
205
218
|
}
|
|
206
219
|
|
|
207
220
|
/**
|
|
@@ -217,18 +230,20 @@ function parseMediaKeys(
|
|
|
217
230
|
} {
|
|
218
231
|
try {
|
|
219
232
|
const parsed = JSON.parse(content);
|
|
233
|
+
const imageKey = normalizeFeishuExternalKey(parsed.image_key);
|
|
234
|
+
const fileKey = normalizeFeishuExternalKey(parsed.file_key);
|
|
220
235
|
switch (messageType) {
|
|
221
236
|
case "image":
|
|
222
|
-
return { imageKey
|
|
237
|
+
return { imageKey };
|
|
223
238
|
case "file":
|
|
224
|
-
return { fileKey
|
|
239
|
+
return { fileKey, fileName: parsed.file_name };
|
|
225
240
|
case "audio":
|
|
226
|
-
return { fileKey
|
|
241
|
+
return { fileKey };
|
|
227
242
|
case "video":
|
|
228
243
|
// Video has both file_key (video) and image_key (thumbnail)
|
|
229
|
-
return { fileKey
|
|
244
|
+
return { fileKey, imageKey };
|
|
230
245
|
case "sticker":
|
|
231
|
-
return { fileKey
|
|
246
|
+
return { fileKey };
|
|
232
247
|
default:
|
|
233
248
|
return {};
|
|
234
249
|
}
|
|
@@ -244,6 +259,7 @@ function parseMediaKeys(
|
|
|
244
259
|
function parsePostContent(content: string): {
|
|
245
260
|
textContent: string;
|
|
246
261
|
imageKeys: string[];
|
|
262
|
+
mentionedOpenIds: string[];
|
|
247
263
|
} {
|
|
248
264
|
try {
|
|
249
265
|
const parsed = JSON.parse(content);
|
|
@@ -251,6 +267,7 @@ function parsePostContent(content: string): {
|
|
|
251
267
|
const contentBlocks = parsed.content || [];
|
|
252
268
|
let textContent = title ? `${title}\n\n` : "";
|
|
253
269
|
const imageKeys: string[] = [];
|
|
270
|
+
const mentionedOpenIds: string[] = [];
|
|
254
271
|
|
|
255
272
|
for (const paragraph of contentBlocks) {
|
|
256
273
|
if (Array.isArray(paragraph)) {
|
|
@@ -263,9 +280,15 @@ function parsePostContent(content: string): {
|
|
|
263
280
|
} else if (element.tag === "at") {
|
|
264
281
|
// Mention: @username
|
|
265
282
|
textContent += `@${element.user_name || element.user_id || ""}`;
|
|
283
|
+
if (element.user_id) {
|
|
284
|
+
mentionedOpenIds.push(element.user_id);
|
|
285
|
+
}
|
|
266
286
|
} else if (element.tag === "img" && element.image_key) {
|
|
267
287
|
// Embedded image
|
|
268
|
-
|
|
288
|
+
const imageKey = normalizeFeishuExternalKey(element.image_key);
|
|
289
|
+
if (imageKey) {
|
|
290
|
+
imageKeys.push(imageKey);
|
|
291
|
+
}
|
|
269
292
|
}
|
|
270
293
|
}
|
|
271
294
|
textContent += "\n";
|
|
@@ -273,11 +296,12 @@ function parsePostContent(content: string): {
|
|
|
273
296
|
}
|
|
274
297
|
|
|
275
298
|
return {
|
|
276
|
-
textContent: textContent.trim() || "[
|
|
299
|
+
textContent: textContent.trim() || "[Rich text message]",
|
|
277
300
|
imageKeys,
|
|
301
|
+
mentionedOpenIds,
|
|
278
302
|
};
|
|
279
303
|
} catch {
|
|
280
|
-
return { textContent: "[
|
|
304
|
+
return { textContent: "[Rich text message]", imageKeys: [], mentionedOpenIds: [] };
|
|
281
305
|
}
|
|
282
306
|
}
|
|
283
307
|
|
package/src/channel.ts
CHANGED
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
DEFAULT_ACCOUNT_ID,
|
|
6
6
|
PAIRING_APPROVED_MESSAGE,
|
|
7
7
|
} from "openclaw/plugin-sdk";
|
|
8
|
-
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
|
9
8
|
import {
|
|
10
9
|
resolveFeishuAccount,
|
|
11
10
|
resolveFeishuCredentials,
|
|
@@ -24,6 +23,7 @@ import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
|
|
24
23
|
import { probeFeishu } from "./probe.js";
|
|
25
24
|
import { sendMessageFeishu } from "./send.js";
|
|
26
25
|
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
|
|
26
|
+
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
|
27
27
|
|
|
28
28
|
const meta: ChannelMeta = {
|
|
29
29
|
id: "feishu",
|
|
@@ -89,6 +89,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
89
89
|
},
|
|
90
90
|
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
|
|
91
91
|
webhookPath: { type: "string" },
|
|
92
|
+
webhookHost: { type: "string" },
|
|
92
93
|
webhookPort: { type: "integer", minimum: 1 },
|
|
93
94
|
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
|
|
94
95
|
allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
|
|
@@ -118,6 +119,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|
|
118
119
|
verificationToken: { type: "string" },
|
|
119
120
|
domain: { type: "string", enum: ["feishu", "lark"] },
|
|
120
121
|
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
|
|
122
|
+
webhookHost: { type: "string" },
|
|
123
|
+
webhookPath: { type: "string" },
|
|
124
|
+
webhookPort: { type: "integer", minimum: 1 },
|
|
121
125
|
},
|
|
122
126
|
},
|
|
123
127
|
},
|