@openclaw/feishu 2026.3.1 → 2026.3.2

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
@@ -44,6 +44,13 @@ type PermissionError = {
44
44
  grantUrl?: string;
45
45
  };
46
46
 
47
+ const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
48
+
49
+ function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean {
50
+ const message = permissionError.message.toLowerCase();
51
+ return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
52
+ }
53
+
47
54
  function extractPermissionError(err: unknown): PermissionError | null {
48
55
  if (!err || typeof err !== "object") return null;
49
56
 
@@ -140,6 +147,10 @@ async function resolveFeishuSenderName(params: {
140
147
  // Check if this is a permission error
141
148
  const permErr = extractPermissionError(err);
142
149
  if (permErr) {
150
+ if (shouldSuppressPermissionErrorNotice(permErr)) {
151
+ log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
152
+ return {};
153
+ }
143
154
  log(`feishu: permission error resolving sender name: code=${permErr.code}`);
144
155
  return { permissionError: permErr };
145
156
  }
@@ -164,8 +175,9 @@ export type FeishuMessageEvent = {
164
175
  message_id: string;
165
176
  root_id?: string;
166
177
  parent_id?: string;
178
+ thread_id?: string;
167
179
  chat_id: string;
168
- chat_type: "p2p" | "group";
180
+ chat_type: "p2p" | "group" | "private";
169
181
  message_type: string;
170
182
  content: string;
171
183
  create_time?: string;
@@ -193,6 +205,94 @@ export type FeishuBotAddedEvent = {
193
205
  operator_tenant_key?: string;
194
206
  };
195
207
 
208
+ type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
209
+
210
+ type ResolvedFeishuGroupSession = {
211
+ peerId: string;
212
+ parentPeer: { kind: "group"; id: string } | null;
213
+ groupSessionScope: GroupSessionScope;
214
+ replyInThread: boolean;
215
+ threadReply: boolean;
216
+ };
217
+
218
+ function resolveFeishuGroupSession(params: {
219
+ chatId: string;
220
+ senderOpenId: string;
221
+ messageId: string;
222
+ rootId?: string;
223
+ threadId?: string;
224
+ groupConfig?: {
225
+ groupSessionScope?: GroupSessionScope;
226
+ topicSessionMode?: "enabled" | "disabled";
227
+ replyInThread?: "enabled" | "disabled";
228
+ };
229
+ feishuCfg?: {
230
+ groupSessionScope?: GroupSessionScope;
231
+ topicSessionMode?: "enabled" | "disabled";
232
+ replyInThread?: "enabled" | "disabled";
233
+ };
234
+ }): ResolvedFeishuGroupSession {
235
+ const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
236
+
237
+ const normalizedThreadId = threadId?.trim();
238
+ const normalizedRootId = rootId?.trim();
239
+ const threadReply = Boolean(normalizedThreadId || normalizedRootId);
240
+ const replyInThread =
241
+ (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
242
+ threadReply;
243
+
244
+ const legacyTopicSessionMode =
245
+ groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
246
+ const groupSessionScope: GroupSessionScope =
247
+ groupConfig?.groupSessionScope ??
248
+ feishuCfg?.groupSessionScope ??
249
+ (legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
250
+
251
+ // Keep topic session keys stable across the "first turn creates thread" flow:
252
+ // first turn may only have message_id, while the next turn carries root_id/thread_id.
253
+ // Prefer root_id first so both turns stay on the same peer key.
254
+ const topicScope =
255
+ groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
256
+ ? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
257
+ : null;
258
+
259
+ let peerId = chatId;
260
+ switch (groupSessionScope) {
261
+ case "group_sender":
262
+ peerId = `${chatId}:sender:${senderOpenId}`;
263
+ break;
264
+ case "group_topic":
265
+ peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
266
+ break;
267
+ case "group_topic_sender":
268
+ peerId = topicScope
269
+ ? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
270
+ : `${chatId}:sender:${senderOpenId}`;
271
+ break;
272
+ case "group":
273
+ default:
274
+ peerId = chatId;
275
+ break;
276
+ }
277
+
278
+ const parentPeer =
279
+ topicScope &&
280
+ (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
281
+ ? {
282
+ kind: "group" as const,
283
+ id: chatId,
284
+ }
285
+ : null;
286
+
287
+ return {
288
+ peerId,
289
+ parentPeer,
290
+ groupSessionScope,
291
+ replyInThread,
292
+ threadReply,
293
+ };
294
+ }
295
+
196
296
  function parseMessageContent(content: string, messageType: string): string {
197
297
  if (messageType === "post") {
198
298
  // Extract text content from rich text post
@@ -624,6 +724,7 @@ export function parseFeishuMessageEvent(
624
724
  mentionedBot,
625
725
  rootId: event.message.root_id || undefined,
626
726
  parentId: event.message.parent_id || undefined,
727
+ threadId: event.message.thread_id || undefined,
627
728
  content,
628
729
  contentType: event.message.message_type,
629
730
  };
@@ -709,6 +810,7 @@ export async function handleFeishuMessage(params: {
709
810
 
710
811
  let ctx = parseFeishuMessageEvent(event, botOpenId);
711
812
  const isGroup = ctx.chatType === "group";
813
+ const isDirect = !isGroup;
712
814
  const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
713
815
 
714
816
  // Handle merge_forward messages: fetch full message via API then expand sub-messages
@@ -784,6 +886,18 @@ export async function handleFeishuMessage(params: {
784
886
  const groupConfig = isGroup
785
887
  ? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
786
888
  : undefined;
889
+ const groupSession = isGroup
890
+ ? resolveFeishuGroupSession({
891
+ chatId: ctx.chatId,
892
+ senderOpenId: ctx.senderOpenId,
893
+ messageId: ctx.messageId,
894
+ rootId: ctx.rootId,
895
+ threadId: ctx.threadId,
896
+ groupConfig,
897
+ feishuCfg,
898
+ })
899
+ : null;
900
+ const groupHistoryKey = isGroup ? (groupSession?.peerId ?? ctx.chatId) : undefined;
787
901
  const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
788
902
  const configAllowFrom = feishuCfg?.allowFrom ?? [];
789
903
  const useAccessGroups = cfg.commands?.useAccessGroups !== false;
@@ -852,10 +966,10 @@ export async function handleFeishuMessage(params: {
852
966
  log(
853
967
  `feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`,
854
968
  );
855
- if (chatHistories) {
969
+ if (chatHistories && groupHistoryKey) {
856
970
  recordPendingHistoryEntryIfEnabled({
857
971
  historyMap: chatHistories,
858
- historyKey: ctx.chatId,
972
+ historyKey: groupHistoryKey,
859
973
  limit: historyLimit,
860
974
  entry: {
861
975
  sender: ctx.senderOpenId,
@@ -895,7 +1009,7 @@ export async function handleFeishuMessage(params: {
895
1009
  senderName: ctx.senderName,
896
1010
  }).allowed;
897
1011
 
898
- if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
1012
+ if (isDirect && dmPolicy !== "open" && !dmAllowed) {
899
1013
  if (dmPolicy === "pairing") {
900
1014
  const { code, created } = await pairing.upsertPairingRequest({
901
1015
  id: ctx.senderOpenId,
@@ -906,7 +1020,7 @@ export async function handleFeishuMessage(params: {
906
1020
  try {
907
1021
  await sendMessageFeishu({
908
1022
  cfg,
909
- to: `user:${ctx.senderOpenId}`,
1023
+ to: `chat:${ctx.chatId}`,
910
1024
  text: core.channel.pairing.buildPairingReply({
911
1025
  channel: "feishu",
912
1026
  idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
@@ -950,50 +1064,14 @@ export async function handleFeishuMessage(params: {
950
1064
  // Using a group-scoped From causes the agent to treat different users as the same person.
951
1065
  const feishuFrom = `feishu:${ctx.senderOpenId}`;
952
1066
  const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
1067
+ const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
1068
+ const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
1069
+ const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
953
1070
 
954
- // Resolve peer ID for session routing.
955
- // Default is one session per group chat; this can be customized with groupSessionScope.
956
- let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
957
- let groupSessionScope: "group" | "group_sender" | "group_topic" | "group_topic_sender" =
958
- "group";
959
- let topicRootForSession: string | null = null;
960
- const replyInThread =
961
- isGroup &&
962
- (groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
963
-
964
- if (isGroup) {
965
- const legacyTopicSessionMode =
966
- groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
967
- groupSessionScope =
968
- groupConfig?.groupSessionScope ??
969
- feishuCfg?.groupSessionScope ??
970
- (legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
971
-
972
- // When topic-scoped sessions are enabled and replyInThread is on, the first
973
- // bot reply creates the thread rooted at the current message ID.
974
- if (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender") {
975
- topicRootForSession = ctx.rootId ?? (replyInThread ? ctx.messageId : null);
976
- }
977
-
978
- switch (groupSessionScope) {
979
- case "group_sender":
980
- peerId = `${ctx.chatId}:sender:${ctx.senderOpenId}`;
981
- break;
982
- case "group_topic":
983
- peerId = topicRootForSession ? `${ctx.chatId}:topic:${topicRootForSession}` : ctx.chatId;
984
- break;
985
- case "group_topic_sender":
986
- peerId = topicRootForSession
987
- ? `${ctx.chatId}:topic:${topicRootForSession}:sender:${ctx.senderOpenId}`
988
- : `${ctx.chatId}:sender:${ctx.senderOpenId}`;
989
- break;
990
- case "group":
991
- default:
992
- peerId = ctx.chatId;
993
- break;
994
- }
995
-
996
- log(`feishu[${account.accountId}]: group session scope=${groupSessionScope}, peer=${peerId}`);
1071
+ if (isGroup && groupSession) {
1072
+ log(
1073
+ `feishu[${account.accountId}]: group session scope=${groupSession.groupSessionScope}, peer=${peerId}`,
1074
+ );
997
1075
  }
998
1076
 
999
1077
  let route = core.channel.routing.resolveAgentRoute({
@@ -1004,16 +1082,7 @@ export async function handleFeishuMessage(params: {
1004
1082
  kind: isGroup ? "group" : "direct",
1005
1083
  id: peerId,
1006
1084
  },
1007
- // Add parentPeer for binding inheritance in topic-scoped modes.
1008
- parentPeer:
1009
- isGroup &&
1010
- topicRootForSession &&
1011
- (groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
1012
- ? {
1013
- kind: "group",
1014
- id: ctx.chatId,
1015
- }
1016
- : null,
1085
+ parentPeer,
1017
1086
  });
1018
1087
 
1019
1088
  // Dynamic agent creation for DM users
@@ -1110,7 +1179,7 @@ export async function handleFeishuMessage(params: {
1110
1179
  });
1111
1180
 
1112
1181
  let combinedBody = body;
1113
- const historyKey = isGroup ? ctx.chatId : undefined;
1182
+ const historyKey = groupHistoryKey;
1114
1183
 
1115
1184
  if (isGroup && historyKey && chatHistories) {
1116
1185
  combinedBody = buildPendingHistoryContextFromMap({
@@ -1173,16 +1242,17 @@ export async function handleFeishuMessage(params: {
1173
1242
  const messageCreateTimeMs = event.message.create_time
1174
1243
  ? parseInt(event.message.create_time, 10)
1175
1244
  : undefined;
1176
-
1245
+ const replyTargetMessageId = ctx.rootId ?? ctx.messageId;
1177
1246
  const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
1178
1247
  cfg,
1179
1248
  agentId: route.agentId,
1180
1249
  runtime: runtime as RuntimeEnv,
1181
1250
  chatId: ctx.chatId,
1182
- replyToMessageId: ctx.messageId,
1251
+ replyToMessageId: replyTargetMessageId,
1183
1252
  skipReplyToInMessages: !isGroup,
1184
1253
  replyInThread,
1185
1254
  rootId: ctx.rootId,
1255
+ threadReply: isGroup ? (groupSession?.threadReply ?? false) : false,
1186
1256
  mentionTargets: ctx.mentionTargets,
1187
1257
  accountId: account.accountId,
1188
1258
  messageCreateTimeMs,
package/src/channel.ts CHANGED
@@ -38,6 +38,22 @@ const meta: ChannelMeta = {
38
38
  order: 70,
39
39
  };
40
40
 
41
+ const secretInputJsonSchema = {
42
+ oneOf: [
43
+ { type: "string" },
44
+ {
45
+ type: "object",
46
+ additionalProperties: false,
47
+ required: ["source", "provider", "id"],
48
+ properties: {
49
+ source: { type: "string", enum: ["env", "file", "exec"] },
50
+ provider: { type: "string", minLength: 1 },
51
+ id: { type: "string", minLength: 1 },
52
+ },
53
+ },
54
+ ],
55
+ } as const;
56
+
41
57
  export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
42
58
  id: "feishu",
43
59
  meta: {
@@ -81,9 +97,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
81
97
  enabled: { type: "boolean" },
82
98
  defaultAccount: { type: "string" },
83
99
  appId: { type: "string" },
84
- appSecret: { type: "string" },
100
+ appSecret: secretInputJsonSchema,
85
101
  encryptKey: { type: "string" },
86
- verificationToken: { type: "string" },
102
+ verificationToken: secretInputJsonSchema,
87
103
  domain: {
88
104
  oneOf: [
89
105
  { type: "string", enum: ["feishu", "lark"] },
@@ -122,9 +138,9 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
122
138
  enabled: { type: "boolean" },
123
139
  name: { type: "string" },
124
140
  appId: { type: "string" },
125
- appSecret: { type: "string" },
141
+ appSecret: secretInputJsonSchema,
126
142
  encryptKey: { type: "string" },
127
- verificationToken: { type: "string" },
143
+ verificationToken: secretInputJsonSchema,
128
144
  domain: { type: "string", enum: ["feishu", "lark"] },
129
145
  connectionMode: { type: "string", enum: ["websocket", "webhook"] },
130
146
  webhookHost: { type: "string" },
@@ -34,6 +34,7 @@ let priorProxyEnv: Partial<Record<ProxyEnvKey, string | undefined>> = {};
34
34
 
35
35
  const baseAccount: ResolvedFeishuAccount = {
36
36
  accountId: "main",
37
+ selectionSource: "explicit",
37
38
  enabled: true,
38
39
  configured: true,
39
40
  appId: "app_123",
@@ -94,6 +95,19 @@ describe("createFeishuWSClient proxy handling", () => {
94
95
  expect(options.agent).toEqual({ proxyUrl: expectedProxy });
95
96
  });
96
97
 
98
+ it("accepts lowercase https_proxy when it is the configured HTTPS proxy var", () => {
99
+ process.env.https_proxy = "http://lower-https:8001";
100
+
101
+ createFeishuWSClient(baseAccount);
102
+
103
+ const expectedHttpsProxy = process.env.https_proxy || process.env.HTTPS_PROXY;
104
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledTimes(1);
105
+ expect(expectedHttpsProxy).toBeTruthy();
106
+ expect(httpsProxyAgentCtorMock).toHaveBeenCalledWith(expectedHttpsProxy);
107
+ const options = firstWsClientOptions();
108
+ expect(options.agent).toEqual({ proxyUrl: expectedHttpsProxy });
109
+ });
110
+
97
111
  it("passes HTTP_PROXY to ws client when https vars are unset", () => {
98
112
  process.env.HTTP_PROXY = "http://upper-http:8999";
99
113
 
@@ -85,6 +85,25 @@ describe("FeishuConfigSchema webhook validation", () => {
85
85
 
86
86
  expect(result.success).toBe(true);
87
87
  });
88
+
89
+ it("accepts SecretRef verificationToken in webhook mode", () => {
90
+ const result = FeishuConfigSchema.safeParse({
91
+ connectionMode: "webhook",
92
+ verificationToken: {
93
+ source: "env",
94
+ provider: "default",
95
+ id: "FEISHU_VERIFICATION_TOKEN",
96
+ },
97
+ appId: "cli_top",
98
+ appSecret: {
99
+ source: "env",
100
+ provider: "default",
101
+ id: "FEISHU_APP_SECRET",
102
+ },
103
+ });
104
+
105
+ expect(result.success).toBe(true);
106
+ });
88
107
  });
89
108
 
90
109
  describe("FeishuConfigSchema replyInThread", () => {
@@ -1,6 +1,7 @@
1
1
  import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
2
2
  import { z } from "zod";
3
3
  export { z };
4
+ import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
4
5
 
5
6
  const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
6
7
  const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
@@ -110,6 +111,9 @@ const GroupSessionScopeSchema = z
110
111
  * Topic session isolation mode for group chats.
111
112
  * - "disabled" (default): All messages in a group share one session
112
113
  * - "enabled": Messages in different topics get separate sessions
114
+ *
115
+ * Topic routing uses `root_id` when present to keep session continuity and
116
+ * falls back to `thread_id` when `root_id` is unavailable.
113
117
  */
114
118
  const TopicSessionModeSchema = z.enum(["disabled", "enabled"]).optional();
115
119
  const ReactionNotificationModeSchema = z.enum(["off", "own", "all"]).optional();
@@ -177,9 +181,9 @@ export const FeishuAccountConfigSchema = z
177
181
  enabled: z.boolean().optional(),
178
182
  name: z.string().optional(), // Display name for this account
179
183
  appId: z.string().optional(),
180
- appSecret: z.string().optional(),
184
+ appSecret: buildSecretInputSchema().optional(),
181
185
  encryptKey: z.string().optional(),
182
- verificationToken: z.string().optional(),
186
+ verificationToken: buildSecretInputSchema().optional(),
183
187
  domain: FeishuDomainSchema.optional(),
184
188
  connectionMode: FeishuConnectionModeSchema.optional(),
185
189
  webhookPath: z.string().optional(),
@@ -195,9 +199,9 @@ export const FeishuConfigSchema = z
195
199
  defaultAccount: z.string().optional(),
196
200
  // Top-level credentials (backward compatible for single-account mode)
197
201
  appId: z.string().optional(),
198
- appSecret: z.string().optional(),
202
+ appSecret: buildSecretInputSchema().optional(),
199
203
  encryptKey: z.string().optional(),
200
- verificationToken: z.string().optional(),
204
+ verificationToken: buildSecretInputSchema().optional(),
201
205
  domain: FeishuDomainSchema.optional().default("feishu"),
202
206
  connectionMode: FeishuConnectionModeSchema.optional().default("websocket"),
203
207
  webhookPath: z.string().optional().default("/feishu/events"),
@@ -231,8 +235,8 @@ export const FeishuConfigSchema = z
231
235
  }
232
236
 
233
237
  const defaultConnectionMode = value.connectionMode ?? "websocket";
234
- const defaultVerificationToken = value.verificationToken?.trim();
235
- if (defaultConnectionMode === "webhook" && !defaultVerificationToken) {
238
+ const defaultVerificationTokenConfigured = hasConfiguredSecretInput(value.verificationToken);
239
+ if (defaultConnectionMode === "webhook" && !defaultVerificationTokenConfigured) {
236
240
  ctx.addIssue({
237
241
  code: z.ZodIssueCode.custom,
238
242
  path: ["verificationToken"],
@@ -249,9 +253,9 @@ export const FeishuConfigSchema = z
249
253
  if (accountConnectionMode !== "webhook") {
250
254
  continue;
251
255
  }
252
- const accountVerificationToken =
253
- account.verificationToken?.trim() || defaultVerificationToken;
254
- if (!accountVerificationToken) {
256
+ const accountVerificationTokenConfigured =
257
+ hasConfiguredSecretInput(account.verificationToken) || defaultVerificationTokenConfigured;
258
+ if (!accountVerificationTokenConfigured) {
255
259
  ctx.addIssue({
256
260
  code: z.ZodIssueCode.custom,
257
261
  path: ["accounts", accountId, "verificationToken"],
package/src/dedup.ts CHANGED
@@ -1,11 +1,16 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
- import { createDedupeCache, createPersistentDedupe } from "openclaw/plugin-sdk";
3
+ import {
4
+ createDedupeCache,
5
+ createPersistentDedupe,
6
+ readJsonFileWithFallback,
7
+ } from "openclaw/plugin-sdk";
4
8
 
5
9
  // Persistent TTL: 24 hours — survives restarts & WebSocket reconnects.
6
10
  const DEDUP_TTL_MS = 24 * 60 * 60 * 1000;
7
11
  const MEMORY_MAX_SIZE = 1_000;
8
12
  const FILE_MAX_ENTRIES = 10_000;
13
+ type PersistentDedupeData = Record<string, number>;
9
14
 
10
15
  const memoryDedupe = createDedupeCache({ ttlMs: DEDUP_TTL_MS, maxSize: MEMORY_MAX_SIZE });
11
16
 
@@ -40,6 +45,14 @@ export function tryRecordMessage(messageId: string): boolean {
40
45
  return !memoryDedupe.check(messageId);
41
46
  }
42
47
 
48
+ export function hasRecordedMessage(messageId: string): boolean {
49
+ const trimmed = messageId.trim();
50
+ if (!trimmed) {
51
+ return false;
52
+ }
53
+ return memoryDedupe.peek(trimmed);
54
+ }
55
+
43
56
  export async function tryRecordMessagePersistent(
44
57
  messageId: string,
45
58
  namespace = "global",
@@ -52,3 +65,36 @@ export async function tryRecordMessagePersistent(
52
65
  },
53
66
  });
54
67
  }
68
+
69
+ export async function hasRecordedMessagePersistent(
70
+ messageId: string,
71
+ namespace = "global",
72
+ log?: (...args: unknown[]) => void,
73
+ ): Promise<boolean> {
74
+ const trimmed = messageId.trim();
75
+ if (!trimmed) {
76
+ return false;
77
+ }
78
+ const now = Date.now();
79
+ const filePath = resolveNamespaceFilePath(namespace);
80
+ try {
81
+ const { value } = await readJsonFileWithFallback<PersistentDedupeData>(filePath, {});
82
+ const seenAt = value[trimmed];
83
+ if (typeof seenAt !== "number" || !Number.isFinite(seenAt)) {
84
+ return false;
85
+ }
86
+ return DEDUP_TTL_MS <= 0 || now - seenAt < DEDUP_TTL_MS;
87
+ } catch (error) {
88
+ log?.(`feishu-dedup: persistent peek failed: ${String(error)}`);
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export async function warmupDedupFromDisk(
94
+ namespace: string,
95
+ log?: (...args: unknown[]) => void,
96
+ ): Promise<number> {
97
+ return persistentDedupe.warmup(namespace, (error) => {
98
+ log?.(`feishu-dedup: warmup disk error: ${String(error)}`);
99
+ });
100
+ }
package/src/doc-schema.ts CHANGED
@@ -1,5 +1,19 @@
1
1
  import { Type, type Static } from "@sinclair/typebox";
2
2
 
3
+ const tableCreationProperties = {
4
+ doc_token: Type.String({ description: "Document token" }),
5
+ parent_block_id: Type.Optional(
6
+ Type.String({ description: "Parent block ID (default: document root)" }),
7
+ ),
8
+ row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
9
+ column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
10
+ column_width: Type.Optional(
11
+ Type.Array(Type.Number({ minimum: 1 }), {
12
+ description: "Column widths in px (length should match column_size)",
13
+ }),
14
+ ),
15
+ };
16
+
3
17
  export const FeishuDocSchema = Type.Union([
4
18
  Type.Object({
5
19
  action: Type.Literal("read"),
@@ -59,17 +73,7 @@ export const FeishuDocSchema = Type.Union([
59
73
  // Table creation (explicit structure)
60
74
  Type.Object({
61
75
  action: Type.Literal("create_table"),
62
- doc_token: Type.String({ description: "Document token" }),
63
- parent_block_id: Type.Optional(
64
- Type.String({ description: "Parent block ID (default: document root)" }),
65
- ),
66
- row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
67
- column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
68
- column_width: Type.Optional(
69
- Type.Array(Type.Number({ minimum: 1 }), {
70
- description: "Column widths in px (length should match column_size)",
71
- }),
72
- ),
76
+ ...tableCreationProperties,
73
77
  }),
74
78
  Type.Object({
75
79
  action: Type.Literal("write_table_cells"),
@@ -82,17 +86,7 @@ export const FeishuDocSchema = Type.Union([
82
86
  }),
83
87
  Type.Object({
84
88
  action: Type.Literal("create_table_with_values"),
85
- doc_token: Type.String({ description: "Document token" }),
86
- parent_block_id: Type.Optional(
87
- Type.String({ description: "Parent block ID (default: document root)" }),
88
- ),
89
- row_size: Type.Integer({ description: "Table row count", minimum: 1 }),
90
- column_size: Type.Integer({ description: "Table column count", minimum: 1 }),
91
- column_width: Type.Optional(
92
- Type.Array(Type.Number({ minimum: 1 }), {
93
- description: "Column widths in px (length should match column_size)",
94
- }),
95
- ),
89
+ ...tableCreationProperties,
96
90
  values: Type.Array(Type.Array(Type.String()), {
97
91
  description: "2D matrix values[row][col] to write into table cells",
98
92
  minItems: 1,
@@ -21,8 +21,8 @@ vi.mock("@larksuiteoapi/node-sdk", () => {
21
21
  });
22
22
 
23
23
  describe("feishu_doc account selection", () => {
24
- test("uses agentAccountId context when params omit accountId", async () => {
25
- const cfg = {
24
+ function createDocEnabledConfig(): OpenClawPluginApi["config"] {
25
+ return {
26
26
  channels: {
27
27
  feishu: {
28
28
  enabled: true,
@@ -33,6 +33,10 @@ describe("feishu_doc account selection", () => {
33
33
  },
34
34
  },
35
35
  } as OpenClawPluginApi["config"];
36
+ }
37
+
38
+ test("uses agentAccountId context when params omit accountId", async () => {
39
+ const cfg = createDocEnabledConfig();
36
40
 
37
41
  const { api, resolveTool } = createToolFactoryHarness(cfg);
38
42
  registerFeishuDocTools(api);
@@ -49,17 +53,7 @@ describe("feishu_doc account selection", () => {
49
53
  });
50
54
 
51
55
  test("explicit accountId param overrides agentAccountId context", async () => {
52
- const cfg = {
53
- channels: {
54
- feishu: {
55
- enabled: true,
56
- accounts: {
57
- a: { appId: "app-a", appSecret: "sec-a", tools: { doc: true } },
58
- b: { appId: "app-b", appSecret: "sec-b", tools: { doc: true } },
59
- },
60
- },
61
- },
62
- } as OpenClawPluginApi["config"];
56
+ const cfg = createDocEnabledConfig();
63
57
 
64
58
  const { api, resolveTool } = createToolFactoryHarness(cfg);
65
59
  registerFeishuDocTools(api);