@m1heng-clawd/feishu 0.1.9 → 0.1.11

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/src/bot.ts CHANGED
@@ -10,6 +10,7 @@ import type { FeishuConfig, FeishuMessageContext, FeishuMediaInfo, ResolvedFeish
10
10
  import { getFeishuRuntime } from "./runtime.js";
11
11
  import { createFeishuClient } from "./client.js";
12
12
  import { resolveFeishuAccount } from "./accounts.js";
13
+ import { tryRecordMessage } from "./dedup.js";
13
14
  import {
14
15
  resolveFeishuGroupConfig,
15
16
  resolveFeishuReplyPolicy,
@@ -17,7 +18,7 @@ import {
17
18
  isFeishuGroupAllowed,
18
19
  } from "./policy.js";
19
20
  import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
20
- import { getMessageFeishu } from "./send.js";
21
+ import { getMessageFeishu, sendMessageFeishu } from "./send.js";
21
22
  import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
22
23
  import {
23
24
  extractMentionTargets,
@@ -25,39 +26,9 @@ import {
25
26
  isMentionForwardRequest,
26
27
  } from "./mention.js";
27
28
  import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
29
+ import { runWithFeishuToolContext } from "./tools-common/tool-context.js";
28
30
  import type { DynamicAgentCreationConfig } from "./types.js";
29
31
 
30
- // --- Message deduplication ---
31
- // Prevent duplicate processing when WebSocket reconnects or Feishu redelivers messages.
32
- const DEDUP_TTL_MS = 30 * 60 * 1000; // 30 minutes
33
- const DEDUP_MAX_SIZE = 1_000;
34
- const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // cleanup every 5 minutes
35
- const processedMessageIds = new Map<string, number>(); // messageId -> timestamp
36
- let lastCleanupTime = Date.now();
37
-
38
- function tryRecordMessage(messageId: string): boolean {
39
- const now = Date.now();
40
-
41
- // Throttled cleanup: evict expired entries at most once per interval
42
- if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
43
- for (const [id, ts] of processedMessageIds) {
44
- if (now - ts > DEDUP_TTL_MS) processedMessageIds.delete(id);
45
- }
46
- lastCleanupTime = now;
47
- }
48
-
49
- if (processedMessageIds.has(messageId)) return false;
50
-
51
- // Evict oldest entries if cache is full
52
- if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
53
- const first = processedMessageIds.keys().next().value!;
54
- processedMessageIds.delete(first);
55
- }
56
-
57
- processedMessageIds.set(messageId, now);
58
- return true;
59
- }
60
-
61
32
  // --- Permission error extraction ---
62
33
  // Extract permission grant URL from Feishu API error response.
63
34
  type PermissionError = {
@@ -66,6 +37,20 @@ type PermissionError = {
66
37
  grantUrl?: string;
67
38
  };
68
39
 
40
+ function decodeHtmlEntities(raw: string): string {
41
+ return raw
42
+ .replace(/&amp;/gi, "&")
43
+ .replace(/&#39;|&apos;/gi, "'")
44
+ .replace(/&quot;/gi, '"');
45
+ }
46
+
47
+ function extractFirstUrl(raw: string): string | undefined {
48
+ if (!raw) return undefined;
49
+ const decoded = decodeHtmlEntities(raw);
50
+ const urlMatch = decoded.match(/https?:\/\/[^\s"'<>]+/i);
51
+ return urlMatch?.[0];
52
+ }
53
+
69
54
  function extractPermissionError(err: unknown): PermissionError | null {
70
55
  if (!err || typeof err !== "object") return null;
71
56
 
@@ -83,10 +68,12 @@ function extractPermissionError(err: unknown): PermissionError | null {
83
68
  // Feishu permission error code: 99991672
84
69
  if (feishuErr.code !== 99991672) return null;
85
70
 
86
- // Extract the grant URL from the error message (contains the direct link)
87
71
  const msg = feishuErr.msg ?? "";
88
- const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
89
- const grantUrl = urlMatch?.[0];
72
+ const grantUrlFromMsg = extractFirstUrl(msg);
73
+ const grantUrlFromViolations = feishuErr.error?.permission_violations
74
+ ?.map((item) => extractFirstUrl(item.uri ?? ""))
75
+ .find((url): url is string => Boolean(url));
76
+ const grantUrl = grantUrlFromMsg ?? grantUrlFromViolations;
90
77
 
91
78
  return {
92
79
  code: feishuErr.code,
@@ -220,7 +207,8 @@ function parseMessageContent(content: string, messageType: string): string {
220
207
  function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
221
208
  const mentions = event.message.mentions ?? [];
222
209
  if (mentions.length === 0) return false;
223
- if (!botOpenId) return mentions.length > 0;
210
+ // Keep explicit bot mention semantics: without a resolved botOpenId, do not trigger.
211
+ if (!botOpenId) return false;
224
212
  return mentions.some((m) => m.id.open_id === botOpenId);
225
213
  }
226
214
 
@@ -541,7 +529,8 @@ export async function handleFeishuMessage(params: {
541
529
 
542
530
  // Dedup check: skip if this message was already processed
543
531
  const messageId = event.message.message_id;
544
- if (!tryRecordMessage(messageId)) {
532
+ const dedupAccountId = accountId || "default";
533
+ if (!tryRecordMessage(messageId, dedupAccountId)) {
545
534
  log(`feishu: skipping duplicate message ${messageId}`);
546
535
  return;
547
536
  }
@@ -582,12 +571,16 @@ export async function handleFeishuMessage(params: {
582
571
  0,
583
572
  feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
584
573
  );
574
+ const groupConfig = isGroup
575
+ ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
576
+ : undefined;
577
+ const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
578
+ const configAllowFrom = feishuCfg?.allowFrom ?? [];
579
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
585
580
 
586
581
  if (isGroup) {
587
582
  const groupPolicy = feishuCfg?.groupPolicy ?? "open";
588
583
  const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
589
- // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
590
- const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
591
584
 
592
585
  // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
593
586
  const groupAllowed = isFeishuGroupAllowed({
@@ -640,24 +633,73 @@ export async function handleFeishuMessage(params: {
640
633
  }
641
634
  return;
642
635
  }
643
- } else {
644
- const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
645
- const allowFrom = feishuCfg?.allowFrom ?? [];
646
-
647
- if (dmPolicy === "allowlist") {
648
- const match = resolveFeishuAllowlistMatch({
649
- allowFrom,
650
- senderId: ctx.senderOpenId,
651
- });
652
- if (!match.allowed) {
653
- log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in DM allowlist`);
654
- return;
655
- }
656
- }
657
636
  }
658
637
 
659
638
  try {
660
639
  const core = getFeishuRuntime();
640
+ const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
641
+ ctx.content,
642
+ cfg,
643
+ );
644
+ const storeAllowFrom =
645
+ !isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
646
+ ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
647
+ : [];
648
+ const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
649
+ const dmAllowed = resolveFeishuAllowlistMatch({
650
+ allowFrom: effectiveDmAllowFrom,
651
+ senderId: ctx.senderOpenId,
652
+ senderName: ctx.senderName,
653
+ }).allowed;
654
+
655
+ if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
656
+ if (dmPolicy === "pairing") {
657
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
658
+ channel: "feishu",
659
+ id: ctx.senderOpenId,
660
+ meta: { name: ctx.senderName },
661
+ });
662
+ if (created) {
663
+ log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
664
+ try {
665
+ await sendMessageFeishu({
666
+ cfg,
667
+ to: `user:${ctx.senderOpenId}`,
668
+ text: core.channel.pairing.buildPairingReply({
669
+ channel: "feishu",
670
+ idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
671
+ code,
672
+ }),
673
+ accountId: account.accountId,
674
+ });
675
+ } catch (err) {
676
+ log(
677
+ `feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
678
+ );
679
+ }
680
+ }
681
+ } else {
682
+ log(
683
+ `feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
684
+ );
685
+ }
686
+ return;
687
+ }
688
+
689
+ const commandAllowFrom = isGroup ? (groupConfig?.allowFrom ?? []) : effectiveDmAllowFrom;
690
+ const senderAllowedForCommands = resolveFeishuAllowlistMatch({
691
+ allowFrom: commandAllowFrom,
692
+ senderId: ctx.senderOpenId,
693
+ senderName: ctx.senderName,
694
+ }).allowed;
695
+ const commandAuthorized = shouldComputeCommandAuthorized
696
+ ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
697
+ useAccessGroups,
698
+ authorizers: [
699
+ { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
700
+ ],
701
+ })
702
+ : undefined;
661
703
 
662
704
  // In group chats, the session is scoped to the group, but the *speaker* is the sender.
663
705
  // Using a group-scoped From causes the agent to treat different users as the same person.
@@ -700,6 +742,7 @@ export async function handleFeishuMessage(params: {
700
742
  runtime,
701
743
  senderOpenId: ctx.senderOpenId,
702
744
  dynamicCfg,
745
+ accountId: account.accountId,
703
746
  log: (msg) => log(msg),
704
747
  });
705
748
  if (result.created) {
@@ -709,7 +752,7 @@ export async function handleFeishuMessage(params: {
709
752
  cfg: result.updatedCfg,
710
753
  channel: "feishu",
711
754
  accountId: account.accountId,
712
- peer: { kind: "dm", id: ctx.senderOpenId },
755
+ peer: { kind: "direct", id: ctx.senderOpenId },
713
756
  });
714
757
  log(`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`);
715
758
  }
@@ -804,7 +847,7 @@ export async function handleFeishuMessage(params: {
804
847
  MessageSid: `${ctx.messageId}:permission-error`,
805
848
  Timestamp: Date.now(),
806
849
  WasMentioned: false,
807
- CommandAuthorized: true,
850
+ CommandAuthorized: commandAuthorized,
808
851
  OriginatingChannel: "feishu" as const,
809
852
  OriginatingTo: feishuTo,
810
853
  });
@@ -821,12 +864,21 @@ export async function handleFeishuMessage(params: {
821
864
 
822
865
  log(`feishu[${account.accountId}]: dispatching permission error notification to agent`);
823
866
 
824
- await core.channel.reply.dispatchReplyFromConfig({
825
- ctx: permissionCtx,
826
- cfg,
827
- dispatcher: permDispatcher,
828
- replyOptions: permReplyOptions,
829
- });
867
+ await runWithFeishuToolContext(
868
+ {
869
+ channel: "feishu",
870
+ accountId: account.accountId,
871
+ sessionKey: route.sessionKey,
872
+ },
873
+ // Keep account context available while the agent executes plugin tools.
874
+ () =>
875
+ core.channel.reply.dispatchReplyFromConfig({
876
+ ctx: permissionCtx,
877
+ cfg,
878
+ dispatcher: permDispatcher,
879
+ replyOptions: permReplyOptions,
880
+ }),
881
+ );
830
882
 
831
883
  markPermIdle();
832
884
  }
@@ -877,9 +929,10 @@ export async function handleFeishuMessage(params: {
877
929
  MessageSid: ctx.messageId,
878
930
  Timestamp: Date.now(),
879
931
  WasMentioned: ctx.mentionedBot,
880
- CommandAuthorized: true,
932
+ CommandAuthorized: commandAuthorized,
881
933
  OriginatingChannel: "feishu" as const,
882
934
  OriginatingTo: feishuTo,
935
+ ReplyToBody: quotedContent,
883
936
  ...mediaPayload,
884
937
  });
885
938
 
@@ -895,12 +948,21 @@ export async function handleFeishuMessage(params: {
895
948
 
896
949
  log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
897
950
 
898
- const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
899
- ctx: ctxPayload,
900
- cfg,
901
- dispatcher,
902
- replyOptions,
903
- });
951
+ const { queuedFinal, counts } = await runWithFeishuToolContext(
952
+ {
953
+ channel: "feishu",
954
+ accountId: account.accountId,
955
+ sessionKey: route.sessionKey,
956
+ },
957
+ // Tool calls produced by this turn should resolve to the same inbound account.
958
+ () =>
959
+ core.channel.reply.dispatchReplyFromConfig({
960
+ ctx: ctxPayload,
961
+ cfg,
962
+ dispatcher,
963
+ replyOptions,
964
+ }),
965
+ );
904
966
 
905
967
  markDispatchIdle();
906
968
 
package/src/channel.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId, PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk";
3
3
  import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
4
4
  import {
5
5
  resolveFeishuAccount,
@@ -29,7 +29,7 @@ const meta = {
29
29
  blurb: "飞书/Lark enterprise messaging.",
30
30
  aliases: ["lark"],
31
31
  order: 70,
32
- } as const;
32
+ };
33
33
 
34
34
  export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
35
35
  id: "feishu",
@@ -39,12 +39,11 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
39
39
  pairing: {
40
40
  idLabel: "feishuUserId",
41
41
  normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""),
42
- notifyApproval: async ({ cfg, id, accountId }) => {
42
+ notifyApproval: async ({ cfg, id }) => {
43
43
  await sendMessageFeishu({
44
44
  cfg,
45
45
  to: id,
46
46
  text: PAIRING_APPROVED_MESSAGE,
47
- accountId,
48
47
  });
49
48
  },
50
49
  },
@@ -201,7 +200,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
201
200
  }),
202
201
  resolveAllowFrom: ({ cfg, accountId }) => {
203
202
  const account = resolveFeishuAccount({ cfg, accountId });
204
- return account.config?.allowFrom ?? [];
203
+ return (account.config?.allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
205
204
  },
206
205
  formatAllowFrom: ({ allowFrom }) =>
207
206
  allowFrom
@@ -222,7 +221,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
222
221
  },
223
222
  },
224
223
  setup: {
225
- resolveAccountId: () => DEFAULT_ACCOUNT_ID,
224
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
226
225
  applyAccountConfig: ({ cfg, accountId }) => {
227
226
  const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
228
227
 
@@ -297,8 +296,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
297
296
  probe: snapshot.probe,
298
297
  lastProbeAt: snapshot.lastProbeAt ?? null,
299
298
  }),
300
- probeAccount: async ({ cfg, accountId }) => {
301
- const account = resolveFeishuAccount({ cfg, accountId });
299
+ probeAccount: async ({ account }) => {
302
300
  return await probeFeishu(account);
303
301
  },
304
302
  buildAccountSnapshot: ({ account, runtime, probe }) => ({
@@ -36,6 +36,9 @@ const MarkdownConfigSchema = z
36
36
  // Message render mode: auto (default) = detect markdown, raw = plain text, card = always card
37
37
  const RenderModeSchema = z.enum(["auto", "raw", "card"]).optional();
38
38
 
39
+ // Streaming card mode: default false. When enabled, card replies use Feishu Card Kit streaming API.
40
+ const StreamingModeSchema = z.boolean().optional();
41
+
39
42
  const BlockStreamingCoalesceSchema = z
40
43
  .object({
41
44
  enabled: z.boolean().optional(),
@@ -74,6 +77,7 @@ const DynamicAgentCreationSchema = z
74
77
  * Dependencies:
75
78
  * - wiki requires doc (wiki content is edited via doc tools)
76
79
  * - perm can work independently but is typically used with drive
80
+ * - task can work independently
77
81
  */
78
82
  const FeishuToolsConfigSchema = z
79
83
  .object({
@@ -82,6 +86,7 @@ const FeishuToolsConfigSchema = z
82
86
  drive: z.boolean().optional(), // Cloud storage operations (default: true)
83
87
  perm: z.boolean().optional(), // Permission management (default: false, sensitive)
84
88
  scopes: z.boolean().optional(), // App scopes diagnostic (default: true)
89
+ task: z.boolean().optional(), // Task operations (default: true)
85
90
  })
86
91
  .strict()
87
92
  .optional();
@@ -142,6 +147,7 @@ export const FeishuAccountConfigSchema = z
142
147
  mediaMaxMb: z.number().positive().optional(),
143
148
  heartbeat: ChannelHeartbeatVisibilitySchema,
144
149
  renderMode: RenderModeSchema,
150
+ streaming: StreamingModeSchema,
145
151
  tools: FeishuToolsConfigSchema,
146
152
  })
147
153
  .strict();
@@ -177,6 +183,7 @@ export const FeishuConfigSchema = z
177
183
  mediaMaxMb: z.number().positive().optional(),
178
184
  heartbeat: ChannelHeartbeatVisibilitySchema,
179
185
  renderMode: RenderModeSchema, // raw = plain text (default), card = interactive card with markdown
186
+ streaming: StreamingModeSchema,
180
187
  tools: FeishuToolsConfigSchema,
181
188
  // Dynamic agent creation for DM users
182
189
  dynamicAgentCreation: DynamicAgentCreationSchema,
package/src/dedup.ts ADDED
@@ -0,0 +1,31 @@
1
+ const DEDUP_TTL_MS = 30 * 60 * 1000;
2
+ const DEDUP_MAX_SIZE = 1_000;
3
+ const DEDUP_CLEANUP_INTERVAL_MS = 5 * 60 * 1000;
4
+ const processedMessageIds = new Map<string, number>();
5
+ let lastCleanupTime = Date.now();
6
+
7
+ export function tryRecordMessage(messageId: string, scope = "default"): boolean {
8
+ const now = Date.now();
9
+ const dedupKey = `${scope}:${messageId}`;
10
+
11
+ if (now - lastCleanupTime > DEDUP_CLEANUP_INTERVAL_MS) {
12
+ for (const [id, ts] of processedMessageIds) {
13
+ if (now - ts > DEDUP_TTL_MS) {
14
+ processedMessageIds.delete(id);
15
+ }
16
+ }
17
+ lastCleanupTime = now;
18
+ }
19
+
20
+ if (processedMessageIds.has(dedupKey)) {
21
+ return false;
22
+ }
23
+
24
+ if (processedMessageIds.size >= DEDUP_MAX_SIZE) {
25
+ const first = processedMessageIds.keys().next().value!;
26
+ processedMessageIds.delete(first);
27
+ }
28
+
29
+ processedMessageIds.set(dedupKey, now);
30
+ return true;
31
+ }
package/src/doc-schema.ts CHANGED
@@ -22,6 +22,14 @@ export const FeishuDocSchema = Type.Union([
22
22
  title: Type.String({ description: "Document title" }),
23
23
  folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
24
24
  }),
25
+ Type.Object({
26
+ action: Type.Literal("create_and_write"),
27
+ title: Type.String({ description: "Document title" }),
28
+ content: Type.String({
29
+ description: "Markdown content to write immediately after document creation",
30
+ }),
31
+ folder_token: Type.Optional(Type.String({ description: "Target folder token (optional)" })),
32
+ }),
25
33
  Type.Object({
26
34
  action: Type.Literal("list_blocks"),
27
35
  doc_token: Type.String({ description: "Document token" }),