@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/package.json +1 -1
- package/src/accounts.test.ts +74 -3
- package/src/accounts.ts +69 -10
- package/src/bot.checkBotMentioned.test.ts +1 -1
- package/src/bot.test.ts +390 -29
- package/src/bot.ts +131 -61
- package/src/channel.ts +20 -4
- package/src/client.test.ts +14 -0
- package/src/config-schema.test.ts +19 -0
- package/src/config-schema.ts +13 -9
- package/src/dedup.ts +47 -1
- package/src/doc-schema.ts +16 -22
- package/src/docx.account-selection.test.ts +7 -13
- package/src/docx.test.ts +41 -189
- package/src/media.test.ts +104 -1
- package/src/media.ts +21 -1
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +266 -18
- package/src/monitor.reaction.test.ts +345 -2
- package/src/monitor.startup.test.ts +17 -1
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +84 -8
- package/src/monitor.test-mocks.ts +12 -0
- package/src/monitor.webhook-security.test.ts +26 -9
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.ts +144 -52
- package/src/probe.test.ts +38 -20
- package/src/probe.ts +57 -37
- package/src/reply-dispatcher.test.ts +41 -0
- package/src/reply-dispatcher.ts +26 -7
- package/src/secret-input.ts +19 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +6 -2
- package/src/targets.test.ts +29 -0
- package/src/targets.ts +21 -1
- package/src/types.ts +9 -1
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:
|
|
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 (
|
|
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: `
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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:
|
|
100
|
+
appSecret: secretInputJsonSchema,
|
|
85
101
|
encryptKey: { type: "string" },
|
|
86
|
-
verificationToken:
|
|
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:
|
|
141
|
+
appSecret: secretInputJsonSchema,
|
|
126
142
|
encryptKey: { type: "string" },
|
|
127
|
-
verificationToken:
|
|
143
|
+
verificationToken: secretInputJsonSchema,
|
|
128
144
|
domain: { type: "string", enum: ["feishu", "lark"] },
|
|
129
145
|
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
|
|
130
146
|
webhookHost: { type: "string" },
|
package/src/client.test.ts
CHANGED
|
@@ -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", () => {
|
package/src/config-schema.ts
CHANGED
|
@@ -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:
|
|
184
|
+
appSecret: buildSecretInputSchema().optional(),
|
|
181
185
|
encryptKey: z.string().optional(),
|
|
182
|
-
verificationToken:
|
|
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:
|
|
202
|
+
appSecret: buildSecretInputSchema().optional(),
|
|
199
203
|
encryptKey: z.string().optional(),
|
|
200
|
-
verificationToken:
|
|
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
|
|
235
|
-
if (defaultConnectionMode === "webhook" && !
|
|
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
|
|
253
|
-
account.verificationToken
|
|
254
|
-
if (!
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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);
|