@openclaw/feishu 2026.2.25 → 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/index.ts +2 -0
- package/package.json +2 -1
- package/skills/feishu-doc/SKILL.md +109 -3
- package/src/accounts.test.ts +161 -0
- package/src/accounts.ts +76 -8
- 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 +56 -1
- package/src/bot.test.ts +1271 -56
- package/src/bot.ts +499 -215
- package/src/card-action.ts +79 -0
- package/src/channel.ts +26 -4
- 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 +121 -0
- package/src/client.ts +13 -0
- package/src/config-schema.test.ts +101 -1
- package/src/config-schema.ts +66 -11
- package/src/dedup.ts +47 -1
- package/src/doc-schema.ts +135 -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 +70 -0
- package/src/docx.test.ts +331 -9
- package/src/docx.ts +996 -72
- package/src/drive.ts +38 -33
- package/src/media.test.ts +227 -7
- package/src/media.ts +52 -11
- package/src/mention.ts +1 -1
- package/src/monitor.account.ts +534 -0
- package/src/monitor.reaction.test.ts +578 -0
- package/src/monitor.startup.test.ts +203 -0
- package/src/monitor.startup.ts +51 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +152 -0
- package/src/monitor.test-mocks.ts +12 -0
- package/src/monitor.transport.ts +163 -0
- package/src/monitor.ts +44 -346
- package/src/monitor.webhook-security.test.ts +53 -10
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.ts +144 -52
- 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 +271 -0
- package/src/probe.ts +131 -19
- package/src/reply-dispatcher.test.ts +300 -0
- package/src/reply-dispatcher.ts +159 -46
- package/src/secret-input.ts +19 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +6 -2
- 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 +55 -1
- package/src/targets.ts +32 -7
- 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 +10 -1
- 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";
|
|
@@ -42,6 +44,13 @@ type PermissionError = {
|
|
|
42
44
|
grantUrl?: string;
|
|
43
45
|
};
|
|
44
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
|
+
|
|
45
54
|
function extractPermissionError(err: unknown): PermissionError | null {
|
|
46
55
|
if (!err || typeof err !== "object") return null;
|
|
47
56
|
|
|
@@ -72,7 +81,7 @@ function extractPermissionError(err: unknown): PermissionError | null {
|
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
// --- 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.
|
|
84
|
+
// Cache display names by sender id (open_id/user_id) to avoid an API call on every message.
|
|
76
85
|
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
|
|
77
86
|
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
|
|
78
87
|
|
|
@@ -86,26 +95,40 @@ type SenderNameResult = {
|
|
|
86
95
|
permissionError?: PermissionError;
|
|
87
96
|
};
|
|
88
97
|
|
|
98
|
+
function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
|
|
99
|
+
const trimmed = senderId.trim();
|
|
100
|
+
if (trimmed.startsWith("ou_")) {
|
|
101
|
+
return "open_id";
|
|
102
|
+
}
|
|
103
|
+
if (trimmed.startsWith("on_")) {
|
|
104
|
+
return "union_id";
|
|
105
|
+
}
|
|
106
|
+
return "user_id";
|
|
107
|
+
}
|
|
108
|
+
|
|
89
109
|
async function resolveFeishuSenderName(params: {
|
|
90
110
|
account: ResolvedFeishuAccount;
|
|
91
|
-
|
|
111
|
+
senderId: string;
|
|
92
112
|
log: (...args: any[]) => void;
|
|
93
113
|
}): Promise<SenderNameResult> {
|
|
94
|
-
const { account,
|
|
114
|
+
const { account, senderId, log } = params;
|
|
95
115
|
if (!account.configured) return {};
|
|
96
|
-
if (!senderOpenId) return {};
|
|
97
116
|
|
|
98
|
-
const
|
|
117
|
+
const normalizedSenderId = senderId.trim();
|
|
118
|
+
if (!normalizedSenderId) return {};
|
|
119
|
+
|
|
120
|
+
const cached = senderNameCache.get(normalizedSenderId);
|
|
99
121
|
const now = Date.now();
|
|
100
122
|
if (cached && cached.expireAt > now) return { name: cached.name };
|
|
101
123
|
|
|
102
124
|
try {
|
|
103
125
|
const client = createFeishuClient(account);
|
|
126
|
+
const userIdType = resolveSenderLookupIdType(normalizedSenderId);
|
|
104
127
|
|
|
105
|
-
// contact/v3/users/:user_id?user_id_type
|
|
128
|
+
// contact/v3/users/:user_id?user_id_type=<open_id|user_id|union_id>
|
|
106
129
|
const res: any = await client.contact.user.get({
|
|
107
|
-
path: { user_id:
|
|
108
|
-
params: { user_id_type:
|
|
130
|
+
path: { user_id: normalizedSenderId },
|
|
131
|
+
params: { user_id_type: userIdType },
|
|
109
132
|
});
|
|
110
133
|
|
|
111
134
|
const name: string | undefined =
|
|
@@ -115,7 +138,7 @@ async function resolveFeishuSenderName(params: {
|
|
|
115
138
|
res?.data?.user?.en_name;
|
|
116
139
|
|
|
117
140
|
if (name && typeof name === "string") {
|
|
118
|
-
senderNameCache.set(
|
|
141
|
+
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
|
|
119
142
|
return { name };
|
|
120
143
|
}
|
|
121
144
|
|
|
@@ -124,12 +147,16 @@ async function resolveFeishuSenderName(params: {
|
|
|
124
147
|
// Check if this is a permission error
|
|
125
148
|
const permErr = extractPermissionError(err);
|
|
126
149
|
if (permErr) {
|
|
150
|
+
if (shouldSuppressPermissionErrorNotice(permErr)) {
|
|
151
|
+
log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
|
|
152
|
+
return {};
|
|
153
|
+
}
|
|
127
154
|
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
|
|
128
155
|
return { permissionError: permErr };
|
|
129
156
|
}
|
|
130
157
|
|
|
131
158
|
// Best-effort. Don't fail message handling if name lookup fails.
|
|
132
|
-
log(`feishu: failed to resolve sender name for ${
|
|
159
|
+
log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
|
|
133
160
|
return {};
|
|
134
161
|
}
|
|
135
162
|
}
|
|
@@ -148,10 +175,12 @@ export type FeishuMessageEvent = {
|
|
|
148
175
|
message_id: string;
|
|
149
176
|
root_id?: string;
|
|
150
177
|
parent_id?: string;
|
|
178
|
+
thread_id?: string;
|
|
151
179
|
chat_id: string;
|
|
152
|
-
chat_type: "p2p" | "group";
|
|
180
|
+
chat_type: "p2p" | "group" | "private";
|
|
153
181
|
message_type: string;
|
|
154
182
|
content: string;
|
|
183
|
+
create_time?: string;
|
|
155
184
|
mentions?: Array<{
|
|
156
185
|
key: string;
|
|
157
186
|
id: {
|
|
@@ -176,16 +205,129 @@ export type FeishuBotAddedEvent = {
|
|
|
176
205
|
operator_tenant_key?: string;
|
|
177
206
|
};
|
|
178
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
|
+
|
|
179
296
|
function parseMessageContent(content: string, messageType: string): string {
|
|
297
|
+
if (messageType === "post") {
|
|
298
|
+
// Extract text content from rich text post
|
|
299
|
+
const { textContent } = parsePostContent(content);
|
|
300
|
+
return textContent;
|
|
301
|
+
}
|
|
302
|
+
|
|
180
303
|
try {
|
|
181
304
|
const parsed = JSON.parse(content);
|
|
182
305
|
if (messageType === "text") {
|
|
183
306
|
return parsed.text || "";
|
|
184
307
|
}
|
|
185
|
-
if (messageType === "
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
308
|
+
if (messageType === "share_chat") {
|
|
309
|
+
// Preserve available summary text for merged/forwarded chat messages.
|
|
310
|
+
if (parsed && typeof parsed === "object") {
|
|
311
|
+
const share = parsed as {
|
|
312
|
+
body?: unknown;
|
|
313
|
+
summary?: unknown;
|
|
314
|
+
share_chat_id?: unknown;
|
|
315
|
+
};
|
|
316
|
+
if (typeof share.body === "string" && share.body.trim().length > 0) {
|
|
317
|
+
return share.body.trim();
|
|
318
|
+
}
|
|
319
|
+
if (typeof share.summary === "string" && share.summary.trim().length > 0) {
|
|
320
|
+
return share.summary.trim();
|
|
321
|
+
}
|
|
322
|
+
if (typeof share.share_chat_id === "string" && share.share_chat_id.trim().length > 0) {
|
|
323
|
+
return `[Forwarded message: ${share.share_chat_id.trim()}]`;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return "[Forwarded message]";
|
|
327
|
+
}
|
|
328
|
+
if (messageType === "merge_forward") {
|
|
329
|
+
// Return placeholder; actual content fetched asynchronously in handleFeishuMessage
|
|
330
|
+
return "[Merged and Forwarded Message - loading...]";
|
|
189
331
|
}
|
|
190
332
|
return content;
|
|
191
333
|
} catch {
|
|
@@ -193,6 +335,109 @@ function parseMessageContent(content: string, messageType: string): string {
|
|
|
193
335
|
}
|
|
194
336
|
}
|
|
195
337
|
|
|
338
|
+
/**
|
|
339
|
+
* Parse merge_forward message content and fetch sub-messages.
|
|
340
|
+
* Returns formatted text content of all sub-messages.
|
|
341
|
+
*/
|
|
342
|
+
function parseMergeForwardContent(params: {
|
|
343
|
+
content: string;
|
|
344
|
+
log?: (...args: any[]) => void;
|
|
345
|
+
}): string {
|
|
346
|
+
const { content, log } = params;
|
|
347
|
+
const maxMessages = 50;
|
|
348
|
+
|
|
349
|
+
// For merge_forward, the API returns all sub-messages in items array
|
|
350
|
+
// with upper_message_id pointing to the merge_forward message.
|
|
351
|
+
// The 'content' parameter here is actually the full API response items array as JSON.
|
|
352
|
+
log?.(`feishu: parsing merge_forward sub-messages from API response`);
|
|
353
|
+
|
|
354
|
+
let items: Array<{
|
|
355
|
+
message_id?: string;
|
|
356
|
+
msg_type?: string;
|
|
357
|
+
body?: { content?: string };
|
|
358
|
+
sender?: { id?: string };
|
|
359
|
+
upper_message_id?: string;
|
|
360
|
+
create_time?: string;
|
|
361
|
+
}>;
|
|
362
|
+
|
|
363
|
+
try {
|
|
364
|
+
items = JSON.parse(content);
|
|
365
|
+
} catch {
|
|
366
|
+
log?.(`feishu: merge_forward items parse failed`);
|
|
367
|
+
return "[Merged and Forwarded Message - parse error]";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
371
|
+
return "[Merged and Forwarded Message - no sub-messages]";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Filter to only sub-messages (those with upper_message_id, skip the merge_forward container itself)
|
|
375
|
+
const subMessages = items.filter((item) => item.upper_message_id);
|
|
376
|
+
|
|
377
|
+
if (subMessages.length === 0) {
|
|
378
|
+
return "[Merged and Forwarded Message - no sub-messages found]";
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
|
|
382
|
+
|
|
383
|
+
// Sort by create_time
|
|
384
|
+
subMessages.sort((a, b) => {
|
|
385
|
+
const timeA = parseInt(a.create_time || "0", 10);
|
|
386
|
+
const timeB = parseInt(b.create_time || "0", 10);
|
|
387
|
+
return timeA - timeB;
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Format output
|
|
391
|
+
const lines: string[] = ["[Merged and Forwarded Messages]"];
|
|
392
|
+
const limitedMessages = subMessages.slice(0, maxMessages);
|
|
393
|
+
|
|
394
|
+
for (const item of limitedMessages) {
|
|
395
|
+
const msgContent = item.body?.content || "";
|
|
396
|
+
const msgType = item.msg_type || "text";
|
|
397
|
+
const formatted = formatSubMessageContent(msgContent, msgType);
|
|
398
|
+
lines.push(`- ${formatted}`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (subMessages.length > maxMessages) {
|
|
402
|
+
lines.push(`... and ${subMessages.length - maxMessages} more messages`);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return lines.join("\n");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Format sub-message content based on message type.
|
|
410
|
+
*/
|
|
411
|
+
function formatSubMessageContent(content: string, contentType: string): string {
|
|
412
|
+
try {
|
|
413
|
+
const parsed = JSON.parse(content);
|
|
414
|
+
switch (contentType) {
|
|
415
|
+
case "text":
|
|
416
|
+
return parsed.text || content;
|
|
417
|
+
case "post": {
|
|
418
|
+
const { textContent } = parsePostContent(content);
|
|
419
|
+
return textContent;
|
|
420
|
+
}
|
|
421
|
+
case "image":
|
|
422
|
+
return "[Image]";
|
|
423
|
+
case "file":
|
|
424
|
+
return `[File: ${parsed.file_name || "unknown"}]`;
|
|
425
|
+
case "audio":
|
|
426
|
+
return "[Audio]";
|
|
427
|
+
case "video":
|
|
428
|
+
return "[Video]";
|
|
429
|
+
case "sticker":
|
|
430
|
+
return "[Sticker]";
|
|
431
|
+
case "merge_forward":
|
|
432
|
+
return "[Nested Merged Forward]";
|
|
433
|
+
default:
|
|
434
|
+
return `[${contentType}]`;
|
|
435
|
+
}
|
|
436
|
+
} catch {
|
|
437
|
+
return content;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
196
441
|
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
|
197
442
|
if (!botOpenId) return false;
|
|
198
443
|
const mentions = event.message.mentions ?? [];
|
|
@@ -243,7 +488,8 @@ function parseMediaKeys(
|
|
|
243
488
|
case "audio":
|
|
244
489
|
return { fileKey };
|
|
245
490
|
case "video":
|
|
246
|
-
|
|
491
|
+
case "media":
|
|
492
|
+
// Video/media has both file_key (video) and image_key (thumbnail)
|
|
247
493
|
return { fileKey, imageKey };
|
|
248
494
|
case "sticker":
|
|
249
495
|
return { fileKey };
|
|
@@ -256,56 +502,11 @@ function parseMediaKeys(
|
|
|
256
502
|
}
|
|
257
503
|
|
|
258
504
|
/**
|
|
259
|
-
*
|
|
260
|
-
*
|
|
505
|
+
* Map Feishu message type to messageResource.get resource type.
|
|
506
|
+
* Feishu messageResource API supports only: image | file.
|
|
261
507
|
*/
|
|
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
|
-
}
|
|
508
|
+
export function toMessageResourceType(messageType: string): "image" | "file" {
|
|
509
|
+
return messageType === "image" ? "image" : "file";
|
|
309
510
|
}
|
|
310
511
|
|
|
311
512
|
/**
|
|
@@ -320,6 +521,7 @@ function inferPlaceholder(messageType: string): string {
|
|
|
320
521
|
case "audio":
|
|
321
522
|
return "<media:audio>";
|
|
322
523
|
case "video":
|
|
524
|
+
case "media":
|
|
323
525
|
return "<media:video>";
|
|
324
526
|
case "sticker":
|
|
325
527
|
return "<media:sticker>";
|
|
@@ -344,7 +546,7 @@ async function resolveFeishuMediaList(params: {
|
|
|
344
546
|
const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
|
|
345
547
|
|
|
346
548
|
// Only process media message types (including post for embedded images)
|
|
347
|
-
const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
|
|
549
|
+
const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
|
|
348
550
|
if (!mediaTypes.includes(messageType)) {
|
|
349
551
|
return [];
|
|
350
552
|
}
|
|
@@ -352,14 +554,19 @@ async function resolveFeishuMediaList(params: {
|
|
|
352
554
|
const out: FeishuMediaInfo[] = [];
|
|
353
555
|
const core = getFeishuRuntime();
|
|
354
556
|
|
|
355
|
-
// Handle post (rich text) messages with embedded images
|
|
557
|
+
// Handle post (rich text) messages with embedded images/media.
|
|
356
558
|
if (messageType === "post") {
|
|
357
|
-
const { imageKeys } = parsePostContent(content);
|
|
358
|
-
if (imageKeys.length === 0) {
|
|
559
|
+
const { imageKeys, mediaKeys: postMediaKeys } = parsePostContent(content);
|
|
560
|
+
if (imageKeys.length === 0 && postMediaKeys.length === 0) {
|
|
359
561
|
return [];
|
|
360
562
|
}
|
|
361
563
|
|
|
362
|
-
|
|
564
|
+
if (imageKeys.length > 0) {
|
|
565
|
+
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
|
|
566
|
+
}
|
|
567
|
+
if (postMediaKeys.length > 0) {
|
|
568
|
+
log?.(`feishu: post message contains ${postMediaKeys.length} embedded media file(s)`);
|
|
569
|
+
}
|
|
363
570
|
|
|
364
571
|
for (const imageKey of imageKeys) {
|
|
365
572
|
try {
|
|
@@ -396,6 +603,40 @@ async function resolveFeishuMediaList(params: {
|
|
|
396
603
|
}
|
|
397
604
|
}
|
|
398
605
|
|
|
606
|
+
for (const media of postMediaKeys) {
|
|
607
|
+
try {
|
|
608
|
+
const result = await downloadMessageResourceFeishu({
|
|
609
|
+
cfg,
|
|
610
|
+
messageId,
|
|
611
|
+
fileKey: media.fileKey,
|
|
612
|
+
type: "file",
|
|
613
|
+
accountId,
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
let contentType = result.contentType;
|
|
617
|
+
if (!contentType) {
|
|
618
|
+
contentType = await core.media.detectMime({ buffer: result.buffer });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
622
|
+
result.buffer,
|
|
623
|
+
contentType,
|
|
624
|
+
"inbound",
|
|
625
|
+
maxBytes,
|
|
626
|
+
);
|
|
627
|
+
|
|
628
|
+
out.push({
|
|
629
|
+
path: saved.path,
|
|
630
|
+
contentType: saved.contentType,
|
|
631
|
+
placeholder: "<media:video>",
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
|
|
635
|
+
} catch (err) {
|
|
636
|
+
log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
399
640
|
return out;
|
|
400
641
|
}
|
|
401
642
|
|
|
@@ -417,7 +658,7 @@ async function resolveFeishuMediaList(params: {
|
|
|
417
658
|
return [];
|
|
418
659
|
}
|
|
419
660
|
|
|
420
|
-
const resourceType = messageType
|
|
661
|
+
const resourceType = toMessageResourceType(messageType);
|
|
421
662
|
const result = await downloadMessageResourceFeishu({
|
|
422
663
|
cfg,
|
|
423
664
|
messageId,
|
|
@@ -468,16 +709,22 @@ export function parseFeishuMessageEvent(
|
|
|
468
709
|
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
|
|
469
710
|
const mentionedBot = checkBotMentioned(event, botOpenId);
|
|
470
711
|
const content = stripBotMention(rawContent, event.message.mentions);
|
|
712
|
+
const senderOpenId = event.sender.sender_id.open_id?.trim();
|
|
713
|
+
const senderUserId = event.sender.sender_id.user_id?.trim();
|
|
714
|
+
const senderFallbackId = senderOpenId || senderUserId || "";
|
|
471
715
|
|
|
472
716
|
const ctx: FeishuMessageContext = {
|
|
473
717
|
chatId: event.message.chat_id,
|
|
474
718
|
messageId: event.message.message_id,
|
|
475
|
-
senderId:
|
|
476
|
-
|
|
719
|
+
senderId: senderUserId || senderOpenId || "",
|
|
720
|
+
// Keep the historical field name, but fall back to user_id when open_id is unavailable
|
|
721
|
+
// (common in some mobile app deliveries).
|
|
722
|
+
senderOpenId: senderFallbackId,
|
|
477
723
|
chatType: event.message.chat_type,
|
|
478
724
|
mentionedBot,
|
|
479
725
|
rootId: event.message.root_id || undefined,
|
|
480
726
|
parentId: event.message.parent_id || undefined,
|
|
727
|
+
threadId: event.message.thread_id || undefined,
|
|
481
728
|
content,
|
|
482
729
|
contentType: event.message.message_type,
|
|
483
730
|
};
|
|
@@ -496,6 +743,40 @@ export function parseFeishuMessageEvent(
|
|
|
496
743
|
return ctx;
|
|
497
744
|
}
|
|
498
745
|
|
|
746
|
+
export function buildFeishuAgentBody(params: {
|
|
747
|
+
ctx: Pick<
|
|
748
|
+
FeishuMessageContext,
|
|
749
|
+
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId"
|
|
750
|
+
>;
|
|
751
|
+
quotedContent?: string;
|
|
752
|
+
permissionErrorForAgent?: PermissionError;
|
|
753
|
+
}): string {
|
|
754
|
+
const { ctx, quotedContent, permissionErrorForAgent } = params;
|
|
755
|
+
let messageBody = ctx.content;
|
|
756
|
+
if (quotedContent) {
|
|
757
|
+
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// DMs already have per-sender sessions, but this label still improves attribution.
|
|
761
|
+
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
|
762
|
+
messageBody = `${speaker}: ${messageBody}`;
|
|
763
|
+
|
|
764
|
+
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
|
765
|
+
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
|
|
766
|
+
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
|
|
770
|
+
messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
|
|
771
|
+
|
|
772
|
+
if (permissionErrorForAgent) {
|
|
773
|
+
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
|
|
774
|
+
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}]`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
return messageBody;
|
|
778
|
+
}
|
|
779
|
+
|
|
499
780
|
export async function handleFeishuMessage(params: {
|
|
500
781
|
cfg: ClawdbotConfig;
|
|
501
782
|
event: FeishuMessageEvent;
|
|
@@ -513,8 +794,15 @@ export async function handleFeishuMessage(params: {
|
|
|
513
794
|
const log = runtime?.log ?? console.log;
|
|
514
795
|
const error = runtime?.error ?? console.error;
|
|
515
796
|
|
|
516
|
-
// Dedup
|
|
797
|
+
// Dedup: synchronous memory guard prevents concurrent duplicate dispatch
|
|
798
|
+
// before the async persistent check completes.
|
|
517
799
|
const messageId = event.message.message_id;
|
|
800
|
+
const memoryDedupeKey = `${account.accountId}:${messageId}`;
|
|
801
|
+
if (!tryRecordMessage(memoryDedupeKey)) {
|
|
802
|
+
log(`feishu: skipping duplicate message ${messageId} (memory dedup)`);
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
// Persistent dedup survives restarts and reconnects.
|
|
518
806
|
if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
|
|
519
807
|
log(`feishu: skipping duplicate message ${messageId}`);
|
|
520
808
|
return;
|
|
@@ -522,26 +810,62 @@ export async function handleFeishuMessage(params: {
|
|
|
522
810
|
|
|
523
811
|
let ctx = parseFeishuMessageEvent(event, botOpenId);
|
|
524
812
|
const isGroup = ctx.chatType === "group";
|
|
813
|
+
const isDirect = !isGroup;
|
|
525
814
|
const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
|
|
526
815
|
|
|
527
|
-
//
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
816
|
+
// Handle merge_forward messages: fetch full message via API then expand sub-messages
|
|
817
|
+
if (event.message.message_type === "merge_forward") {
|
|
818
|
+
log(
|
|
819
|
+
`feishu[${account.accountId}]: processing merge_forward message, fetching full content via API`,
|
|
820
|
+
);
|
|
821
|
+
try {
|
|
822
|
+
// Websocket event doesn't include sub-messages, need to fetch via API
|
|
823
|
+
// The API returns all sub-messages in the items array
|
|
824
|
+
const client = createFeishuClient(account);
|
|
825
|
+
const response = (await client.im.message.get({
|
|
826
|
+
path: { message_id: event.message.message_id },
|
|
827
|
+
})) as { code?: number; data?: { items?: unknown[] } };
|
|
828
|
+
|
|
829
|
+
if (response.code === 0 && response.data?.items && response.data.items.length > 0) {
|
|
830
|
+
log(
|
|
831
|
+
`feishu[${account.accountId}]: merge_forward API returned ${response.data.items.length} items`,
|
|
832
|
+
);
|
|
833
|
+
const expandedContent = parseMergeForwardContent({
|
|
834
|
+
content: JSON.stringify(response.data.items),
|
|
835
|
+
log,
|
|
836
|
+
});
|
|
837
|
+
ctx = { ...ctx, content: expandedContent };
|
|
838
|
+
} else {
|
|
839
|
+
log(`feishu[${account.accountId}]: merge_forward API returned no items`);
|
|
840
|
+
ctx = { ...ctx, content: "[Merged and Forwarded Message - could not fetch]" };
|
|
841
|
+
}
|
|
842
|
+
} catch (err) {
|
|
843
|
+
log(`feishu[${account.accountId}]: merge_forward fetch failed: ${String(err)}`);
|
|
844
|
+
ctx = { ...ctx, content: "[Merged and Forwarded Message - fetch error]" };
|
|
845
|
+
}
|
|
846
|
+
}
|
|
534
847
|
|
|
535
|
-
//
|
|
848
|
+
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
|
|
849
|
+
// Optimization: skip if disabled to save API quota (Feishu free tier limit).
|
|
536
850
|
let permissionErrorForAgent: PermissionError | undefined;
|
|
537
|
-
if (
|
|
538
|
-
const
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
851
|
+
if (feishuCfg?.resolveSenderNames ?? true) {
|
|
852
|
+
const senderResult = await resolveFeishuSenderName({
|
|
853
|
+
account,
|
|
854
|
+
senderId: ctx.senderOpenId,
|
|
855
|
+
log,
|
|
856
|
+
});
|
|
857
|
+
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
|
|
858
|
+
|
|
859
|
+
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
|
860
|
+
if (senderResult.permissionError) {
|
|
861
|
+
const appKey = account.appId ?? "default";
|
|
862
|
+
const now = Date.now();
|
|
863
|
+
const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
|
|
864
|
+
|
|
865
|
+
if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
|
|
866
|
+
permissionErrorNotifiedAt.set(appKey, now);
|
|
867
|
+
permissionErrorForAgent = senderResult.permissionError;
|
|
868
|
+
}
|
|
545
869
|
}
|
|
546
870
|
}
|
|
547
871
|
|
|
@@ -562,11 +886,27 @@ export async function handleFeishuMessage(params: {
|
|
|
562
886
|
const groupConfig = isGroup
|
|
563
887
|
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
|
|
564
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;
|
|
565
901
|
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
|
566
902
|
const configAllowFrom = feishuCfg?.allowFrom ?? [];
|
|
567
903
|
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
568
904
|
|
|
569
905
|
if (isGroup) {
|
|
906
|
+
if (groupConfig?.enabled === false) {
|
|
907
|
+
log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
570
910
|
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
|
571
911
|
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
|
|
572
912
|
providerConfigPresent: cfg.channels?.feishu !== undefined,
|
|
@@ -591,16 +931,21 @@ export async function handleFeishuMessage(params: {
|
|
|
591
931
|
});
|
|
592
932
|
|
|
593
933
|
if (!groupAllowed) {
|
|
594
|
-
log(
|
|
934
|
+
log(
|
|
935
|
+
`feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
|
|
936
|
+
);
|
|
595
937
|
return;
|
|
596
938
|
}
|
|
597
939
|
|
|
598
|
-
//
|
|
599
|
-
const
|
|
600
|
-
|
|
940
|
+
// Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
|
|
941
|
+
const perGroupSenderAllowFrom = groupConfig?.allowFrom ?? [];
|
|
942
|
+
const globalSenderAllowFrom = feishuCfg?.groupSenderAllowFrom ?? [];
|
|
943
|
+
const effectiveSenderAllowFrom =
|
|
944
|
+
perGroupSenderAllowFrom.length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom;
|
|
945
|
+
if (effectiveSenderAllowFrom.length > 0) {
|
|
601
946
|
const senderAllowed = isFeishuGroupAllowed({
|
|
602
947
|
groupPolicy: "allowlist",
|
|
603
|
-
allowFrom:
|
|
948
|
+
allowFrom: effectiveSenderAllowFrom,
|
|
604
949
|
senderId: ctx.senderOpenId,
|
|
605
950
|
senderIds: [senderUserId],
|
|
606
951
|
senderName: ctx.senderName,
|
|
@@ -621,10 +966,10 @@ export async function handleFeishuMessage(params: {
|
|
|
621
966
|
log(
|
|
622
967
|
`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot, recording to history`,
|
|
623
968
|
);
|
|
624
|
-
if (chatHistories) {
|
|
969
|
+
if (chatHistories && groupHistoryKey) {
|
|
625
970
|
recordPendingHistoryEntryIfEnabled({
|
|
626
971
|
historyMap: chatHistories,
|
|
627
|
-
historyKey:
|
|
972
|
+
historyKey: groupHistoryKey,
|
|
628
973
|
limit: historyLimit,
|
|
629
974
|
entry: {
|
|
630
975
|
sender: ctx.senderOpenId,
|
|
@@ -641,6 +986,11 @@ export async function handleFeishuMessage(params: {
|
|
|
641
986
|
|
|
642
987
|
try {
|
|
643
988
|
const core = getFeishuRuntime();
|
|
989
|
+
const pairing = createScopedPairingAccess({
|
|
990
|
+
core,
|
|
991
|
+
channel: "feishu",
|
|
992
|
+
accountId: account.accountId,
|
|
993
|
+
});
|
|
644
994
|
const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
|
|
645
995
|
ctx.content,
|
|
646
996
|
cfg,
|
|
@@ -649,7 +999,7 @@ export async function handleFeishuMessage(params: {
|
|
|
649
999
|
!isGroup &&
|
|
650
1000
|
dmPolicy !== "allowlist" &&
|
|
651
1001
|
(dmPolicy !== "open" || shouldComputeCommandAuthorized)
|
|
652
|
-
? await
|
|
1002
|
+
? await pairing.readAllowFromStore().catch(() => [])
|
|
653
1003
|
: [];
|
|
654
1004
|
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
655
1005
|
const dmAllowed = resolveFeishuAllowlistMatch({
|
|
@@ -659,10 +1009,9 @@ export async function handleFeishuMessage(params: {
|
|
|
659
1009
|
senderName: ctx.senderName,
|
|
660
1010
|
}).allowed;
|
|
661
1011
|
|
|
662
|
-
if (
|
|
1012
|
+
if (isDirect && dmPolicy !== "open" && !dmAllowed) {
|
|
663
1013
|
if (dmPolicy === "pairing") {
|
|
664
|
-
const { code, created } = await
|
|
665
|
-
channel: "feishu",
|
|
1014
|
+
const { code, created } = await pairing.upsertPairingRequest({
|
|
666
1015
|
id: ctx.senderOpenId,
|
|
667
1016
|
meta: { name: ctx.senderName },
|
|
668
1017
|
});
|
|
@@ -671,7 +1020,7 @@ export async function handleFeishuMessage(params: {
|
|
|
671
1020
|
try {
|
|
672
1021
|
await sendMessageFeishu({
|
|
673
1022
|
cfg,
|
|
674
|
-
to: `
|
|
1023
|
+
to: `chat:${ctx.chatId}`,
|
|
675
1024
|
text: core.channel.pairing.buildPairingReply({
|
|
676
1025
|
channel: "feishu",
|
|
677
1026
|
idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
|
|
@@ -715,20 +1064,14 @@ export async function handleFeishuMessage(params: {
|
|
|
715
1064
|
// Using a group-scoped From causes the agent to treat different users as the same person.
|
|
716
1065
|
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
|
717
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;
|
|
718
1070
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
let topicSessionMode: "enabled" | "disabled" = "disabled";
|
|
724
|
-
if (isGroup && ctx.rootId) {
|
|
725
|
-
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
|
726
|
-
topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
|
727
|
-
if (topicSessionMode === "enabled") {
|
|
728
|
-
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
|
|
729
|
-
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
|
|
730
|
-
log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
|
|
731
|
-
}
|
|
1071
|
+
if (isGroup && groupSession) {
|
|
1072
|
+
log(
|
|
1073
|
+
`feishu[${account.accountId}]: group session scope=${groupSession.groupSessionScope}, peer=${peerId}`,
|
|
1074
|
+
);
|
|
732
1075
|
}
|
|
733
1076
|
|
|
734
1077
|
let route = core.channel.routing.resolveAgentRoute({
|
|
@@ -739,14 +1082,7 @@ export async function handleFeishuMessage(params: {
|
|
|
739
1082
|
kind: isGroup ? "group" : "direct",
|
|
740
1083
|
id: peerId,
|
|
741
1084
|
},
|
|
742
|
-
|
|
743
|
-
parentPeer:
|
|
744
|
-
isGroup && ctx.rootId && topicSessionMode === "enabled"
|
|
745
|
-
? {
|
|
746
|
-
kind: "group",
|
|
747
|
-
id: ctx.chatId,
|
|
748
|
-
}
|
|
749
|
-
: null,
|
|
1085
|
+
parentPeer,
|
|
750
1086
|
});
|
|
751
1087
|
|
|
752
1088
|
// Dynamic agent creation for DM users
|
|
@@ -784,10 +1120,10 @@ export async function handleFeishuMessage(params: {
|
|
|
784
1120
|
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
|
785
1121
|
: `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
|
|
786
1122
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
});
|
|
1123
|
+
// Do not enqueue inbound user previews as system events.
|
|
1124
|
+
// System events are prepended to future prompts and can be misread as
|
|
1125
|
+
// authoritative transcript turns.
|
|
1126
|
+
log(`feishu[${account.accountId}]: ${inboundLabel}: ${preview}`);
|
|
791
1127
|
|
|
792
1128
|
// Resolve media from message
|
|
793
1129
|
const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
|
|
@@ -823,85 +1159,15 @@ export async function handleFeishuMessage(params: {
|
|
|
823
1159
|
}
|
|
824
1160
|
|
|
825
1161
|
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
|
-
|
|
1162
|
+
const messageBody = buildFeishuAgentBody({
|
|
1163
|
+
ctx,
|
|
1164
|
+
quotedContent,
|
|
1165
|
+
permissionErrorForAgent,
|
|
1166
|
+
});
|
|
844
1167
|
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
|
|
845
|
-
|
|
846
|
-
// If there's a permission error, dispatch a separate notification first
|
|
847
1168
|
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();
|
|
1169
|
+
// Keep the notice in a single dispatch to avoid duplicate replies (#27372).
|
|
1170
|
+
log(`feishu[${account.accountId}]: appending permission error notice to message body`);
|
|
905
1171
|
}
|
|
906
1172
|
|
|
907
1173
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
@@ -913,7 +1179,7 @@ export async function handleFeishuMessage(params: {
|
|
|
913
1179
|
});
|
|
914
1180
|
|
|
915
1181
|
let combinedBody = body;
|
|
916
|
-
const historyKey =
|
|
1182
|
+
const historyKey = groupHistoryKey;
|
|
917
1183
|
|
|
918
1184
|
if (isGroup && historyKey && chatHistories) {
|
|
919
1185
|
combinedBody = buildPendingHistoryContextFromMap({
|
|
@@ -944,8 +1210,12 @@ export async function handleFeishuMessage(params: {
|
|
|
944
1210
|
|
|
945
1211
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
946
1212
|
Body: combinedBody,
|
|
947
|
-
BodyForAgent:
|
|
1213
|
+
BodyForAgent: messageBody,
|
|
948
1214
|
InboundHistory: inboundHistory,
|
|
1215
|
+
// Quote/reply message support: use standard ReplyToId for parent,
|
|
1216
|
+
// and pass root_id for thread reconstruction.
|
|
1217
|
+
ReplyToId: ctx.parentId,
|
|
1218
|
+
RootMessageId: ctx.rootId,
|
|
949
1219
|
RawBody: ctx.content,
|
|
950
1220
|
CommandBody: ctx.content,
|
|
951
1221
|
From: feishuFrom,
|
|
@@ -968,27 +1238,41 @@ export async function handleFeishuMessage(params: {
|
|
|
968
1238
|
...mediaPayload,
|
|
969
1239
|
});
|
|
970
1240
|
|
|
1241
|
+
// Parse message create_time (Feishu uses millisecond epoch string).
|
|
1242
|
+
const messageCreateTimeMs = event.message.create_time
|
|
1243
|
+
? parseInt(event.message.create_time, 10)
|
|
1244
|
+
: undefined;
|
|
1245
|
+
const replyTargetMessageId = ctx.rootId ?? ctx.messageId;
|
|
971
1246
|
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
972
1247
|
cfg,
|
|
973
1248
|
agentId: route.agentId,
|
|
974
1249
|
runtime: runtime as RuntimeEnv,
|
|
975
1250
|
chatId: ctx.chatId,
|
|
976
|
-
replyToMessageId:
|
|
1251
|
+
replyToMessageId: replyTargetMessageId,
|
|
1252
|
+
skipReplyToInMessages: !isGroup,
|
|
1253
|
+
replyInThread,
|
|
1254
|
+
rootId: ctx.rootId,
|
|
1255
|
+
threadReply: isGroup ? (groupSession?.threadReply ?? false) : false,
|
|
977
1256
|
mentionTargets: ctx.mentionTargets,
|
|
978
1257
|
accountId: account.accountId,
|
|
1258
|
+
messageCreateTimeMs,
|
|
979
1259
|
});
|
|
980
1260
|
|
|
981
1261
|
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,
|
|
1262
|
+
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
|
|
986
1263
|
dispatcher,
|
|
987
|
-
|
|
1264
|
+
onSettled: () => {
|
|
1265
|
+
markDispatchIdle();
|
|
1266
|
+
},
|
|
1267
|
+
run: () =>
|
|
1268
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1269
|
+
ctx: ctxPayload,
|
|
1270
|
+
cfg,
|
|
1271
|
+
dispatcher,
|
|
1272
|
+
replyOptions,
|
|
1273
|
+
}),
|
|
988
1274
|
});
|
|
989
1275
|
|
|
990
|
-
markDispatchIdle();
|
|
991
|
-
|
|
992
1276
|
if (isGroup && historyKey && chatHistories) {
|
|
993
1277
|
clearHistoryEntriesIfEnabled({
|
|
994
1278
|
historyMap: chatHistories,
|