@openclaw-plugins/feishu-plus 0.1.7
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/LICENSE +21 -0
- package/README.md +560 -0
- package/index.ts +63 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +65 -0
- package/skills/feishu-doc/SKILL.md +99 -0
- package/skills/feishu-doc/references/block-types.md +102 -0
- package/skills/feishu-drive/SKILL.md +96 -0
- package/skills/feishu-perm/SKILL.md +90 -0
- package/skills/feishu-wiki/SKILL.md +96 -0
- package/src/accounts.ts +140 -0
- package/src/bitable.ts +441 -0
- package/src/bot.ts +881 -0
- package/src/channel.ts +334 -0
- package/src/client.ts +114 -0
- package/src/config-schema.ts +199 -0
- package/src/directory.ts +165 -0
- package/src/doc-schema.ts +47 -0
- package/src/docx.ts +480 -0
- package/src/drive-schema.ts +46 -0
- package/src/drive.ts +207 -0
- package/src/dynamic-agent.ts +131 -0
- package/src/media.ts +523 -0
- package/src/mention.ts +121 -0
- package/src/monitor.ts +190 -0
- package/src/onboarding.ts +358 -0
- package/src/outbound.ts +40 -0
- package/src/perm-schema.ts +52 -0
- package/src/perm.ts +166 -0
- package/src/policy.ts +92 -0
- package/src/probe.ts +43 -0
- package/src/reactions.ts +160 -0
- package/src/reply-dispatcher.ts +174 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +360 -0
- package/src/targets.ts +58 -0
- package/src/tools-config.ts +21 -0
- package/src/types.ts +77 -0
- package/src/typing.ts +75 -0
- package/src/wiki-schema.ts +55 -0
- package/src/wiki.ts +224 -0
package/src/bot.ts
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
2
|
+
import {
|
|
3
|
+
buildPendingHistoryContextFromMap,
|
|
4
|
+
recordPendingHistoryEntryIfEnabled,
|
|
5
|
+
clearHistoryEntriesIfEnabled,
|
|
6
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
7
|
+
type HistoryEntry,
|
|
8
|
+
} from "openclaw/plugin-sdk";
|
|
9
|
+
import type { FeishuConfig, FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
|
10
|
+
import { getFeishuRuntime } from "./runtime.js";
|
|
11
|
+
import { createFeishuClient } from "./client.js";
|
|
12
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
13
|
+
import {
|
|
14
|
+
resolveFeishuGroupConfig,
|
|
15
|
+
resolveFeishuReplyPolicy,
|
|
16
|
+
resolveFeishuAllowlistMatch,
|
|
17
|
+
isFeishuGroupAllowed,
|
|
18
|
+
} from "./policy.js";
|
|
19
|
+
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|
20
|
+
import { getMessageFeishu } from "./send.js";
|
|
21
|
+
import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
|
|
22
|
+
import {
|
|
23
|
+
extractMentionTargets,
|
|
24
|
+
extractMessageBody,
|
|
25
|
+
isMentionForwardRequest,
|
|
26
|
+
} from "./mention.js";
|
|
27
|
+
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
|
28
|
+
import type { DynamicAgentCreationConfig } from "./types.js";
|
|
29
|
+
|
|
30
|
+
// --- Permission error extraction ---
|
|
31
|
+
// Extract permission grant URL from Feishu API error response.
|
|
32
|
+
type PermissionError = {
|
|
33
|
+
code: number;
|
|
34
|
+
message: string;
|
|
35
|
+
grantUrl?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
function extractPermissionError(err: unknown): PermissionError | null {
|
|
39
|
+
if (!err || typeof err !== "object") return null;
|
|
40
|
+
|
|
41
|
+
// Axios error structure: err.response.data contains the Feishu error
|
|
42
|
+
const axiosErr = err as { response?: { data?: unknown } };
|
|
43
|
+
const data = axiosErr.response?.data;
|
|
44
|
+
if (!data || typeof data !== "object") return null;
|
|
45
|
+
|
|
46
|
+
const feishuErr = data as {
|
|
47
|
+
code?: number;
|
|
48
|
+
msg?: string;
|
|
49
|
+
error?: { permission_violations?: Array<{ uri?: string }> };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Feishu permission error code: 99991672
|
|
53
|
+
if (feishuErr.code !== 99991672) return null;
|
|
54
|
+
|
|
55
|
+
// Extract the grant URL from the error message (contains the direct link)
|
|
56
|
+
const msg = feishuErr.msg ?? "";
|
|
57
|
+
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
|
|
58
|
+
const grantUrl = urlMatch?.[0];
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
code: feishuErr.code,
|
|
62
|
+
message: msg,
|
|
63
|
+
grantUrl,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
|
|
68
|
+
// Cache display names by open_id to avoid an API call on every message.
|
|
69
|
+
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
|
|
70
|
+
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
|
|
71
|
+
|
|
72
|
+
// Cache permission errors to avoid spamming the user with repeated notifications.
|
|
73
|
+
// Key: appId or "default", Value: timestamp of last notification
|
|
74
|
+
const permissionErrorNotifiedAt = new Map<string, number>();
|
|
75
|
+
const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
|
76
|
+
|
|
77
|
+
type SenderNameResult = {
|
|
78
|
+
name?: string;
|
|
79
|
+
permissionError?: PermissionError;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
async function resolveFeishuSenderName(params: {
|
|
83
|
+
account: ResolvedFeishuAccount;
|
|
84
|
+
senderOpenId: string;
|
|
85
|
+
log: (...args: any[]) => void;
|
|
86
|
+
}): Promise<SenderNameResult> {
|
|
87
|
+
const { account, senderOpenId, log } = params;
|
|
88
|
+
if (!account.configured) return {};
|
|
89
|
+
if (!senderOpenId) return {};
|
|
90
|
+
|
|
91
|
+
const cached = senderNameCache.get(senderOpenId);
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
if (cached && cached.expireAt > now) return { name: cached.name };
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
const client = createFeishuClient(account);
|
|
97
|
+
|
|
98
|
+
// contact/v3/users/:user_id?user_id_type=open_id
|
|
99
|
+
const res: any = await client.contact.user.get({
|
|
100
|
+
path: { user_id: senderOpenId },
|
|
101
|
+
params: { user_id_type: "open_id" },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const name: string | undefined =
|
|
105
|
+
res?.data?.user?.name ||
|
|
106
|
+
res?.data?.user?.display_name ||
|
|
107
|
+
res?.data?.user?.nickname ||
|
|
108
|
+
res?.data?.user?.en_name;
|
|
109
|
+
|
|
110
|
+
if (name && typeof name === "string") {
|
|
111
|
+
senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
|
|
112
|
+
return { name };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {};
|
|
116
|
+
} catch (err) {
|
|
117
|
+
// Check if this is a permission error
|
|
118
|
+
const permErr = extractPermissionError(err);
|
|
119
|
+
if (permErr) {
|
|
120
|
+
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
|
|
121
|
+
return { permissionError: permErr };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Best-effort. Don't fail message handling if name lookup fails.
|
|
125
|
+
log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`);
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export type FeishuMessageEvent = {
|
|
131
|
+
sender: {
|
|
132
|
+
sender_id: {
|
|
133
|
+
open_id?: string;
|
|
134
|
+
user_id?: string;
|
|
135
|
+
union_id?: string;
|
|
136
|
+
};
|
|
137
|
+
sender_type?: string;
|
|
138
|
+
tenant_key?: string;
|
|
139
|
+
};
|
|
140
|
+
message: {
|
|
141
|
+
message_id: string;
|
|
142
|
+
root_id?: string;
|
|
143
|
+
parent_id?: string;
|
|
144
|
+
chat_id: string;
|
|
145
|
+
chat_type: "p2p" | "group";
|
|
146
|
+
message_type: string;
|
|
147
|
+
content: string;
|
|
148
|
+
mentions?: Array<{
|
|
149
|
+
key: string;
|
|
150
|
+
id: {
|
|
151
|
+
open_id?: string;
|
|
152
|
+
user_id?: string;
|
|
153
|
+
union_id?: string;
|
|
154
|
+
};
|
|
155
|
+
name: string;
|
|
156
|
+
tenant_key?: string;
|
|
157
|
+
}>;
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export type FeishuBotAddedEvent = {
|
|
162
|
+
chat_id: string;
|
|
163
|
+
operator_id: {
|
|
164
|
+
open_id?: string;
|
|
165
|
+
user_id?: string;
|
|
166
|
+
union_id?: string;
|
|
167
|
+
};
|
|
168
|
+
external: boolean;
|
|
169
|
+
operator_tenant_key?: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
function parseMessageContent(content: string, messageType: string): string {
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(content);
|
|
175
|
+
if (messageType === "text") {
|
|
176
|
+
return parsed.text || "";
|
|
177
|
+
}
|
|
178
|
+
if (messageType === "post") {
|
|
179
|
+
// Extract text content from rich text post
|
|
180
|
+
const { textContent } = parsePostContent(content);
|
|
181
|
+
return textContent;
|
|
182
|
+
}
|
|
183
|
+
return content;
|
|
184
|
+
} catch {
|
|
185
|
+
return content;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
|
190
|
+
const mentions = event.message.mentions ?? [];
|
|
191
|
+
if (mentions.length === 0) return false;
|
|
192
|
+
if (!botOpenId) return mentions.length > 0;
|
|
193
|
+
return mentions.some((m) => m.id.open_id === botOpenId);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function stripBotMention(text: string, mentions?: FeishuMessageEvent["message"]["mentions"]): string {
|
|
197
|
+
if (!mentions || mentions.length === 0) return text;
|
|
198
|
+
let result = text;
|
|
199
|
+
for (const mention of mentions) {
|
|
200
|
+
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
|
|
201
|
+
result = result.replace(new RegExp(mention.key, "g"), "").trim();
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Parse media keys from message content based on message type.
|
|
208
|
+
*/
|
|
209
|
+
function parseMediaKeys(
|
|
210
|
+
content: string,
|
|
211
|
+
messageType: string,
|
|
212
|
+
): {
|
|
213
|
+
imageKey?: string;
|
|
214
|
+
fileKey?: string;
|
|
215
|
+
fileName?: string;
|
|
216
|
+
} {
|
|
217
|
+
try {
|
|
218
|
+
const parsed = JSON.parse(content);
|
|
219
|
+
switch (messageType) {
|
|
220
|
+
case "image":
|
|
221
|
+
return { imageKey: parsed.image_key };
|
|
222
|
+
case "file":
|
|
223
|
+
return { fileKey: parsed.file_key, fileName: parsed.file_name };
|
|
224
|
+
case "audio":
|
|
225
|
+
return { fileKey: parsed.file_key };
|
|
226
|
+
case "video":
|
|
227
|
+
// Video has both file_key (video) and image_key (thumbnail)
|
|
228
|
+
return { fileKey: parsed.file_key, imageKey: parsed.image_key };
|
|
229
|
+
case "sticker":
|
|
230
|
+
return { fileKey: parsed.file_key };
|
|
231
|
+
default:
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
} catch {
|
|
235
|
+
return {};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Parse post (rich text) content and extract embedded image keys.
|
|
241
|
+
* Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] }
|
|
242
|
+
*/
|
|
243
|
+
function parsePostContent(content: string): {
|
|
244
|
+
textContent: string;
|
|
245
|
+
imageKeys: string[];
|
|
246
|
+
} {
|
|
247
|
+
try {
|
|
248
|
+
const parsed = JSON.parse(content);
|
|
249
|
+
const title = parsed.title || "";
|
|
250
|
+
const contentBlocks = parsed.content || [];
|
|
251
|
+
let textContent = title ? `${title}\n\n` : "";
|
|
252
|
+
const imageKeys: string[] = [];
|
|
253
|
+
|
|
254
|
+
for (const paragraph of contentBlocks) {
|
|
255
|
+
if (Array.isArray(paragraph)) {
|
|
256
|
+
for (const element of paragraph) {
|
|
257
|
+
if (element.tag === "text") {
|
|
258
|
+
textContent += element.text || "";
|
|
259
|
+
} else if (element.tag === "a") {
|
|
260
|
+
// Link: show text or href
|
|
261
|
+
textContent += element.text || element.href || "";
|
|
262
|
+
} else if (element.tag === "at") {
|
|
263
|
+
// Mention: @username
|
|
264
|
+
textContent += `@${element.user_name || element.user_id || ""}`;
|
|
265
|
+
} else if (element.tag === "img" && element.image_key) {
|
|
266
|
+
// Embedded image
|
|
267
|
+
imageKeys.push(element.image_key);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
textContent += "\n";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
textContent: textContent.trim() || "[富文本消息]",
|
|
276
|
+
imageKeys,
|
|
277
|
+
};
|
|
278
|
+
} catch {
|
|
279
|
+
return { textContent: "[富文本消息]", imageKeys: [] };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Infer placeholder text based on message type.
|
|
285
|
+
*/
|
|
286
|
+
function inferPlaceholder(messageType: string): string {
|
|
287
|
+
switch (messageType) {
|
|
288
|
+
case "image":
|
|
289
|
+
return "<media:image>";
|
|
290
|
+
case "file":
|
|
291
|
+
return "<media:document>";
|
|
292
|
+
case "audio":
|
|
293
|
+
return "<media:audio>";
|
|
294
|
+
case "video":
|
|
295
|
+
return "<media:video>";
|
|
296
|
+
case "sticker":
|
|
297
|
+
return "<media:sticker>";
|
|
298
|
+
default:
|
|
299
|
+
return "<media:document>";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Resolve media from a Feishu message, downloading and saving to disk.
|
|
305
|
+
* Similar to Discord's resolveMediaList().
|
|
306
|
+
*/
|
|
307
|
+
async function resolveFeishuMediaList(params: {
|
|
308
|
+
cfg: ClawdbotConfig;
|
|
309
|
+
messageId: string;
|
|
310
|
+
messageType: string;
|
|
311
|
+
content: string;
|
|
312
|
+
maxBytes: number;
|
|
313
|
+
log?: (msg: string) => void;
|
|
314
|
+
accountId?: string;
|
|
315
|
+
}): Promise<FeishuMediaInfo[]> {
|
|
316
|
+
const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
|
|
317
|
+
|
|
318
|
+
// Only process media message types (including post for embedded images)
|
|
319
|
+
const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
|
|
320
|
+
if (!mediaTypes.includes(messageType)) {
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const out: FeishuMediaInfo[] = [];
|
|
325
|
+
const core = getFeishuRuntime();
|
|
326
|
+
|
|
327
|
+
// Handle post (rich text) messages with embedded images
|
|
328
|
+
if (messageType === "post") {
|
|
329
|
+
const { imageKeys } = parsePostContent(content);
|
|
330
|
+
if (imageKeys.length === 0) {
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
|
|
335
|
+
|
|
336
|
+
for (const imageKey of imageKeys) {
|
|
337
|
+
try {
|
|
338
|
+
// Embedded images in post use messageResource API with image_key as file_key
|
|
339
|
+
const result = await downloadMessageResourceFeishu({
|
|
340
|
+
cfg,
|
|
341
|
+
messageId,
|
|
342
|
+
fileKey: imageKey,
|
|
343
|
+
type: "image",
|
|
344
|
+
accountId,
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
let contentType = result.contentType;
|
|
348
|
+
if (!contentType) {
|
|
349
|
+
contentType = await core.media.detectMime({ buffer: result.buffer });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
353
|
+
result.buffer,
|
|
354
|
+
contentType,
|
|
355
|
+
"inbound",
|
|
356
|
+
maxBytes,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
out.push({
|
|
360
|
+
path: saved.path,
|
|
361
|
+
contentType: saved.contentType,
|
|
362
|
+
placeholder: "<media:image>",
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
|
|
366
|
+
} catch (err) {
|
|
367
|
+
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Handle other media types
|
|
375
|
+
const mediaKeys = parseMediaKeys(content, messageType);
|
|
376
|
+
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
|
|
377
|
+
return [];
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
let buffer: Buffer;
|
|
382
|
+
let contentType: string | undefined;
|
|
383
|
+
let fileName: string | undefined;
|
|
384
|
+
|
|
385
|
+
// For message media, always use messageResource API
|
|
386
|
+
// The image.get API is only for images uploaded via im/v1/images, not for message attachments
|
|
387
|
+
const fileKey = mediaKeys.imageKey || mediaKeys.fileKey;
|
|
388
|
+
if (!fileKey) {
|
|
389
|
+
return [];
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const resourceType = messageType === "image" ? "image" : "file";
|
|
393
|
+
const result = await downloadMessageResourceFeishu({
|
|
394
|
+
cfg,
|
|
395
|
+
messageId,
|
|
396
|
+
fileKey,
|
|
397
|
+
type: resourceType,
|
|
398
|
+
accountId,
|
|
399
|
+
});
|
|
400
|
+
buffer = result.buffer;
|
|
401
|
+
contentType = result.contentType;
|
|
402
|
+
fileName = result.fileName || mediaKeys.fileName;
|
|
403
|
+
|
|
404
|
+
// Detect mime type if not provided
|
|
405
|
+
if (!contentType) {
|
|
406
|
+
contentType = await core.media.detectMime({ buffer });
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Save to disk using core's saveMediaBuffer
|
|
410
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
411
|
+
buffer,
|
|
412
|
+
contentType,
|
|
413
|
+
"inbound",
|
|
414
|
+
maxBytes,
|
|
415
|
+
fileName,
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
out.push({
|
|
419
|
+
path: saved.path,
|
|
420
|
+
contentType: saved.contentType,
|
|
421
|
+
placeholder: inferPlaceholder(messageType),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
|
|
425
|
+
} catch (err) {
|
|
426
|
+
log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Build media payload for inbound context.
|
|
434
|
+
* Similar to Discord's buildDiscordMediaPayload().
|
|
435
|
+
*/
|
|
436
|
+
function buildFeishuMediaPayload(
|
|
437
|
+
mediaList: FeishuMediaInfo[],
|
|
438
|
+
): {
|
|
439
|
+
MediaPath?: string;
|
|
440
|
+
MediaType?: string;
|
|
441
|
+
MediaUrl?: string;
|
|
442
|
+
MediaPaths?: string[];
|
|
443
|
+
MediaUrls?: string[];
|
|
444
|
+
MediaTypes?: string[];
|
|
445
|
+
} {
|
|
446
|
+
const first = mediaList[0];
|
|
447
|
+
const mediaPaths = mediaList.map((media) => media.path);
|
|
448
|
+
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
|
449
|
+
return {
|
|
450
|
+
MediaPath: first?.path,
|
|
451
|
+
MediaType: first?.contentType,
|
|
452
|
+
MediaUrl: first?.path,
|
|
453
|
+
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
454
|
+
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
455
|
+
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function parseFeishuMessageEvent(
|
|
460
|
+
event: FeishuMessageEvent,
|
|
461
|
+
botOpenId?: string,
|
|
462
|
+
): FeishuMessageContext {
|
|
463
|
+
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
|
|
464
|
+
const mentionedBot = checkBotMentioned(event, botOpenId);
|
|
465
|
+
const content = stripBotMention(rawContent, event.message.mentions);
|
|
466
|
+
|
|
467
|
+
const ctx: FeishuMessageContext = {
|
|
468
|
+
chatId: event.message.chat_id,
|
|
469
|
+
messageId: event.message.message_id,
|
|
470
|
+
senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "",
|
|
471
|
+
senderOpenId: event.sender.sender_id.open_id || "",
|
|
472
|
+
chatType: event.message.chat_type,
|
|
473
|
+
mentionedBot,
|
|
474
|
+
rootId: event.message.root_id || undefined,
|
|
475
|
+
parentId: event.message.parent_id || undefined,
|
|
476
|
+
content,
|
|
477
|
+
contentType: event.message.message_type,
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
// Detect mention forward request: message mentions bot + at least one other user
|
|
481
|
+
if (isMentionForwardRequest(event, botOpenId)) {
|
|
482
|
+
const mentionTargets = extractMentionTargets(event, botOpenId);
|
|
483
|
+
if (mentionTargets.length > 0) {
|
|
484
|
+
ctx.mentionTargets = mentionTargets;
|
|
485
|
+
// Extract message body (remove all @ placeholders)
|
|
486
|
+
const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key);
|
|
487
|
+
ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
return ctx;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export async function handleFeishuMessage(params: {
|
|
495
|
+
cfg: ClawdbotConfig;
|
|
496
|
+
event: FeishuMessageEvent;
|
|
497
|
+
botOpenId?: string;
|
|
498
|
+
runtime?: RuntimeEnv;
|
|
499
|
+
chatHistories?: Map<string, HistoryEntry[]>;
|
|
500
|
+
accountId?: string;
|
|
501
|
+
}): Promise<void> {
|
|
502
|
+
const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
|
|
503
|
+
|
|
504
|
+
// Resolve account with merged config
|
|
505
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
506
|
+
const feishuCfg = account.config;
|
|
507
|
+
|
|
508
|
+
const log = runtime?.log ?? console.log;
|
|
509
|
+
const error = runtime?.error ?? console.error;
|
|
510
|
+
|
|
511
|
+
let ctx = parseFeishuMessageEvent(event, botOpenId);
|
|
512
|
+
const isGroup = ctx.chatType === "group";
|
|
513
|
+
|
|
514
|
+
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
|
|
515
|
+
const senderResult = await resolveFeishuSenderName({
|
|
516
|
+
account,
|
|
517
|
+
senderOpenId: ctx.senderOpenId,
|
|
518
|
+
log,
|
|
519
|
+
});
|
|
520
|
+
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
|
|
521
|
+
|
|
522
|
+
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
|
523
|
+
let permissionErrorForAgent: PermissionError | undefined;
|
|
524
|
+
if (senderResult.permissionError) {
|
|
525
|
+
const appKey = account.appId ?? "default";
|
|
526
|
+
const now = Date.now();
|
|
527
|
+
const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
|
|
528
|
+
|
|
529
|
+
if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
|
|
530
|
+
permissionErrorNotifiedAt.set(appKey, now);
|
|
531
|
+
permissionErrorForAgent = senderResult.permissionError;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
log(`feishu[${account.accountId}]: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`);
|
|
536
|
+
|
|
537
|
+
// Log mention targets if detected
|
|
538
|
+
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
|
539
|
+
const names = ctx.mentionTargets.map((t) => t.name).join(", ");
|
|
540
|
+
log(`feishu[${account.accountId}]: detected @ forward request, targets: [${names}]`);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const historyLimit = Math.max(
|
|
544
|
+
0,
|
|
545
|
+
feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
if (isGroup) {
|
|
549
|
+
const groupPolicy = feishuCfg?.groupPolicy ?? "open";
|
|
550
|
+
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
|
|
551
|
+
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
|
|
552
|
+
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
|
553
|
+
|
|
554
|
+
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
|
|
555
|
+
const groupAllowed = isFeishuGroupAllowed({
|
|
556
|
+
groupPolicy,
|
|
557
|
+
allowFrom: groupAllowFrom,
|
|
558
|
+
senderId: ctx.chatId, // Check group ID, not sender ID
|
|
559
|
+
senderName: undefined,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
if (!groupAllowed) {
|
|
563
|
+
log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in group allowlist`);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Additional sender-level allowlist check if group has specific allowFrom config
|
|
568
|
+
const senderAllowFrom = groupConfig?.allowFrom ?? [];
|
|
569
|
+
if (senderAllowFrom.length > 0) {
|
|
570
|
+
const senderAllowed = isFeishuGroupAllowed({
|
|
571
|
+
groupPolicy: "allowlist",
|
|
572
|
+
allowFrom: senderAllowFrom,
|
|
573
|
+
senderId: ctx.senderOpenId,
|
|
574
|
+
senderName: ctx.senderName,
|
|
575
|
+
});
|
|
576
|
+
if (!senderAllowed) {
|
|
577
|
+
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const { requireMention } = resolveFeishuReplyPolicy({
|
|
583
|
+
isDirectMessage: false,
|
|
584
|
+
globalConfig: feishuCfg,
|
|
585
|
+
groupConfig,
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
if (requireMention && !ctx.mentionedBot) {
|
|
589
|
+
log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`);
|
|
590
|
+
if (chatHistories) {
|
|
591
|
+
recordPendingHistoryEntryIfEnabled({
|
|
592
|
+
historyMap: chatHistories,
|
|
593
|
+
historyKey: ctx.chatId,
|
|
594
|
+
limit: historyLimit,
|
|
595
|
+
entry: {
|
|
596
|
+
sender: ctx.senderOpenId,
|
|
597
|
+
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
|
|
598
|
+
timestamp: Date.now(),
|
|
599
|
+
messageId: ctx.messageId,
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
} else {
|
|
606
|
+
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
|
607
|
+
const allowFrom = feishuCfg?.allowFrom ?? [];
|
|
608
|
+
|
|
609
|
+
if (dmPolicy === "allowlist") {
|
|
610
|
+
const match = resolveFeishuAllowlistMatch({
|
|
611
|
+
allowFrom,
|
|
612
|
+
senderId: ctx.senderOpenId,
|
|
613
|
+
});
|
|
614
|
+
if (!match.allowed) {
|
|
615
|
+
log(`feishu[${account.accountId}]: sender ${ctx.senderOpenId} not in DM allowlist`);
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
const core = getFeishuRuntime();
|
|
623
|
+
|
|
624
|
+
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
|
|
625
|
+
// Using a group-scoped From causes the agent to treat different users as the same person.
|
|
626
|
+
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
|
627
|
+
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
|
628
|
+
|
|
629
|
+
// Resolve peer ID for session routing
|
|
630
|
+
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
|
|
631
|
+
// get a separate session from the main group chat.
|
|
632
|
+
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
|
|
633
|
+
if (isGroup && ctx.rootId) {
|
|
634
|
+
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
|
635
|
+
const topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
|
636
|
+
if (topicSessionMode === "enabled") {
|
|
637
|
+
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
|
|
638
|
+
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
|
|
639
|
+
log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
let route = core.channel.routing.resolveAgentRoute({
|
|
644
|
+
cfg,
|
|
645
|
+
channel: "feishu",
|
|
646
|
+
accountId: account.accountId,
|
|
647
|
+
peer: {
|
|
648
|
+
kind: isGroup ? "group" : "dm",
|
|
649
|
+
id: peerId,
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
// Dynamic agent creation for DM users
|
|
654
|
+
// When enabled, creates a unique agent instance with its own workspace for each DM user.
|
|
655
|
+
let effectiveCfg = cfg;
|
|
656
|
+
if (!isGroup && route.matchedBy === "default") {
|
|
657
|
+
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
|
|
658
|
+
if (dynamicCfg?.enabled) {
|
|
659
|
+
const runtime = getFeishuRuntime();
|
|
660
|
+
const result = await maybeCreateDynamicAgent({
|
|
661
|
+
cfg,
|
|
662
|
+
runtime,
|
|
663
|
+
senderOpenId: ctx.senderOpenId,
|
|
664
|
+
dynamicCfg,
|
|
665
|
+
log: (msg) => log(msg),
|
|
666
|
+
});
|
|
667
|
+
if (result.created) {
|
|
668
|
+
effectiveCfg = result.updatedCfg;
|
|
669
|
+
// Re-resolve route with updated config
|
|
670
|
+
route = core.channel.routing.resolveAgentRoute({
|
|
671
|
+
cfg: result.updatedCfg,
|
|
672
|
+
channel: "feishu",
|
|
673
|
+
accountId: account.accountId,
|
|
674
|
+
peer: { kind: "dm", id: ctx.senderOpenId },
|
|
675
|
+
});
|
|
676
|
+
log(`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
|
682
|
+
const inboundLabel = isGroup
|
|
683
|
+
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
|
684
|
+
: `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
|
|
685
|
+
|
|
686
|
+
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
|
687
|
+
sessionKey: route.sessionKey,
|
|
688
|
+
contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
// Resolve media from message
|
|
692
|
+
const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
|
|
693
|
+
const mediaList = await resolveFeishuMediaList({
|
|
694
|
+
cfg,
|
|
695
|
+
messageId: ctx.messageId,
|
|
696
|
+
messageType: event.message.message_type,
|
|
697
|
+
content: event.message.content,
|
|
698
|
+
maxBytes: mediaMaxBytes,
|
|
699
|
+
log,
|
|
700
|
+
accountId: account.accountId,
|
|
701
|
+
});
|
|
702
|
+
const mediaPayload = buildFeishuMediaPayload(mediaList);
|
|
703
|
+
|
|
704
|
+
// Fetch quoted/replied message content if parentId exists
|
|
705
|
+
let quotedContent: string | undefined;
|
|
706
|
+
if (ctx.parentId) {
|
|
707
|
+
try {
|
|
708
|
+
const quotedMsg = await getMessageFeishu({ cfg, messageId: ctx.parentId, accountId: account.accountId });
|
|
709
|
+
if (quotedMsg) {
|
|
710
|
+
quotedContent = quotedMsg.content;
|
|
711
|
+
log(`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`);
|
|
712
|
+
}
|
|
713
|
+
} catch (err) {
|
|
714
|
+
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
719
|
+
|
|
720
|
+
// Build message body with quoted content if available
|
|
721
|
+
let messageBody = ctx.content;
|
|
722
|
+
if (quotedContent) {
|
|
723
|
+
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Include a readable speaker label so the model can attribute instructions.
|
|
727
|
+
// (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
|
|
728
|
+
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
|
729
|
+
messageBody = `${speaker}: ${messageBody}`;
|
|
730
|
+
|
|
731
|
+
// If there are mention targets, inform the agent that replies will auto-mention them
|
|
732
|
+
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
|
733
|
+
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
|
|
734
|
+
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
|
|
738
|
+
|
|
739
|
+
// If there's a permission error, dispatch a separate notification first
|
|
740
|
+
if (permissionErrorForAgent) {
|
|
741
|
+
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
|
|
742
|
+
const permissionNotifyBody = `[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
|
|
743
|
+
|
|
744
|
+
const permissionBody = core.channel.reply.formatAgentEnvelope({
|
|
745
|
+
channel: "Feishu",
|
|
746
|
+
from: envelopeFrom,
|
|
747
|
+
timestamp: new Date(),
|
|
748
|
+
envelope: envelopeOptions,
|
|
749
|
+
body: permissionNotifyBody,
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
const permissionCtx = core.channel.reply.finalizeInboundContext({
|
|
753
|
+
Body: permissionBody,
|
|
754
|
+
RawBody: permissionNotifyBody,
|
|
755
|
+
CommandBody: permissionNotifyBody,
|
|
756
|
+
From: feishuFrom,
|
|
757
|
+
To: feishuTo,
|
|
758
|
+
SessionKey: route.sessionKey,
|
|
759
|
+
AccountId: route.accountId,
|
|
760
|
+
ChatType: isGroup ? "group" : "direct",
|
|
761
|
+
GroupSubject: isGroup ? ctx.chatId : undefined,
|
|
762
|
+
SenderName: "system",
|
|
763
|
+
SenderId: "system",
|
|
764
|
+
Provider: "feishu" as const,
|
|
765
|
+
Surface: "feishu" as const,
|
|
766
|
+
MessageSid: `${ctx.messageId}:permission-error`,
|
|
767
|
+
Timestamp: Date.now(),
|
|
768
|
+
WasMentioned: false,
|
|
769
|
+
CommandAuthorized: true,
|
|
770
|
+
OriginatingChannel: "feishu" as const,
|
|
771
|
+
OriginatingTo: feishuTo,
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
const { dispatcher: permDispatcher, replyOptions: permReplyOptions, markDispatchIdle: markPermIdle } =
|
|
775
|
+
createFeishuReplyDispatcher({
|
|
776
|
+
cfg,
|
|
777
|
+
agentId: route.agentId,
|
|
778
|
+
runtime: runtime as RuntimeEnv,
|
|
779
|
+
chatId: ctx.chatId,
|
|
780
|
+
replyToMessageId: ctx.messageId,
|
|
781
|
+
accountId: account.accountId,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
log(`feishu[${account.accountId}]: dispatching permission error notification to agent`);
|
|
785
|
+
|
|
786
|
+
await core.channel.reply.dispatchReplyFromConfig({
|
|
787
|
+
ctx: permissionCtx,
|
|
788
|
+
cfg,
|
|
789
|
+
dispatcher: permDispatcher,
|
|
790
|
+
replyOptions: permReplyOptions,
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
markPermIdle();
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
797
|
+
channel: "Feishu",
|
|
798
|
+
from: envelopeFrom,
|
|
799
|
+
timestamp: new Date(),
|
|
800
|
+
envelope: envelopeOptions,
|
|
801
|
+
body: messageBody,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
let combinedBody = body;
|
|
805
|
+
const historyKey = isGroup ? ctx.chatId : undefined;
|
|
806
|
+
|
|
807
|
+
if (isGroup && historyKey && chatHistories) {
|
|
808
|
+
combinedBody = buildPendingHistoryContextFromMap({
|
|
809
|
+
historyMap: chatHistories,
|
|
810
|
+
historyKey,
|
|
811
|
+
limit: historyLimit,
|
|
812
|
+
currentMessage: combinedBody,
|
|
813
|
+
formatEntry: (entry) =>
|
|
814
|
+
core.channel.reply.formatAgentEnvelope({
|
|
815
|
+
channel: "Feishu",
|
|
816
|
+
// Preserve speaker identity in group history as well.
|
|
817
|
+
from: `${ctx.chatId}:${entry.sender}`,
|
|
818
|
+
timestamp: entry.timestamp,
|
|
819
|
+
body: entry.body,
|
|
820
|
+
envelope: envelopeOptions,
|
|
821
|
+
}),
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
826
|
+
Body: combinedBody,
|
|
827
|
+
RawBody: ctx.content,
|
|
828
|
+
CommandBody: ctx.content,
|
|
829
|
+
From: feishuFrom,
|
|
830
|
+
To: feishuTo,
|
|
831
|
+
SessionKey: route.sessionKey,
|
|
832
|
+
AccountId: route.accountId,
|
|
833
|
+
ChatType: isGroup ? "group" : "direct",
|
|
834
|
+
GroupSubject: isGroup ? ctx.chatId : undefined,
|
|
835
|
+
SenderName: ctx.senderName ?? ctx.senderOpenId,
|
|
836
|
+
SenderId: ctx.senderOpenId,
|
|
837
|
+
Provider: "feishu" as const,
|
|
838
|
+
Surface: "feishu" as const,
|
|
839
|
+
MessageSid: ctx.messageId,
|
|
840
|
+
Timestamp: Date.now(),
|
|
841
|
+
WasMentioned: ctx.mentionedBot,
|
|
842
|
+
CommandAuthorized: true,
|
|
843
|
+
OriginatingChannel: "feishu" as const,
|
|
844
|
+
OriginatingTo: feishuTo,
|
|
845
|
+
...mediaPayload,
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
849
|
+
cfg,
|
|
850
|
+
agentId: route.agentId,
|
|
851
|
+
runtime: runtime as RuntimeEnv,
|
|
852
|
+
chatId: ctx.chatId,
|
|
853
|
+
replyToMessageId: ctx.messageId,
|
|
854
|
+
mentionTargets: ctx.mentionTargets,
|
|
855
|
+
accountId: account.accountId,
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
|
859
|
+
|
|
860
|
+
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
|
861
|
+
ctx: ctxPayload,
|
|
862
|
+
cfg,
|
|
863
|
+
dispatcher,
|
|
864
|
+
replyOptions,
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
markDispatchIdle();
|
|
868
|
+
|
|
869
|
+
if (isGroup && historyKey && chatHistories) {
|
|
870
|
+
clearHistoryEntriesIfEnabled({
|
|
871
|
+
historyMap: chatHistories,
|
|
872
|
+
historyKey,
|
|
873
|
+
limit: historyLimit,
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
log(`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
|
|
878
|
+
} catch (err) {
|
|
879
|
+
error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
|
|
880
|
+
}
|
|
881
|
+
}
|