@m1heng-clawd/feishu 0.1.11 → 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 (46) hide show
  1. package/README.md +123 -4
  2. package/index.ts +6 -6
  3. package/package.json +13 -3
  4. package/skills/feishu-doc/SKILL.md +40 -2
  5. package/skills/feishu-task/SKILL.md +210 -0
  6. package/src/bitable-tools/index.ts +1 -0
  7. package/src/bot.ts +181 -70
  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} +31 -1
  15. package/src/drive-tools/actions.ts +182 -0
  16. package/src/drive-tools/common.ts +18 -0
  17. package/src/drive-tools/index.ts +2 -0
  18. package/src/drive-tools/register.ts +71 -0
  19. package/src/{drive-schema.ts → drive-tools/schemas.ts} +2 -1
  20. package/src/media.ts +5 -3
  21. package/src/perm-tools/actions.ts +111 -0
  22. package/src/perm-tools/common.ts +18 -0
  23. package/src/perm-tools/index.ts +2 -0
  24. package/src/perm-tools/register.ts +65 -0
  25. package/src/policy.ts +13 -0
  26. package/src/reply-dispatcher.ts +6 -4
  27. package/src/send.ts +33 -2
  28. package/src/task-tools/actions.ts +466 -13
  29. package/src/task-tools/constants.ts +13 -0
  30. package/src/task-tools/index.ts +1 -0
  31. package/src/task-tools/register.ts +175 -13
  32. package/src/task-tools/schemas.ts +433 -4
  33. package/src/text/markdown-links.ts +104 -0
  34. package/src/types.ts +1 -0
  35. package/src/wiki-tools/actions.ts +166 -0
  36. package/src/wiki-tools/common.ts +18 -0
  37. package/src/wiki-tools/index.ts +2 -0
  38. package/src/wiki-tools/register.ts +66 -0
  39. package/src/bitable.ts +0 -1
  40. package/src/docx.ts +0 -276
  41. package/src/drive.ts +0 -237
  42. package/src/perm.ts +0 -165
  43. package/src/task.ts +0 -1
  44. package/src/wiki.ts +0 -223
  45. /package/src/{perm-schema.ts → perm-tools/schemas.ts} +0 -0
  46. /package/src/{wiki-schema.ts → wiki-tools/schemas.ts} +0 -0
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,12 +240,21 @@ function parseMessageContent(content: string, messageType: string): string {
204
240
  }
205
241
  }
206
242
 
207
- function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
208
- const mentions = event.message.mentions ?? [];
209
- if (mentions.length === 0) return false;
243
+ function checkBotMentioned(
244
+ event: FeishuMessageEvent,
245
+ botOpenId?: string,
246
+ postMentionIds: string[] = [],
247
+ ): boolean {
248
+ const normalizedBotOpenId = botOpenId?.trim();
210
249
  // Keep explicit bot mention semantics: without a resolved botOpenId, do not trigger.
211
- if (!botOpenId) return false;
212
- return mentions.some((m) => m.id.open_id === botOpenId);
250
+ if (!normalizedBotOpenId) return false;
251
+
252
+ const mentions = event.message.mentions ?? [];
253
+ return (
254
+ mentions.some(
255
+ (m) => m.id.open_id === normalizedBotOpenId || m.id.user_id === normalizedBotOpenId,
256
+ ) || postMentionIds.some((id) => id === normalizedBotOpenId)
257
+ );
213
258
  }
214
259
 
215
260
  function stripBotMention(text: string, mentions?: FeishuMessageEvent["message"]["mentions"]): string {
@@ -262,6 +307,7 @@ function parseMediaKeys(
262
307
  function parsePostContent(content: string): {
263
308
  textContent: string;
264
309
  imageKeys: string[];
310
+ mentionIds: string[];
265
311
  } {
266
312
  try {
267
313
  const parsed = JSON.parse(content);
@@ -269,6 +315,7 @@ function parsePostContent(content: string): {
269
315
  const contentBlocks = parsed.content || [];
270
316
  let textContent = title ? `${title}\n\n` : "";
271
317
  const imageKeys: string[] = [];
318
+ const mentionIds: string[] = [];
272
319
 
273
320
  for (const paragraph of contentBlocks) {
274
321
  if (Array.isArray(paragraph)) {
@@ -280,7 +327,11 @@ function parsePostContent(content: string): {
280
327
  textContent += element.text || element.href || "";
281
328
  } else if (element.tag === "at") {
282
329
  // Mention: @username
283
- 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 || ""}`;
284
335
  } else if (element.tag === "img" && element.image_key) {
285
336
  // Embedded image
286
337
  imageKeys.push(element.image_key);
@@ -293,9 +344,10 @@ function parsePostContent(content: string): {
293
344
  return {
294
345
  textContent: textContent.trim() || "[富文本消息]",
295
346
  imageKeys,
347
+ mentionIds,
296
348
  };
297
349
  } catch {
298
- return { textContent: "[富文本消息]", imageKeys: [] };
350
+ return { textContent: "[富文本消息]", imageKeys: [], mentionIds: [] };
299
351
  }
300
352
  }
301
353
 
@@ -479,8 +531,12 @@ export function parseFeishuMessageEvent(
479
531
  event: FeishuMessageEvent,
480
532
  botOpenId?: string,
481
533
  ): FeishuMessageContext {
534
+ const parsedPost =
535
+ event.message.message_type === "post" ? parsePostContent(event.message.content) : undefined;
482
536
  const rawContent = parseMessageContent(event.message.content, event.message.message_type);
483
- 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;
484
540
  const content = stripBotMention(rawContent, event.message.mentions);
485
541
 
486
542
  const ctx: FeishuMessageContext = {
@@ -494,6 +550,7 @@ export function parseFeishuMessageEvent(
494
550
  parentId: event.message.parent_id || undefined,
495
551
  content,
496
552
  contentType: event.message.message_type,
553
+ hasAnyMention,
497
554
  };
498
555
 
499
556
  // Detect mention forward request: message mentions bot + at least one other user
@@ -519,11 +576,11 @@ export async function handleFeishuMessage(params: {
519
576
  accountId?: string;
520
577
  }): Promise<void> {
521
578
  const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
522
-
579
+
523
580
  // Resolve account with merged config
524
581
  const account = resolveFeishuAccount({ cfg, accountId });
525
582
  const feishuCfg = account.config;
526
-
583
+
527
584
  const log = runtime?.log ?? console.log;
528
585
  const error = runtime?.error ?? console.error;
529
586
 
@@ -578,69 +635,17 @@ export async function handleFeishuMessage(params: {
578
635
  const configAllowFrom = feishuCfg?.allowFrom ?? [];
579
636
  const useAccessGroups = cfg.commands?.useAccessGroups !== false;
580
637
 
581
- if (isGroup) {
582
- const groupPolicy = feishuCfg?.groupPolicy ?? "open";
583
- const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
584
-
585
- // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
586
- const groupAllowed = isFeishuGroupAllowed({
587
- groupPolicy,
588
- allowFrom: groupAllowFrom,
589
- senderId: ctx.chatId, // Check group ID, not sender ID
590
- senderName: undefined,
591
- });
592
-
593
- if (!groupAllowed) {
594
- log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in group allowlist`);
595
- return;
596
- }
597
-
598
- // Additional sender-level allowlist check if group has specific allowFrom config
599
- const senderAllowFrom = groupConfig?.allowFrom ?? [];
600
- if (senderAllowFrom.length > 0) {
601
- const senderAllowed = isFeishuGroupAllowed({
602
- groupPolicy: "allowlist",
603
- allowFrom: senderAllowFrom,
604
- senderId: ctx.senderOpenId,
605
- senderName: ctx.senderName,
606
- });
607
- if (!senderAllowed) {
608
- log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
609
- return;
610
- }
611
- }
612
-
613
- const { requireMention } = resolveFeishuReplyPolicy({
614
- isDirectMessage: false,
615
- globalConfig: feishuCfg,
616
- groupConfig,
617
- });
618
-
619
- if (requireMention && !ctx.mentionedBot) {
620
- log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`);
621
- if (chatHistories) {
622
- recordPendingHistoryEntryIfEnabled({
623
- historyMap: chatHistories,
624
- historyKey: ctx.chatId,
625
- limit: historyLimit,
626
- entry: {
627
- sender: ctx.senderOpenId,
628
- body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
629
- timestamp: Date.now(),
630
- messageId: ctx.messageId,
631
- },
632
- });
633
- }
634
- return;
635
- }
636
- }
637
-
638
638
  try {
639
639
  const core = getFeishuRuntime();
640
640
  const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
641
641
  ctx.content,
642
642
  cfg,
643
643
  );
644
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
645
+ cfg,
646
+ surface: "feishu",
647
+ });
648
+ const hasControlCommand = core.channel.text.hasControlCommand(ctx.content, cfg);
644
649
  const storeAllowFrom =
645
650
  !isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
646
651
  ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
@@ -686,7 +691,11 @@ export async function handleFeishuMessage(params: {
686
691
  return;
687
692
  }
688
693
 
689
- const commandAllowFrom = isGroup ? (groupConfig?.allowFrom ?? []) : effectiveDmAllowFrom;
694
+ const commandAllowFrom = isGroup
695
+ ? groupConfig?.allowFrom && groupConfig.allowFrom.length > 0
696
+ ? groupConfig.allowFrom
697
+ : configAllowFrom
698
+ : effectiveDmAllowFrom;
690
699
  const senderAllowedForCommands = resolveFeishuAllowlistMatch({
691
700
  allowFrom: commandAllowFrom,
692
701
  senderId: ctx.senderOpenId,
@@ -700,6 +709,105 @@ export async function handleFeishuMessage(params: {
700
709
  ],
701
710
  })
702
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
+ }
703
811
 
704
812
  // In group chats, the session is scoped to the group, but the *speaker* is the sender.
705
813
  // Using a group-scoped From causes the agent to treat different users as the same person.
@@ -763,8 +871,11 @@ export async function handleFeishuMessage(params: {
763
871
  const inboundLabel = isGroup
764
872
  ? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
765
873
  : `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
874
+ const systemEventText = permissionErrorForAgent
875
+ ? inboundLabel
876
+ : `${inboundLabel}: ${preview}`;
766
877
 
767
- core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
878
+ core.system.enqueueSystemEvent(systemEventText, {
768
879
  sessionKey: route.sessionKey,
769
880
  contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
770
881
  });
@@ -928,7 +1039,7 @@ export async function handleFeishuMessage(params: {
928
1039
  Surface: "feishu" as const,
929
1040
  MessageSid: ctx.messageId,
930
1041
  Timestamp: Date.now(),
931
- WasMentioned: ctx.mentionedBot,
1042
+ WasMentioned: effectiveWasMentioned,
932
1043
  CommandAuthorized: commandAuthorized,
933
1044
  OriginatingChannel: "feishu" as const,
934
1045
  OriginatingTo: feishuTo,
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(),