@m1heng-clawd/feishu 0.1.10 → 0.1.12

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.
Files changed (47) hide show
  1. package/README.md +280 -7
  2. package/index.ts +6 -6
  3. package/package.json +13 -3
  4. package/skills/feishu-doc/SKILL.md +64 -2
  5. package/skills/feishu-task/SKILL.md +210 -0
  6. package/src/bitable-tools/index.ts +1 -0
  7. package/src/bot.ts +182 -71
  8. package/src/channel.ts +2 -0
  9. package/src/config-schema.ts +4 -0
  10. package/src/doc-tools/actions.ts +341 -0
  11. package/src/doc-tools/common.ts +33 -0
  12. package/src/doc-tools/index.ts +2 -0
  13. package/src/doc-tools/register.ts +90 -0
  14. package/src/{doc-schema.ts → doc-tools/schemas.ts} +39 -1
  15. package/src/{docx.ts → doc-write-service.ts} +204 -351
  16. package/src/drive-tools/actions.ts +182 -0
  17. package/src/drive-tools/common.ts +18 -0
  18. package/src/drive-tools/index.ts +2 -0
  19. package/src/drive-tools/register.ts +71 -0
  20. package/src/{drive-schema.ts → drive-tools/schemas.ts} +2 -1
  21. package/src/media.ts +5 -3
  22. package/src/onboarding.ts +14 -4
  23. package/src/perm-tools/actions.ts +111 -0
  24. package/src/perm-tools/common.ts +18 -0
  25. package/src/perm-tools/index.ts +2 -0
  26. package/src/perm-tools/register.ts +65 -0
  27. package/src/policy.ts +13 -0
  28. package/src/reply-dispatcher.ts +6 -4
  29. package/src/send.ts +33 -2
  30. package/src/task-tools/actions.ts +466 -13
  31. package/src/task-tools/constants.ts +13 -0
  32. package/src/task-tools/index.ts +1 -0
  33. package/src/task-tools/register.ts +175 -13
  34. package/src/task-tools/schemas.ts +433 -4
  35. package/src/text/markdown-links.ts +104 -0
  36. package/src/types.ts +1 -0
  37. package/src/wiki-tools/actions.ts +166 -0
  38. package/src/wiki-tools/common.ts +18 -0
  39. package/src/wiki-tools/index.ts +2 -0
  40. package/src/wiki-tools/register.ts +66 -0
  41. package/src/bitable.ts +0 -1
  42. package/src/drive.ts +0 -264
  43. package/src/perm.ts +0 -165
  44. package/src/task.ts +0 -1
  45. package/src/wiki.ts +0 -223
  46. /package/src/{perm-schema.ts → perm-tools/schemas.ts} +0 -0
  47. /package/src/{wiki-schema.ts → wiki-tools/schemas.ts} +0 -0
@@ -0,0 +1,210 @@
1
+ ---
2
+ name: feishu-task
3
+ description: |
4
+ Feishu Task, tasklist, subtask, comment, and attachment management. Activate when user mentions tasks, tasklists, subtasks, task comments, task attachments, or task links.
5
+ ---
6
+
7
+ # Feishu Task Tools
8
+
9
+ Tools:
10
+ - `feishu_task_create`
11
+ - `feishu_task_subtask_create`
12
+ - `feishu_task_get`
13
+ - `feishu_task_update`
14
+ - `feishu_task_delete`
15
+ - `feishu_task_comment_create`
16
+ - `feishu_task_comment_list`
17
+ - `feishu_task_comment_get`
18
+ - `feishu_task_comment_update`
19
+ - `feishu_task_comment_delete`
20
+ - `feishu_task_attachment_upload`
21
+ - `feishu_task_attachment_list`
22
+ - `feishu_task_attachment_get`
23
+ - `feishu_task_attachment_delete`
24
+ - `feishu_task_add_tasklist`
25
+ - `feishu_task_remove_tasklist`
26
+ - `feishu_tasklist_create`
27
+ - `feishu_tasklist_get`
28
+ - `feishu_tasklist_list`
29
+ - `feishu_tasklist_update`
30
+ - `feishu_tasklist_delete`
31
+ - `feishu_tasklist_add_members`
32
+ - `feishu_tasklist_remove_members`
33
+
34
+ ## Notes
35
+
36
+ - `task_guid` can be taken from a task URL (guid query param) or from `feishu_task_get` output.
37
+ - `comment_id` can be obtained from `feishu_task_comment_list` output.
38
+ - `attachment_guid` can be obtained from `feishu_task_attachment_list` output.
39
+ - `user_id_type` controls returned/accepted user identity type (`open_id`, `user_id`, `union_id`).
40
+ - If no assignee is specified, set the assignee to the requesting user. Avoid creating unassigned tasks because the user may not be able to view them.
41
+ - Task visibility: users can only view tasks when they are included as assignee.
42
+ - Current limitation: the bot can only create subtasks for tasks created by itself.
43
+ - Attachment upload supports local `file_path` and remote `file_url`. Remote URLs are fetched with runtime media safety checks and size limit (`mediaMaxMb`).
44
+ - Keep tasklist owner as the bot. Add users as members to avoid losing bot access.
45
+ - Use tasklist tools for tasklist membership changes; do not use `feishu_task_update` to move tasks between tasklists.
46
+
47
+ ## Create Task
48
+
49
+ ```json
50
+ {
51
+ "summary": "Quarterly review",
52
+ "description": "Prepare review notes",
53
+ "due": { "timestamp": "1735689600000", "is_all_day": true },
54
+ "members": [
55
+ { "id": "ou_xxx", "role": "assignee", "type": "user" }
56
+ ],
57
+ "user_id_type": "open_id"
58
+ }
59
+ ```
60
+
61
+ ## Create Subtask
62
+
63
+ ```json
64
+ {
65
+ "task_guid": "e297ddff-06ca-4166-b917-4ce57cd3a7a0",
66
+ "summary": "Draft report outline",
67
+ "description": "Collect key metrics",
68
+ "due": { "timestamp": "1735689600000", "is_all_day": true },
69
+ "members": [
70
+ { "id": "ou_xxx", "role": "assignee", "type": "user" }
71
+ ],
72
+ "user_id_type": "open_id"
73
+ }
74
+ ```
75
+
76
+ ## Create Comment
77
+
78
+ ```json
79
+ {
80
+ "task_guid": "e297ddff-06ca-4166-b917-4ce57cd3a7a0",
81
+ "content": "Looks good to me",
82
+ "user_id_type": "open_id"
83
+ }
84
+ ```
85
+
86
+ ## Upload Attachment (file_path)
87
+
88
+ ```json
89
+ {
90
+ "task_guid": "e297ddff-06ca-4166-b917-4ce57cd3a7a0",
91
+ "file_path": "/path/to/report.pdf",
92
+ "user_id_type": "open_id"
93
+ }
94
+ ```
95
+
96
+ ## Upload Attachment (file_url)
97
+
98
+ ```json
99
+ {
100
+ "task_guid": "e297ddff-06ca-4166-b917-4ce57cd3a7a0",
101
+ "file_url": "https://oss-example.com/bucket/report.pdf",
102
+ "filename": "report.pdf",
103
+ "user_id_type": "open_id"
104
+ }
105
+ ```
106
+
107
+ ## Tasklist Membership For Tasks
108
+
109
+ ### Add Task to Tasklist
110
+
111
+ ```json
112
+ {
113
+ "task_guid": "e297ddff-06ca-4166-b917-4ce57cd3a7a0",
114
+ "tasklist_guid": "cc371766-6584-cf50-a222-c22cd9055004",
115
+ "section_guid": "6d0f9f48-2e06-4e3d-8a0f-acde196e8c61",
116
+ "user_id_type": "open_id"
117
+ }
118
+ ```
119
+
120
+ ### Remove Task from Tasklist
121
+
122
+ ```json
123
+ {
124
+ "task_guid": "e297ddff-06ca-4166-b917-4ce57cd3a7a0",
125
+ "tasklist_guid": "cc371766-6584-cf50-a222-c22cd9055004",
126
+ "user_id_type": "open_id"
127
+ }
128
+ ```
129
+
130
+ ## Tasklists
131
+
132
+ Tasklists support three roles: owner (read/edit/manage), editor (read/edit), viewer (read).
133
+
134
+ ### Create Tasklist
135
+
136
+ ```json
137
+ {
138
+ "name": "Project Alpha Tasklist",
139
+ "members": [
140
+ { "id": "ou_xxx", "type": "user", "role": "editor" }
141
+ ],
142
+ "user_id_type": "open_id"
143
+ }
144
+ ```
145
+
146
+ ### Get Tasklist
147
+
148
+ ```json
149
+ {
150
+ "tasklist_guid": "cc371766-6584-cf50-a222-c22cd9055004",
151
+ "user_id_type": "open_id"
152
+ }
153
+ ```
154
+
155
+ ### List Tasklists
156
+
157
+ ```json
158
+ {
159
+ "page_size": 50,
160
+ "page_token": "aWQ9NzEwMjMzMjMxMDE=",
161
+ "user_id_type": "open_id"
162
+ }
163
+ ```
164
+
165
+ ### Update Tasklist
166
+
167
+ ```json
168
+ {
169
+ "tasklist_guid": "cc371766-6584-cf50-a222-c22cd9055004",
170
+ "tasklist": {
171
+ "name": "Renamed Tasklist",
172
+ "owner": { "id": "ou_xxx", "type": "user", "role": "owner" }
173
+ },
174
+ "update_fields": ["name", "owner"],
175
+ "origin_owner_to_role": "editor",
176
+ "user_id_type": "open_id"
177
+ }
178
+ ```
179
+
180
+ ### Delete Tasklist
181
+
182
+ ```json
183
+ {
184
+ "tasklist_guid": "cc371766-6584-cf50-a222-c22cd9055004"
185
+ }
186
+ ```
187
+
188
+ ### Add Tasklist Members
189
+
190
+ ```json
191
+ {
192
+ "tasklist_guid": "cc371766-6584-cf50-a222-c22cd9055004",
193
+ "members": [
194
+ { "id": "ou_xxx", "type": "user", "role": "editor" }
195
+ ],
196
+ "user_id_type": "open_id"
197
+ }
198
+ ```
199
+
200
+ ### Remove Tasklist Members
201
+
202
+ ```json
203
+ {
204
+ "tasklist_guid": "cc371766-6584-cf50-a222-c22cd9055004",
205
+ "members": [
206
+ { "id": "ou_xxx", "type": "user", "role": "viewer" }
207
+ ],
208
+ "user_id_type": "open_id"
209
+ }
210
+ ```
@@ -0,0 +1 @@
1
+ export { registerFeishuBitableTools } from "./register.js";
package/src/bot.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  recordPendingHistoryEntryIfEnabled,
5
5
  clearHistoryEntriesIfEnabled,
6
6
  DEFAULT_GROUP_HISTORY_LIMIT,
7
+ resolveMentionGatingWithBypass,
7
8
  type HistoryEntry,
8
9
  } from "openclaw/plugin-sdk";
9
10
  import type { FeishuConfig, FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
@@ -14,6 +15,7 @@ import { tryRecordMessage } from "./dedup.js";
14
15
  import {
15
16
  resolveFeishuGroupConfig,
16
17
  resolveFeishuReplyPolicy,
18
+ resolveFeishuGroupCommandMentionBypass,
17
19
  resolveFeishuAllowlistMatch,
18
20
  isFeishuGroupAllowed,
19
21
  } from "./policy.js";
@@ -145,6 +147,40 @@ async function resolveFeishuSenderName(params: {
145
147
  }
146
148
  }
147
149
 
150
+ // Cache group bot counts for command mention bypass policy checks.
151
+ const GROUP_BOT_COUNT_TTL_MS = 10 * 60 * 1000;
152
+ const groupBotCountCache = new Map<string, { count: number; expireAt: number }>();
153
+
154
+ async function resolveFeishuGroupBotCount(params: {
155
+ account: ResolvedFeishuAccount;
156
+ chatId: string;
157
+ log: (...args: any[]) => void;
158
+ }): Promise<number | undefined> {
159
+ const { account, chatId, log } = params;
160
+ if (!account.configured || !chatId) return undefined;
161
+
162
+ const cacheKey = `${account.accountId}:${chatId}`;
163
+ const now = Date.now();
164
+ const cached = groupBotCountCache.get(cacheKey);
165
+ if (cached && cached.expireAt > now) return cached.count;
166
+
167
+ try {
168
+ const client = createFeishuClient(account);
169
+ const res: any = await client.im.chat.get({
170
+ path: { chat_id: chatId },
171
+ });
172
+ const parsed = Number.parseInt(String(res?.data?.bot_count ?? ""), 10);
173
+ if (Number.isFinite(parsed) && parsed >= 0) {
174
+ groupBotCountCache.set(cacheKey, { count: parsed, expireAt: now + GROUP_BOT_COUNT_TTL_MS });
175
+ return parsed;
176
+ }
177
+ return undefined;
178
+ } catch (err) {
179
+ log(`feishu[${account.accountId}]: failed to resolve bot_count for ${chatId}: ${String(err)}`);
180
+ return undefined;
181
+ }
182
+ }
183
+
148
184
  export type FeishuMessageEvent = {
149
185
  sender: {
150
186
  sender_id: {
@@ -204,13 +240,21 @@ function parseMessageContent(content: string, messageType: string): string {
204
240
  }
205
241
  }
206
242
 
207
- function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
243
+ function checkBotMentioned(
244
+ event: FeishuMessageEvent,
245
+ botOpenId?: string,
246
+ postMentionIds: string[] = [],
247
+ ): boolean {
248
+ const normalizedBotOpenId = botOpenId?.trim();
249
+ // Keep explicit bot mention semantics: without a resolved botOpenId, do not trigger.
250
+ if (!normalizedBotOpenId) return false;
251
+
208
252
  const mentions = event.message.mentions ?? [];
209
- if (mentions.length === 0) return false;
210
- // Fallback for degraded startup/probe scenarios: if botOpenId is unavailable,
211
- // keep historical behavior and treat any mention as a mention trigger.
212
- if (!botOpenId) return mentions.length > 0;
213
- return mentions.some((m) => m.id.open_id === botOpenId);
253
+ return (
254
+ mentions.some(
255
+ (m) => m.id.open_id === normalizedBotOpenId || m.id.user_id === normalizedBotOpenId,
256
+ ) || postMentionIds.some((id) => id === normalizedBotOpenId)
257
+ );
214
258
  }
215
259
 
216
260
  function stripBotMention(text: string, mentions?: FeishuMessageEvent["message"]["mentions"]): string {
@@ -263,6 +307,7 @@ function parseMediaKeys(
263
307
  function parsePostContent(content: string): {
264
308
  textContent: string;
265
309
  imageKeys: string[];
310
+ mentionIds: string[];
266
311
  } {
267
312
  try {
268
313
  const parsed = JSON.parse(content);
@@ -270,6 +315,7 @@ function parsePostContent(content: string): {
270
315
  const contentBlocks = parsed.content || [];
271
316
  let textContent = title ? `${title}\n\n` : "";
272
317
  const imageKeys: string[] = [];
318
+ const mentionIds: string[] = [];
273
319
 
274
320
  for (const paragraph of contentBlocks) {
275
321
  if (Array.isArray(paragraph)) {
@@ -281,7 +327,11 @@ function parsePostContent(content: string): {
281
327
  textContent += element.text || element.href || "";
282
328
  } else if (element.tag === "at") {
283
329
  // Mention: @username
284
- textContent += `@${element.user_name || element.user_id || ""}`;
330
+ const mentionId =
331
+ String(element.open_id ?? element.user_id ?? element.union_id ?? "").trim() ||
332
+ undefined;
333
+ if (mentionId) mentionIds.push(mentionId);
334
+ textContent += `@${element.user_name || mentionId || ""}`;
285
335
  } else if (element.tag === "img" && element.image_key) {
286
336
  // Embedded image
287
337
  imageKeys.push(element.image_key);
@@ -294,9 +344,10 @@ function parsePostContent(content: string): {
294
344
  return {
295
345
  textContent: textContent.trim() || "[富文本消息]",
296
346
  imageKeys,
347
+ mentionIds,
297
348
  };
298
349
  } catch {
299
- return { textContent: "[富文本消息]", imageKeys: [] };
350
+ return { textContent: "[富文本消息]", imageKeys: [], mentionIds: [] };
300
351
  }
301
352
  }
302
353
 
@@ -480,8 +531,12 @@ export function parseFeishuMessageEvent(
480
531
  event: FeishuMessageEvent,
481
532
  botOpenId?: string,
482
533
  ): FeishuMessageContext {
534
+ const parsedPost =
535
+ event.message.message_type === "post" ? parsePostContent(event.message.content) : undefined;
483
536
  const rawContent = parseMessageContent(event.message.content, event.message.message_type);
484
- const mentionedBot = checkBotMentioned(event, botOpenId);
537
+ const mentionedBot = checkBotMentioned(event, botOpenId, parsedPost?.mentionIds ?? []);
538
+ const hasAnyMention =
539
+ (event.message.mentions?.length ?? 0) > 0 || (parsedPost?.mentionIds.length ?? 0) > 0;
485
540
  const content = stripBotMention(rawContent, event.message.mentions);
486
541
 
487
542
  const ctx: FeishuMessageContext = {
@@ -495,6 +550,7 @@ export function parseFeishuMessageEvent(
495
550
  parentId: event.message.parent_id || undefined,
496
551
  content,
497
552
  contentType: event.message.message_type,
553
+ hasAnyMention,
498
554
  };
499
555
 
500
556
  // Detect mention forward request: message mentions bot + at least one other user
@@ -520,11 +576,11 @@ export async function handleFeishuMessage(params: {
520
576
  accountId?: string;
521
577
  }): Promise<void> {
522
578
  const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
523
-
579
+
524
580
  // Resolve account with merged config
525
581
  const account = resolveFeishuAccount({ cfg, accountId });
526
582
  const feishuCfg = account.config;
527
-
583
+
528
584
  const log = runtime?.log ?? console.log;
529
585
  const error = runtime?.error ?? console.error;
530
586
 
@@ -579,69 +635,17 @@ export async function handleFeishuMessage(params: {
579
635
  const configAllowFrom = feishuCfg?.allowFrom ?? [];
580
636
  const useAccessGroups = cfg.commands?.useAccessGroups !== false;
581
637
 
582
- if (isGroup) {
583
- const groupPolicy = feishuCfg?.groupPolicy ?? "open";
584
- const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
585
-
586
- // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
587
- const groupAllowed = isFeishuGroupAllowed({
588
- groupPolicy,
589
- allowFrom: groupAllowFrom,
590
- senderId: ctx.chatId, // Check group ID, not sender ID
591
- senderName: undefined,
592
- });
593
-
594
- if (!groupAllowed) {
595
- log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in group allowlist`);
596
- return;
597
- }
598
-
599
- // Additional sender-level allowlist check if group has specific allowFrom config
600
- const senderAllowFrom = groupConfig?.allowFrom ?? [];
601
- if (senderAllowFrom.length > 0) {
602
- const senderAllowed = isFeishuGroupAllowed({
603
- groupPolicy: "allowlist",
604
- allowFrom: senderAllowFrom,
605
- senderId: ctx.senderOpenId,
606
- senderName: ctx.senderName,
607
- });
608
- if (!senderAllowed) {
609
- log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
610
- return;
611
- }
612
- }
613
-
614
- const { requireMention } = resolveFeishuReplyPolicy({
615
- isDirectMessage: false,
616
- globalConfig: feishuCfg,
617
- groupConfig,
618
- });
619
-
620
- if (requireMention && !ctx.mentionedBot) {
621
- log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`);
622
- if (chatHistories) {
623
- recordPendingHistoryEntryIfEnabled({
624
- historyMap: chatHistories,
625
- historyKey: ctx.chatId,
626
- limit: historyLimit,
627
- entry: {
628
- sender: ctx.senderOpenId,
629
- body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
630
- timestamp: Date.now(),
631
- messageId: ctx.messageId,
632
- },
633
- });
634
- }
635
- return;
636
- }
637
- }
638
-
639
638
  try {
640
639
  const core = getFeishuRuntime();
641
640
  const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
642
641
  ctx.content,
643
642
  cfg,
644
643
  );
644
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
645
+ cfg,
646
+ surface: "feishu",
647
+ });
648
+ const hasControlCommand = core.channel.text.hasControlCommand(ctx.content, cfg);
645
649
  const storeAllowFrom =
646
650
  !isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
647
651
  ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
@@ -687,7 +691,11 @@ export async function handleFeishuMessage(params: {
687
691
  return;
688
692
  }
689
693
 
690
- const commandAllowFrom = isGroup ? (groupConfig?.allowFrom ?? []) : effectiveDmAllowFrom;
694
+ const commandAllowFrom = isGroup
695
+ ? groupConfig?.allowFrom && groupConfig.allowFrom.length > 0
696
+ ? groupConfig.allowFrom
697
+ : configAllowFrom
698
+ : effectiveDmAllowFrom;
691
699
  const senderAllowedForCommands = resolveFeishuAllowlistMatch({
692
700
  allowFrom: commandAllowFrom,
693
701
  senderId: ctx.senderOpenId,
@@ -701,6 +709,105 @@ export async function handleFeishuMessage(params: {
701
709
  ],
702
710
  })
703
711
  : undefined;
712
+ let effectiveWasMentioned = ctx.mentionedBot;
713
+
714
+ if (isGroup) {
715
+ if (groupConfig?.enabled === false) {
716
+ log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
717
+ return;
718
+ }
719
+
720
+ const groupPolicy = feishuCfg?.groupPolicy ?? "open";
721
+ const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
722
+
723
+ // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
724
+ const groupAllowed = isFeishuGroupAllowed({
725
+ groupPolicy,
726
+ allowFrom: groupAllowFrom,
727
+ senderId: ctx.chatId, // Check group ID, not sender ID
728
+ senderName: undefined,
729
+ });
730
+
731
+ if (!groupAllowed) {
732
+ log(`feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`);
733
+ return;
734
+ }
735
+
736
+ // Additional sender-level allowlist check if group has specific allowFrom config
737
+ const senderAllowFrom = groupConfig?.allowFrom ?? [];
738
+ if (senderAllowFrom.length > 0) {
739
+ const senderAllowed = isFeishuGroupAllowed({
740
+ groupPolicy: "allowlist",
741
+ allowFrom: senderAllowFrom,
742
+ senderId: ctx.senderOpenId,
743
+ senderName: ctx.senderName,
744
+ });
745
+ if (!senderAllowed) {
746
+ log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
747
+ return;
748
+ }
749
+ }
750
+
751
+ const { requireMention } = resolveFeishuReplyPolicy({
752
+ isDirectMessage: false,
753
+ globalConfig: feishuCfg,
754
+ groupConfig,
755
+ });
756
+
757
+ if (requireMention) {
758
+ const bypassPolicy = resolveFeishuGroupCommandMentionBypass({
759
+ globalConfig: feishuCfg,
760
+ groupConfig,
761
+ });
762
+ let bypassAllowedByPolicy = bypassPolicy === "always";
763
+
764
+ if (!bypassAllowedByPolicy && bypassPolicy === "single_bot" && hasControlCommand) {
765
+ const botCount = await resolveFeishuGroupBotCount({
766
+ account,
767
+ chatId: ctx.chatId,
768
+ log,
769
+ });
770
+ bypassAllowedByPolicy = botCount !== undefined && botCount <= 1;
771
+ if (botCount === undefined) {
772
+ log(
773
+ `feishu[${account.accountId}]: unable to resolve bot count for ${ctx.chatId}, command mention bypass disabled`,
774
+ );
775
+ }
776
+ }
777
+
778
+ const mentionGate = resolveMentionGatingWithBypass({
779
+ isGroup: true,
780
+ requireMention,
781
+ canDetectMention: true,
782
+ wasMentioned: ctx.mentionedBot,
783
+ hasAnyMention: ctx.hasAnyMention,
784
+ allowTextCommands: allowTextCommands && bypassAllowedByPolicy,
785
+ hasControlCommand,
786
+ commandAuthorized: commandAuthorized === true,
787
+ });
788
+ effectiveWasMentioned = mentionGate.effectiveWasMentioned;
789
+
790
+ if (mentionGate.shouldSkip) {
791
+ log(
792
+ `feishu[${account.accountId}]: message in group ${ctx.chatId} skipped (mention required)`,
793
+ );
794
+ if (chatHistories) {
795
+ recordPendingHistoryEntryIfEnabled({
796
+ historyMap: chatHistories,
797
+ historyKey: ctx.chatId,
798
+ limit: historyLimit,
799
+ entry: {
800
+ sender: ctx.senderOpenId,
801
+ body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
802
+ timestamp: Date.now(),
803
+ messageId: ctx.messageId,
804
+ },
805
+ });
806
+ }
807
+ return;
808
+ }
809
+ }
810
+ }
704
811
 
705
812
  // In group chats, the session is scoped to the group, but the *speaker* is the sender.
706
813
  // Using a group-scoped From causes the agent to treat different users as the same person.
@@ -764,8 +871,11 @@ export async function handleFeishuMessage(params: {
764
871
  const inboundLabel = isGroup
765
872
  ? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
766
873
  : `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
874
+ const systemEventText = permissionErrorForAgent
875
+ ? inboundLabel
876
+ : `${inboundLabel}: ${preview}`;
767
877
 
768
- core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
878
+ core.system.enqueueSystemEvent(systemEventText, {
769
879
  sessionKey: route.sessionKey,
770
880
  contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
771
881
  });
@@ -929,10 +1039,11 @@ export async function handleFeishuMessage(params: {
929
1039
  Surface: "feishu" as const,
930
1040
  MessageSid: ctx.messageId,
931
1041
  Timestamp: Date.now(),
932
- WasMentioned: ctx.mentionedBot,
1042
+ WasMentioned: effectiveWasMentioned,
933
1043
  CommandAuthorized: commandAuthorized,
934
1044
  OriginatingChannel: "feishu" as const,
935
1045
  OriginatingTo: feishuTo,
1046
+ ReplyToBody: quotedContent,
936
1047
  ...mediaPayload,
937
1048
  });
938
1049
 
package/src/channel.ts CHANGED
@@ -90,6 +90,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
90
90
  groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
91
91
  groupAllowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
92
92
  requireMention: { type: "boolean" },
93
+ groupCommandMentionBypass: { type: "string", enum: ["never", "single_bot", "always"] },
93
94
  topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
94
95
  historyLimit: { type: "integer", minimum: 0 },
95
96
  dmHistoryLimit: { type: "integer", minimum: 0 },
@@ -110,6 +111,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
110
111
  verificationToken: { type: "string" },
111
112
  domain: { type: "string", enum: ["feishu", "lark"] },
112
113
  connectionMode: { type: "string", enum: ["websocket", "webhook"] },
114
+ groupCommandMentionBypass: { type: "string", enum: ["never", "single_bot", "always"] },
113
115
  },
114
116
  },
115
117
  },
@@ -3,6 +3,7 @@ export { z };
3
3
 
4
4
  const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
5
5
  const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
6
+ const GroupCommandMentionBypassSchema = z.enum(["never", "single_bot", "always"]).optional();
6
7
  const FeishuDomainSchema = z.union([
7
8
  z.enum(["feishu", "lark"]),
8
9
  z.string().url().startsWith("https://"),
@@ -104,6 +105,7 @@ const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
104
105
  export const FeishuGroupSchema = z
105
106
  .object({
106
107
  requireMention: z.boolean().optional(),
108
+ groupCommandMentionBypass: GroupCommandMentionBypassSchema,
107
109
  tools: ToolPolicySchema,
108
110
  skills: z.array(z.string()).optional(),
109
111
  enabled: z.boolean().optional(),
@@ -137,6 +139,7 @@ export const FeishuAccountConfigSchema = z
137
139
  groupPolicy: GroupPolicySchema.optional(),
138
140
  groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
139
141
  requireMention: z.boolean().optional(),
142
+ groupCommandMentionBypass: GroupCommandMentionBypassSchema,
140
143
  groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
141
144
  historyLimit: z.number().int().min(0).optional(),
142
145
  dmHistoryLimit: z.number().int().min(0).optional(),
@@ -172,6 +175,7 @@ export const FeishuConfigSchema = z
172
175
  groupPolicy: GroupPolicySchema.optional().default("allowlist"),
173
176
  groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
174
177
  requireMention: z.boolean().optional().default(true),
178
+ groupCommandMentionBypass: GroupCommandMentionBypassSchema.default("single_bot"),
175
179
  groups: z.record(z.string(), FeishuGroupSchema.optional()).optional(),
176
180
  topicSessionMode: TopicSessionModeSchema,
177
181
  historyLimit: z.number().int().min(0).optional(),