@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/feishu",
3
- "version": "2026.2.15",
3
+ "version": "2026.2.19",
4
4
  "description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
5
5
  "type": "module",
6
6
  "dependencies": {
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 { FeishuConfig } from "./types.js";
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
- api.logger.info?.(`feishu_bitable: Registered 6 bitable tools`);
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: "hello" }),
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: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
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: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
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: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
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: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
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 { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
17
- import { extractMentionTargets, extractMessageBody, isMentionForwardRequest } from "./mention.js";
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
- return mentions.some((m) => m.id.open_id === botOpenId);
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"), "").trim();
202
- result = result.replace(new RegExp(mention.key, "g"), "").trim();
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: parsed.image_key };
237
+ return { imageKey };
223
238
  case "file":
224
- return { fileKey: parsed.file_key, fileName: parsed.file_name };
239
+ return { fileKey, fileName: parsed.file_name };
225
240
  case "audio":
226
- return { fileKey: parsed.file_key };
241
+ return { fileKey };
227
242
  case "video":
228
243
  // Video has both file_key (video) and image_key (thumbnail)
229
- return { fileKey: parsed.file_key, imageKey: parsed.image_key };
244
+ return { fileKey, imageKey };
230
245
  case "sticker":
231
- return { fileKey: parsed.file_key };
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
- imageKeys.push(element.image_key);
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: "[富文本消息]", imageKeys: [] };
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
  },