@m1heng-clawd/feishu 0.1.8 → 0.1.10

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,6 +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;
210
+ // Fallback for degraded startup/probe scenarios: if botOpenId is unavailable,
211
+ // keep historical behavior and treat any mention as a mention trigger.
223
212
  if (!botOpenId) return mentions.length > 0;
224
213
  return mentions.some((m) => m.id.open_id === botOpenId);
225
214
  }
@@ -541,7 +530,8 @@ export async function handleFeishuMessage(params: {
541
530
 
542
531
  // Dedup check: skip if this message was already processed
543
532
  const messageId = event.message.message_id;
544
- if (!tryRecordMessage(messageId)) {
533
+ const dedupAccountId = accountId || "default";
534
+ if (!tryRecordMessage(messageId, dedupAccountId)) {
545
535
  log(`feishu: skipping duplicate message ${messageId}`);
546
536
  return;
547
537
  }
@@ -582,12 +572,16 @@ export async function handleFeishuMessage(params: {
582
572
  0,
583
573
  feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
584
574
  );
575
+ const groupConfig = isGroup
576
+ ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
577
+ : undefined;
578
+ const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
579
+ const configAllowFrom = feishuCfg?.allowFrom ?? [];
580
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
585
581
 
586
582
  if (isGroup) {
587
583
  const groupPolicy = feishuCfg?.groupPolicy ?? "open";
588
584
  const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
589
- // DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
590
- const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
591
585
 
592
586
  // Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
593
587
  const groupAllowed = isFeishuGroupAllowed({
@@ -640,24 +634,73 @@ export async function handleFeishuMessage(params: {
640
634
  }
641
635
  return;
642
636
  }
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
637
  }
658
638
 
659
639
  try {
660
640
  const core = getFeishuRuntime();
641
+ const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
642
+ ctx.content,
643
+ cfg,
644
+ );
645
+ const storeAllowFrom =
646
+ !isGroup && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
647
+ ? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
648
+ : [];
649
+ const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
650
+ const dmAllowed = resolveFeishuAllowlistMatch({
651
+ allowFrom: effectiveDmAllowFrom,
652
+ senderId: ctx.senderOpenId,
653
+ senderName: ctx.senderName,
654
+ }).allowed;
655
+
656
+ if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
657
+ if (dmPolicy === "pairing") {
658
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
659
+ channel: "feishu",
660
+ id: ctx.senderOpenId,
661
+ meta: { name: ctx.senderName },
662
+ });
663
+ if (created) {
664
+ log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
665
+ try {
666
+ await sendMessageFeishu({
667
+ cfg,
668
+ to: `user:${ctx.senderOpenId}`,
669
+ text: core.channel.pairing.buildPairingReply({
670
+ channel: "feishu",
671
+ idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
672
+ code,
673
+ }),
674
+ accountId: account.accountId,
675
+ });
676
+ } catch (err) {
677
+ log(
678
+ `feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
679
+ );
680
+ }
681
+ }
682
+ } else {
683
+ log(
684
+ `feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
685
+ );
686
+ }
687
+ return;
688
+ }
689
+
690
+ const commandAllowFrom = isGroup ? (groupConfig?.allowFrom ?? []) : effectiveDmAllowFrom;
691
+ const senderAllowedForCommands = resolveFeishuAllowlistMatch({
692
+ allowFrom: commandAllowFrom,
693
+ senderId: ctx.senderOpenId,
694
+ senderName: ctx.senderName,
695
+ }).allowed;
696
+ const commandAuthorized = shouldComputeCommandAuthorized
697
+ ? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
698
+ useAccessGroups,
699
+ authorizers: [
700
+ { configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
701
+ ],
702
+ })
703
+ : undefined;
661
704
 
662
705
  // In group chats, the session is scoped to the group, but the *speaker* is the sender.
663
706
  // Using a group-scoped From causes the agent to treat different users as the same person.
@@ -683,7 +726,7 @@ export async function handleFeishuMessage(params: {
683
726
  channel: "feishu",
684
727
  accountId: account.accountId,
685
728
  peer: {
686
- kind: isGroup ? "group" : "dm",
729
+ kind: isGroup ? "group" : "direct",
687
730
  id: peerId,
688
731
  },
689
732
  });
@@ -700,6 +743,7 @@ export async function handleFeishuMessage(params: {
700
743
  runtime,
701
744
  senderOpenId: ctx.senderOpenId,
702
745
  dynamicCfg,
746
+ accountId: account.accountId,
703
747
  log: (msg) => log(msg),
704
748
  });
705
749
  if (result.created) {
@@ -709,7 +753,7 @@ export async function handleFeishuMessage(params: {
709
753
  cfg: result.updatedCfg,
710
754
  channel: "feishu",
711
755
  accountId: account.accountId,
712
- peer: { kind: "dm", id: ctx.senderOpenId },
756
+ peer: { kind: "direct", id: ctx.senderOpenId },
713
757
  });
714
758
  log(`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`);
715
759
  }
@@ -804,7 +848,7 @@ export async function handleFeishuMessage(params: {
804
848
  MessageSid: `${ctx.messageId}:permission-error`,
805
849
  Timestamp: Date.now(),
806
850
  WasMentioned: false,
807
- CommandAuthorized: true,
851
+ CommandAuthorized: commandAuthorized,
808
852
  OriginatingChannel: "feishu" as const,
809
853
  OriginatingTo: feishuTo,
810
854
  });
@@ -821,12 +865,21 @@ export async function handleFeishuMessage(params: {
821
865
 
822
866
  log(`feishu[${account.accountId}]: dispatching permission error notification to agent`);
823
867
 
824
- await core.channel.reply.dispatchReplyFromConfig({
825
- ctx: permissionCtx,
826
- cfg,
827
- dispatcher: permDispatcher,
828
- replyOptions: permReplyOptions,
829
- });
868
+ await runWithFeishuToolContext(
869
+ {
870
+ channel: "feishu",
871
+ accountId: account.accountId,
872
+ sessionKey: route.sessionKey,
873
+ },
874
+ // Keep account context available while the agent executes plugin tools.
875
+ () =>
876
+ core.channel.reply.dispatchReplyFromConfig({
877
+ ctx: permissionCtx,
878
+ cfg,
879
+ dispatcher: permDispatcher,
880
+ replyOptions: permReplyOptions,
881
+ }),
882
+ );
830
883
 
831
884
  markPermIdle();
832
885
  }
@@ -877,7 +930,7 @@ export async function handleFeishuMessage(params: {
877
930
  MessageSid: ctx.messageId,
878
931
  Timestamp: Date.now(),
879
932
  WasMentioned: ctx.mentionedBot,
880
- CommandAuthorized: true,
933
+ CommandAuthorized: commandAuthorized,
881
934
  OriginatingChannel: "feishu" as const,
882
935
  OriginatingTo: feishuTo,
883
936
  ...mediaPayload,
@@ -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
+ }