@nextclaw/channel-plugin-feishu 0.2.12 → 0.2.14
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/README.md +3 -1
- package/index.ts +65 -0
- package/openclaw.plugin.json +3 -7
- package/package.json +33 -9
- package/skills/feishu-doc/SKILL.md +211 -0
- package/skills/feishu-doc/references/block-types.md +103 -0
- package/skills/feishu-drive/SKILL.md +97 -0
- package/skills/feishu-perm/SKILL.md +119 -0
- package/skills/feishu-wiki/SKILL.md +111 -0
- package/src/accounts.test.ts +371 -0
- package/src/accounts.ts +244 -0
- package/src/async.ts +62 -0
- package/src/bitable.ts +725 -0
- package/src/bot.card-action.test.ts +63 -0
- package/src/bot.checkBotMentioned.test.ts +193 -0
- package/src/bot.stripBotMention.test.ts +134 -0
- package/src/bot.test.ts +2107 -0
- package/src/bot.ts +1556 -0
- package/src/card-action.ts +79 -0
- package/src/channel.test.ts +48 -0
- package/src/channel.ts +369 -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 +324 -0
- package/src/client.ts +196 -0
- package/src/config-schema.test.ts +247 -0
- package/src/config-schema.ts +306 -0
- package/src/dedup.ts +203 -0
- package/src/directory.test.ts +40 -0
- package/src/directory.ts +156 -0
- package/src/doc-schema.ts +182 -0
- package/src/docx-batch-insert.test.ts +90 -0
- package/src/docx-batch-insert.ts +187 -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 +445 -0
- package/src/docx.ts +1460 -0
- package/src/drive-schema.ts +46 -0
- package/src/drive.ts +228 -0
- package/src/dynamic-agent.ts +131 -0
- package/src/external-keys.test.ts +20 -0
- package/src/external-keys.ts +19 -0
- package/src/feishu-command-handler.ts +59 -0
- package/src/media.test.ts +523 -0
- package/src/media.ts +484 -0
- package/src/mention.ts +133 -0
- package/src/monitor.account.ts +562 -0
- package/src/monitor.reaction.test.ts +653 -0
- package/src/monitor.startup.test.ts +190 -0
- package/src/monitor.startup.ts +64 -0
- package/src/monitor.state.defaults.test.ts +46 -0
- package/src/monitor.state.ts +155 -0
- package/src/monitor.test-mocks.ts +45 -0
- package/src/monitor.transport.ts +264 -0
- package/src/monitor.ts +95 -0
- package/src/monitor.webhook-e2e.test.ts +214 -0
- package/src/monitor.webhook-security.test.ts +142 -0
- package/src/monitor.webhook.test-helpers.ts +98 -0
- package/src/onboarding.status.test.ts +25 -0
- package/src/onboarding.test.ts +143 -0
- package/src/onboarding.ts +489 -0
- package/src/outbound.test.ts +356 -0
- package/src/outbound.ts +176 -0
- package/src/perm-schema.ts +52 -0
- package/src/perm.ts +176 -0
- package/src/policy.test.ts +154 -0
- package/src/policy.ts +123 -0
- package/src/post.test.ts +105 -0
- package/src/post.ts +274 -0
- package/src/probe.test.ts +270 -0
- package/src/probe.ts +156 -0
- package/src/reactions.ts +153 -0
- package/src/reply-dispatcher.test.ts +513 -0
- package/src/reply-dispatcher.ts +397 -0
- package/src/runtime.ts +6 -0
- package/src/secret-input.ts +13 -0
- package/src/send-message.ts +71 -0
- package/src/send-result.ts +29 -0
- package/src/send-target.test.ts +74 -0
- package/src/send-target.ts +29 -0
- package/src/send.reply-fallback.test.ts +189 -0
- package/src/send.test.ts +168 -0
- package/src/send.ts +481 -0
- package/src/streaming-card.test.ts +54 -0
- package/src/streaming-card.ts +374 -0
- package/src/targets.test.ts +70 -0
- package/src/targets.ts +107 -0
- 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/tool-result.test.ts +32 -0
- package/src/tool-result.ts +14 -0
- package/src/tools-config.test.ts +21 -0
- package/src/tools-config.ts +22 -0
- package/src/types.ts +103 -0
- package/src/typing.test.ts +144 -0
- package/src/typing.ts +210 -0
- package/src/wiki-schema.ts +55 -0
- package/src/wiki.ts +233 -0
- package/index.js +0 -27
package/src/bot.ts
ADDED
|
@@ -0,0 +1,1556 @@
|
|
|
1
|
+
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
|
|
2
|
+
import {
|
|
3
|
+
buildAgentMediaPayload,
|
|
4
|
+
buildPendingHistoryContextFromMap,
|
|
5
|
+
clearHistoryEntriesIfEnabled,
|
|
6
|
+
createScopedPairingAccess,
|
|
7
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
8
|
+
type HistoryEntry,
|
|
9
|
+
issuePairingChallenge,
|
|
10
|
+
normalizeAgentId,
|
|
11
|
+
recordPendingHistoryEntryIfEnabled,
|
|
12
|
+
resolveOpenProviderRuntimeGroupPolicy,
|
|
13
|
+
resolveDefaultGroupPolicy,
|
|
14
|
+
warnMissingProviderGroupPolicyFallbackOnce,
|
|
15
|
+
} from "openclaw/plugin-sdk/feishu";
|
|
16
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
17
|
+
import { createFeishuClient } from "./client.js";
|
|
18
|
+
import { finalizeFeishuMessageProcessing, tryRecordMessagePersistent } from "./dedup.js";
|
|
19
|
+
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
|
20
|
+
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
21
|
+
import { downloadMessageResourceFeishu } from "./media.js";
|
|
22
|
+
import { extractMentionTargets, isMentionForwardRequest } from "./mention.js";
|
|
23
|
+
import {
|
|
24
|
+
resolveFeishuGroupConfig,
|
|
25
|
+
resolveFeishuReplyPolicy,
|
|
26
|
+
resolveFeishuAllowlistMatch,
|
|
27
|
+
isFeishuGroupAllowed,
|
|
28
|
+
} from "./policy.js";
|
|
29
|
+
import { parsePostContent } from "./post.js";
|
|
30
|
+
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|
31
|
+
import { getFeishuRuntime } from "./runtime.js";
|
|
32
|
+
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
|
|
33
|
+
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
|
34
|
+
import type { DynamicAgentCreationConfig } from "./types.js";
|
|
35
|
+
|
|
36
|
+
// --- Permission error extraction ---
|
|
37
|
+
// Extract permission grant URL from Feishu API error response.
|
|
38
|
+
type PermissionError = {
|
|
39
|
+
code: number;
|
|
40
|
+
message: string;
|
|
41
|
+
grantUrl?: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const IGNORED_PERMISSION_SCOPE_TOKENS = ["contact:contact.base:readonly"];
|
|
45
|
+
|
|
46
|
+
// Feishu API sometimes returns incorrect scope names in permission error
|
|
47
|
+
// responses (e.g. "contact:contact.base:readonly" instead of the valid
|
|
48
|
+
// "contact:user.base:readonly"). This map corrects known mismatches.
|
|
49
|
+
const FEISHU_SCOPE_CORRECTIONS: Record<string, string> = {
|
|
50
|
+
"contact:contact.base:readonly": "contact:user.base:readonly",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function correctFeishuScopeInUrl(url: string): string {
|
|
54
|
+
let corrected = url;
|
|
55
|
+
for (const [wrong, right] of Object.entries(FEISHU_SCOPE_CORRECTIONS)) {
|
|
56
|
+
corrected = corrected.replaceAll(encodeURIComponent(wrong), encodeURIComponent(right));
|
|
57
|
+
corrected = corrected.replaceAll(wrong, right);
|
|
58
|
+
}
|
|
59
|
+
return corrected;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function shouldSuppressPermissionErrorNotice(permissionError: PermissionError): boolean {
|
|
63
|
+
const message = permissionError.message.toLowerCase();
|
|
64
|
+
return IGNORED_PERMISSION_SCOPE_TOKENS.some((token) => message.includes(token));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function extractPermissionError(err: unknown): PermissionError | null {
|
|
68
|
+
if (!err || typeof err !== "object") return null;
|
|
69
|
+
|
|
70
|
+
// Axios error structure: err.response.data contains the Feishu error
|
|
71
|
+
const axiosErr = err as { response?: { data?: unknown } };
|
|
72
|
+
const data = axiosErr.response?.data;
|
|
73
|
+
if (!data || typeof data !== "object") return null;
|
|
74
|
+
|
|
75
|
+
const feishuErr = data as {
|
|
76
|
+
code?: number;
|
|
77
|
+
msg?: string;
|
|
78
|
+
error?: { permission_violations?: Array<{ uri?: string }> };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// Feishu permission error code: 99991672
|
|
82
|
+
if (feishuErr.code !== 99991672) return null;
|
|
83
|
+
|
|
84
|
+
// Extract the grant URL from the error message (contains the direct link)
|
|
85
|
+
const msg = feishuErr.msg ?? "";
|
|
86
|
+
const urlMatch = msg.match(/https:\/\/[^\s,]+\/app\/[^\s,]+/);
|
|
87
|
+
const grantUrl = urlMatch?.[0] ? correctFeishuScopeInUrl(urlMatch[0]) : undefined;
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
code: feishuErr.code,
|
|
91
|
+
message: msg,
|
|
92
|
+
grantUrl,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
|
|
97
|
+
// Cache display names by sender id (open_id/user_id) to avoid an API call on every message.
|
|
98
|
+
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
|
|
99
|
+
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
|
|
100
|
+
|
|
101
|
+
// Cache permission errors to avoid spamming the user with repeated notifications.
|
|
102
|
+
// Key: appId or "default", Value: timestamp of last notification
|
|
103
|
+
const permissionErrorNotifiedAt = new Map<string, number>();
|
|
104
|
+
const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
|
105
|
+
|
|
106
|
+
type SenderNameResult = {
|
|
107
|
+
name?: string;
|
|
108
|
+
permissionError?: PermissionError;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function resolveSenderLookupIdType(senderId: string): "open_id" | "user_id" | "union_id" {
|
|
112
|
+
const trimmed = senderId.trim();
|
|
113
|
+
if (trimmed.startsWith("ou_")) {
|
|
114
|
+
return "open_id";
|
|
115
|
+
}
|
|
116
|
+
if (trimmed.startsWith("on_")) {
|
|
117
|
+
return "union_id";
|
|
118
|
+
}
|
|
119
|
+
return "user_id";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function resolveFeishuSenderName(params: {
|
|
123
|
+
account: ResolvedFeishuAccount;
|
|
124
|
+
senderId: string;
|
|
125
|
+
log: (...args: any[]) => void;
|
|
126
|
+
}): Promise<SenderNameResult> {
|
|
127
|
+
const { account, senderId, log } = params;
|
|
128
|
+
if (!account.configured) return {};
|
|
129
|
+
|
|
130
|
+
const normalizedSenderId = senderId.trim();
|
|
131
|
+
if (!normalizedSenderId) return {};
|
|
132
|
+
|
|
133
|
+
const cached = senderNameCache.get(normalizedSenderId);
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
if (cached && cached.expireAt > now) return { name: cached.name };
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
const client = createFeishuClient(account);
|
|
139
|
+
const userIdType = resolveSenderLookupIdType(normalizedSenderId);
|
|
140
|
+
|
|
141
|
+
// contact/v3/users/:user_id?user_id_type=<open_id|user_id|union_id>
|
|
142
|
+
const res: any = await client.contact.user.get({
|
|
143
|
+
path: { user_id: normalizedSenderId },
|
|
144
|
+
params: { user_id_type: userIdType },
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const name: string | undefined =
|
|
148
|
+
res?.data?.user?.name ||
|
|
149
|
+
res?.data?.user?.display_name ||
|
|
150
|
+
res?.data?.user?.nickname ||
|
|
151
|
+
res?.data?.user?.en_name;
|
|
152
|
+
|
|
153
|
+
if (name && typeof name === "string") {
|
|
154
|
+
senderNameCache.set(normalizedSenderId, { name, expireAt: now + SENDER_NAME_TTL_MS });
|
|
155
|
+
return { name };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {};
|
|
159
|
+
} catch (err) {
|
|
160
|
+
// Check if this is a permission error
|
|
161
|
+
const permErr = extractPermissionError(err);
|
|
162
|
+
if (permErr) {
|
|
163
|
+
if (shouldSuppressPermissionErrorNotice(permErr)) {
|
|
164
|
+
log(`feishu: ignoring stale permission scope error: ${permErr.message}`);
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
167
|
+
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
|
|
168
|
+
return { permissionError: permErr };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Best-effort. Don't fail message handling if name lookup fails.
|
|
172
|
+
log(`feishu: failed to resolve sender name for ${normalizedSenderId}: ${String(err)}`);
|
|
173
|
+
return {};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export type FeishuMessageEvent = {
|
|
178
|
+
sender: {
|
|
179
|
+
sender_id: {
|
|
180
|
+
open_id?: string;
|
|
181
|
+
user_id?: string;
|
|
182
|
+
union_id?: string;
|
|
183
|
+
};
|
|
184
|
+
sender_type?: string;
|
|
185
|
+
tenant_key?: string;
|
|
186
|
+
};
|
|
187
|
+
message: {
|
|
188
|
+
message_id: string;
|
|
189
|
+
root_id?: string;
|
|
190
|
+
parent_id?: string;
|
|
191
|
+
thread_id?: string;
|
|
192
|
+
chat_id: string;
|
|
193
|
+
chat_type: "p2p" | "group" | "private";
|
|
194
|
+
message_type: string;
|
|
195
|
+
content: string;
|
|
196
|
+
create_time?: string;
|
|
197
|
+
mentions?: Array<{
|
|
198
|
+
key: string;
|
|
199
|
+
id: {
|
|
200
|
+
open_id?: string;
|
|
201
|
+
user_id?: string;
|
|
202
|
+
union_id?: string;
|
|
203
|
+
};
|
|
204
|
+
name: string;
|
|
205
|
+
tenant_key?: string;
|
|
206
|
+
}>;
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export type FeishuBotAddedEvent = {
|
|
211
|
+
chat_id: string;
|
|
212
|
+
operator_id: {
|
|
213
|
+
open_id?: string;
|
|
214
|
+
user_id?: string;
|
|
215
|
+
union_id?: string;
|
|
216
|
+
};
|
|
217
|
+
external: boolean;
|
|
218
|
+
operator_tenant_key?: string;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
|
|
222
|
+
|
|
223
|
+
type ResolvedFeishuGroupSession = {
|
|
224
|
+
peerId: string;
|
|
225
|
+
parentPeer: { kind: "group"; id: string } | null;
|
|
226
|
+
groupSessionScope: GroupSessionScope;
|
|
227
|
+
replyInThread: boolean;
|
|
228
|
+
threadReply: boolean;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
function resolveFeishuGroupSession(params: {
|
|
232
|
+
chatId: string;
|
|
233
|
+
senderOpenId: string;
|
|
234
|
+
messageId: string;
|
|
235
|
+
rootId?: string;
|
|
236
|
+
threadId?: string;
|
|
237
|
+
groupConfig?: {
|
|
238
|
+
groupSessionScope?: GroupSessionScope;
|
|
239
|
+
topicSessionMode?: "enabled" | "disabled";
|
|
240
|
+
replyInThread?: "enabled" | "disabled";
|
|
241
|
+
};
|
|
242
|
+
feishuCfg?: {
|
|
243
|
+
groupSessionScope?: GroupSessionScope;
|
|
244
|
+
topicSessionMode?: "enabled" | "disabled";
|
|
245
|
+
replyInThread?: "enabled" | "disabled";
|
|
246
|
+
};
|
|
247
|
+
}): ResolvedFeishuGroupSession {
|
|
248
|
+
const { chatId, senderOpenId, messageId, rootId, threadId, groupConfig, feishuCfg } = params;
|
|
249
|
+
|
|
250
|
+
const normalizedThreadId = threadId?.trim();
|
|
251
|
+
const normalizedRootId = rootId?.trim();
|
|
252
|
+
const threadReply = Boolean(normalizedThreadId || normalizedRootId);
|
|
253
|
+
const replyInThread =
|
|
254
|
+
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled" ||
|
|
255
|
+
threadReply;
|
|
256
|
+
|
|
257
|
+
const legacyTopicSessionMode =
|
|
258
|
+
groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
|
259
|
+
const groupSessionScope: GroupSessionScope =
|
|
260
|
+
groupConfig?.groupSessionScope ??
|
|
261
|
+
feishuCfg?.groupSessionScope ??
|
|
262
|
+
(legacyTopicSessionMode === "enabled" ? "group_topic" : "group");
|
|
263
|
+
|
|
264
|
+
// Keep topic session keys stable across the "first turn creates thread" flow:
|
|
265
|
+
// first turn may only have message_id, while the next turn carries root_id/thread_id.
|
|
266
|
+
// Prefer root_id first so both turns stay on the same peer key.
|
|
267
|
+
const topicScope =
|
|
268
|
+
groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender"
|
|
269
|
+
? (normalizedRootId ?? normalizedThreadId ?? (replyInThread ? messageId : null))
|
|
270
|
+
: null;
|
|
271
|
+
|
|
272
|
+
let peerId = chatId;
|
|
273
|
+
switch (groupSessionScope) {
|
|
274
|
+
case "group_sender":
|
|
275
|
+
peerId = `${chatId}:sender:${senderOpenId}`;
|
|
276
|
+
break;
|
|
277
|
+
case "group_topic":
|
|
278
|
+
peerId = topicScope ? `${chatId}:topic:${topicScope}` : chatId;
|
|
279
|
+
break;
|
|
280
|
+
case "group_topic_sender":
|
|
281
|
+
peerId = topicScope
|
|
282
|
+
? `${chatId}:topic:${topicScope}:sender:${senderOpenId}`
|
|
283
|
+
: `${chatId}:sender:${senderOpenId}`;
|
|
284
|
+
break;
|
|
285
|
+
case "group":
|
|
286
|
+
default:
|
|
287
|
+
peerId = chatId;
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const parentPeer =
|
|
292
|
+
topicScope &&
|
|
293
|
+
(groupSessionScope === "group_topic" || groupSessionScope === "group_topic_sender")
|
|
294
|
+
? {
|
|
295
|
+
kind: "group" as const,
|
|
296
|
+
id: chatId,
|
|
297
|
+
}
|
|
298
|
+
: null;
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
peerId,
|
|
302
|
+
parentPeer,
|
|
303
|
+
groupSessionScope,
|
|
304
|
+
replyInThread,
|
|
305
|
+
threadReply,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function parseMessageContent(content: string, messageType: string): string {
|
|
310
|
+
if (messageType === "post") {
|
|
311
|
+
// Extract text content from rich text post
|
|
312
|
+
const { textContent } = parsePostContent(content);
|
|
313
|
+
return textContent;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const parsed = JSON.parse(content);
|
|
318
|
+
if (messageType === "text") {
|
|
319
|
+
return parsed.text || "";
|
|
320
|
+
}
|
|
321
|
+
if (messageType === "share_chat") {
|
|
322
|
+
// Preserve available summary text for merged/forwarded chat messages.
|
|
323
|
+
if (parsed && typeof parsed === "object") {
|
|
324
|
+
const share = parsed as {
|
|
325
|
+
body?: unknown;
|
|
326
|
+
summary?: unknown;
|
|
327
|
+
share_chat_id?: unknown;
|
|
328
|
+
};
|
|
329
|
+
if (typeof share.body === "string" && share.body.trim().length > 0) {
|
|
330
|
+
return share.body.trim();
|
|
331
|
+
}
|
|
332
|
+
if (typeof share.summary === "string" && share.summary.trim().length > 0) {
|
|
333
|
+
return share.summary.trim();
|
|
334
|
+
}
|
|
335
|
+
if (typeof share.share_chat_id === "string" && share.share_chat_id.trim().length > 0) {
|
|
336
|
+
return `[Forwarded message: ${share.share_chat_id.trim()}]`;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return "[Forwarded message]";
|
|
340
|
+
}
|
|
341
|
+
if (messageType === "merge_forward") {
|
|
342
|
+
// Return placeholder; actual content fetched asynchronously in handleFeishuMessage
|
|
343
|
+
return "[Merged and Forwarded Message - loading...]";
|
|
344
|
+
}
|
|
345
|
+
return content;
|
|
346
|
+
} catch {
|
|
347
|
+
return content;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Parse merge_forward message content and fetch sub-messages.
|
|
353
|
+
* Returns formatted text content of all sub-messages.
|
|
354
|
+
*/
|
|
355
|
+
function parseMergeForwardContent(params: {
|
|
356
|
+
content: string;
|
|
357
|
+
log?: (...args: any[]) => void;
|
|
358
|
+
}): string {
|
|
359
|
+
const { content, log } = params;
|
|
360
|
+
const maxMessages = 50;
|
|
361
|
+
|
|
362
|
+
// For merge_forward, the API returns all sub-messages in items array
|
|
363
|
+
// with upper_message_id pointing to the merge_forward message.
|
|
364
|
+
// The 'content' parameter here is actually the full API response items array as JSON.
|
|
365
|
+
log?.(`feishu: parsing merge_forward sub-messages from API response`);
|
|
366
|
+
|
|
367
|
+
let items: Array<{
|
|
368
|
+
message_id?: string;
|
|
369
|
+
msg_type?: string;
|
|
370
|
+
body?: { content?: string };
|
|
371
|
+
sender?: { id?: string };
|
|
372
|
+
upper_message_id?: string;
|
|
373
|
+
create_time?: string;
|
|
374
|
+
}>;
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
items = JSON.parse(content);
|
|
378
|
+
} catch {
|
|
379
|
+
log?.(`feishu: merge_forward items parse failed`);
|
|
380
|
+
return "[Merged and Forwarded Message - parse error]";
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
384
|
+
return "[Merged and Forwarded Message - no sub-messages]";
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Filter to only sub-messages (those with upper_message_id, skip the merge_forward container itself)
|
|
388
|
+
const subMessages = items.filter((item) => item.upper_message_id);
|
|
389
|
+
|
|
390
|
+
if (subMessages.length === 0) {
|
|
391
|
+
return "[Merged and Forwarded Message - no sub-messages found]";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
log?.(`feishu: merge_forward contains ${subMessages.length} sub-messages`);
|
|
395
|
+
|
|
396
|
+
// Sort by create_time
|
|
397
|
+
subMessages.sort((a, b) => {
|
|
398
|
+
const timeA = parseInt(a.create_time || "0", 10);
|
|
399
|
+
const timeB = parseInt(b.create_time || "0", 10);
|
|
400
|
+
return timeA - timeB;
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Format output
|
|
404
|
+
const lines: string[] = ["[Merged and Forwarded Messages]"];
|
|
405
|
+
const limitedMessages = subMessages.slice(0, maxMessages);
|
|
406
|
+
|
|
407
|
+
for (const item of limitedMessages) {
|
|
408
|
+
const msgContent = item.body?.content || "";
|
|
409
|
+
const msgType = item.msg_type || "text";
|
|
410
|
+
const formatted = formatSubMessageContent(msgContent, msgType);
|
|
411
|
+
lines.push(`- ${formatted}`);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (subMessages.length > maxMessages) {
|
|
415
|
+
lines.push(`... and ${subMessages.length - maxMessages} more messages`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return lines.join("\n");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Format sub-message content based on message type.
|
|
423
|
+
*/
|
|
424
|
+
function formatSubMessageContent(content: string, contentType: string): string {
|
|
425
|
+
try {
|
|
426
|
+
const parsed = JSON.parse(content);
|
|
427
|
+
switch (contentType) {
|
|
428
|
+
case "text":
|
|
429
|
+
return parsed.text || content;
|
|
430
|
+
case "post": {
|
|
431
|
+
const { textContent } = parsePostContent(content);
|
|
432
|
+
return textContent;
|
|
433
|
+
}
|
|
434
|
+
case "image":
|
|
435
|
+
return "[Image]";
|
|
436
|
+
case "file":
|
|
437
|
+
return `[File: ${parsed.file_name || "unknown"}]`;
|
|
438
|
+
case "audio":
|
|
439
|
+
return "[Audio]";
|
|
440
|
+
case "video":
|
|
441
|
+
return "[Video]";
|
|
442
|
+
case "sticker":
|
|
443
|
+
return "[Sticker]";
|
|
444
|
+
case "merge_forward":
|
|
445
|
+
return "[Nested Merged Forward]";
|
|
446
|
+
default:
|
|
447
|
+
return `[${contentType}]`;
|
|
448
|
+
}
|
|
449
|
+
} catch {
|
|
450
|
+
return content;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
|
455
|
+
if (!botOpenId) return false;
|
|
456
|
+
// Check for @all (@_all in Feishu) — treat as mentioning every bot
|
|
457
|
+
const rawContent = event.message.content ?? "";
|
|
458
|
+
if (rawContent.includes("@_all")) return true;
|
|
459
|
+
const mentions = event.message.mentions ?? [];
|
|
460
|
+
if (mentions.length > 0) {
|
|
461
|
+
// Rely on Feishu mention IDs; display names can vary by alias/context.
|
|
462
|
+
return mentions.some((m) => m.id.open_id === botOpenId);
|
|
463
|
+
}
|
|
464
|
+
// Post (rich text) messages may have empty message.mentions when they contain docs/paste
|
|
465
|
+
if (event.message.message_type === "post") {
|
|
466
|
+
const { mentionedOpenIds } = parsePostContent(event.message.content);
|
|
467
|
+
return mentionedOpenIds.some((id) => id === botOpenId);
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function normalizeMentions(
|
|
473
|
+
text: string,
|
|
474
|
+
mentions?: FeishuMessageEvent["message"]["mentions"],
|
|
475
|
+
botStripId?: string,
|
|
476
|
+
): string {
|
|
477
|
+
if (!mentions || mentions.length === 0) return text;
|
|
478
|
+
|
|
479
|
+
const escaped = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
480
|
+
const escapeName = (value: string) => value.replace(/</g, "<").replace(/>/g, ">");
|
|
481
|
+
let result = text;
|
|
482
|
+
|
|
483
|
+
for (const mention of mentions) {
|
|
484
|
+
const mentionId = mention.id.open_id;
|
|
485
|
+
const replacement =
|
|
486
|
+
botStripId && mentionId === botStripId
|
|
487
|
+
? ""
|
|
488
|
+
: mentionId
|
|
489
|
+
? `<at user_id="${mentionId}">${escapeName(mention.name)}</at>`
|
|
490
|
+
: `@${mention.name}`;
|
|
491
|
+
|
|
492
|
+
result = result.replace(new RegExp(escaped(mention.key), "g"), () => replacement).trim();
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return result;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function normalizeFeishuCommandProbeBody(text: string): string {
|
|
499
|
+
if (!text) {
|
|
500
|
+
return "";
|
|
501
|
+
}
|
|
502
|
+
return text
|
|
503
|
+
.replace(/<at\b[^>]*>[^<]*<\/at>/giu, " ")
|
|
504
|
+
.replace(/(^|\s)@[^/\s]+(?=\s|$|\/)/gu, "$1")
|
|
505
|
+
.replace(/\s+/g, " ")
|
|
506
|
+
.trim();
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Parse media keys from message content based on message type.
|
|
511
|
+
*/
|
|
512
|
+
function parseMediaKeys(
|
|
513
|
+
content: string,
|
|
514
|
+
messageType: string,
|
|
515
|
+
): {
|
|
516
|
+
imageKey?: string;
|
|
517
|
+
fileKey?: string;
|
|
518
|
+
fileName?: string;
|
|
519
|
+
} {
|
|
520
|
+
try {
|
|
521
|
+
const parsed = JSON.parse(content);
|
|
522
|
+
const imageKey = normalizeFeishuExternalKey(parsed.image_key);
|
|
523
|
+
const fileKey = normalizeFeishuExternalKey(parsed.file_key);
|
|
524
|
+
switch (messageType) {
|
|
525
|
+
case "image":
|
|
526
|
+
return { imageKey };
|
|
527
|
+
case "file":
|
|
528
|
+
return { fileKey, fileName: parsed.file_name };
|
|
529
|
+
case "audio":
|
|
530
|
+
return { fileKey };
|
|
531
|
+
case "video":
|
|
532
|
+
case "media":
|
|
533
|
+
// Video/media has both file_key (video) and image_key (thumbnail)
|
|
534
|
+
return { fileKey, imageKey };
|
|
535
|
+
case "sticker":
|
|
536
|
+
return { fileKey };
|
|
537
|
+
default:
|
|
538
|
+
return {};
|
|
539
|
+
}
|
|
540
|
+
} catch {
|
|
541
|
+
return {};
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Map Feishu message type to messageResource.get resource type.
|
|
547
|
+
* Feishu messageResource API supports only: image | file.
|
|
548
|
+
*/
|
|
549
|
+
export function toMessageResourceType(messageType: string): "image" | "file" {
|
|
550
|
+
return messageType === "image" ? "image" : "file";
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Infer placeholder text based on message type.
|
|
555
|
+
*/
|
|
556
|
+
function inferPlaceholder(messageType: string): string {
|
|
557
|
+
switch (messageType) {
|
|
558
|
+
case "image":
|
|
559
|
+
return "<media:image>";
|
|
560
|
+
case "file":
|
|
561
|
+
return "<media:document>";
|
|
562
|
+
case "audio":
|
|
563
|
+
return "<media:audio>";
|
|
564
|
+
case "video":
|
|
565
|
+
case "media":
|
|
566
|
+
return "<media:video>";
|
|
567
|
+
case "sticker":
|
|
568
|
+
return "<media:sticker>";
|
|
569
|
+
default:
|
|
570
|
+
return "<media:document>";
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Resolve media from a Feishu message, downloading and saving to disk.
|
|
576
|
+
* Similar to Discord's resolveMediaList().
|
|
577
|
+
*/
|
|
578
|
+
async function resolveFeishuMediaList(params: {
|
|
579
|
+
cfg: ClawdbotConfig;
|
|
580
|
+
messageId: string;
|
|
581
|
+
messageType: string;
|
|
582
|
+
content: string;
|
|
583
|
+
maxBytes: number;
|
|
584
|
+
log?: (msg: string) => void;
|
|
585
|
+
accountId?: string;
|
|
586
|
+
}): Promise<FeishuMediaInfo[]> {
|
|
587
|
+
const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
|
|
588
|
+
|
|
589
|
+
// Only process media message types (including post for embedded images)
|
|
590
|
+
const mediaTypes = ["image", "file", "audio", "video", "media", "sticker", "post"];
|
|
591
|
+
if (!mediaTypes.includes(messageType)) {
|
|
592
|
+
return [];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const out: FeishuMediaInfo[] = [];
|
|
596
|
+
const core = getFeishuRuntime();
|
|
597
|
+
|
|
598
|
+
// Handle post (rich text) messages with embedded images/media.
|
|
599
|
+
if (messageType === "post") {
|
|
600
|
+
const { imageKeys, mediaKeys: postMediaKeys } = parsePostContent(content);
|
|
601
|
+
if (imageKeys.length === 0 && postMediaKeys.length === 0) {
|
|
602
|
+
return [];
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (imageKeys.length > 0) {
|
|
606
|
+
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
|
|
607
|
+
}
|
|
608
|
+
if (postMediaKeys.length > 0) {
|
|
609
|
+
log?.(`feishu: post message contains ${postMediaKeys.length} embedded media file(s)`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
for (const imageKey of imageKeys) {
|
|
613
|
+
try {
|
|
614
|
+
// Embedded images in post use messageResource API with image_key as file_key
|
|
615
|
+
const result = await downloadMessageResourceFeishu({
|
|
616
|
+
cfg,
|
|
617
|
+
messageId,
|
|
618
|
+
fileKey: imageKey,
|
|
619
|
+
type: "image",
|
|
620
|
+
accountId,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
let contentType = result.contentType;
|
|
624
|
+
if (!contentType) {
|
|
625
|
+
contentType = await core.media.detectMime({ buffer: result.buffer });
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
629
|
+
result.buffer,
|
|
630
|
+
contentType,
|
|
631
|
+
"inbound",
|
|
632
|
+
maxBytes,
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
out.push({
|
|
636
|
+
path: saved.path,
|
|
637
|
+
contentType: saved.contentType,
|
|
638
|
+
placeholder: "<media:image>",
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
|
|
642
|
+
} catch (err) {
|
|
643
|
+
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
for (const media of postMediaKeys) {
|
|
648
|
+
try {
|
|
649
|
+
const result = await downloadMessageResourceFeishu({
|
|
650
|
+
cfg,
|
|
651
|
+
messageId,
|
|
652
|
+
fileKey: media.fileKey,
|
|
653
|
+
type: "file",
|
|
654
|
+
accountId,
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
let contentType = result.contentType;
|
|
658
|
+
if (!contentType) {
|
|
659
|
+
contentType = await core.media.detectMime({ buffer: result.buffer });
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
663
|
+
result.buffer,
|
|
664
|
+
contentType,
|
|
665
|
+
"inbound",
|
|
666
|
+
maxBytes,
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
out.push({
|
|
670
|
+
path: saved.path,
|
|
671
|
+
contentType: saved.contentType,
|
|
672
|
+
placeholder: "<media:video>",
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
log?.(`feishu: downloaded embedded media ${media.fileKey}, saved to ${saved.path}`);
|
|
676
|
+
} catch (err) {
|
|
677
|
+
log?.(`feishu: failed to download embedded media ${media.fileKey}: ${String(err)}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
return out;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Handle other media types
|
|
685
|
+
const mediaKeys = parseMediaKeys(content, messageType);
|
|
686
|
+
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
|
|
687
|
+
return [];
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
try {
|
|
691
|
+
let buffer: Buffer;
|
|
692
|
+
let contentType: string | undefined;
|
|
693
|
+
let fileName: string | undefined;
|
|
694
|
+
|
|
695
|
+
// For message media, always use messageResource API
|
|
696
|
+
// The image.get API is only for images uploaded via im/v1/images, not for message attachments
|
|
697
|
+
const fileKey = mediaKeys.fileKey || mediaKeys.imageKey;
|
|
698
|
+
if (!fileKey) {
|
|
699
|
+
return [];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const resourceType = toMessageResourceType(messageType);
|
|
703
|
+
const result = await downloadMessageResourceFeishu({
|
|
704
|
+
cfg,
|
|
705
|
+
messageId,
|
|
706
|
+
fileKey,
|
|
707
|
+
type: resourceType,
|
|
708
|
+
accountId,
|
|
709
|
+
});
|
|
710
|
+
buffer = result.buffer;
|
|
711
|
+
contentType = result.contentType;
|
|
712
|
+
fileName = result.fileName || mediaKeys.fileName;
|
|
713
|
+
|
|
714
|
+
// Detect mime type if not provided
|
|
715
|
+
if (!contentType) {
|
|
716
|
+
contentType = await core.media.detectMime({ buffer });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// Save to disk using core's saveMediaBuffer
|
|
720
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
721
|
+
buffer,
|
|
722
|
+
contentType,
|
|
723
|
+
"inbound",
|
|
724
|
+
maxBytes,
|
|
725
|
+
fileName,
|
|
726
|
+
);
|
|
727
|
+
|
|
728
|
+
out.push({
|
|
729
|
+
path: saved.path,
|
|
730
|
+
contentType: saved.contentType,
|
|
731
|
+
placeholder: inferPlaceholder(messageType),
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
|
|
735
|
+
} catch (err) {
|
|
736
|
+
log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return out;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// --- Broadcast support ---
|
|
743
|
+
// Resolve broadcast agent list for a given peer (group) ID.
|
|
744
|
+
// Returns null if no broadcast config exists or the peer is not in the broadcast list.
|
|
745
|
+
export function resolveBroadcastAgents(cfg: ClawdbotConfig, peerId: string): string[] | null {
|
|
746
|
+
const broadcast = (cfg as Record<string, unknown>).broadcast;
|
|
747
|
+
if (!broadcast || typeof broadcast !== "object") return null;
|
|
748
|
+
const agents = (broadcast as Record<string, unknown>)[peerId];
|
|
749
|
+
if (!Array.isArray(agents) || agents.length === 0) return null;
|
|
750
|
+
return agents as string[];
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Build a session key for a broadcast target agent by replacing the agent ID prefix.
|
|
754
|
+
// Session keys follow the format: agent:<agentId>:<channel>:<peerKind>:<peerId>
|
|
755
|
+
export function buildBroadcastSessionKey(
|
|
756
|
+
baseSessionKey: string,
|
|
757
|
+
originalAgentId: string,
|
|
758
|
+
targetAgentId: string,
|
|
759
|
+
): string {
|
|
760
|
+
const prefix = `agent:${originalAgentId}:`;
|
|
761
|
+
if (baseSessionKey.startsWith(prefix)) {
|
|
762
|
+
return `agent:${targetAgentId}:${baseSessionKey.slice(prefix.length)}`;
|
|
763
|
+
}
|
|
764
|
+
return baseSessionKey;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Build media payload for inbound context.
|
|
769
|
+
* Similar to Discord's buildDiscordMediaPayload().
|
|
770
|
+
*/
|
|
771
|
+
export function parseFeishuMessageEvent(
|
|
772
|
+
event: FeishuMessageEvent,
|
|
773
|
+
botOpenId?: string,
|
|
774
|
+
_botName?: string,
|
|
775
|
+
): FeishuMessageContext {
|
|
776
|
+
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
|
|
777
|
+
const mentionedBot = checkBotMentioned(event, botOpenId);
|
|
778
|
+
const hasAnyMention = (event.message.mentions?.length ?? 0) > 0;
|
|
779
|
+
// Strip the bot's own mention so slash commands like @Bot /help retain
|
|
780
|
+
// the leading /. This applies in both p2p *and* group contexts — the
|
|
781
|
+
// mentionedBot flag already captures whether the bot was addressed, so
|
|
782
|
+
// keeping the mention tag in content only breaks command detection (#35994).
|
|
783
|
+
// Non-bot mentions (e.g. mention-forward targets) are still normalized to <at> tags.
|
|
784
|
+
const content = normalizeMentions(rawContent, event.message.mentions, botOpenId);
|
|
785
|
+
const senderOpenId = event.sender.sender_id.open_id?.trim();
|
|
786
|
+
const senderUserId = event.sender.sender_id.user_id?.trim();
|
|
787
|
+
const senderFallbackId = senderOpenId || senderUserId || "";
|
|
788
|
+
|
|
789
|
+
const ctx: FeishuMessageContext = {
|
|
790
|
+
chatId: event.message.chat_id,
|
|
791
|
+
messageId: event.message.message_id,
|
|
792
|
+
senderId: senderUserId || senderOpenId || "",
|
|
793
|
+
// Keep the historical field name, but fall back to user_id when open_id is unavailable
|
|
794
|
+
// (common in some mobile app deliveries).
|
|
795
|
+
senderOpenId: senderFallbackId,
|
|
796
|
+
chatType: event.message.chat_type,
|
|
797
|
+
mentionedBot,
|
|
798
|
+
hasAnyMention,
|
|
799
|
+
rootId: event.message.root_id || undefined,
|
|
800
|
+
parentId: event.message.parent_id || undefined,
|
|
801
|
+
threadId: event.message.thread_id || undefined,
|
|
802
|
+
content,
|
|
803
|
+
contentType: event.message.message_type,
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
// Detect mention forward request: message mentions bot + at least one other user
|
|
807
|
+
if (isMentionForwardRequest(event, botOpenId)) {
|
|
808
|
+
const mentionTargets = extractMentionTargets(event, botOpenId);
|
|
809
|
+
if (mentionTargets.length > 0) {
|
|
810
|
+
ctx.mentionTargets = mentionTargets;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
return ctx;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export function buildFeishuAgentBody(params: {
|
|
818
|
+
ctx: Pick<
|
|
819
|
+
FeishuMessageContext,
|
|
820
|
+
"content" | "senderName" | "senderOpenId" | "mentionTargets" | "messageId" | "hasAnyMention"
|
|
821
|
+
>;
|
|
822
|
+
quotedContent?: string;
|
|
823
|
+
permissionErrorForAgent?: PermissionError;
|
|
824
|
+
botOpenId?: string;
|
|
825
|
+
}): string {
|
|
826
|
+
const { ctx, quotedContent, permissionErrorForAgent, botOpenId } = params;
|
|
827
|
+
let messageBody = ctx.content;
|
|
828
|
+
if (quotedContent) {
|
|
829
|
+
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// DMs already have per-sender sessions, but this label still improves attribution.
|
|
833
|
+
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
|
834
|
+
messageBody = `${speaker}: ${messageBody}`;
|
|
835
|
+
|
|
836
|
+
if (ctx.hasAnyMention) {
|
|
837
|
+
const botIdHint = botOpenId?.trim();
|
|
838
|
+
messageBody +=
|
|
839
|
+
`\n\n[System: The content may include mention tags in the form <at user_id="...">name</at>. ` +
|
|
840
|
+
`Treat these as real mentions of Feishu entities (users or bots).]`;
|
|
841
|
+
if (botIdHint) {
|
|
842
|
+
messageBody += `\n[System: If user_id is "${botIdHint}", that mention refers to you.]`;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
|
847
|
+
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
|
|
848
|
+
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Keep message_id on its own line so shared message-id hint stripping can parse it reliably.
|
|
852
|
+
messageBody = `[message_id: ${ctx.messageId}]\n${messageBody}`;
|
|
853
|
+
|
|
854
|
+
if (permissionErrorForAgent) {
|
|
855
|
+
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
|
|
856
|
+
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}]`;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
return messageBody;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export async function handleFeishuMessage(params: {
|
|
863
|
+
cfg: ClawdbotConfig;
|
|
864
|
+
event: FeishuMessageEvent;
|
|
865
|
+
botOpenId?: string;
|
|
866
|
+
botName?: string;
|
|
867
|
+
runtime?: RuntimeEnv;
|
|
868
|
+
chatHistories?: Map<string, HistoryEntry[]>;
|
|
869
|
+
accountId?: string;
|
|
870
|
+
processingClaimHeld?: boolean;
|
|
871
|
+
}): Promise<void> {
|
|
872
|
+
const {
|
|
873
|
+
cfg,
|
|
874
|
+
event,
|
|
875
|
+
botOpenId,
|
|
876
|
+
botName,
|
|
877
|
+
runtime,
|
|
878
|
+
chatHistories,
|
|
879
|
+
accountId,
|
|
880
|
+
processingClaimHeld = false,
|
|
881
|
+
} = params;
|
|
882
|
+
|
|
883
|
+
// Resolve account with merged config
|
|
884
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
885
|
+
const feishuCfg = account.config;
|
|
886
|
+
|
|
887
|
+
const log = runtime?.log ?? console.log;
|
|
888
|
+
const error = runtime?.error ?? console.error;
|
|
889
|
+
|
|
890
|
+
const messageId = event.message.message_id;
|
|
891
|
+
if (
|
|
892
|
+
!(await finalizeFeishuMessageProcessing({
|
|
893
|
+
messageId,
|
|
894
|
+
namespace: account.accountId,
|
|
895
|
+
log,
|
|
896
|
+
claimHeld: processingClaimHeld,
|
|
897
|
+
}))
|
|
898
|
+
) {
|
|
899
|
+
log(`feishu: skipping duplicate message ${messageId}`);
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
let ctx = parseFeishuMessageEvent(event, botOpenId, botName);
|
|
904
|
+
const isGroup = ctx.chatType === "group";
|
|
905
|
+
const isDirect = !isGroup;
|
|
906
|
+
const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
|
|
907
|
+
|
|
908
|
+
// Handle merge_forward messages: fetch full message via API then expand sub-messages
|
|
909
|
+
if (event.message.message_type === "merge_forward") {
|
|
910
|
+
log(
|
|
911
|
+
`feishu[${account.accountId}]: processing merge_forward message, fetching full content via API`,
|
|
912
|
+
);
|
|
913
|
+
try {
|
|
914
|
+
// Websocket event doesn't include sub-messages, need to fetch via API
|
|
915
|
+
// The API returns all sub-messages in the items array
|
|
916
|
+
const client = createFeishuClient(account);
|
|
917
|
+
const response = (await client.im.message.get({
|
|
918
|
+
path: { message_id: event.message.message_id },
|
|
919
|
+
})) as { code?: number; data?: { items?: unknown[] } };
|
|
920
|
+
|
|
921
|
+
if (response.code === 0 && response.data?.items && response.data.items.length > 0) {
|
|
922
|
+
log(
|
|
923
|
+
`feishu[${account.accountId}]: merge_forward API returned ${response.data.items.length} items`,
|
|
924
|
+
);
|
|
925
|
+
const expandedContent = parseMergeForwardContent({
|
|
926
|
+
content: JSON.stringify(response.data.items),
|
|
927
|
+
log,
|
|
928
|
+
});
|
|
929
|
+
ctx = { ...ctx, content: expandedContent };
|
|
930
|
+
} else {
|
|
931
|
+
log(`feishu[${account.accountId}]: merge_forward API returned no items`);
|
|
932
|
+
ctx = { ...ctx, content: "[Merged and Forwarded Message - could not fetch]" };
|
|
933
|
+
}
|
|
934
|
+
} catch (err) {
|
|
935
|
+
log(`feishu[${account.accountId}]: merge_forward fetch failed: ${String(err)}`);
|
|
936
|
+
ctx = { ...ctx, content: "[Merged and Forwarded Message - fetch error]" };
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
|
|
941
|
+
// Optimization: skip if disabled to save API quota (Feishu free tier limit).
|
|
942
|
+
let permissionErrorForAgent: PermissionError | undefined;
|
|
943
|
+
if (feishuCfg?.resolveSenderNames ?? true) {
|
|
944
|
+
const senderResult = await resolveFeishuSenderName({
|
|
945
|
+
account,
|
|
946
|
+
senderId: ctx.senderOpenId,
|
|
947
|
+
log,
|
|
948
|
+
});
|
|
949
|
+
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
|
|
950
|
+
|
|
951
|
+
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
|
952
|
+
if (senderResult.permissionError) {
|
|
953
|
+
const appKey = account.appId ?? "default";
|
|
954
|
+
const now = Date.now();
|
|
955
|
+
const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
|
|
956
|
+
|
|
957
|
+
if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
|
|
958
|
+
permissionErrorNotifiedAt.set(appKey, now);
|
|
959
|
+
permissionErrorForAgent = senderResult.permissionError;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
log(
|
|
965
|
+
`feishu[${account.accountId}]: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`,
|
|
966
|
+
);
|
|
967
|
+
|
|
968
|
+
// Log mention targets if detected
|
|
969
|
+
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
|
970
|
+
const names = ctx.mentionTargets.map((t) => t.name).join(", ");
|
|
971
|
+
log(`feishu[${account.accountId}]: detected @ forward request, targets: [${names}]`);
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
const historyLimit = Math.max(
|
|
975
|
+
0,
|
|
976
|
+
feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
|
977
|
+
);
|
|
978
|
+
const groupConfig = isGroup
|
|
979
|
+
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
|
|
980
|
+
: undefined;
|
|
981
|
+
const groupSession = isGroup
|
|
982
|
+
? resolveFeishuGroupSession({
|
|
983
|
+
chatId: ctx.chatId,
|
|
984
|
+
senderOpenId: ctx.senderOpenId,
|
|
985
|
+
messageId: ctx.messageId,
|
|
986
|
+
rootId: ctx.rootId,
|
|
987
|
+
threadId: ctx.threadId,
|
|
988
|
+
groupConfig,
|
|
989
|
+
feishuCfg,
|
|
990
|
+
})
|
|
991
|
+
: null;
|
|
992
|
+
const groupHistoryKey = isGroup ? (groupSession?.peerId ?? ctx.chatId) : undefined;
|
|
993
|
+
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
|
994
|
+
const configAllowFrom = feishuCfg?.allowFrom ?? [];
|
|
995
|
+
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
996
|
+
const rawBroadcastAgents = isGroup ? resolveBroadcastAgents(cfg, ctx.chatId) : null;
|
|
997
|
+
const broadcastAgents = rawBroadcastAgents
|
|
998
|
+
? [...new Set(rawBroadcastAgents.map((id) => normalizeAgentId(id)))]
|
|
999
|
+
: null;
|
|
1000
|
+
|
|
1001
|
+
let requireMention = false; // DMs never require mention; groups may override below
|
|
1002
|
+
if (isGroup) {
|
|
1003
|
+
if (groupConfig?.enabled === false) {
|
|
1004
|
+
log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
|
|
1008
|
+
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
|
|
1009
|
+
providerConfigPresent: cfg.channels?.feishu !== undefined,
|
|
1010
|
+
groupPolicy: feishuCfg?.groupPolicy,
|
|
1011
|
+
defaultGroupPolicy,
|
|
1012
|
+
});
|
|
1013
|
+
warnMissingProviderGroupPolicyFallbackOnce({
|
|
1014
|
+
providerMissingFallbackApplied,
|
|
1015
|
+
providerKey: "feishu",
|
|
1016
|
+
accountId: account.accountId,
|
|
1017
|
+
log,
|
|
1018
|
+
});
|
|
1019
|
+
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
|
|
1020
|
+
// DEBUG: log(`feishu[${account.accountId}]: groupPolicy=${groupPolicy}`);
|
|
1021
|
+
|
|
1022
|
+
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
|
|
1023
|
+
const groupAllowed = isFeishuGroupAllowed({
|
|
1024
|
+
groupPolicy,
|
|
1025
|
+
allowFrom: groupAllowFrom,
|
|
1026
|
+
senderId: ctx.chatId, // Check group ID, not sender ID
|
|
1027
|
+
senderName: undefined,
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
if (!groupAllowed) {
|
|
1031
|
+
log(
|
|
1032
|
+
`feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`,
|
|
1033
|
+
);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// Sender-level allowlist: per-group allowFrom takes precedence, then global groupSenderAllowFrom
|
|
1038
|
+
const perGroupSenderAllowFrom = groupConfig?.allowFrom ?? [];
|
|
1039
|
+
const globalSenderAllowFrom = feishuCfg?.groupSenderAllowFrom ?? [];
|
|
1040
|
+
const effectiveSenderAllowFrom =
|
|
1041
|
+
perGroupSenderAllowFrom.length > 0 ? perGroupSenderAllowFrom : globalSenderAllowFrom;
|
|
1042
|
+
if (effectiveSenderAllowFrom.length > 0) {
|
|
1043
|
+
const senderAllowed = isFeishuGroupAllowed({
|
|
1044
|
+
groupPolicy: "allowlist",
|
|
1045
|
+
allowFrom: effectiveSenderAllowFrom,
|
|
1046
|
+
senderId: ctx.senderOpenId,
|
|
1047
|
+
senderIds: [senderUserId],
|
|
1048
|
+
senderName: ctx.senderName,
|
|
1049
|
+
});
|
|
1050
|
+
if (!senderAllowed) {
|
|
1051
|
+
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
({ requireMention } = resolveFeishuReplyPolicy({
|
|
1057
|
+
isDirectMessage: false,
|
|
1058
|
+
globalConfig: feishuCfg,
|
|
1059
|
+
groupConfig,
|
|
1060
|
+
}));
|
|
1061
|
+
|
|
1062
|
+
if (requireMention && !ctx.mentionedBot) {
|
|
1063
|
+
log(`feishu[${account.accountId}]: message in group ${ctx.chatId} did not mention bot`);
|
|
1064
|
+
// Record to pending history for non-broadcast groups only. For broadcast groups,
|
|
1065
|
+
// the mentioned handler's broadcast dispatch writes the turn directly into all
|
|
1066
|
+
// agent sessions — buffering here would cause duplicate replay when this account
|
|
1067
|
+
// later becomes active via buildPendingHistoryContextFromMap.
|
|
1068
|
+
if (!broadcastAgents && chatHistories && groupHistoryKey) {
|
|
1069
|
+
recordPendingHistoryEntryIfEnabled({
|
|
1070
|
+
historyMap: chatHistories,
|
|
1071
|
+
historyKey: groupHistoryKey,
|
|
1072
|
+
limit: historyLimit,
|
|
1073
|
+
entry: {
|
|
1074
|
+
sender: ctx.senderOpenId,
|
|
1075
|
+
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
|
|
1076
|
+
timestamp: Date.now(),
|
|
1077
|
+
messageId: ctx.messageId,
|
|
1078
|
+
},
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
} else {
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
try {
|
|
1087
|
+
const core = getFeishuRuntime();
|
|
1088
|
+
const pairing = createScopedPairingAccess({
|
|
1089
|
+
core,
|
|
1090
|
+
channel: "feishu",
|
|
1091
|
+
accountId: account.accountId,
|
|
1092
|
+
});
|
|
1093
|
+
const commandProbeBody = isGroup ? normalizeFeishuCommandProbeBody(ctx.content) : ctx.content;
|
|
1094
|
+
const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
|
|
1095
|
+
commandProbeBody,
|
|
1096
|
+
cfg,
|
|
1097
|
+
);
|
|
1098
|
+
const storeAllowFrom =
|
|
1099
|
+
!isGroup &&
|
|
1100
|
+
dmPolicy !== "allowlist" &&
|
|
1101
|
+
(dmPolicy !== "open" || shouldComputeCommandAuthorized)
|
|
1102
|
+
? await pairing.readAllowFromStore().catch(() => [])
|
|
1103
|
+
: [];
|
|
1104
|
+
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
1105
|
+
const dmAllowed = resolveFeishuAllowlistMatch({
|
|
1106
|
+
allowFrom: effectiveDmAllowFrom,
|
|
1107
|
+
senderId: ctx.senderOpenId,
|
|
1108
|
+
senderIds: [senderUserId],
|
|
1109
|
+
senderName: ctx.senderName,
|
|
1110
|
+
}).allowed;
|
|
1111
|
+
|
|
1112
|
+
if (isDirect && dmPolicy !== "open" && !dmAllowed) {
|
|
1113
|
+
if (dmPolicy === "pairing") {
|
|
1114
|
+
await issuePairingChallenge({
|
|
1115
|
+
channel: "feishu",
|
|
1116
|
+
senderId: ctx.senderOpenId,
|
|
1117
|
+
senderIdLine: `Your Feishu user id: ${ctx.senderOpenId}`,
|
|
1118
|
+
meta: { name: ctx.senderName },
|
|
1119
|
+
upsertPairingRequest: pairing.upsertPairingRequest,
|
|
1120
|
+
onCreated: () => {
|
|
1121
|
+
log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
|
|
1122
|
+
},
|
|
1123
|
+
sendPairingReply: async (text) => {
|
|
1124
|
+
await sendMessageFeishu({
|
|
1125
|
+
cfg,
|
|
1126
|
+
to: `chat:${ctx.chatId}`,
|
|
1127
|
+
text,
|
|
1128
|
+
accountId: account.accountId,
|
|
1129
|
+
});
|
|
1130
|
+
},
|
|
1131
|
+
onReplyError: (err) => {
|
|
1132
|
+
log(
|
|
1133
|
+
`feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
|
|
1134
|
+
);
|
|
1135
|
+
},
|
|
1136
|
+
});
|
|
1137
|
+
} else {
|
|
1138
|
+
log(
|
|
1139
|
+
`feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const commandAllowFrom = isGroup
|
|
1146
|
+
? (groupConfig?.allowFrom ?? configAllowFrom)
|
|
1147
|
+
: effectiveDmAllowFrom;
|
|
1148
|
+
const senderAllowedForCommands = resolveFeishuAllowlistMatch({
|
|
1149
|
+
allowFrom: commandAllowFrom,
|
|
1150
|
+
senderId: ctx.senderOpenId,
|
|
1151
|
+
senderIds: [senderUserId],
|
|
1152
|
+
senderName: ctx.senderName,
|
|
1153
|
+
}).allowed;
|
|
1154
|
+
const commandAuthorized = shouldComputeCommandAuthorized
|
|
1155
|
+
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
1156
|
+
useAccessGroups,
|
|
1157
|
+
authorizers: [
|
|
1158
|
+
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
1159
|
+
],
|
|
1160
|
+
})
|
|
1161
|
+
: undefined;
|
|
1162
|
+
|
|
1163
|
+
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
|
|
1164
|
+
// Using a group-scoped From causes the agent to treat different users as the same person.
|
|
1165
|
+
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
|
1166
|
+
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
|
1167
|
+
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
|
|
1168
|
+
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
|
|
1169
|
+
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
|
|
1170
|
+
|
|
1171
|
+
if (isGroup && groupSession) {
|
|
1172
|
+
log(
|
|
1173
|
+
`feishu[${account.accountId}]: group session scope=${groupSession.groupSessionScope}, peer=${peerId}`,
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
let route = core.channel.routing.resolveAgentRoute({
|
|
1178
|
+
cfg,
|
|
1179
|
+
channel: "feishu",
|
|
1180
|
+
accountId: account.accountId,
|
|
1181
|
+
peer: {
|
|
1182
|
+
kind: isGroup ? "group" : "direct",
|
|
1183
|
+
id: peerId,
|
|
1184
|
+
},
|
|
1185
|
+
parentPeer,
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
// Dynamic agent creation for DM users
|
|
1189
|
+
// When enabled, creates a unique agent instance with its own workspace for each DM user.
|
|
1190
|
+
let effectiveCfg = cfg;
|
|
1191
|
+
if (!isGroup && route.matchedBy === "default") {
|
|
1192
|
+
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
|
|
1193
|
+
if (dynamicCfg?.enabled) {
|
|
1194
|
+
const runtime = getFeishuRuntime();
|
|
1195
|
+
const result = await maybeCreateDynamicAgent({
|
|
1196
|
+
cfg,
|
|
1197
|
+
runtime,
|
|
1198
|
+
senderOpenId: ctx.senderOpenId,
|
|
1199
|
+
dynamicCfg,
|
|
1200
|
+
log: (msg) => log(msg),
|
|
1201
|
+
});
|
|
1202
|
+
if (result.created) {
|
|
1203
|
+
effectiveCfg = result.updatedCfg;
|
|
1204
|
+
// Re-resolve route with updated config
|
|
1205
|
+
route = core.channel.routing.resolveAgentRoute({
|
|
1206
|
+
cfg: result.updatedCfg,
|
|
1207
|
+
channel: "feishu",
|
|
1208
|
+
accountId: account.accountId,
|
|
1209
|
+
peer: { kind: "direct", id: ctx.senderOpenId },
|
|
1210
|
+
});
|
|
1211
|
+
log(
|
|
1212
|
+
`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`,
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
|
1219
|
+
const inboundLabel = isGroup
|
|
1220
|
+
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
|
1221
|
+
: `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
|
|
1222
|
+
|
|
1223
|
+
// Do not enqueue inbound user previews as system events.
|
|
1224
|
+
// System events are prepended to future prompts and can be misread as
|
|
1225
|
+
// authoritative transcript turns.
|
|
1226
|
+
log(`feishu[${account.accountId}]: ${inboundLabel}: ${preview}`);
|
|
1227
|
+
|
|
1228
|
+
// Resolve media from message
|
|
1229
|
+
const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
|
|
1230
|
+
const mediaList = await resolveFeishuMediaList({
|
|
1231
|
+
cfg,
|
|
1232
|
+
messageId: ctx.messageId,
|
|
1233
|
+
messageType: event.message.message_type,
|
|
1234
|
+
content: event.message.content,
|
|
1235
|
+
maxBytes: mediaMaxBytes,
|
|
1236
|
+
log,
|
|
1237
|
+
accountId: account.accountId,
|
|
1238
|
+
});
|
|
1239
|
+
const mediaPayload = buildAgentMediaPayload(mediaList);
|
|
1240
|
+
|
|
1241
|
+
// Fetch quoted/replied message content if parentId exists
|
|
1242
|
+
let quotedContent: string | undefined;
|
|
1243
|
+
if (ctx.parentId) {
|
|
1244
|
+
try {
|
|
1245
|
+
const quotedMsg = await getMessageFeishu({
|
|
1246
|
+
cfg,
|
|
1247
|
+
messageId: ctx.parentId,
|
|
1248
|
+
accountId: account.accountId,
|
|
1249
|
+
});
|
|
1250
|
+
if (quotedMsg) {
|
|
1251
|
+
quotedContent = quotedMsg.content;
|
|
1252
|
+
log(
|
|
1253
|
+
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
1262
|
+
const messageBody = buildFeishuAgentBody({
|
|
1263
|
+
ctx,
|
|
1264
|
+
quotedContent,
|
|
1265
|
+
permissionErrorForAgent,
|
|
1266
|
+
botOpenId,
|
|
1267
|
+
});
|
|
1268
|
+
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
|
|
1269
|
+
if (permissionErrorForAgent) {
|
|
1270
|
+
// Keep the notice in a single dispatch to avoid duplicate replies (#27372).
|
|
1271
|
+
log(`feishu[${account.accountId}]: appending permission error notice to message body`);
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
1275
|
+
channel: "Feishu",
|
|
1276
|
+
from: envelopeFrom,
|
|
1277
|
+
timestamp: new Date(),
|
|
1278
|
+
envelope: envelopeOptions,
|
|
1279
|
+
body: messageBody,
|
|
1280
|
+
});
|
|
1281
|
+
|
|
1282
|
+
let combinedBody = body;
|
|
1283
|
+
const historyKey = groupHistoryKey;
|
|
1284
|
+
|
|
1285
|
+
if (isGroup && historyKey && chatHistories) {
|
|
1286
|
+
combinedBody = buildPendingHistoryContextFromMap({
|
|
1287
|
+
historyMap: chatHistories,
|
|
1288
|
+
historyKey,
|
|
1289
|
+
limit: historyLimit,
|
|
1290
|
+
currentMessage: combinedBody,
|
|
1291
|
+
formatEntry: (entry) =>
|
|
1292
|
+
core.channel.reply.formatAgentEnvelope({
|
|
1293
|
+
channel: "Feishu",
|
|
1294
|
+
// Preserve speaker identity in group history as well.
|
|
1295
|
+
from: `${ctx.chatId}:${entry.sender}`,
|
|
1296
|
+
timestamp: entry.timestamp,
|
|
1297
|
+
body: entry.body,
|
|
1298
|
+
envelope: envelopeOptions,
|
|
1299
|
+
}),
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
const inboundHistory =
|
|
1304
|
+
isGroup && historyKey && historyLimit > 0 && chatHistories
|
|
1305
|
+
? (chatHistories.get(historyKey) ?? []).map((entry) => ({
|
|
1306
|
+
sender: entry.sender,
|
|
1307
|
+
body: entry.body,
|
|
1308
|
+
timestamp: entry.timestamp,
|
|
1309
|
+
}))
|
|
1310
|
+
: undefined;
|
|
1311
|
+
|
|
1312
|
+
// --- Shared context builder for dispatch ---
|
|
1313
|
+
const buildCtxPayloadForAgent = (
|
|
1314
|
+
agentSessionKey: string,
|
|
1315
|
+
agentAccountId: string,
|
|
1316
|
+
wasMentioned: boolean,
|
|
1317
|
+
) =>
|
|
1318
|
+
core.channel.reply.finalizeInboundContext({
|
|
1319
|
+
Body: combinedBody,
|
|
1320
|
+
BodyForAgent: messageBody,
|
|
1321
|
+
InboundHistory: inboundHistory,
|
|
1322
|
+
ReplyToId: ctx.parentId,
|
|
1323
|
+
RootMessageId: ctx.rootId,
|
|
1324
|
+
RawBody: ctx.content,
|
|
1325
|
+
CommandBody: ctx.content,
|
|
1326
|
+
From: feishuFrom,
|
|
1327
|
+
To: feishuTo,
|
|
1328
|
+
SessionKey: agentSessionKey,
|
|
1329
|
+
AccountId: agentAccountId,
|
|
1330
|
+
ChatType: isGroup ? "group" : "direct",
|
|
1331
|
+
GroupSubject: isGroup ? ctx.chatId : undefined,
|
|
1332
|
+
SenderName: ctx.senderName ?? ctx.senderOpenId,
|
|
1333
|
+
SenderId: ctx.senderOpenId,
|
|
1334
|
+
Provider: "feishu" as const,
|
|
1335
|
+
Surface: "feishu" as const,
|
|
1336
|
+
MessageSid: ctx.messageId,
|
|
1337
|
+
ReplyToBody: quotedContent ?? undefined,
|
|
1338
|
+
Timestamp: Date.now(),
|
|
1339
|
+
WasMentioned: wasMentioned,
|
|
1340
|
+
CommandAuthorized: commandAuthorized,
|
|
1341
|
+
OriginatingChannel: "feishu" as const,
|
|
1342
|
+
OriginatingTo: feishuTo,
|
|
1343
|
+
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
|
|
1344
|
+
...mediaPayload,
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
// Parse message create_time (Feishu uses millisecond epoch string).
|
|
1348
|
+
const messageCreateTimeMs = event.message.create_time
|
|
1349
|
+
? parseInt(event.message.create_time, 10)
|
|
1350
|
+
: undefined;
|
|
1351
|
+
// Determine reply target based on group session mode:
|
|
1352
|
+
// - Topic-mode groups (group_topic / group_topic_sender): reply to the topic
|
|
1353
|
+
// root so the bot stays in the same thread.
|
|
1354
|
+
// - Groups with explicit replyInThread config: reply to the root so the bot
|
|
1355
|
+
// stays in the thread the user expects.
|
|
1356
|
+
// - Normal groups (auto-detected threadReply from root_id): reply to the
|
|
1357
|
+
// triggering message itself. Using rootId here would silently push the
|
|
1358
|
+
// reply into a topic thread invisible in the main chat view (#32980).
|
|
1359
|
+
const isTopicSession =
|
|
1360
|
+
isGroup &&
|
|
1361
|
+
(groupSession?.groupSessionScope === "group_topic" ||
|
|
1362
|
+
groupSession?.groupSessionScope === "group_topic_sender");
|
|
1363
|
+
const configReplyInThread =
|
|
1364
|
+
isGroup &&
|
|
1365
|
+
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
|
|
1366
|
+
const replyTargetMessageId =
|
|
1367
|
+
isTopicSession || configReplyInThread ? (ctx.rootId ?? ctx.messageId) : ctx.messageId;
|
|
1368
|
+
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
|
|
1369
|
+
|
|
1370
|
+
if (broadcastAgents) {
|
|
1371
|
+
// Cross-account dedup: in multi-account setups, Feishu delivers the same
|
|
1372
|
+
// event to every bot account in the group. Only one account should handle
|
|
1373
|
+
// broadcast dispatch to avoid duplicate agent sessions and race conditions.
|
|
1374
|
+
// Uses a shared "broadcast" namespace (not per-account) so the first handler
|
|
1375
|
+
// to reach this point claims the message; subsequent accounts skip.
|
|
1376
|
+
if (!(await tryRecordMessagePersistent(ctx.messageId, "broadcast", log))) {
|
|
1377
|
+
log(
|
|
1378
|
+
`feishu[${account.accountId}]: broadcast already claimed by another account for message ${ctx.messageId}; skipping`,
|
|
1379
|
+
);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// --- Broadcast dispatch: send message to all configured agents ---
|
|
1384
|
+
const strategy =
|
|
1385
|
+
((cfg as Record<string, unknown>).broadcast as Record<string, unknown> | undefined)
|
|
1386
|
+
?.strategy || "parallel";
|
|
1387
|
+
const activeAgentId =
|
|
1388
|
+
ctx.mentionedBot || !requireMention ? normalizeAgentId(route.agentId) : null;
|
|
1389
|
+
const agentIds = (cfg.agents?.list ?? []).map((a: { id: string }) => normalizeAgentId(a.id));
|
|
1390
|
+
const hasKnownAgents = agentIds.length > 0;
|
|
1391
|
+
|
|
1392
|
+
log(
|
|
1393
|
+
`feishu[${account.accountId}]: broadcasting to ${broadcastAgents.length} agents (strategy=${strategy}, active=${activeAgentId ?? "none"})`,
|
|
1394
|
+
);
|
|
1395
|
+
|
|
1396
|
+
const dispatchForAgent = async (agentId: string) => {
|
|
1397
|
+
if (hasKnownAgents && !agentIds.includes(normalizeAgentId(agentId))) {
|
|
1398
|
+
log(
|
|
1399
|
+
`feishu[${account.accountId}]: broadcast agent ${agentId} not found in agents.list; skipping`,
|
|
1400
|
+
);
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
|
|
1405
|
+
const agentCtx = buildCtxPayloadForAgent(
|
|
1406
|
+
agentSessionKey,
|
|
1407
|
+
route.accountId,
|
|
1408
|
+
ctx.mentionedBot && agentId === activeAgentId,
|
|
1409
|
+
);
|
|
1410
|
+
|
|
1411
|
+
if (agentId === activeAgentId) {
|
|
1412
|
+
// Active agent: real Feishu dispatcher (responds on Feishu)
|
|
1413
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
1414
|
+
cfg,
|
|
1415
|
+
agentId,
|
|
1416
|
+
runtime: runtime as RuntimeEnv,
|
|
1417
|
+
chatId: ctx.chatId,
|
|
1418
|
+
replyToMessageId: replyTargetMessageId,
|
|
1419
|
+
skipReplyToInMessages: !isGroup,
|
|
1420
|
+
replyInThread,
|
|
1421
|
+
rootId: ctx.rootId,
|
|
1422
|
+
threadReply,
|
|
1423
|
+
mentionTargets: ctx.mentionTargets,
|
|
1424
|
+
accountId: account.accountId,
|
|
1425
|
+
messageCreateTimeMs,
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
log(
|
|
1429
|
+
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
|
|
1430
|
+
);
|
|
1431
|
+
await core.channel.reply.withReplyDispatcher({
|
|
1432
|
+
dispatcher,
|
|
1433
|
+
onSettled: () => markDispatchIdle(),
|
|
1434
|
+
run: () =>
|
|
1435
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1436
|
+
ctx: agentCtx,
|
|
1437
|
+
cfg,
|
|
1438
|
+
dispatcher,
|
|
1439
|
+
replyOptions,
|
|
1440
|
+
}),
|
|
1441
|
+
});
|
|
1442
|
+
} else {
|
|
1443
|
+
// Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
|
|
1444
|
+
// Strip CommandAuthorized so slash commands (e.g. /reset) don't silently
|
|
1445
|
+
// mutate observer sessions — only the active agent should execute commands.
|
|
1446
|
+
delete (agentCtx as Record<string, unknown>).CommandAuthorized;
|
|
1447
|
+
const noopDispatcher = {
|
|
1448
|
+
sendToolResult: () => false,
|
|
1449
|
+
sendBlockReply: () => false,
|
|
1450
|
+
sendFinalReply: () => false,
|
|
1451
|
+
waitForIdle: async () => {},
|
|
1452
|
+
getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
|
|
1453
|
+
markComplete: () => {},
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
log(
|
|
1457
|
+
`feishu[${account.accountId}]: broadcast observer dispatch agent=${agentId} (session=${agentSessionKey})`,
|
|
1458
|
+
);
|
|
1459
|
+
await core.channel.reply.withReplyDispatcher({
|
|
1460
|
+
dispatcher: noopDispatcher,
|
|
1461
|
+
run: () =>
|
|
1462
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1463
|
+
ctx: agentCtx,
|
|
1464
|
+
cfg,
|
|
1465
|
+
dispatcher: noopDispatcher,
|
|
1466
|
+
}),
|
|
1467
|
+
});
|
|
1468
|
+
}
|
|
1469
|
+
};
|
|
1470
|
+
|
|
1471
|
+
if (strategy === "sequential") {
|
|
1472
|
+
for (const agentId of broadcastAgents) {
|
|
1473
|
+
try {
|
|
1474
|
+
await dispatchForAgent(agentId);
|
|
1475
|
+
} catch (err) {
|
|
1476
|
+
log(
|
|
1477
|
+
`feishu[${account.accountId}]: broadcast dispatch failed for agent=${agentId}: ${String(err)}`,
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
} else {
|
|
1482
|
+
const results = await Promise.allSettled(broadcastAgents.map(dispatchForAgent));
|
|
1483
|
+
for (let i = 0; i < results.length; i++) {
|
|
1484
|
+
if (results[i].status === "rejected") {
|
|
1485
|
+
log(
|
|
1486
|
+
`feishu[${account.accountId}]: broadcast dispatch failed for agent=${broadcastAgents[i]}: ${String((results[i] as PromiseRejectedResult).reason)}`,
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
if (isGroup && historyKey && chatHistories) {
|
|
1493
|
+
clearHistoryEntriesIfEnabled({
|
|
1494
|
+
historyMap: chatHistories,
|
|
1495
|
+
historyKey,
|
|
1496
|
+
limit: historyLimit,
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
log(
|
|
1501
|
+
`feishu[${account.accountId}]: broadcast dispatch complete for ${broadcastAgents.length} agents`,
|
|
1502
|
+
);
|
|
1503
|
+
} else {
|
|
1504
|
+
// --- Single-agent dispatch (existing behavior) ---
|
|
1505
|
+
const ctxPayload = buildCtxPayloadForAgent(
|
|
1506
|
+
route.sessionKey,
|
|
1507
|
+
route.accountId,
|
|
1508
|
+
ctx.mentionedBot,
|
|
1509
|
+
);
|
|
1510
|
+
|
|
1511
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
1512
|
+
cfg,
|
|
1513
|
+
agentId: route.agentId,
|
|
1514
|
+
runtime: runtime as RuntimeEnv,
|
|
1515
|
+
chatId: ctx.chatId,
|
|
1516
|
+
replyToMessageId: replyTargetMessageId,
|
|
1517
|
+
skipReplyToInMessages: !isGroup,
|
|
1518
|
+
replyInThread,
|
|
1519
|
+
rootId: ctx.rootId,
|
|
1520
|
+
threadReply,
|
|
1521
|
+
mentionTargets: ctx.mentionTargets,
|
|
1522
|
+
accountId: account.accountId,
|
|
1523
|
+
messageCreateTimeMs,
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
|
1527
|
+
const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
|
|
1528
|
+
dispatcher,
|
|
1529
|
+
onSettled: () => {
|
|
1530
|
+
markDispatchIdle();
|
|
1531
|
+
},
|
|
1532
|
+
run: () =>
|
|
1533
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1534
|
+
ctx: ctxPayload,
|
|
1535
|
+
cfg,
|
|
1536
|
+
dispatcher,
|
|
1537
|
+
replyOptions,
|
|
1538
|
+
}),
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
if (isGroup && historyKey && chatHistories) {
|
|
1542
|
+
clearHistoryEntriesIfEnabled({
|
|
1543
|
+
historyMap: chatHistories,
|
|
1544
|
+
historyKey,
|
|
1545
|
+
limit: historyLimit,
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
log(
|
|
1550
|
+
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
|
|
1551
|
+
);
|
|
1552
|
+
}
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
|
|
1555
|
+
}
|
|
1556
|
+
}
|