@bobotu/feishu-fork 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +922 -0
- package/index.ts +65 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +72 -0
- package/skills/feishu-doc/SKILL.md +161 -0
- package/skills/feishu-doc/references/block-types.md +102 -0
- package/skills/feishu-drive/SKILL.md +96 -0
- package/skills/feishu-perm/SKILL.md +90 -0
- package/skills/feishu-task/SKILL.md +210 -0
- package/skills/feishu-wiki/SKILL.md +96 -0
- package/src/accounts.ts +140 -0
- package/src/bitable-tools/actions.ts +199 -0
- package/src/bitable-tools/common.ts +90 -0
- package/src/bitable-tools/index.ts +1 -0
- package/src/bitable-tools/meta.ts +80 -0
- package/src/bitable-tools/register.ts +195 -0
- package/src/bitable-tools/schemas.ts +221 -0
- package/src/bot.ts +1125 -0
- package/src/channel.ts +334 -0
- package/src/client.ts +114 -0
- package/src/config-schema.ts +237 -0
- package/src/dedup.ts +54 -0
- package/src/directory.ts +165 -0
- package/src/doc-tools/actions.ts +341 -0
- package/src/doc-tools/common.ts +33 -0
- package/src/doc-tools/index.ts +2 -0
- package/src/doc-tools/register.ts +90 -0
- package/src/doc-tools/schemas.ts +85 -0
- package/src/doc-write-service.ts +711 -0
- package/src/drive-tools/actions.ts +182 -0
- package/src/drive-tools/common.ts +18 -0
- package/src/drive-tools/index.ts +2 -0
- package/src/drive-tools/register.ts +71 -0
- package/src/drive-tools/schemas.ts +67 -0
- package/src/dynamic-agent.ts +135 -0
- package/src/external-keys.ts +19 -0
- package/src/media.ts +510 -0
- package/src/mention.ts +121 -0
- package/src/monitor.ts +323 -0
- package/src/onboarding.ts +449 -0
- package/src/outbound.ts +40 -0
- package/src/perm-tools/actions.ts +111 -0
- package/src/perm-tools/common.ts +18 -0
- package/src/perm-tools/index.ts +2 -0
- package/src/perm-tools/register.ts +65 -0
- package/src/perm-tools/schemas.ts +52 -0
- package/src/policy.ts +117 -0
- package/src/probe.ts +147 -0
- package/src/reactions.ts +160 -0
- package/src/reply-dispatcher.ts +240 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +391 -0
- package/src/streaming-card.ts +211 -0
- package/src/targets.ts +58 -0
- package/src/task-tools/actions.ts +590 -0
- package/src/task-tools/common.ts +18 -0
- package/src/task-tools/constants.ts +13 -0
- package/src/task-tools/index.ts +1 -0
- package/src/task-tools/register.ts +263 -0
- package/src/task-tools/schemas.ts +567 -0
- package/src/text/markdown-links.ts +104 -0
- package/src/tools-common/feishu-api.ts +184 -0
- package/src/tools-common/tool-context.ts +23 -0
- package/src/tools-common/tool-exec.ts +73 -0
- package/src/tools-config.ts +22 -0
- package/src/types.ts +79 -0
- package/src/typing.ts +75 -0
- package/src/wiki-tools/actions.ts +166 -0
- package/src/wiki-tools/common.ts +18 -0
- package/src/wiki-tools/index.ts +2 -0
- package/src/wiki-tools/register.ts +66 -0
- package/src/wiki-tools/schemas.ts +55 -0
package/src/bot.ts
ADDED
|
@@ -0,0 +1,1125 @@
|
|
|
1
|
+
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk";
|
|
2
|
+
import {
|
|
3
|
+
buildPendingHistoryContextFromMap,
|
|
4
|
+
recordPendingHistoryEntryIfEnabled,
|
|
5
|
+
clearHistoryEntriesIfEnabled,
|
|
6
|
+
resolveOpenProviderRuntimeGroupPolicy,
|
|
7
|
+
warnMissingProviderGroupPolicyFallbackOnce,
|
|
8
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
9
|
+
resolveMentionGatingWithBypass,
|
|
10
|
+
type HistoryEntry,
|
|
11
|
+
} from "openclaw/plugin-sdk";
|
|
12
|
+
import type { FeishuConfig, FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
|
13
|
+
import { getFeishuRuntime } from "./runtime.js";
|
|
14
|
+
import { createFeishuClient } from "./client.js";
|
|
15
|
+
import { resolveFeishuAccount } from "./accounts.js";
|
|
16
|
+
import { tryRecordMessagePersistent } from "./dedup.js";
|
|
17
|
+
import {
|
|
18
|
+
resolveFeishuGroupConfig,
|
|
19
|
+
resolveFeishuReplyPolicy,
|
|
20
|
+
resolveFeishuGroupCommandMentionBypass,
|
|
21
|
+
resolveFeishuAllowlistMatch,
|
|
22
|
+
isFeishuGroupAllowed,
|
|
23
|
+
} from "./policy.js";
|
|
24
|
+
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|
25
|
+
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
|
|
26
|
+
import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
|
|
27
|
+
import {
|
|
28
|
+
extractMentionTargets,
|
|
29
|
+
extractMessageBody,
|
|
30
|
+
isMentionForwardRequest,
|
|
31
|
+
} from "./mention.js";
|
|
32
|
+
import { maybeCreateDynamicAgent } from "./dynamic-agent.js";
|
|
33
|
+
import { runWithFeishuToolContext } from "./tools-common/tool-context.js";
|
|
34
|
+
import type { DynamicAgentCreationConfig } from "./types.js";
|
|
35
|
+
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
|
36
|
+
|
|
37
|
+
// --- Permission error extraction ---
|
|
38
|
+
// Extract permission grant URL from Feishu API error response.
|
|
39
|
+
type PermissionError = {
|
|
40
|
+
code: number;
|
|
41
|
+
message: string;
|
|
42
|
+
grantUrl?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function decodeHtmlEntities(raw: string): string {
|
|
46
|
+
return raw
|
|
47
|
+
.replace(/&/gi, "&")
|
|
48
|
+
.replace(/'|'/gi, "'")
|
|
49
|
+
.replace(/"/gi, '"');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractFirstUrl(raw: string): string | undefined {
|
|
53
|
+
if (!raw) return undefined;
|
|
54
|
+
const decoded = decodeHtmlEntities(raw);
|
|
55
|
+
const urlMatch = decoded.match(/https?:\/\/[^\s"'<>]+/i);
|
|
56
|
+
return urlMatch?.[0];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extractPermissionError(err: unknown): PermissionError | null {
|
|
60
|
+
if (!err || typeof err !== "object") return null;
|
|
61
|
+
|
|
62
|
+
// Axios error structure: err.response.data contains the Feishu error
|
|
63
|
+
const axiosErr = err as { response?: { data?: unknown } };
|
|
64
|
+
const data = axiosErr.response?.data;
|
|
65
|
+
if (!data || typeof data !== "object") return null;
|
|
66
|
+
|
|
67
|
+
const feishuErr = data as {
|
|
68
|
+
code?: number;
|
|
69
|
+
msg?: string;
|
|
70
|
+
error?: { permission_violations?: Array<{ uri?: string }> };
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Feishu permission error code: 99991672
|
|
74
|
+
if (feishuErr.code !== 99991672) return null;
|
|
75
|
+
|
|
76
|
+
const msg = feishuErr.msg ?? "";
|
|
77
|
+
const grantUrlFromMsg = extractFirstUrl(msg);
|
|
78
|
+
const grantUrlFromViolations = feishuErr.error?.permission_violations
|
|
79
|
+
?.map((item) => extractFirstUrl(item.uri ?? ""))
|
|
80
|
+
.find((url): url is string => Boolean(url));
|
|
81
|
+
const grantUrl = grantUrlFromMsg ?? grantUrlFromViolations;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
code: feishuErr.code,
|
|
85
|
+
message: msg,
|
|
86
|
+
grantUrl,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
|
|
91
|
+
// Cache display names by open_id to avoid an API call on every message.
|
|
92
|
+
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
|
|
93
|
+
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
|
|
94
|
+
|
|
95
|
+
// Cache permission errors to avoid spamming the user with repeated notifications.
|
|
96
|
+
// Key: appId or "default", Value: timestamp of last notification
|
|
97
|
+
const permissionErrorNotifiedAt = new Map<string, number>();
|
|
98
|
+
const PERMISSION_ERROR_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
|
99
|
+
|
|
100
|
+
type SenderNameResult = {
|
|
101
|
+
name?: string;
|
|
102
|
+
permissionError?: PermissionError;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
async function resolveFeishuSenderName(params: {
|
|
106
|
+
account: ResolvedFeishuAccount;
|
|
107
|
+
senderOpenId: string;
|
|
108
|
+
log: (...args: any[]) => void;
|
|
109
|
+
}): Promise<SenderNameResult> {
|
|
110
|
+
const { account, senderOpenId, log } = params;
|
|
111
|
+
if (!account.configured) return {};
|
|
112
|
+
if (!senderOpenId) return {};
|
|
113
|
+
|
|
114
|
+
const cached = senderNameCache.get(senderOpenId);
|
|
115
|
+
const now = Date.now();
|
|
116
|
+
if (cached && cached.expireAt > now) return { name: cached.name };
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const client = createFeishuClient(account);
|
|
120
|
+
|
|
121
|
+
// contact/v3/users/:user_id?user_id_type=open_id
|
|
122
|
+
const res: any = await client.contact.user.get({
|
|
123
|
+
path: { user_id: senderOpenId },
|
|
124
|
+
params: { user_id_type: "open_id" },
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const name: string | undefined =
|
|
128
|
+
res?.data?.user?.name ||
|
|
129
|
+
res?.data?.user?.display_name ||
|
|
130
|
+
res?.data?.user?.nickname ||
|
|
131
|
+
res?.data?.user?.en_name;
|
|
132
|
+
|
|
133
|
+
if (name && typeof name === "string") {
|
|
134
|
+
senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
|
|
135
|
+
return { name };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {};
|
|
139
|
+
} catch (err) {
|
|
140
|
+
// Check if this is a permission error
|
|
141
|
+
const permErr = extractPermissionError(err);
|
|
142
|
+
if (permErr) {
|
|
143
|
+
log(`feishu: permission error resolving sender name: code=${permErr.code}`);
|
|
144
|
+
return { permissionError: permErr };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Best-effort. Don't fail message handling if name lookup fails.
|
|
148
|
+
log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`);
|
|
149
|
+
return {};
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Cache group bot counts for command mention bypass policy checks.
|
|
154
|
+
const GROUP_BOT_COUNT_TTL_MS = 10 * 60 * 1000;
|
|
155
|
+
const groupBotCountCache = new Map<string, { count: number; expireAt: number }>();
|
|
156
|
+
|
|
157
|
+
async function resolveFeishuGroupBotCount(params: {
|
|
158
|
+
account: ResolvedFeishuAccount;
|
|
159
|
+
chatId: string;
|
|
160
|
+
log: (...args: any[]) => void;
|
|
161
|
+
}): Promise<number | undefined> {
|
|
162
|
+
const { account, chatId, log } = params;
|
|
163
|
+
if (!account.configured || !chatId) return undefined;
|
|
164
|
+
|
|
165
|
+
const cacheKey = `${account.accountId}:${chatId}`;
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
const cached = groupBotCountCache.get(cacheKey);
|
|
168
|
+
if (cached && cached.expireAt > now) return cached.count;
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const client = createFeishuClient(account);
|
|
172
|
+
const res: any = await client.im.chat.get({
|
|
173
|
+
path: { chat_id: chatId },
|
|
174
|
+
});
|
|
175
|
+
const parsed = Number.parseInt(String(res?.data?.bot_count ?? ""), 10);
|
|
176
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
177
|
+
groupBotCountCache.set(cacheKey, { count: parsed, expireAt: now + GROUP_BOT_COUNT_TTL_MS });
|
|
178
|
+
return parsed;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
log(`feishu[${account.accountId}]: failed to resolve bot_count for ${chatId}: ${String(err)}`);
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type FeishuMessageEvent = {
|
|
188
|
+
sender: {
|
|
189
|
+
sender_id: {
|
|
190
|
+
open_id?: string;
|
|
191
|
+
user_id?: string;
|
|
192
|
+
union_id?: string;
|
|
193
|
+
};
|
|
194
|
+
sender_type?: string;
|
|
195
|
+
tenant_key?: string;
|
|
196
|
+
};
|
|
197
|
+
message: {
|
|
198
|
+
message_id: string;
|
|
199
|
+
root_id?: string;
|
|
200
|
+
parent_id?: string;
|
|
201
|
+
chat_id: string;
|
|
202
|
+
chat_type: "p2p" | "group";
|
|
203
|
+
message_type: string;
|
|
204
|
+
content: string;
|
|
205
|
+
mentions?: Array<{
|
|
206
|
+
key: string;
|
|
207
|
+
id: {
|
|
208
|
+
open_id?: string;
|
|
209
|
+
user_id?: string;
|
|
210
|
+
union_id?: string;
|
|
211
|
+
};
|
|
212
|
+
name: string;
|
|
213
|
+
tenant_key?: string;
|
|
214
|
+
}>;
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export type FeishuBotAddedEvent = {
|
|
219
|
+
chat_id: string;
|
|
220
|
+
operator_id: {
|
|
221
|
+
open_id?: string;
|
|
222
|
+
user_id?: string;
|
|
223
|
+
union_id?: string;
|
|
224
|
+
};
|
|
225
|
+
external: boolean;
|
|
226
|
+
operator_tenant_key?: string;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
function parseMessageContent(content: string, messageType: string): string {
|
|
230
|
+
try {
|
|
231
|
+
const parsed = JSON.parse(content);
|
|
232
|
+
if (messageType === "text") {
|
|
233
|
+
return parsed.text || "";
|
|
234
|
+
}
|
|
235
|
+
if (messageType === "post") {
|
|
236
|
+
// Extract text content from rich text post
|
|
237
|
+
const { textContent } = parsePostContent(content);
|
|
238
|
+
return textContent;
|
|
239
|
+
}
|
|
240
|
+
return content;
|
|
241
|
+
} catch {
|
|
242
|
+
return content;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function checkBotMentioned(
|
|
247
|
+
event: FeishuMessageEvent,
|
|
248
|
+
botOpenId?: string,
|
|
249
|
+
postMentionIds: string[] = [],
|
|
250
|
+
): boolean {
|
|
251
|
+
const normalizedBotOpenId = botOpenId?.trim();
|
|
252
|
+
// Keep explicit bot mention semantics: without a resolved botOpenId, do not trigger.
|
|
253
|
+
if (!normalizedBotOpenId) return false;
|
|
254
|
+
|
|
255
|
+
const mentions = event.message.mentions ?? [];
|
|
256
|
+
return (
|
|
257
|
+
mentions.some(
|
|
258
|
+
(m) => m.id.open_id === normalizedBotOpenId || m.id.user_id === normalizedBotOpenId,
|
|
259
|
+
) || postMentionIds.some((id) => id === normalizedBotOpenId)
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function escapeRegExp(s: string): string {
|
|
264
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function stripBotMention(text: string, mentions?: FeishuMessageEvent["message"]["mentions"]): string {
|
|
268
|
+
if (!mentions || mentions.length === 0) return text;
|
|
269
|
+
let result = text;
|
|
270
|
+
for (const mention of mentions) {
|
|
271
|
+
result = result.replace(new RegExp(`@${escapeRegExp(mention.name)}\\s*`, "g"), "").trim();
|
|
272
|
+
result = result.replace(new RegExp(escapeRegExp(mention.key), "g"), "").trim();
|
|
273
|
+
}
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Parse media keys from message content based on message type.
|
|
279
|
+
*/
|
|
280
|
+
function parseMediaKeys(
|
|
281
|
+
content: string,
|
|
282
|
+
messageType: string,
|
|
283
|
+
): {
|
|
284
|
+
imageKey?: string;
|
|
285
|
+
fileKey?: string;
|
|
286
|
+
fileName?: string;
|
|
287
|
+
} {
|
|
288
|
+
try {
|
|
289
|
+
const parsed = JSON.parse(content);
|
|
290
|
+
const imageKey = normalizeFeishuExternalKey(parsed.image_key);
|
|
291
|
+
const fileKey = normalizeFeishuExternalKey(parsed.file_key);
|
|
292
|
+
switch (messageType) {
|
|
293
|
+
case "image":
|
|
294
|
+
return { imageKey };
|
|
295
|
+
case "file":
|
|
296
|
+
return { fileKey, fileName: parsed.file_name };
|
|
297
|
+
case "audio":
|
|
298
|
+
return { fileKey };
|
|
299
|
+
case "video":
|
|
300
|
+
return { fileKey, imageKey };
|
|
301
|
+
case "sticker":
|
|
302
|
+
return { fileKey };
|
|
303
|
+
default:
|
|
304
|
+
return {};
|
|
305
|
+
}
|
|
306
|
+
} catch {
|
|
307
|
+
return {};
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Parse post (rich text) content and extract embedded image keys.
|
|
313
|
+
* Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] }
|
|
314
|
+
*/
|
|
315
|
+
function parsePostContent(content: string): {
|
|
316
|
+
textContent: string;
|
|
317
|
+
imageKeys: string[];
|
|
318
|
+
mentionIds: string[];
|
|
319
|
+
} {
|
|
320
|
+
try {
|
|
321
|
+
const parsed = JSON.parse(content);
|
|
322
|
+
const title = parsed.title || "";
|
|
323
|
+
const contentBlocks = parsed.content || [];
|
|
324
|
+
let textContent = title ? `${title}\n\n` : "";
|
|
325
|
+
const imageKeys: string[] = [];
|
|
326
|
+
const mentionIds: string[] = [];
|
|
327
|
+
|
|
328
|
+
for (const paragraph of contentBlocks) {
|
|
329
|
+
if (Array.isArray(paragraph)) {
|
|
330
|
+
for (const element of paragraph) {
|
|
331
|
+
if (element.tag === "text") {
|
|
332
|
+
textContent += element.text || "";
|
|
333
|
+
} else if (element.tag === "a") {
|
|
334
|
+
// Link: show text or href
|
|
335
|
+
textContent += element.text || element.href || "";
|
|
336
|
+
} else if (element.tag === "at") {
|
|
337
|
+
// Mention: @username
|
|
338
|
+
const mentionId =
|
|
339
|
+
String(element.open_id ?? element.user_id ?? element.union_id ?? "").trim() ||
|
|
340
|
+
undefined;
|
|
341
|
+
if (mentionId) mentionIds.push(mentionId);
|
|
342
|
+
textContent += `@${element.user_name || mentionId || ""}`;
|
|
343
|
+
} else if (element.tag === "img" && element.image_key) {
|
|
344
|
+
// Embedded image - skip invalid keys silently
|
|
345
|
+
const imageKey = normalizeFeishuExternalKey(element.image_key);
|
|
346
|
+
if (imageKey) {
|
|
347
|
+
imageKeys.push(imageKey);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
textContent += "\n";
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
textContent: textContent.trim() || "[富文本消息]",
|
|
357
|
+
imageKeys,
|
|
358
|
+
mentionIds,
|
|
359
|
+
};
|
|
360
|
+
} catch {
|
|
361
|
+
return { textContent: "[富文本消息]", imageKeys: [], mentionIds: [] };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Infer placeholder text based on message type.
|
|
367
|
+
*/
|
|
368
|
+
function inferPlaceholder(messageType: string): string {
|
|
369
|
+
switch (messageType) {
|
|
370
|
+
case "image":
|
|
371
|
+
return "<media:image>";
|
|
372
|
+
case "file":
|
|
373
|
+
return "<media:document>";
|
|
374
|
+
case "audio":
|
|
375
|
+
return "<media:audio>";
|
|
376
|
+
case "video":
|
|
377
|
+
return "<media:video>";
|
|
378
|
+
case "sticker":
|
|
379
|
+
return "<media:sticker>";
|
|
380
|
+
default:
|
|
381
|
+
return "<media:document>";
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Resolve media from a Feishu message, downloading and saving to disk.
|
|
387
|
+
* Similar to Discord's resolveMediaList().
|
|
388
|
+
*/
|
|
389
|
+
async function resolveFeishuMediaList(params: {
|
|
390
|
+
cfg: ClawdbotConfig;
|
|
391
|
+
messageId: string;
|
|
392
|
+
messageType: string;
|
|
393
|
+
content: string;
|
|
394
|
+
maxBytes: number;
|
|
395
|
+
log?: (msg: string) => void;
|
|
396
|
+
accountId?: string;
|
|
397
|
+
}): Promise<FeishuMediaInfo[]> {
|
|
398
|
+
const { cfg, messageId, messageType, content, maxBytes, log, accountId } = params;
|
|
399
|
+
|
|
400
|
+
// Only process media message types (including post for embedded images)
|
|
401
|
+
const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
|
|
402
|
+
if (!mediaTypes.includes(messageType)) {
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const out: FeishuMediaInfo[] = [];
|
|
407
|
+
const core = getFeishuRuntime();
|
|
408
|
+
|
|
409
|
+
// Handle post (rich text) messages with embedded images
|
|
410
|
+
if (messageType === "post") {
|
|
411
|
+
const { imageKeys } = parsePostContent(content);
|
|
412
|
+
if (imageKeys.length === 0) {
|
|
413
|
+
return [];
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
|
|
417
|
+
|
|
418
|
+
for (const imageKey of imageKeys) {
|
|
419
|
+
try {
|
|
420
|
+
// Embedded images in post use messageResource API with image_key as file_key
|
|
421
|
+
const result = await downloadMessageResourceFeishu({
|
|
422
|
+
cfg,
|
|
423
|
+
messageId,
|
|
424
|
+
fileKey: imageKey,
|
|
425
|
+
type: "image",
|
|
426
|
+
accountId,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
let contentType = result.contentType;
|
|
430
|
+
if (!contentType) {
|
|
431
|
+
contentType = await core.media.detectMime({ buffer: result.buffer });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
435
|
+
result.buffer,
|
|
436
|
+
contentType,
|
|
437
|
+
"inbound",
|
|
438
|
+
maxBytes,
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
out.push({
|
|
442
|
+
path: saved.path,
|
|
443
|
+
contentType: saved.contentType,
|
|
444
|
+
placeholder: "<media:image>",
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
|
|
448
|
+
} catch (err) {
|
|
449
|
+
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Handle other media types
|
|
457
|
+
const mediaKeys = parseMediaKeys(content, messageType);
|
|
458
|
+
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
|
|
459
|
+
return [];
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
try {
|
|
463
|
+
let buffer: Buffer;
|
|
464
|
+
let contentType: string | undefined;
|
|
465
|
+
let fileName: string | undefined;
|
|
466
|
+
|
|
467
|
+
// For message media, always use messageResource API
|
|
468
|
+
// The image.get API is only for images uploaded via im/v1/images, not for message attachments
|
|
469
|
+
const fileKey = mediaKeys.fileKey || mediaKeys.imageKey;
|
|
470
|
+
if (!fileKey) {
|
|
471
|
+
return [];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const resourceType = messageType === "image" ? "image" : "file";
|
|
475
|
+
const result = await downloadMessageResourceFeishu({
|
|
476
|
+
cfg,
|
|
477
|
+
messageId,
|
|
478
|
+
fileKey,
|
|
479
|
+
type: resourceType,
|
|
480
|
+
accountId,
|
|
481
|
+
});
|
|
482
|
+
buffer = result.buffer;
|
|
483
|
+
contentType = result.contentType;
|
|
484
|
+
fileName = result.fileName || mediaKeys.fileName;
|
|
485
|
+
|
|
486
|
+
// Detect mime type if not provided
|
|
487
|
+
if (!contentType) {
|
|
488
|
+
contentType = await core.media.detectMime({ buffer });
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Save to disk using core's saveMediaBuffer
|
|
492
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
493
|
+
buffer,
|
|
494
|
+
contentType,
|
|
495
|
+
"inbound",
|
|
496
|
+
maxBytes,
|
|
497
|
+
fileName,
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
out.push({
|
|
501
|
+
path: saved.path,
|
|
502
|
+
contentType: saved.contentType,
|
|
503
|
+
placeholder: inferPlaceholder(messageType),
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
|
|
507
|
+
} catch (err) {
|
|
508
|
+
log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Build media payload for inbound context.
|
|
516
|
+
* Similar to Discord's buildDiscordMediaPayload().
|
|
517
|
+
*/
|
|
518
|
+
function buildFeishuMediaPayload(
|
|
519
|
+
mediaList: FeishuMediaInfo[],
|
|
520
|
+
): {
|
|
521
|
+
MediaPath?: string;
|
|
522
|
+
MediaType?: string;
|
|
523
|
+
MediaUrl?: string;
|
|
524
|
+
MediaPaths?: string[];
|
|
525
|
+
MediaUrls?: string[];
|
|
526
|
+
MediaTypes?: string[];
|
|
527
|
+
} {
|
|
528
|
+
const first = mediaList[0];
|
|
529
|
+
const mediaPaths = mediaList.map((media) => media.path);
|
|
530
|
+
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
|
531
|
+
return {
|
|
532
|
+
MediaPath: first?.path,
|
|
533
|
+
MediaType: first?.contentType,
|
|
534
|
+
MediaUrl: first?.path,
|
|
535
|
+
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
536
|
+
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
537
|
+
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function parseFeishuMessageEvent(
|
|
542
|
+
event: FeishuMessageEvent,
|
|
543
|
+
botOpenId?: string,
|
|
544
|
+
): FeishuMessageContext {
|
|
545
|
+
const parsedPost =
|
|
546
|
+
event.message.message_type === "post" ? parsePostContent(event.message.content) : undefined;
|
|
547
|
+
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
|
|
548
|
+
const mentionedBot = checkBotMentioned(event, botOpenId, parsedPost?.mentionIds ?? []);
|
|
549
|
+
const hasAnyMention =
|
|
550
|
+
(event.message.mentions?.length ?? 0) > 0 || (parsedPost?.mentionIds.length ?? 0) > 0;
|
|
551
|
+
const content = stripBotMention(rawContent, event.message.mentions);
|
|
552
|
+
|
|
553
|
+
const ctx: FeishuMessageContext = {
|
|
554
|
+
chatId: event.message.chat_id,
|
|
555
|
+
messageId: event.message.message_id,
|
|
556
|
+
senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "",
|
|
557
|
+
senderOpenId: event.sender.sender_id.open_id || "",
|
|
558
|
+
chatType: event.message.chat_type,
|
|
559
|
+
mentionedBot,
|
|
560
|
+
rootId: event.message.root_id || undefined,
|
|
561
|
+
parentId: event.message.parent_id || undefined,
|
|
562
|
+
content,
|
|
563
|
+
contentType: event.message.message_type,
|
|
564
|
+
hasAnyMention,
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
// Detect mention forward request: message mentions bot + at least one other user
|
|
568
|
+
if (isMentionForwardRequest(event, botOpenId)) {
|
|
569
|
+
const mentionTargets = extractMentionTargets(event, botOpenId);
|
|
570
|
+
if (mentionTargets.length > 0) {
|
|
571
|
+
ctx.mentionTargets = mentionTargets;
|
|
572
|
+
// Extract message body (remove all @ placeholders)
|
|
573
|
+
const allMentionKeys = (event.message.mentions ?? []).map((m) => m.key);
|
|
574
|
+
ctx.mentionMessageBody = extractMessageBody(content, allMentionKeys);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return ctx;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export async function handleFeishuMessage(params: {
|
|
582
|
+
cfg: ClawdbotConfig;
|
|
583
|
+
event: FeishuMessageEvent;
|
|
584
|
+
botOpenId?: string;
|
|
585
|
+
runtime?: RuntimeEnv;
|
|
586
|
+
chatHistories?: Map<string, HistoryEntry[]>;
|
|
587
|
+
accountId?: string;
|
|
588
|
+
}): Promise<void> {
|
|
589
|
+
const { cfg, event, botOpenId, runtime, chatHistories, accountId } = params;
|
|
590
|
+
|
|
591
|
+
// Resolve account with merged config
|
|
592
|
+
const account = resolveFeishuAccount({ cfg, accountId });
|
|
593
|
+
const feishuCfg = account.config;
|
|
594
|
+
|
|
595
|
+
const log = runtime?.log ?? console.log;
|
|
596
|
+
const error = runtime?.error ?? console.error;
|
|
597
|
+
const senderUserId = event.sender.sender_id.user_id?.trim() || undefined;
|
|
598
|
+
|
|
599
|
+
// Dedup check: skip if this message was already processed
|
|
600
|
+
const messageId = event.message.message_id;
|
|
601
|
+
if (!(await tryRecordMessagePersistent(messageId, account.accountId, log))) {
|
|
602
|
+
log(`feishu: skipping duplicate message ${messageId}`);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
let ctx = parseFeishuMessageEvent(event, botOpenId);
|
|
607
|
+
const isGroup = ctx.chatType === "group";
|
|
608
|
+
|
|
609
|
+
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
|
|
610
|
+
const senderResult = await resolveFeishuSenderName({
|
|
611
|
+
account,
|
|
612
|
+
senderOpenId: ctx.senderOpenId,
|
|
613
|
+
log,
|
|
614
|
+
});
|
|
615
|
+
if (senderResult.name) ctx = { ...ctx, senderName: senderResult.name };
|
|
616
|
+
|
|
617
|
+
// Track permission error to inform agent later (with cooldown to avoid repetition)
|
|
618
|
+
let permissionErrorForAgent: PermissionError | undefined;
|
|
619
|
+
if (senderResult.permissionError) {
|
|
620
|
+
const appKey = account.appId ?? "default";
|
|
621
|
+
const now = Date.now();
|
|
622
|
+
const lastNotified = permissionErrorNotifiedAt.get(appKey) ?? 0;
|
|
623
|
+
|
|
624
|
+
if (now - lastNotified > PERMISSION_ERROR_COOLDOWN_MS) {
|
|
625
|
+
permissionErrorNotifiedAt.set(appKey, now);
|
|
626
|
+
permissionErrorForAgent = senderResult.permissionError;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
log(`feishu[${account.accountId}]: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`);
|
|
631
|
+
|
|
632
|
+
// Log mention targets if detected
|
|
633
|
+
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
|
634
|
+
const names = ctx.mentionTargets.map((t) => t.name).join(", ");
|
|
635
|
+
log(`feishu[${account.accountId}]: detected @ forward request, targets: [${names}]`);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const historyLimit = Math.max(
|
|
639
|
+
0,
|
|
640
|
+
feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
|
641
|
+
);
|
|
642
|
+
const groupConfig = isGroup
|
|
643
|
+
? resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId })
|
|
644
|
+
: undefined;
|
|
645
|
+
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
|
646
|
+
const configAllowFrom = feishuCfg?.allowFrom ?? [];
|
|
647
|
+
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
const core = getFeishuRuntime();
|
|
651
|
+
const shouldComputeCommandAuthorized = core.channel.commands.shouldComputeCommandAuthorized(
|
|
652
|
+
ctx.content,
|
|
653
|
+
cfg,
|
|
654
|
+
);
|
|
655
|
+
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
|
656
|
+
cfg,
|
|
657
|
+
surface: "feishu",
|
|
658
|
+
});
|
|
659
|
+
const hasControlCommand = core.channel.text.hasControlCommand(ctx.content, cfg);
|
|
660
|
+
const storeAllowFrom =
|
|
661
|
+
!isGroup && dmPolicy !== "allowlist" && (dmPolicy !== "open" || shouldComputeCommandAuthorized)
|
|
662
|
+
? await core.channel.pairing.readAllowFromStore("feishu").catch(() => [])
|
|
663
|
+
: [];
|
|
664
|
+
const effectiveDmAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
|
665
|
+
const dmAllowed = resolveFeishuAllowlistMatch({
|
|
666
|
+
allowFrom: effectiveDmAllowFrom,
|
|
667
|
+
senderId: ctx.senderOpenId,
|
|
668
|
+
senderIds: [senderUserId],
|
|
669
|
+
senderName: ctx.senderName,
|
|
670
|
+
}).allowed;
|
|
671
|
+
|
|
672
|
+
if (!isGroup && dmPolicy !== "open" && !dmAllowed) {
|
|
673
|
+
if (dmPolicy === "pairing") {
|
|
674
|
+
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
|
675
|
+
channel: "feishu",
|
|
676
|
+
id: ctx.senderOpenId,
|
|
677
|
+
meta: { name: ctx.senderName },
|
|
678
|
+
});
|
|
679
|
+
if (created) {
|
|
680
|
+
log(`feishu[${account.accountId}]: pairing request sender=${ctx.senderOpenId}`);
|
|
681
|
+
try {
|
|
682
|
+
await sendMessageFeishu({
|
|
683
|
+
cfg,
|
|
684
|
+
to: `user:${ctx.senderOpenId}`,
|
|
685
|
+
text: core.channel.pairing.buildPairingReply({
|
|
686
|
+
channel: "feishu",
|
|
687
|
+
idLine: `Your Feishu user id: ${ctx.senderOpenId}`,
|
|
688
|
+
code,
|
|
689
|
+
}),
|
|
690
|
+
accountId: account.accountId,
|
|
691
|
+
});
|
|
692
|
+
} catch (err) {
|
|
693
|
+
log(
|
|
694
|
+
`feishu[${account.accountId}]: pairing reply failed for ${ctx.senderOpenId}: ${String(err)}`,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
} else {
|
|
699
|
+
log(
|
|
700
|
+
`feishu[${account.accountId}]: blocked unauthorized sender ${ctx.senderOpenId} (dmPolicy=${dmPolicy})`,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const commandAllowFrom = isGroup
|
|
707
|
+
? groupConfig?.allowFrom && groupConfig.allowFrom.length > 0
|
|
708
|
+
? groupConfig.allowFrom
|
|
709
|
+
: configAllowFrom
|
|
710
|
+
: effectiveDmAllowFrom;
|
|
711
|
+
const senderAllowedForCommands = resolveFeishuAllowlistMatch({
|
|
712
|
+
allowFrom: commandAllowFrom,
|
|
713
|
+
senderId: ctx.senderOpenId,
|
|
714
|
+
senderIds: [senderUserId],
|
|
715
|
+
senderName: ctx.senderName,
|
|
716
|
+
}).allowed;
|
|
717
|
+
const commandAuthorized = shouldComputeCommandAuthorized
|
|
718
|
+
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
|
719
|
+
useAccessGroups,
|
|
720
|
+
authorizers: [
|
|
721
|
+
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
|
722
|
+
],
|
|
723
|
+
})
|
|
724
|
+
: undefined;
|
|
725
|
+
let effectiveWasMentioned = ctx.mentionedBot;
|
|
726
|
+
|
|
727
|
+
if (isGroup) {
|
|
728
|
+
if (groupConfig?.enabled === false) {
|
|
729
|
+
log(`feishu[${account.accountId}]: group ${ctx.chatId} is disabled`);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
|
734
|
+
const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({
|
|
735
|
+
providerConfigPresent: cfg.channels?.feishu !== undefined,
|
|
736
|
+
groupPolicy: feishuCfg?.groupPolicy,
|
|
737
|
+
defaultGroupPolicy,
|
|
738
|
+
});
|
|
739
|
+
warnMissingProviderGroupPolicyFallbackOnce({
|
|
740
|
+
providerMissingFallbackApplied,
|
|
741
|
+
providerKey: "feishu",
|
|
742
|
+
accountId: account.accountId,
|
|
743
|
+
log,
|
|
744
|
+
});
|
|
745
|
+
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
|
|
746
|
+
|
|
747
|
+
// Check if this GROUP is allowed (groupAllowFrom contains group IDs like oc_xxx, not user IDs)
|
|
748
|
+
const groupAllowed = isFeishuGroupAllowed({
|
|
749
|
+
groupPolicy,
|
|
750
|
+
allowFrom: groupAllowFrom,
|
|
751
|
+
senderId: ctx.chatId, // Check group ID, not sender ID
|
|
752
|
+
senderName: undefined,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
if (!groupAllowed) {
|
|
756
|
+
log(`feishu[${account.accountId}]: group ${ctx.chatId} not in groupAllowFrom (groupPolicy=${groupPolicy})`);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Additional sender-level allowlist check if group has specific allowFrom config
|
|
761
|
+
const senderAllowFrom = groupConfig?.allowFrom ?? [];
|
|
762
|
+
if (senderAllowFrom.length > 0) {
|
|
763
|
+
const senderAllowed = isFeishuGroupAllowed({
|
|
764
|
+
groupPolicy: "allowlist",
|
|
765
|
+
allowFrom: senderAllowFrom,
|
|
766
|
+
senderId: ctx.senderOpenId,
|
|
767
|
+
senderIds: [senderUserId],
|
|
768
|
+
senderName: ctx.senderName,
|
|
769
|
+
});
|
|
770
|
+
if (!senderAllowed) {
|
|
771
|
+
log(`feishu: sender ${ctx.senderOpenId} not in group ${ctx.chatId} sender allowlist`);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const { requireMention } = resolveFeishuReplyPolicy({
|
|
777
|
+
isDirectMessage: false,
|
|
778
|
+
globalConfig: feishuCfg,
|
|
779
|
+
groupConfig,
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
if (requireMention) {
|
|
783
|
+
const bypassPolicy = resolveFeishuGroupCommandMentionBypass({
|
|
784
|
+
globalConfig: feishuCfg,
|
|
785
|
+
groupConfig,
|
|
786
|
+
});
|
|
787
|
+
let bypassAllowedByPolicy = bypassPolicy === "always";
|
|
788
|
+
|
|
789
|
+
if (!bypassAllowedByPolicy && bypassPolicy === "single_bot" && hasControlCommand) {
|
|
790
|
+
const botCount = await resolveFeishuGroupBotCount({
|
|
791
|
+
account,
|
|
792
|
+
chatId: ctx.chatId,
|
|
793
|
+
log,
|
|
794
|
+
});
|
|
795
|
+
bypassAllowedByPolicy = botCount !== undefined && botCount <= 1;
|
|
796
|
+
if (botCount === undefined) {
|
|
797
|
+
log(
|
|
798
|
+
`feishu[${account.accountId}]: unable to resolve bot count for ${ctx.chatId}, command mention bypass disabled`,
|
|
799
|
+
);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const mentionGate = resolveMentionGatingWithBypass({
|
|
804
|
+
isGroup: true,
|
|
805
|
+
requireMention,
|
|
806
|
+
canDetectMention: true,
|
|
807
|
+
wasMentioned: ctx.mentionedBot,
|
|
808
|
+
hasAnyMention: ctx.hasAnyMention,
|
|
809
|
+
allowTextCommands: allowTextCommands && bypassAllowedByPolicy,
|
|
810
|
+
hasControlCommand,
|
|
811
|
+
commandAuthorized: commandAuthorized === true,
|
|
812
|
+
});
|
|
813
|
+
effectiveWasMentioned = mentionGate.effectiveWasMentioned;
|
|
814
|
+
|
|
815
|
+
if (mentionGate.shouldSkip) {
|
|
816
|
+
log(
|
|
817
|
+
`feishu[${account.accountId}]: message in group ${ctx.chatId} skipped (mention required)`,
|
|
818
|
+
);
|
|
819
|
+
if (chatHistories) {
|
|
820
|
+
recordPendingHistoryEntryIfEnabled({
|
|
821
|
+
historyMap: chatHistories,
|
|
822
|
+
historyKey: ctx.chatId,
|
|
823
|
+
limit: historyLimit,
|
|
824
|
+
entry: {
|
|
825
|
+
sender: ctx.senderOpenId,
|
|
826
|
+
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
|
|
827
|
+
timestamp: Date.now(),
|
|
828
|
+
messageId: ctx.messageId,
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
|
|
838
|
+
// Using a group-scoped From causes the agent to treat different users as the same person.
|
|
839
|
+
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
|
840
|
+
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
|
841
|
+
|
|
842
|
+
// Resolve peer ID for session routing
|
|
843
|
+
// When topicSessionMode is enabled, messages within a topic (identified by root_id)
|
|
844
|
+
// get a separate session from the main group chat.
|
|
845
|
+
let peerId = isGroup ? ctx.chatId : ctx.senderOpenId;
|
|
846
|
+
let topicSessionMode: "enabled" | "disabled" = "disabled";
|
|
847
|
+
if (isGroup && ctx.rootId) {
|
|
848
|
+
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
|
849
|
+
topicSessionMode = groupConfig?.topicSessionMode ?? feishuCfg?.topicSessionMode ?? "disabled";
|
|
850
|
+
if (topicSessionMode === "enabled") {
|
|
851
|
+
// Use chatId:topic:rootId as peer ID for topic-scoped sessions
|
|
852
|
+
peerId = `${ctx.chatId}:topic:${ctx.rootId}`;
|
|
853
|
+
log(`feishu[${account.accountId}]: topic session isolation enabled, peer=${peerId}`);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
let route = core.channel.routing.resolveAgentRoute({
|
|
858
|
+
cfg,
|
|
859
|
+
channel: "feishu",
|
|
860
|
+
accountId: account.accountId,
|
|
861
|
+
peer: {
|
|
862
|
+
kind: isGroup ? "group" : "direct",
|
|
863
|
+
id: peerId,
|
|
864
|
+
},
|
|
865
|
+
parentPeer:
|
|
866
|
+
isGroup && ctx.rootId && topicSessionMode === "enabled"
|
|
867
|
+
? {
|
|
868
|
+
kind: "group",
|
|
869
|
+
id: ctx.chatId,
|
|
870
|
+
}
|
|
871
|
+
: null,
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
// Dynamic agent creation for DM users
|
|
875
|
+
// When enabled, creates a unique agent instance with its own workspace for each DM user.
|
|
876
|
+
let effectiveCfg = cfg;
|
|
877
|
+
if (!isGroup && route.matchedBy === "default") {
|
|
878
|
+
const dynamicCfg = feishuCfg?.dynamicAgentCreation as DynamicAgentCreationConfig | undefined;
|
|
879
|
+
if (dynamicCfg?.enabled) {
|
|
880
|
+
const runtime = getFeishuRuntime();
|
|
881
|
+
const result = await maybeCreateDynamicAgent({
|
|
882
|
+
cfg,
|
|
883
|
+
runtime,
|
|
884
|
+
senderOpenId: ctx.senderOpenId,
|
|
885
|
+
dynamicCfg,
|
|
886
|
+
accountId: account.accountId,
|
|
887
|
+
log: (msg) => log(msg),
|
|
888
|
+
});
|
|
889
|
+
if (result.created) {
|
|
890
|
+
effectiveCfg = result.updatedCfg;
|
|
891
|
+
// Re-resolve route with updated config
|
|
892
|
+
route = core.channel.routing.resolveAgentRoute({
|
|
893
|
+
cfg: result.updatedCfg,
|
|
894
|
+
channel: "feishu",
|
|
895
|
+
accountId: account.accountId,
|
|
896
|
+
peer: { kind: "direct", id: ctx.senderOpenId },
|
|
897
|
+
});
|
|
898
|
+
log(`feishu[${account.accountId}]: dynamic agent created, new route: ${route.sessionKey}`);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
|
904
|
+
const inboundLabel = isGroup
|
|
905
|
+
? `Feishu[${account.accountId}] message in group ${ctx.chatId}`
|
|
906
|
+
: `Feishu[${account.accountId}] DM from ${ctx.senderOpenId}`;
|
|
907
|
+
const systemEventText = permissionErrorForAgent
|
|
908
|
+
? inboundLabel
|
|
909
|
+
: `${inboundLabel}: ${preview}`;
|
|
910
|
+
|
|
911
|
+
core.system.enqueueSystemEvent(systemEventText, {
|
|
912
|
+
sessionKey: route.sessionKey,
|
|
913
|
+
contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
// Resolve media from message
|
|
917
|
+
const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
|
|
918
|
+
const mediaList = await resolveFeishuMediaList({
|
|
919
|
+
cfg,
|
|
920
|
+
messageId: ctx.messageId,
|
|
921
|
+
messageType: event.message.message_type,
|
|
922
|
+
content: event.message.content,
|
|
923
|
+
maxBytes: mediaMaxBytes,
|
|
924
|
+
log,
|
|
925
|
+
accountId: account.accountId,
|
|
926
|
+
});
|
|
927
|
+
const mediaPayload = buildFeishuMediaPayload(mediaList);
|
|
928
|
+
|
|
929
|
+
// Fetch quoted/replied message content if parentId exists
|
|
930
|
+
let quotedContent: string | undefined;
|
|
931
|
+
if (ctx.parentId) {
|
|
932
|
+
try {
|
|
933
|
+
const quotedMsg = await getMessageFeishu({ cfg, messageId: ctx.parentId, accountId: account.accountId });
|
|
934
|
+
if (quotedMsg) {
|
|
935
|
+
quotedContent = quotedMsg.content;
|
|
936
|
+
log(`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`);
|
|
937
|
+
}
|
|
938
|
+
} catch (err) {
|
|
939
|
+
log(`feishu[${account.accountId}]: failed to fetch quoted message: ${String(err)}`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
944
|
+
|
|
945
|
+
// Build message body with quoted content if available
|
|
946
|
+
let messageBody = ctx.content;
|
|
947
|
+
if (quotedContent) {
|
|
948
|
+
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Include a readable speaker label so the model can attribute instructions.
|
|
952
|
+
// (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
|
|
953
|
+
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
|
954
|
+
messageBody = `${speaker}: ${messageBody}`;
|
|
955
|
+
|
|
956
|
+
// If there are mention targets, inform the agent that replies will auto-mention them
|
|
957
|
+
if (ctx.mentionTargets && ctx.mentionTargets.length > 0) {
|
|
958
|
+
const targetNames = ctx.mentionTargets.map((t) => t.name).join(", ");
|
|
959
|
+
messageBody += `\n\n[System: Your reply will automatically @mention: ${targetNames}. Do not write @xxx yourself.]`;
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
|
|
963
|
+
|
|
964
|
+
// If there's a permission error, dispatch a separate notification first
|
|
965
|
+
if (permissionErrorForAgent) {
|
|
966
|
+
const grantUrl = permissionErrorForAgent.grantUrl ?? "";
|
|
967
|
+
const permissionNotifyBody = `[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: ${grantUrl}]`;
|
|
968
|
+
|
|
969
|
+
const permissionBody = core.channel.reply.formatAgentEnvelope({
|
|
970
|
+
channel: "Feishu",
|
|
971
|
+
from: envelopeFrom,
|
|
972
|
+
timestamp: new Date(),
|
|
973
|
+
envelope: envelopeOptions,
|
|
974
|
+
body: permissionNotifyBody,
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
const permissionCtx = core.channel.reply.finalizeInboundContext({
|
|
978
|
+
Body: permissionBody,
|
|
979
|
+
RawBody: permissionNotifyBody,
|
|
980
|
+
CommandBody: permissionNotifyBody,
|
|
981
|
+
From: feishuFrom,
|
|
982
|
+
To: feishuTo,
|
|
983
|
+
SessionKey: route.sessionKey,
|
|
984
|
+
AccountId: route.accountId,
|
|
985
|
+
ChatType: isGroup ? "group" : "direct",
|
|
986
|
+
GroupSubject: isGroup ? ctx.chatId : undefined,
|
|
987
|
+
SenderName: "system",
|
|
988
|
+
SenderId: "system",
|
|
989
|
+
Provider: "feishu" as const,
|
|
990
|
+
Surface: "feishu" as const,
|
|
991
|
+
MessageSid: `${ctx.messageId}:permission-error`,
|
|
992
|
+
Timestamp: Date.now(),
|
|
993
|
+
WasMentioned: false,
|
|
994
|
+
CommandAuthorized: commandAuthorized,
|
|
995
|
+
OriginatingChannel: "feishu" as const,
|
|
996
|
+
OriginatingTo: feishuTo,
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
const { dispatcher: permDispatcher, replyOptions: permReplyOptions, markDispatchIdle: markPermIdle } =
|
|
1000
|
+
createFeishuReplyDispatcher({
|
|
1001
|
+
cfg,
|
|
1002
|
+
agentId: route.agentId,
|
|
1003
|
+
runtime: runtime as RuntimeEnv,
|
|
1004
|
+
chatId: ctx.chatId,
|
|
1005
|
+
replyToMessageId: ctx.messageId,
|
|
1006
|
+
accountId: account.accountId,
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
log(`feishu[${account.accountId}]: dispatching permission error notification to agent`);
|
|
1010
|
+
|
|
1011
|
+
await runWithFeishuToolContext(
|
|
1012
|
+
{
|
|
1013
|
+
channel: "feishu",
|
|
1014
|
+
accountId: account.accountId,
|
|
1015
|
+
sessionKey: route.sessionKey,
|
|
1016
|
+
},
|
|
1017
|
+
// Keep account context available while the agent executes plugin tools.
|
|
1018
|
+
() =>
|
|
1019
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1020
|
+
ctx: permissionCtx,
|
|
1021
|
+
cfg,
|
|
1022
|
+
dispatcher: permDispatcher,
|
|
1023
|
+
replyOptions: permReplyOptions,
|
|
1024
|
+
}),
|
|
1025
|
+
);
|
|
1026
|
+
|
|
1027
|
+
markPermIdle();
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
1031
|
+
channel: "Feishu",
|
|
1032
|
+
from: envelopeFrom,
|
|
1033
|
+
timestamp: new Date(),
|
|
1034
|
+
envelope: envelopeOptions,
|
|
1035
|
+
body: messageBody,
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
let combinedBody = body;
|
|
1039
|
+
const historyKey = isGroup ? ctx.chatId : undefined;
|
|
1040
|
+
|
|
1041
|
+
if (isGroup && historyKey && chatHistories) {
|
|
1042
|
+
combinedBody = buildPendingHistoryContextFromMap({
|
|
1043
|
+
historyMap: chatHistories,
|
|
1044
|
+
historyKey,
|
|
1045
|
+
limit: historyLimit,
|
|
1046
|
+
currentMessage: combinedBody,
|
|
1047
|
+
formatEntry: (entry) =>
|
|
1048
|
+
core.channel.reply.formatAgentEnvelope({
|
|
1049
|
+
channel: "Feishu",
|
|
1050
|
+
// Preserve speaker identity in group history as well.
|
|
1051
|
+
from: `${ctx.chatId}:${entry.sender}`,
|
|
1052
|
+
timestamp: entry.timestamp,
|
|
1053
|
+
body: entry.body,
|
|
1054
|
+
envelope: envelopeOptions,
|
|
1055
|
+
}),
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
1060
|
+
Body: combinedBody,
|
|
1061
|
+
RawBody: ctx.content,
|
|
1062
|
+
CommandBody: ctx.content,
|
|
1063
|
+
From: feishuFrom,
|
|
1064
|
+
To: feishuTo,
|
|
1065
|
+
SessionKey: route.sessionKey,
|
|
1066
|
+
AccountId: route.accountId,
|
|
1067
|
+
ChatType: isGroup ? "group" : "direct",
|
|
1068
|
+
GroupSubject: isGroup ? ctx.chatId : undefined,
|
|
1069
|
+
SenderName: ctx.senderName ?? ctx.senderOpenId,
|
|
1070
|
+
SenderId: ctx.senderOpenId,
|
|
1071
|
+
Provider: "feishu" as const,
|
|
1072
|
+
Surface: "feishu" as const,
|
|
1073
|
+
MessageSid: ctx.messageId,
|
|
1074
|
+
Timestamp: Date.now(),
|
|
1075
|
+
WasMentioned: effectiveWasMentioned,
|
|
1076
|
+
CommandAuthorized: commandAuthorized,
|
|
1077
|
+
OriginatingChannel: "feishu" as const,
|
|
1078
|
+
OriginatingTo: feishuTo,
|
|
1079
|
+
ReplyToBody: quotedContent,
|
|
1080
|
+
...mediaPayload,
|
|
1081
|
+
});
|
|
1082
|
+
|
|
1083
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
1084
|
+
cfg,
|
|
1085
|
+
agentId: route.agentId,
|
|
1086
|
+
runtime: runtime as RuntimeEnv,
|
|
1087
|
+
chatId: ctx.chatId,
|
|
1088
|
+
replyToMessageId: ctx.messageId,
|
|
1089
|
+
mentionTargets: ctx.mentionTargets,
|
|
1090
|
+
accountId: account.accountId,
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
|
1094
|
+
|
|
1095
|
+
const { queuedFinal, counts } = await runWithFeishuToolContext(
|
|
1096
|
+
{
|
|
1097
|
+
channel: "feishu",
|
|
1098
|
+
accountId: account.accountId,
|
|
1099
|
+
sessionKey: route.sessionKey,
|
|
1100
|
+
},
|
|
1101
|
+
// Tool calls produced by this turn should resolve to the same inbound account.
|
|
1102
|
+
() =>
|
|
1103
|
+
core.channel.reply.dispatchReplyFromConfig({
|
|
1104
|
+
ctx: ctxPayload,
|
|
1105
|
+
cfg,
|
|
1106
|
+
dispatcher,
|
|
1107
|
+
replyOptions,
|
|
1108
|
+
}),
|
|
1109
|
+
);
|
|
1110
|
+
|
|
1111
|
+
markDispatchIdle();
|
|
1112
|
+
|
|
1113
|
+
if (isGroup && historyKey && chatHistories) {
|
|
1114
|
+
clearHistoryEntriesIfEnabled({
|
|
1115
|
+
historyMap: chatHistories,
|
|
1116
|
+
historyKey,
|
|
1117
|
+
limit: historyLimit,
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
log(`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
error(`feishu[${account.accountId}]: failed to dispatch message: ${String(err)}`);
|
|
1124
|
+
}
|
|
1125
|
+
}
|