@openclaw/feishu 2026.2.24 → 2026.3.1
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/index.ts +2 -0
- package/package.json +2 -1
- package/skills/feishu-doc/SKILL.md +109 -3
- package/src/accounts.test.ts +90 -0
- package/src/accounts.ts +11 -2
- package/src/async.ts +62 -0
- package/src/bitable.ts +189 -215
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +55 -0
- package/src/bot.test.ts +863 -9
- package/src/bot.ts +414 -200
- package/src/card-action.ts +79 -0
- package/src/channel.ts +6 -0
- package/src/chat-schema.ts +24 -0
- package/src/chat.test.ts +89 -0
- package/src/chat.ts +130 -0
- package/src/client.test.ts +107 -0
- package/src/client.ts +13 -0
- package/src/config-schema.test.ts +82 -1
- package/src/config-schema.ts +54 -3
- package/src/doc-schema.ts +141 -0
- package/src/docx-batch-insert.ts +190 -0
- package/src/docx-color-text.ts +149 -0
- package/src/docx-table-ops.ts +298 -0
- package/src/docx.account-selection.test.ts +76 -0
- package/src/docx.test.ts +470 -0
- package/src/docx.ts +996 -72
- package/src/drive.ts +38 -33
- package/src/media.test.ts +123 -6
- package/src/media.ts +31 -10
- package/src/monitor.account.ts +286 -0
- package/src/monitor.reaction.test.ts +235 -0
- package/src/monitor.startup.test.ts +187 -0
- package/src/monitor.startup.ts +51 -0
- package/src/monitor.state.ts +76 -0
- package/src/monitor.transport.ts +163 -0
- package/src/monitor.ts +44 -346
- package/src/monitor.webhook-security.test.ts +27 -1
- package/src/outbound.test.ts +181 -0
- package/src/outbound.ts +94 -7
- package/src/perm.ts +37 -30
- package/src/policy.test.ts +56 -1
- package/src/policy.ts +5 -1
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +253 -0
- package/src/probe.ts +99 -7
- package/src/reply-dispatcher.test.ts +259 -0
- package/src/reply-dispatcher.ts +139 -45
- package/src/send.reply-fallback.test.ts +105 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +143 -18
- package/src/streaming-card.ts +131 -43
- package/src/targets.test.ts +26 -1
- package/src/targets.ts +11 -6
- package/src/tool-account-routing.test.ts +129 -0
- package/src/tool-account.ts +70 -0
- package/src/tool-factory-test-harness.ts +76 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +2 -1
- package/src/types.ts +1 -0
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +140 -10
- package/src/wiki.ts +55 -50
package/src/bot.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
buildAgentMediaPayload,
|
|
4
4
|
buildPendingHistoryContextFromMap,
|
|
5
5
|
clearHistoryEntriesIfEnabled,
|
|
6
|
+
createScopedPairingAccess,
|
|
6
7
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
7
8
|
type HistoryEntry,
|
|
8
9
|
recordPendingHistoryEntryIfEnabled,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
} from "openclaw/plugin-sdk";
|
|
13
14
|
import { resolveFeishuAccount } from "./accounts.js";
|
|
14
15
|
import { createFeishuClient } from "./client.js";
|
|
15
|
-
import { tryRecordMessagePersistent } from "./dedup.js";
|
|
16
|
+
import { tryRecordMessage, tryRecordMessagePersistent } from "./dedup.js";
|
|
16
17
|
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
|
17
18
|
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
18
19
|
import { downloadMessageResourceFeishu } from "./media.js";
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
resolveFeishuAllowlistMatch,
|
|
29
30
|
isFeishuGroupAllowed,
|
|
30
31
|
} from "./policy.js";
|
|
32
|
+
import { parsePostContent } from "./post.js";
|
|
31
33
|
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|
32
34
|
import { getFeishuRuntime } from "./runtime.js";
|
|
33
35
|
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
|
|
@@ -72,7 +74,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
|
|
75
|
-
// Cache display names by open_id to avoid an API call on every message.
|
|
77
|
+
// Cache display names by sender id (open_id/user_id) to avoid an API call on every message.
|
|
76
78
|
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
|
|
77
79
|
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
|
|
78
80
|
|
|
@@ -86,26 +88,40 @@ type SenderNameResult = {
|
|
|
86
88
|
permissionError?: PermissionError;
|
|
87
89
|
};
|
|
88
90
|
|
|
91
|
+
function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
|
|
92
|
+
const trimmed = senderId.trim();
|
|
93
|
+
if (trimmed.startsWith("ou_")) {
|
|
94
|
+
return "open_id";
|
|
95
|
+
}
|
|
96
|
+
if (trimmed.startsWith("on_")) {
|
|
97
|
+
return "union_id";
|
|
98
|
+
}
|
|
99
|
+
return "user_id";
|
|
100
|
+
}
|
|
101
|
+
|
|
89
102
|
async function resolveFeishuSenderName(params: {
|
|
90
103
|
account: ResolvedFeishuAccount;
|
|
91
|
-
|
|
104
|
+
senderId: string;
|
|
92
105
|
log: (...args: any[]) => void;
|
|
93
106
|
}): Promise<SenderNameResult> {
|
|
94
|
-
const { account,
|
|
107
|
+
const { account, senderId, log } = params;
|
|
95
108
|
if (!account.configured) return {};
|
|
96
|
-
if (!senderOpenId) return {};
|
|
97
109
|
|
|
98
|
-
const
|
|
110
|
+
const normalizedSenderId = senderId.trim();
|
|
111
|
+
if (!normalizedSenderId) return {};
|
|
112
|
+
|
|
113
|
+
const cached = senderNameCache.get(normalizedSenderId);
|
|
99
114
|
const now = Date.now();
|
|
100
115
|
if (cached && cached.expireAt > now) return { name: cached.name };
|
|
101
116
|
|
|
102
117
|
try {
|
|
103
118
|
const client = createFeishuClient(account);
|
|
119
|
+
const userIdType = resolveSenderLookupIdType(normalizedSenderId);
|
|
104
120
|
|
|
105
|
-
// contact/v3/users/:user_id?user_id_type
|
|
121
|
+
// contact/v3/users/:user_id?user_id_type=<open_id|user_id|union_id>
|
|
106
122
|
const res: any = await client.contact.user.get({
|
|
107
|
-
path: { user_id:
|
|
108
|
-
params: { user_id_type:
|
|
123
|
+
path: { user_id: normalizedSenderId },
|
|
124
|
+
params: { user_id_type: userIdType },
|
|
109
125
|
});
|
|
110
126
|
|
|
111
127
|
const name: string | undefined =
|
|
@@ -115,7 +131,7 @@ async function resolveFeishuSenderName(params: {
|
|
|
115
131
|
res?.data?.user?.en_name;
|
|
116
132
|
|
|
117
133
|
if (name && typeof name === "string") {
|
|
118
|
-
senderNameCache.set(
|
|
134
|
+
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
|
|
119
135
|
return { name };
|
|
120
136
|
}
|
|
121
137
|
|
|
@@ -129,7 +145,7 @@ async function resolveFeishuSenderName(params: {
|
|
|
129
145
|
}
|
|
130
146
|
|
|
131
147
|
// Best-effort. Don't fail message handling if name lookup fails.
|
|
132
|
-
log(`feishu: failed to resolve sender name for ${
|
|
148
|
+
log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
|
|
133
149
|
return {};
|
|
134
150
|
}
|
|
135
151
|
}
|
|
@@ -152,6 +168,7 @@ export type FeishuMessageEvent = {
|
|
|
152
168
|
chat_type: "p2p" | "group";
|
|
153
169
|
message_type: string;
|
|
154
170
|
content: string;
|
|
171
|
+
create_time?: string;
|
|
155
172
|
mentions?: Array<{
|
|
156
173
|
key: string;
|
|
157
174
|
id: {
|
|
@@ -177,15 +194,40 @@ export type FeishuBotAddedEvent = {
|
|
|
177
194
|
};
|
|
178
195
|
|
|
179
196
|
function parseMessageContent(content: string, messageType: string): string {
|
|
197
|
+
if (messageType === "post") {
|
|
198
|
+
// Extract text content from rich text post
|
|
199
|
+
const { textContent } = parsePostContent(content);
|
|
200
|
+
return textContent;
|
|
201
|
+
}
|
|
202
|
+
|
|
180
203
|
try {
|
|
181
204
|
const parsed = JSON.parse(content);
|
|
182
205
|
if (messageType === "text") {
|
|
183
206
|
return parsed.text || "";
|
|
184
207
|
}
|
|
185
|
-
if (messageType === "
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
208
|
+
if (messageType === "share_chat") {
|
|
209
|
+
// Preserve available summary text for merged/forwarded chat messages.
|
|
210
|
+
if (parsed && typeof parsed === "object") {
|
|
211
|
+
const share = parsed as {
|
|
212
|
+
body?: unknown;
|
|
213
|
+
summary?: unknown;
|
|
214
|
+
share_chat_id?: unknown;
|
|
215
|
+
};
|
|
216
|
+
if (typeof share.body === "string" && share.body.trim().length > 0) {
|
|
217
|
+
return share.body.trim();
|
|
218
|
+
}
|
|
219
|
+
if (typeof share.summary === "string" && share.summary.trim().length > 0) {
|
|
220
|
+
return share.summary.trim();
|
|
221
|
+
}
|
|
222
|
+
if (typeof share.share_chat_id === "string" && share.share_chat_id.trim().length > 0) {
|
|
223
|
+
return `[Forwarded message: ${share.share_chat_id.trim()}]`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return "[Forwarded message]";
|
|
227
|
+
}
|
|
228
|
+
if (messageType === "merge_forward") {
|
|
229
|
+
// Return placeholder; actual content fetched asynchronously in handleFeishuMessage
|
|
230
|
+
return "[Merged and Forwarded Message - loading...]";
|
|
189
231
|
}
|
|
190
232
|
return content;
|
|
191
233
|
} catch {
|
|
@@ -193,6 +235,109 @@ function parseMessageContent(content: string, messageType: string): string {
|
|
|
193
235
|
}
|
|
194
236
|
}
|
|
195
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Parse merge_forward message content and fetch sub-messages.
|
|
240
|
+
* Returns formatted text content of all sub-messages.
|
|
241
|
+
*/
|
|
242
|
+
function parseMergeForwardContent(params: {
|
|
243
|
+
content: string;
|
|
244
|
+
log?: (...args: any[]) => void;
|
|
245
|
+
}): string {
|
|
246
|
+
const { content, log } = params;
|
|
247
|
+
const maxMessages = 50;
|
|
248
|
+
|
|
249
|
+
// For merge_forward, the API returns all sub-messages in items array
|
|
250
|
+
// with upper_message_id pointing to the merge_forward message.
|
|
251
|
+
// The 'content' parameter here is actually the full API response items array as JSON.
|
|
252
|
+
log?.(`feishu: parsing merge_forward sub-messages from API response`);
|
|
253
|
+
|
|
254
|
+
let items: Array<{
|
|
255
|
+
message_id?: string;
|
|
256
|
+
msg_type?: string;
|
|
257
|
+
body?: { content?: string };
|
|
258
|
+
sender?: { id?: string };
|
|
259
|
+
upper_message_id?: string;
|
|
260
|
+
create_time?: string;
|
|
261
|
+
}>;
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
items = JSON.parse(content);
|
|
265
|
+
} catch {
|
|
266
|
+
log?.(`feishu: merge_forward items parse failed`);
|
|
267
|
+
return "[Merged and Forwarded Message - parse error]";
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
271
|
+
return "[Merged and Forwarded Message - no sub-messages]";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Filter to only sub-messages (those with upper_message_id, skip the merge_forward container itself)
|
|
275
|
+
const subMessages = items.filter((item) => item.upper_message_id);
|
|
276
|
+
|
|
277
|
+
if (subMessages.length === 0) {
|
|
278
|
+
return "[Merged and Forwarded Message - no sub-messages found]";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
|
|
282
|
+
|
|
283
|
+
// Sort by create_time
|
|
284
|
+
subMessages.sort((a, b) => {
|
|
285
|
+
const timeA = parseInt(a.create_time || "0", 10);
|
|
286
|
+
const timeB = parseInt(b.create_time || "0", 10);
|
|
287
|
+
return timeA - timeB;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Format output
|
|
291
|
+
const lines: string[] = ["[Merged and Forwarded Messages]"];
|
|
292
|
+
const limitedMessages = subMessages.slice(0, maxMessages);
|
|
293
|
+
|
|
294
|
+
for (const item of limitedMessages) {
|
|
295
|
+
const msgContent = item.body?.content || "";
|
|
296
|
+
const msgType = item.msg_type || "text";
|
|
297
|
+
const formatted = formatSubMessageContent(msgContent, msgType);
|
|
298
|
+
lines.push(`- ${formatted}`);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (subMessages.length > maxMessages) {
|
|
302
|
+
lines.push(`... and ${subMessages.length - maxMessages} more messages`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return lines.join("\n");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Format sub-message content based on message type.
|
|
310
|
+
*/
|
|
311
|
+
function formatSubMessageContent(content: string, contentType: string): string {
|
|
312
|
+
try {
|
|
313
|
+
const parsed = JSON.parse(content);
|
|
314
|
+
switch (contentType) {
|
|
315
|
+
case "text":
|
|
316
|
+
return parsed.text || content;
|
|
317
|
+
case "post": {
|
|
318
|
+
const { textContent } = parsePostContent(content);
|
|
319
|
+
return textContent;
|
|
320
|
+
}
|
|
321
|
+
case "image":
|
|
322
|
+
return "[Image]";
|
|
323
|
+
case "file":
|
|
324
|
+
return `[File: ${parsed.file_name || "unknown"}]`;
|
|
325
|
+
case "audio":
|
|
326
|
+
return "[Audio]";
|
|
327
|
+
case "video":
|
|
328
|
+
return "[Video]";
|
|
329
|
+
case "sticker":
|
|
330
|
+
return "[Sticker]";
|
|
331
|
+
case "merge_forward":
|
|
332
|
+
return "[Nested Merged Forward]";
|
|
333
|
+
default:
|
|
334
|
+
return `[${contentType}]`;
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
return content;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
196
341
|
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
|
197
342
|
if (!botOpenId) return false;
|
|
198
343
|
const mentions = event.message.mentions ?? [];
|
|
@@ -243,7 +388,8 @@ function parseMediaKeys(
|
|
|
243
388
|
case "audio":
|
|
244
389
|
return { fileKey };
|
|
245
390
|
case "video":
|
|
246
|
-
|
|
391
|
+
case "media":
|
|
392
|
+
// Video/media has both file_key (video) and image_key (thumbnail)
|
|
247
393
|
return { fileKey, imageKey };
|
|
248
394
|
case "sticker":
|
|
249
395
|
return { fileKey };
|
|
@@ -256,56 +402,11 @@ function parseMediaKeys(
|
|
|
256
402
|
}
|
|
257
403
|
|
|
258
404
|
/**
|
|
259
|
-
*
|
|
260
|
-
*
|
|
405
|
+
* Map Feishu message type to messageResource.get resource type.
|
|
406
|
+
* Feishu messageResource API supports only: image | file.
|
|
261
407
|
*/
|
|
262
|
-
function
|
|
263
|
-
|
|
264
|
-
imageKeys: string[];
|
|
265
|
-
mentionedOpenIds: string[];
|
|
266
|
-
} {
|
|
267
|
-
try {
|
|
268
|
-
const parsed = JSON.parse(content);
|
|
269
|
-
const title = parsed.title || "";
|
|
270
|
-
const contentBlocks = parsed.content || [];
|
|
271
|
-
let textContent = title ? `${title}\n\n` : "";
|
|
272
|
-
const imageKeys: string[] = [];
|
|
273
|
-
const mentionedOpenIds: string[] = [];
|
|
274
|
-
|
|
275
|
-
for (const paragraph of contentBlocks) {
|
|
276
|
-
if (Array.isArray(paragraph)) {
|
|
277
|
-
for (const element of paragraph) {
|
|
278
|
-
if (element.tag === "text") {
|
|
279
|
-
textContent += element.text || "";
|
|
280
|
-
} else if (element.tag === "a") {
|
|
281
|
-
// Link: show text or href
|
|
282
|
-
textContent += element.text || element.href || "";
|
|
283
|
-
} else if (element.tag === "at") {
|
|
284
|
-
// Mention: @username
|
|
285
|
-
textContent += `@${element.user_name || element.user_id || ""}`;
|
|
286
|
-
if (element.user_id) {
|
|
287
|
-
mentionedOpenIds.push(element.user_id);
|
|
288
|
-
}
|
|
289
|
-
} else if (element.tag === "img" && element.image_key) {
|
|
290
|
-
// Embedded image
|
|
291
|
-
const imageKey = normalizeFeishuExternalKey(element.image_key);
|
|
292
|
-
if (imageKey) {
|
|
293
|
-
imageKeys.push(imageKey);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
textContent += "\n";
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
return {
|
|
302
|
-
textContent: textContent.trim() || "[Rich text message]",
|
|
303
|
-
imageKeys,
|
|
304
|
-
mentionedOpenIds,
|
|
305
|
-
};
|
|
306
|
-
} catch {
|
|
307
|
-
return { textContent: "[Rich text message]", imageKeys: [], mentionedOpenIds: [] };
|
|
308
|
-
}
|
|
408
|
+
export function toMessageResourceType(messageType: string): "image" | "file" {
|
|
409
|
+
return messageType === "image" ? "image" : "file";
|
|
309
410
|
}
|
|
310
411
|
|
|
311
412
|
/**
|
|
@@ -320,6 +421,7 @@ function inferPlaceholder(messageType: string): string {
|
|
|
320
421
|
case "audio":
|
|
321
422
|
return "<media:audio>";
|
|
322
423
|
case "video":
|
|
424
|
+
case "media":
|
|
323
425
|
return "<media:video>";
|
|
324
426
|
case "sticker":
|
|
325
427
|
return "<media:sticker>";
|
|
@@ -344,7 +446,7 @@ async function resolveFeishuMediaList(params: {
|
|
|
344
446
|
const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
|
|
345
447
|
|
|
346
448
|
// Only process media message types (including post for embedded images)
|
|
347
|
-
const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
|
|
449
|
+
const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
|
|
348
450
|
if (!mediaTypes.includes(messageType)) {
|
|
349
451
|
return [];
|
|
350
452
|
}
|
|
@@ -352,14 +454,19 @@ async function resolveFeishuMediaList(params: {
|
|
|
352
454
|
const out: FeishuMediaInfo[] = [];
|
|
353
455
|
const core = getFeishuRuntime();
|
|
354
456
|
|
|
355
|
-
// Handle post (rich text) messages with embedded images
|
|
457
|
+
// Handle post (rich text) messages with embedded images/media.
|
|
356
458
|
if (messageType === "post") {
|
|
357
|
-
const { imageKeys } = parsePostContent(content);
|
|
358
|
-
if (imageKeys.length === 0) {
|
|
459
|
+
const { imageKeys, mediaKeys: postMediaKeys } = parsePostContent(content);
|
|
460
|
+
if (imageKeys.length === 0 && postMediaKeys.length === 0) {
|
|
359
461
|
return [];
|
|
360
462
|
}
|
|
361
463
|
|
|
362
|
-
|
|
464
|
+
if (imageKeys.length > 0) {
|
|
465
|
+
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
|
|
466
|
+
}
|
|
467
|
+
if (postMediaKeys.length > 0) {
|
|
468
|
+
log?.(`feishu: post message contains ${postMediaKeys.length} embedded media file(s)`);
|
|
469
|
+
}
|
|
363
470
|
|
|
364
471
|
for (const imageKey of imageKeys) {
|
|
365
472
|
try {
|
|
@@ -396,6 +503,40 @@ async function resolveFeishuMediaList(params: {
|
|
|
396
503
|
}
|
|
397
504
|
}
|
|
398
505
|
|
|
506
|
+
for (const media of postMediaKeys) {
|
|
507
|
+
try {
|
|
508
|
+
const result = await downloadMessageResourceFeishu({
|
|
509
|
+
cfg,
|
|
510
|
+
messageId,
|
|
511
|
+
fileKey: media.fileKey,
|
|
512
|
+
type: "file",
|
|
513
|
+
accountId,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
let contentType = result.contentType;
|
|
517
|
+
if (!contentType) {
|
|
518
|
+
contentType = await core.media.detectMime({ buffer: result.buffer });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
522
|
+
result.buffer,
|
|
523
|
+
contentType,
|
|
524
|
+
"inbound",
|
|
525
|
+
maxBytes,
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
out.push({
|
|
529
|
+
path: saved.path,
|
|
530
|
+
contentType: saved.contentType,
|
|
531
|
+
placeholder: "<media:video>",
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
|
|
535
|
+
} catch (err) {
|
|
536
|
+
log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
399
540
|
return out;
|
|
400
541
|
}
|
|
401
542
|
|
|
@@ -417,7 +558,7 @@ async function resolveFeishuMediaList(params: {
|
|
|
417
558
|
return [];
|
|
418
559
|
}
|
|
419
560
|
|
|
420
|
-
const resourceType = messageType
|
|
561
|
+
const resourceType = toMessageResourceType(messageType);
|
|
421
562
|
const result = await downloadMessageResourceFeishu({
|
|
422
563
|
cfg,
|
|
423
564
|
messageId,
|
|
@@ -468,12 +609,17 @@ export function parseFeishuMessageEvent(
|
|
|
468
609
|
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
|
|
469
610
|
const mentionedBot = checkBotMentioned(event, botOpenId);
|
|
470
611
|
const content = stripBotMention(rawContent, event.message.mentions);
|
|
612
|
+
const senderOpenId = event.sender.sender_id.open_id?.trim();
|
|
613
|
+
const senderUserId = event.sender.sender_id.user_id?.trim();
|
|
614
|
+
const senderFallbackId = senderOpenId || senderUserId || "";
|
|
471
615
|
|
|
472
616
|
const ctx: FeishuMessageContext = {
|
|
473
617
|
chatId: event.message.chat_id,
|
|
474
618
|
messageId: event.message.message_id,
|
|
475
|
-
senderId:
|
|
476
|
-
|
|
619
|
+
senderId: senderUserId || senderOpenId || "",
|
|
620
|
+
// Keep the historical field name, but fall back to user_id when open_id is unavailable
|
|
621
|
+
// (common in some mobile app deliveries).
|
|
622
|
+
senderOpenId: senderFallbackId,
|
|
477
623
|
chatType: event.message.chat_type,
|
|
478
624
|
mentionedBot,
|
|
479
625
|
rootId: event.message.root_id || undefined,
|
|
@@ -496,6 +642,40 @@ export function parseFeishuMessageEvent(
|
|
|
496
642
|
return ctx;
|
|
497
643
|
}
|
|
498
644
|
|
|
645
|
+
export function buildFeishuAgentBody(params: {
|
|
646
|
+
ctx: Pick<
|
|
647
|
+
FeishuMessageContext,
|
|
648
|
+
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
|
|
649
|
+
>;
|
|
650
|
+
quotedContent?: string;
|
|
651
|
+
permissionErrorForAgent?: PermissionError;
|
|
652
|
+
}): string {
|
|
653
|
+
const { ctx, quotedContent, permissionErrorForAgent } = params;
|
|
654
|
+
let messageBody = ctx.content;
|
|
655
|
+
if (quotedContent) {
|
|
656
|
+
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// DMs already have per-sender sessions, but this label still improves attribution.
|
|
660
|
+
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
|
661
|
+
messageBody = `${speaker}: ${messageBody}`;
|
|
662
|
+
|
|
663
|
+
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
|
664
|
+
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
|
|
665
|
+
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
|
|
669
|
+
messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
|
|
670
|
+
|
|
671
|
+
if (permissionErrorForAgent) {
|
|
672
|
+
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
|
|
673
|
+
messageBody += `\n\n[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}]`;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
return messageBody;
|
|
677
|
+
}
|
|
678
|
+
|
|
499
679
|
export async function handleFeishuMessage(params: {
|
|
500
680
|
cfg: ClawdbotConfig;
|
|
501
681
|
event: FeishuMessageEvent;
|
|
@@ -513,8 +693,15 @@ export async function handleFeishuMessage(params: {
|
|
|
513
693
|
const log = runtime?.log ?? console.log;
|
|
514
694
|
const error = runtime?.error ?? console.error;
|
|
515
695
|
|
|
516
|
-
// Dedup
|
|
696
|
+
// Dedup: synchronous memory guard prevents concurrent duplicate dispatch
|
|
697
|
+
// before the async persistent check completes.
|
|
517
698
|
const messageId = event.message.message_id;
|
|
699
|
+
const memoryDedupeKey = `${account.accountId}:${messageId}`;
|
|
700
|
+
if (!tryRecordMessage(memoryDedupeKey)) {
|
|
701
|
+
log(`feishu: skipping duplicate message ${messageId} (memory dedup)`);
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
// Persistent dedup survives restarts and reconnects.
|
|
518
705
|
if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
|
|
519
706
|
log(`feishu: skipping duplicate message ${messageId}`);
|
|
520
707
|
return;
|
|
@@ -524,24 +711,59 @@ export async function handleFeishuMessage(params: {
|
|
|
524
711
|
const isGroup = ctx.chatType === "group";
|
|
525
712
|
const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
|
|
526
713
|
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
714
|
+
// Handle merge_forward messages: fetch full message via API then expand sub-messages
|
|
715
|
+
if (event.message.message_type === "merge_forward") {
|
|
716
|
+
log(
|
|
717
|
+
`feishu[${account.accountId}]: processing merge_forward message, fetching full content via API`,
|
|
718
|
+
);
|
|
719
|
+
try {
|
|
720
|
+
// Websocket event doesn't include sub-messages, need to fetch via API
|
|
721
|
+
// The API returns all sub-messages in the items array
|
|
722
|
+
const client = createFeishuClient(account);
|
|
723
|
+
const response = (await client.im.message.get({
|
|
724
|
+
path: { message_id: event.message.message_id },
|
|
725
|
+
})) as { code?: number; data?: { items?: unknown[] } };
|
|
726
|
+
|
|
727
|
+
if (response.code === 0 && response.data?.items && response.data.items.length > 0) {
|
|
728
|
+
log(
|
|
729
|
+
`feishu[${account.accountId}]: merge_forward API returned ${response.data.items.length} items`,
|
|
730
|
+
);
|
|
731
|
+
const expandedContent = parseMergeForwardContent({
|
|
732
|
+
content: JSON.stringify(response.data.items),
|
|
733
|
+
log,
|
|
734
|
+
});
|
|
735
|
+
ctx = { ...ctx, content: expandedContent };
|
|
736
|
+
} else {
|
|
737
|
+
log(`feishu[${account.accountId}]: merge_forward API returned no items`);
|
|
738
|
+
ctx = { ...ctx, content: "[Merged and Forwarded Message - could not fetch]" };
|
|
739
|
+
}
|
|
740
|
+
} catch (err) {
|
|
741
|
+
log(`feishu[${account.accountId}]: merge_forward fetch failed: ${String(err)}`);
|
|
742
|
+
ctx = { ...ctx, content: "[Merged and Forwarded Message - fetch error]" };
|
|
743
|
+
}
|
|
744
|
+
}
|
|
534
745
|
|
|
535
|
-
//
|
|
746
|
+
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
|
|
747
|
+
// Optimization: skip if disabled to save API quota (Feishu free tier limit).
|
|
536
748
|
let permissionErrorForAgent: PermissionError | undefined;
|
|
537
|
-
if (
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
749
|
+
if (feishuCfg?.resolveSenderNames ?? true) {
|
|
750
|
+
const senderResult = await resolveFeishuSenderName({
|
|
751
|
+
account,
|
|
752
|
+
senderId: ctx.senderOpenId,
|
|
753
|
+
log,
|
|
754
|
+
});
|
|
755
|
+
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
|
|
756
|
+
|
|
757
|
+
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
|
758
|
+
if (senderResult.permissionError) {
|
|
759
|
+
const appKey = account.appId ?? "default";
|
|
760
|
+
const now = Date.now();
|
|
761
|
+
const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
|
|
762
|
+
|
|
763
|
+
if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
|
|
764
|
+
permissionErrorNotifiedAt.set(appKey, now);
|
|
765
|
+
permissionErrorForAgent = senderResult.permissionError;
|
|
766
|
+
}
|
|
545
767
|
}
|
|
546
768
|
}
|
|
547
769
|
|
|
@@ -567,6 +789,10 @@ export async function handleFeishuMessage(params: {
|
|
|
567
789
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
568
790
|
|
|
569
791
|
if (isGroup) {
|
|
792
|
+
if (groupConfig?.enabled === false) {
|
|
793
|
+
log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
570
796
|
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
|
571
797
|
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
|
|
572
798
|
providerConfigPresent: cfg.channels?.feishu !== undefined,
|
|
@@ -591,16 +817,21 @@ export async function handleFeishuMessage(params: {
|
|
|
591
817
|
});
|
|
592
818
|
|
|
593
819
|
if (!groupAllowed) {
|
|
594
|
-
log(
|
|
820
|
+
log(
|
|
821
|
+
`feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
|
|
822
|
+
);
|
|
595
823
|
return;
|
|
596
824
|
}
|
|
597
825
|
|
|
598
|
-
//
|
|
599
|
-
const
|
|
600
|
-
|
|
826
|
+
// Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
|
|
827
|
+
const perGroupSenderAllowFrom = groupConfig?.allowFrom ?? [];
|
|
828
|
+
const globalSenderAllowFrom = feishuCfg?.groupSenderAllowFrom ?? [];
|
|
829
|
+
const effectiveSenderAllowFrom =
|
|
830
|
+
perGroupSenderAllowFrom.length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom;
|
|
831
|
+
if (effectiveSenderAllowFrom.length > 0) {
|
|
601
832
|
const senderAllowed = isFeishuGroupAllowed({
|
|
602
833
|
groupPolicy: "allowlist",
|
|
603
|
-
allowFrom:
|
|
834
|
+
allowFrom: effectiveSenderAllowFrom,
|
|
604
835
|
senderId: ctx.senderOpenId,
|
|
605
836
|
senderIds: [senderUserId],
|
|
606
837
|
senderName: ctx.senderName,
|
|
@@ -641,6 +872,11 @@ export async function handleFeishuMessage(params: {
|
|
|
641
872
|
|
|
642
873
|
try {
|
|
643
874
|
const core = getFeishuRuntime();
|
|
875
|
+
const pairing = createScopedPairingAccess({
|
|
876
|
+
core,
|
|
877
|
+
channel: "feishu",
|
|
878
|
+
accountId: account.accountId,
|
|
879
|
+
});
|
|
644
880
|
const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
|
|
645
881
|
ctx.content,
|
|
646
882
|
cfg,
|
|
@@ -649,7 +885,7 @@ export async function handleFeishuMessage(params: {
|
|
|
649
885
|
!isGroup &&
|
|
650
886
|
dmPolicy !== "allowlist" &&
|
|
651
887
|
(dmPolicy !== "open" || shouldComputeCommandAuthorized)
|
|
652
|
-
? await
|
|
888
|
+
? await pairing.readAllowFromStore().catch(() => [])
|
|
653
889
|
: [];
|
|
654
890
|
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
655
891
|
const dmAllowed = resolveFeishuAllowlistMatch({
|
|
@@ -661,8 +897,7 @@ export async function handleFeishuMessage(params: {
|
|
|
661
897
|
|
|
662
898
|
if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
|
|
663
899
|
if (dmPolicy === "pairing") {
|
|
664
|
-
const { code, created } = await
|
|
665
|
-
channel: "feishu",
|
|
900
|
+
const { code, created } = await pairing.upsertPairingRequest({
|
|
666
901
|
id: ctx.senderOpenId,
|
|
667
902
|
meta: { name: ctx.senderName },
|
|
668
903
|
});
|
|
@@ -716,19 +951,49 @@ export async function handleFeishuMessage(params: {
|
|
|
716
951
|
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
|
717
952
|
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
|
718
953
|
|
|
719
|
-
// Resolve peer ID for session routing
|
|
720
|
-
//
|
|
721
|
-
// get a separate session from the main group chat.
|
|
954
|
+
// Resolve peer ID for session routing.
|
|
955
|
+
// Default is one session per group chat; this can be customized with groupSessionScope.
|
|
722
956
|
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
|
|
723
|
-
let
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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);
|
|
731
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}`);
|
|
732
997
|
}
|
|
733
998
|
|
|
734
999
|
let route = core.channel.routing.resolveAgentRoute({
|
|
@@ -739,9 +1004,11 @@ export async function handleFeishuMessage(params: {
|
|
|
739
1004
|
kind: isGroup ? "group" : "direct",
|
|
740
1005
|
id: peerId,
|
|
741
1006
|
},
|
|
742
|
-
// Add parentPeer for binding inheritance in topic
|
|
1007
|
+
// Add parentPeer for binding inheritance in topic-scoped modes.
|
|
743
1008
|
parentPeer:
|
|
744
|
-
isGroup &&
|
|
1009
|
+
isGroup &&
|
|
1010
|
+
topicRootForSession &&
|
|
1011
|
+
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
|
|
745
1012
|
? {
|
|
746
1013
|
kind: "group",
|
|
747
1014
|
id: ctx.chatId,
|
|
@@ -784,10 +1051,10 @@ export async function handleFeishuMessage(params: {
|
|
|
784
1051
|
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
|
785
1052
|
: `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
|
|
786
1053
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
});
|
|
1054
|
+
// Do not enqueue inbound user previews as system events.
|
|
1055
|
+
// System events are prepended to future prompts and can be misread as
|
|
1056
|
+
// authoritative transcript turns.
|
|
1057
|
+
log(`feishu[${account.accountId}]: ${inboundLabel}: ${preview}`);
|
|
791
1058
|
|
|
792
1059
|
// Resolve media from message
|
|
793
1060
|
const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
|
|
@@ -823,85 +1090,15 @@ export async function handleFeishuMessage(params: {
|
|
|
823
1090
|
}
|
|
824
1091
|
|
|
825
1092
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
}
|
|
832
|
-
|
|
833
|
-
// Include a readable speaker label so the model can attribute instructions.
|
|
834
|
-
// (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
|
|
835
|
-
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
|
836
|
-
messageBody = `${speaker}: ${messageBody}`;
|
|
837
|
-
|
|
838
|
-
// If there are mention targets, inform the agent that replies will auto-mention them
|
|
839
|
-
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
|
840
|
-
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
|
|
841
|
-
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
|
|
842
|
-
}
|
|
843
|
-
|
|
1093
|
+
const messageBody = buildFeishuAgentBody({
|
|
1094
|
+
ctx,
|
|
1095
|
+
quotedContent,
|
|
1096
|
+
permissionErrorForAgent,
|
|
1097
|
+
});
|
|
844
1098
|
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
|
|
845
|
-
|
|
846
|
-
// If there's a permission error, dispatch a separate notification first
|
|
847
1099
|
if (permissionErrorForAgent) {
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
const permissionBody = core.channel.reply.formatAgentEnvelope({
|
|
852
|
-
channel: "Feishu",
|
|
853
|
-
from: envelopeFrom,
|
|
854
|
-
timestamp: new Date(),
|
|
855
|
-
envelope: envelopeOptions,
|
|
856
|
-
body: permissionNotifyBody,
|
|
857
|
-
});
|
|
858
|
-
|
|
859
|
-
const permissionCtx = core.channel.reply.finalizeInboundContext({
|
|
860
|
-
Body: permissionBody,
|
|
861
|
-
BodyForAgent: permissionNotifyBody,
|
|
862
|
-
RawBody: permissionNotifyBody,
|
|
863
|
-
CommandBody: permissionNotifyBody,
|
|
864
|
-
From: feishuFrom,
|
|
865
|
-
To: feishuTo,
|
|
866
|
-
SessionKey: route.sessionKey,
|
|
867
|
-
AccountId: route.accountId,
|
|
868
|
-
ChatType: isGroup ? "group" : "direct",
|
|
869
|
-
GroupSubject: isGroup ? ctx.chatId : undefined,
|
|
870
|
-
SenderName: "system",
|
|
871
|
-
SenderId: "system",
|
|
872
|
-
Provider: "feishu" as const,
|
|
873
|
-
Surface: "feishu" as const,
|
|
874
|
-
MessageSid: `${ctx.messageId}:permission-error`,
|
|
875
|
-
Timestamp: Date.now(),
|
|
876
|
-
WasMentioned: false,
|
|
877
|
-
CommandAuthorized: commandAuthorized,
|
|
878
|
-
OriginatingChannel: "feishu" as const,
|
|
879
|
-
OriginatingTo: feishuTo,
|
|
880
|
-
});
|
|
881
|
-
|
|
882
|
-
const {
|
|
883
|
-
dispatcher: permDispatcher,
|
|
884
|
-
replyOptions: permReplyOptions,
|
|
885
|
-
markDispatchIdle: markPermIdle,
|
|
886
|
-
} = createFeishuReplyDispatcher({
|
|
887
|
-
cfg,
|
|
888
|
-
agentId: route.agentId,
|
|
889
|
-
runtime: runtime as RuntimeEnv,
|
|
890
|
-
chatId: ctx.chatId,
|
|
891
|
-
replyToMessageId: ctx.messageId,
|
|
892
|
-
accountId: account.accountId,
|
|
893
|
-
});
|
|
894
|
-
|
|
895
|
-
log(`feishu[${account.accountId}]: dispatching permission error notification to agent`);
|
|
896
|
-
|
|
897
|
-
await core.channel.reply.dispatchReplyFromConfig({
|
|
898
|
-
ctx: permissionCtx,
|
|
899
|
-
cfg,
|
|
900
|
-
dispatcher: permDispatcher,
|
|
901
|
-
replyOptions: permReplyOptions,
|
|
902
|
-
});
|
|
903
|
-
|
|
904
|
-
markPermIdle();
|
|
1100
|
+
// Keep the notice in a single dispatch to avoid duplicate replies (#27372).
|
|
1101
|
+
log(`feishu[${account.accountId}]: appending permission error notice to message body`);
|
|
905
1102
|
}
|
|
906
1103
|
|
|
907
1104
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
@@ -944,8 +1141,12 @@ export async function handleFeishuMessage(params: {
|
|
|
944
1141
|
|
|
945
1142
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
946
1143
|
Body: combinedBody,
|
|
947
|
-
BodyForAgent:
|
|
1144
|
+
BodyForAgent: messageBody,
|
|
948
1145
|
InboundHistory: inboundHistory,
|
|
1146
|
+
// Quote/reply message support: use standard ReplyToId for parent,
|
|
1147
|
+
// and pass root_id for thread reconstruction.
|
|
1148
|
+
ReplyToId: ctx.parentId,
|
|
1149
|
+
RootMessageId: ctx.rootId,
|
|
949
1150
|
RawBody: ctx.content,
|
|
950
1151
|
CommandBody: ctx.content,
|
|
951
1152
|
From: feishuFrom,
|
|
@@ -968,27 +1169,40 @@ export async function handleFeishuMessage(params: {
|
|
|
968
1169
|
...mediaPayload,
|
|
969
1170
|
});
|
|
970
1171
|
|
|
1172
|
+
// Parse message create_time (Feishu uses millisecond epoch string).
|
|
1173
|
+
const messageCreateTimeMs = event.message.create_time
|
|
1174
|
+
? parseInt(event.message.create_time, 10)
|
|
1175
|
+
: undefined;
|
|
1176
|
+
|
|
971
1177
|
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
972
1178
|
cfg,
|
|
973
1179
|
agentId: route.agentId,
|
|
974
1180
|
runtime: runtime as RuntimeEnv,
|
|
975
1181
|
chatId: ctx.chatId,
|
|
976
1182
|
replyToMessageId: ctx.messageId,
|
|
1183
|
+
skipReplyToInMessages: !isGroup,
|
|
1184
|
+
replyInThread,
|
|
1185
|
+
rootId: ctx.rootId,
|
|
977
1186
|
mentionTargets: ctx.mentionTargets,
|
|
978
1187
|
accountId: account.accountId,
|
|
1188
|
+
messageCreateTimeMs,
|
|
979
1189
|
});
|
|
980
1190
|
|
|
981
1191
|
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
|
982
|
-
|
|
983
|
-
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
|
984
|
-
ctx: ctxPayload,
|
|
985
|
-
cfg,
|
|
1192
|
+
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
|
|
986
1193
|
dispatcher,
|
|
987
|
-
|
|
1194
|
+
onSettled: () => {
|
|
1195
|
+
markDispatchIdle();
|
|
1196
|
+
},
|
|
1197
|
+
run: () =>
|
|
1198
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1199
|
+
ctx: ctxPayload,
|
|
1200
|
+
cfg,
|
|
1201
|
+
dispatcher,
|
|
1202
|
+
replyOptions,
|
|
1203
|
+
}),
|
|
988
1204
|
});
|
|
989
1205
|
|
|
990
|
-
markDispatchIdle();
|
|
991
|
-
|
|
992
1206
|
if (isGroup && historyKey && chatHistories) {
|
|
993
1207
|
clearHistoryEntriesIfEnabled({
|
|
994
1208
|
historyMap: chatHistories,
|