@colin3191/feishu 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/index.ts +41 -0
- package/openclaw.plugin.json +9 -0
- package/package.json +60 -0
- package/src/accounts.ts +53 -0
- package/src/bot.ts +654 -0
- package/src/channel.ts +224 -0
- package/src/client.ts +66 -0
- package/src/config-schema.ts +107 -0
- package/src/directory.ts +159 -0
- package/src/media.ts +515 -0
- package/src/monitor.ts +151 -0
- package/src/onboarding.ts +358 -0
- package/src/outbound.ts +40 -0
- package/src/policy.ts +92 -0
- package/src/probe.ts +46 -0
- package/src/reactions.ts +157 -0
- package/src/reply-dispatcher.ts +156 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +308 -0
- package/src/targets.ts +58 -0
- package/src/types.ts +50 -0
- package/src/typing.ts +73 -0
package/src/bot.ts
ADDED
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
|
|
2
|
+
import {
|
|
3
|
+
buildPendingHistoryContextFromMap,
|
|
4
|
+
recordPendingHistoryEntryIfEnabled,
|
|
5
|
+
clearHistoryEntriesIfEnabled,
|
|
6
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
7
|
+
type HistoryEntry,
|
|
8
|
+
} from "clawdbot/plugin-sdk";
|
|
9
|
+
import type { FeishuConfig, FeishuMessageContext, FeishuMediaInfo } from "./types.js";
|
|
10
|
+
import { getFeishuRuntime } from "./runtime.js";
|
|
11
|
+
import { createFeishuClient } from "./client.js";
|
|
12
|
+
import {
|
|
13
|
+
resolveFeishuGroupConfig,
|
|
14
|
+
resolveFeishuReplyPolicy,
|
|
15
|
+
resolveFeishuAllowlistMatch,
|
|
16
|
+
isFeishuGroupAllowed,
|
|
17
|
+
} from "./policy.js";
|
|
18
|
+
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
|
19
|
+
import { getMessageFeishu } from "./send.js";
|
|
20
|
+
import { downloadImageFeishu, downloadMessageResourceFeishu } from "./media.js";
|
|
21
|
+
|
|
22
|
+
// --- Sender name resolution (so the agent can distinguish who is speaking in group chats) ---
|
|
23
|
+
// Cache display names by open_id to avoid an API call on every message.
|
|
24
|
+
const SENDER_NAME_TTL_MS = 10 * 60 * 1000;
|
|
25
|
+
const senderNameCache = new Map<string, { name: string; expireAt: number }>();
|
|
26
|
+
|
|
27
|
+
async function resolveFeishuSenderName(params: {
|
|
28
|
+
feishuCfg?: FeishuConfig;
|
|
29
|
+
senderOpenId: string;
|
|
30
|
+
log: (...args: any[]) => void;
|
|
31
|
+
}): Promise<string | undefined> {
|
|
32
|
+
const { feishuCfg, senderOpenId, log } = params;
|
|
33
|
+
if (!feishuCfg) return undefined;
|
|
34
|
+
if (!senderOpenId) return undefined;
|
|
35
|
+
|
|
36
|
+
const cached = senderNameCache.get(senderOpenId);
|
|
37
|
+
const now = Date.now();
|
|
38
|
+
if (cached && cached.expireAt > now) return cached.name;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const client = createFeishuClient(feishuCfg);
|
|
42
|
+
|
|
43
|
+
// contact/v3/users/:user_id?user_id_type=open_id
|
|
44
|
+
const res: any = await client.contact.user.get({
|
|
45
|
+
path: { user_id: senderOpenId },
|
|
46
|
+
params: { user_id_type: "open_id" },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const name: string | undefined =
|
|
50
|
+
res?.data?.user?.name ||
|
|
51
|
+
res?.data?.user?.display_name ||
|
|
52
|
+
res?.data?.user?.nickname ||
|
|
53
|
+
res?.data?.user?.en_name;
|
|
54
|
+
|
|
55
|
+
if (name && typeof name === "string") {
|
|
56
|
+
senderNameCache.set(senderOpenId, { name, expireAt: now + SENDER_NAME_TTL_MS });
|
|
57
|
+
return name;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return undefined;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// Best-effort. Don't fail message handling if name lookup fails.
|
|
63
|
+
log(`feishu: failed to resolve sender name for ${senderOpenId}: ${String(err)}`);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type FeishuMessageEvent = {
|
|
69
|
+
sender: {
|
|
70
|
+
sender_id: {
|
|
71
|
+
open_id?: string;
|
|
72
|
+
user_id?: string;
|
|
73
|
+
union_id?: string;
|
|
74
|
+
};
|
|
75
|
+
sender_type?: string;
|
|
76
|
+
tenant_key?: string;
|
|
77
|
+
};
|
|
78
|
+
message: {
|
|
79
|
+
message_id: string;
|
|
80
|
+
root_id?: string;
|
|
81
|
+
parent_id?: string;
|
|
82
|
+
chat_id: string;
|
|
83
|
+
chat_type: "p2p" | "group";
|
|
84
|
+
message_type: string;
|
|
85
|
+
content: string;
|
|
86
|
+
mentions?: Array<{
|
|
87
|
+
key: string;
|
|
88
|
+
id: {
|
|
89
|
+
open_id?: string;
|
|
90
|
+
user_id?: string;
|
|
91
|
+
union_id?: string;
|
|
92
|
+
};
|
|
93
|
+
name: string;
|
|
94
|
+
tenant_key?: string;
|
|
95
|
+
}>;
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export type FeishuBotAddedEvent = {
|
|
100
|
+
chat_id: string;
|
|
101
|
+
operator_id: {
|
|
102
|
+
open_id?: string;
|
|
103
|
+
user_id?: string;
|
|
104
|
+
union_id?: string;
|
|
105
|
+
};
|
|
106
|
+
external: boolean;
|
|
107
|
+
operator_tenant_key?: string;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function parseMessageContent(content: string, messageType: string): string {
|
|
111
|
+
try {
|
|
112
|
+
const parsed = JSON.parse(content);
|
|
113
|
+
if (messageType === "text") {
|
|
114
|
+
return parsed.text || "";
|
|
115
|
+
}
|
|
116
|
+
if (messageType === "post") {
|
|
117
|
+
// Extract text content from rich text post
|
|
118
|
+
const { textContent } = parsePostContent(content);
|
|
119
|
+
return textContent;
|
|
120
|
+
}
|
|
121
|
+
return content;
|
|
122
|
+
} catch {
|
|
123
|
+
return content;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
|
128
|
+
const mentions = event.message.mentions ?? [];
|
|
129
|
+
if (mentions.length === 0) return false;
|
|
130
|
+
if (!botOpenId) return mentions.length > 0;
|
|
131
|
+
return mentions.some((m) => m.id.open_id === botOpenId);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function stripBotMention(text: string, mentions?: FeishuMessageEvent["message"]["mentions"]): string {
|
|
135
|
+
if (!mentions || mentions.length === 0) return text;
|
|
136
|
+
let result = text;
|
|
137
|
+
for (const mention of mentions) {
|
|
138
|
+
result = result.replace(new RegExp(`@${mention.name}\\s*`, "g"), "").trim();
|
|
139
|
+
result = result.replace(new RegExp(mention.key, "g"), "").trim();
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse media keys from message content based on message type.
|
|
146
|
+
*/
|
|
147
|
+
function parseMediaKeys(
|
|
148
|
+
content: string,
|
|
149
|
+
messageType: string,
|
|
150
|
+
): {
|
|
151
|
+
imageKey?: string;
|
|
152
|
+
fileKey?: string;
|
|
153
|
+
fileName?: string;
|
|
154
|
+
} {
|
|
155
|
+
try {
|
|
156
|
+
const parsed = JSON.parse(content);
|
|
157
|
+
switch (messageType) {
|
|
158
|
+
case "image":
|
|
159
|
+
return { imageKey: parsed.image_key };
|
|
160
|
+
case "file":
|
|
161
|
+
return { fileKey: parsed.file_key, fileName: parsed.file_name };
|
|
162
|
+
case "audio":
|
|
163
|
+
return { fileKey: parsed.file_key };
|
|
164
|
+
case "video":
|
|
165
|
+
// Video has both file_key (video) and image_key (thumbnail)
|
|
166
|
+
return { fileKey: parsed.file_key, imageKey: parsed.image_key };
|
|
167
|
+
case "sticker":
|
|
168
|
+
return { fileKey: parsed.file_key };
|
|
169
|
+
default:
|
|
170
|
+
return {};
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
return {};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Parse post (rich text) content and extract embedded image keys.
|
|
179
|
+
* Post structure: { title?: string, content: [[{ tag, text?, image_key?, ... }]] }
|
|
180
|
+
*/
|
|
181
|
+
function parsePostContent(content: string): {
|
|
182
|
+
textContent: string;
|
|
183
|
+
imageKeys: string[];
|
|
184
|
+
} {
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(content);
|
|
187
|
+
const title = parsed.title || "";
|
|
188
|
+
const contentBlocks = parsed.content || [];
|
|
189
|
+
let textContent = title ? `${title}\n\n` : "";
|
|
190
|
+
const imageKeys: string[] = [];
|
|
191
|
+
|
|
192
|
+
for (const paragraph of contentBlocks) {
|
|
193
|
+
if (Array.isArray(paragraph)) {
|
|
194
|
+
for (const element of paragraph) {
|
|
195
|
+
if (element.tag === "text") {
|
|
196
|
+
textContent += element.text || "";
|
|
197
|
+
} else if (element.tag === "a") {
|
|
198
|
+
// Link: show text or href
|
|
199
|
+
textContent += element.text || element.href || "";
|
|
200
|
+
} else if (element.tag === "at") {
|
|
201
|
+
// Mention: @username
|
|
202
|
+
textContent += `@${element.user_name || element.user_id || ""}`;
|
|
203
|
+
} else if (element.tag === "img" && element.image_key) {
|
|
204
|
+
// Embedded image
|
|
205
|
+
imageKeys.push(element.image_key);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
textContent += "\n";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
textContent: textContent.trim() || "[富文本消息]",
|
|
214
|
+
imageKeys,
|
|
215
|
+
};
|
|
216
|
+
} catch {
|
|
217
|
+
return { textContent: "[富文本消息]", imageKeys: [] };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Infer placeholder text based on message type.
|
|
223
|
+
*/
|
|
224
|
+
function inferPlaceholder(messageType: string): string {
|
|
225
|
+
switch (messageType) {
|
|
226
|
+
case "image":
|
|
227
|
+
return "<media:image>";
|
|
228
|
+
case "file":
|
|
229
|
+
return "<media:document>";
|
|
230
|
+
case "audio":
|
|
231
|
+
return "<media:audio>";
|
|
232
|
+
case "video":
|
|
233
|
+
return "<media:video>";
|
|
234
|
+
case "sticker":
|
|
235
|
+
return "<media:sticker>";
|
|
236
|
+
default:
|
|
237
|
+
return "<media:document>";
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Resolve media from a Feishu message, downloading and saving to disk.
|
|
243
|
+
* Similar to Discord's resolveMediaList().
|
|
244
|
+
*/
|
|
245
|
+
async function resolveFeishuMediaList(params: {
|
|
246
|
+
cfg: ClawdbotConfig;
|
|
247
|
+
messageId: string;
|
|
248
|
+
messageType: string;
|
|
249
|
+
content: string;
|
|
250
|
+
maxBytes: number;
|
|
251
|
+
log?: (msg: string) => void;
|
|
252
|
+
}): Promise<FeishuMediaInfo[]> {
|
|
253
|
+
const { cfg, messageId, messageType, content, maxBytes, log } = params;
|
|
254
|
+
|
|
255
|
+
// Only process media message types (including post for embedded images)
|
|
256
|
+
const mediaTypes = ["image", "file", "audio", "video", "sticker", "post"];
|
|
257
|
+
if (!mediaTypes.includes(messageType)) {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const out: FeishuMediaInfo[] = [];
|
|
262
|
+
const core = getFeishuRuntime();
|
|
263
|
+
|
|
264
|
+
// Handle post (rich text) messages with embedded images
|
|
265
|
+
if (messageType === "post") {
|
|
266
|
+
const { imageKeys } = parsePostContent(content);
|
|
267
|
+
if (imageKeys.length === 0) {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
log?.(`feishu: post message contains ${imageKeys.length} embedded image(s)`);
|
|
272
|
+
|
|
273
|
+
for (const imageKey of imageKeys) {
|
|
274
|
+
try {
|
|
275
|
+
// Embedded images in post use messageResource API with image_key as file_key
|
|
276
|
+
const result = await downloadMessageResourceFeishu({
|
|
277
|
+
cfg,
|
|
278
|
+
messageId,
|
|
279
|
+
fileKey: imageKey,
|
|
280
|
+
type: "image",
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
let contentType = result.contentType;
|
|
284
|
+
if (!contentType) {
|
|
285
|
+
contentType = await core.media.detectMime({ buffer: result.buffer });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
289
|
+
result.buffer,
|
|
290
|
+
contentType,
|
|
291
|
+
"inbound",
|
|
292
|
+
maxBytes,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
out.push({
|
|
296
|
+
path: saved.path,
|
|
297
|
+
contentType: saved.contentType,
|
|
298
|
+
placeholder: "<media:image>",
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
log?.(`feishu: downloaded embedded image ${imageKey}, saved to ${saved.path}`);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
log?.(`feishu: failed to download embedded image ${imageKey}: ${String(err)}`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Handle other media types
|
|
311
|
+
const mediaKeys = parseMediaKeys(content, messageType);
|
|
312
|
+
if (!mediaKeys.imageKey && !mediaKeys.fileKey) {
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
let buffer: Buffer;
|
|
318
|
+
let contentType: string | undefined;
|
|
319
|
+
let fileName: string | undefined;
|
|
320
|
+
|
|
321
|
+
// For message media, always use messageResource API
|
|
322
|
+
// The image.get API is only for images uploaded via im/v1/images, not for message attachments
|
|
323
|
+
const fileKey = mediaKeys.imageKey || mediaKeys.fileKey;
|
|
324
|
+
if (!fileKey) {
|
|
325
|
+
return [];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const resourceType = messageType === "image" ? "image" : "file";
|
|
329
|
+
const result = await downloadMessageResourceFeishu({
|
|
330
|
+
cfg,
|
|
331
|
+
messageId,
|
|
332
|
+
fileKey,
|
|
333
|
+
type: resourceType,
|
|
334
|
+
});
|
|
335
|
+
buffer = result.buffer;
|
|
336
|
+
contentType = result.contentType;
|
|
337
|
+
fileName = result.fileName || mediaKeys.fileName;
|
|
338
|
+
|
|
339
|
+
// Detect mime type if not provided
|
|
340
|
+
if (!contentType) {
|
|
341
|
+
contentType = await core.media.detectMime({ buffer });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Save to disk using core's saveMediaBuffer
|
|
345
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
346
|
+
buffer,
|
|
347
|
+
contentType,
|
|
348
|
+
"inbound",
|
|
349
|
+
maxBytes,
|
|
350
|
+
fileName,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
out.push({
|
|
354
|
+
path: saved.path,
|
|
355
|
+
contentType: saved.contentType,
|
|
356
|
+
placeholder: inferPlaceholder(messageType),
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
log?.(`feishu: downloaded ${messageType} media, saved to ${saved.path}`);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
log?.(`feishu: failed to download ${messageType} media: ${String(err)}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Build media payload for inbound context.
|
|
369
|
+
* Similar to Discord's buildDiscordMediaPayload().
|
|
370
|
+
*/
|
|
371
|
+
function buildFeishuMediaPayload(
|
|
372
|
+
mediaList: FeishuMediaInfo[],
|
|
373
|
+
): {
|
|
374
|
+
MediaPath?: string;
|
|
375
|
+
MediaType?: string;
|
|
376
|
+
MediaUrl?: string;
|
|
377
|
+
MediaPaths?: string[];
|
|
378
|
+
MediaUrls?: string[];
|
|
379
|
+
MediaTypes?: string[];
|
|
380
|
+
} {
|
|
381
|
+
const first = mediaList[0];
|
|
382
|
+
const mediaPaths = mediaList.map((media) => media.path);
|
|
383
|
+
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
|
|
384
|
+
return {
|
|
385
|
+
MediaPath: first?.path,
|
|
386
|
+
MediaType: first?.contentType,
|
|
387
|
+
MediaUrl: first?.path,
|
|
388
|
+
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
389
|
+
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
|
|
390
|
+
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function parseFeishuMessageEvent(
|
|
395
|
+
event: FeishuMessageEvent,
|
|
396
|
+
botOpenId?: string,
|
|
397
|
+
): FeishuMessageContext {
|
|
398
|
+
const rawContent = parseMessageContent(event.message.content, event.message.message_type);
|
|
399
|
+
const mentionedBot = checkBotMentioned(event, botOpenId);
|
|
400
|
+
const content = stripBotMention(rawContent, event.message.mentions);
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
chatId: event.message.chat_id,
|
|
404
|
+
messageId: event.message.message_id,
|
|
405
|
+
senderId: event.sender.sender_id.user_id || event.sender.sender_id.open_id || "",
|
|
406
|
+
senderOpenId: event.sender.sender_id.open_id || "",
|
|
407
|
+
chatType: event.message.chat_type,
|
|
408
|
+
mentionedBot,
|
|
409
|
+
rootId: event.message.root_id || undefined,
|
|
410
|
+
parentId: event.message.parent_id || undefined,
|
|
411
|
+
content,
|
|
412
|
+
contentType: event.message.message_type,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export async function handleFeishuMessage(params: {
|
|
417
|
+
cfg: ClawdbotConfig;
|
|
418
|
+
event: FeishuMessageEvent;
|
|
419
|
+
botOpenId?: string;
|
|
420
|
+
runtime?: RuntimeEnv;
|
|
421
|
+
chatHistories?: Map<string, HistoryEntry[]>;
|
|
422
|
+
}): Promise<void> {
|
|
423
|
+
const { cfg, event, botOpenId, runtime, chatHistories } = params;
|
|
424
|
+
const feishuCfg = cfg.channels?.feishu as FeishuConfig | undefined;
|
|
425
|
+
const log = runtime?.log ?? console.log;
|
|
426
|
+
const error = runtime?.error ?? console.error;
|
|
427
|
+
|
|
428
|
+
let ctx = parseFeishuMessageEvent(event, botOpenId);
|
|
429
|
+
const isGroup = ctx.chatType === "group";
|
|
430
|
+
|
|
431
|
+
// Resolve sender display name (best-effort) so the agent can attribute messages correctly.
|
|
432
|
+
const senderName = await resolveFeishuSenderName({
|
|
433
|
+
feishuCfg,
|
|
434
|
+
senderOpenId: ctx.senderOpenId,
|
|
435
|
+
log,
|
|
436
|
+
});
|
|
437
|
+
if (senderName) ctx = { ...ctx, senderName };
|
|
438
|
+
|
|
439
|
+
log(`feishu: received message from ${ctx.senderOpenId} in ${ctx.chatId} (${ctx.chatType})`);
|
|
440
|
+
|
|
441
|
+
const historyLimit = Math.max(
|
|
442
|
+
0,
|
|
443
|
+
feishuCfg?.historyLimit ?? cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
if (isGroup) {
|
|
447
|
+
const groupPolicy = feishuCfg?.groupPolicy ?? "open";
|
|
448
|
+
const groupAllowFrom = feishuCfg?.groupAllowFrom ?? [];
|
|
449
|
+
const groupConfig = resolveFeishuGroupConfig({ cfg: feishuCfg, groupId: ctx.chatId });
|
|
450
|
+
|
|
451
|
+
const senderAllowFrom = groupConfig?.allowFrom ?? groupAllowFrom;
|
|
452
|
+
const allowed = isFeishuGroupAllowed({
|
|
453
|
+
groupPolicy,
|
|
454
|
+
allowFrom: senderAllowFrom,
|
|
455
|
+
senderId: ctx.senderOpenId,
|
|
456
|
+
senderName: ctx.senderName,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
if (!allowed) {
|
|
460
|
+
log(`feishu: sender ${ctx.senderOpenId} not in group allowlist`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const { requireMention } = resolveFeishuReplyPolicy({
|
|
465
|
+
isDirectMessage: false,
|
|
466
|
+
globalConfig: feishuCfg,
|
|
467
|
+
groupConfig,
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
if (requireMention && !ctx.mentionedBot) {
|
|
471
|
+
log(`feishu: message in group ${ctx.chatId} did not mention bot, recording to history`);
|
|
472
|
+
if (chatHistories) {
|
|
473
|
+
recordPendingHistoryEntryIfEnabled({
|
|
474
|
+
historyMap: chatHistories,
|
|
475
|
+
historyKey: ctx.chatId,
|
|
476
|
+
limit: historyLimit,
|
|
477
|
+
entry: {
|
|
478
|
+
sender: ctx.senderOpenId,
|
|
479
|
+
body: `${ctx.senderName ?? ctx.senderOpenId}: ${ctx.content}`,
|
|
480
|
+
timestamp: Date.now(),
|
|
481
|
+
messageId: ctx.messageId,
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
const dmPolicy = feishuCfg?.dmPolicy ?? "pairing";
|
|
489
|
+
const allowFrom = feishuCfg?.allowFrom ?? [];
|
|
490
|
+
|
|
491
|
+
if (dmPolicy === "allowlist") {
|
|
492
|
+
const match = resolveFeishuAllowlistMatch({
|
|
493
|
+
allowFrom,
|
|
494
|
+
senderId: ctx.senderOpenId,
|
|
495
|
+
});
|
|
496
|
+
if (!match.allowed) {
|
|
497
|
+
log(`feishu: sender ${ctx.senderOpenId} not in DM allowlist`);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
try {
|
|
504
|
+
const core = getFeishuRuntime();
|
|
505
|
+
|
|
506
|
+
// In group chats, the session is scoped to the group, but the *speaker* is the sender.
|
|
507
|
+
// Using a group-scoped From causes the agent to treat different users as the same person.
|
|
508
|
+
const feishuFrom = `feishu:${ctx.senderOpenId}`;
|
|
509
|
+
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
|
|
510
|
+
|
|
511
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
512
|
+
cfg,
|
|
513
|
+
channel: "feishu",
|
|
514
|
+
peer: {
|
|
515
|
+
kind: isGroup ? "group" : "dm",
|
|
516
|
+
id: isGroup ? ctx.chatId : ctx.senderOpenId,
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const preview = ctx.content.replace(/\s+/g, " ").slice(0, 160);
|
|
521
|
+
const inboundLabel = isGroup
|
|
522
|
+
? `Feishu message in group ${ctx.chatId}`
|
|
523
|
+
: `Feishu DM from ${ctx.senderOpenId}`;
|
|
524
|
+
|
|
525
|
+
core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
|
|
526
|
+
sessionKey: route.sessionKey,
|
|
527
|
+
contextKey: `feishu:message:${ctx.chatId}:${ctx.messageId}`,
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// Resolve media from message
|
|
531
|
+
const mediaMaxBytes = (feishuCfg?.mediaMaxMb ?? 30) * 1024 * 1024; // 30MB default
|
|
532
|
+
const mediaList = await resolveFeishuMediaList({
|
|
533
|
+
cfg,
|
|
534
|
+
messageId: ctx.messageId,
|
|
535
|
+
messageType: event.message.message_type,
|
|
536
|
+
content: event.message.content,
|
|
537
|
+
maxBytes: mediaMaxBytes,
|
|
538
|
+
log,
|
|
539
|
+
});
|
|
540
|
+
const mediaPayload = buildFeishuMediaPayload(mediaList);
|
|
541
|
+
|
|
542
|
+
// Fetch quoted/replied message content if parentId exists
|
|
543
|
+
let quotedContent: string | undefined;
|
|
544
|
+
if (ctx.parentId) {
|
|
545
|
+
try {
|
|
546
|
+
const quotedMsg = await getMessageFeishu({ cfg, messageId: ctx.parentId });
|
|
547
|
+
if (quotedMsg) {
|
|
548
|
+
quotedContent = quotedMsg.content;
|
|
549
|
+
log(`feishu: fetched quoted message: ${quotedContent?.slice(0, 100)}`);
|
|
550
|
+
}
|
|
551
|
+
} catch (err) {
|
|
552
|
+
log(`feishu: failed to fetch quoted message: ${String(err)}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
557
|
+
|
|
558
|
+
// Build message body with quoted content if available
|
|
559
|
+
let messageBody = ctx.content;
|
|
560
|
+
if (quotedContent) {
|
|
561
|
+
messageBody = `[Replying to: "${quotedContent}"]\n\n${ctx.content}`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Include a readable speaker label so the model can attribute instructions.
|
|
565
|
+
// (DMs already have per-sender sessions, but the prefix is still useful for clarity.)
|
|
566
|
+
const speaker = ctx.senderName ?? ctx.senderOpenId;
|
|
567
|
+
messageBody = `${speaker}: ${messageBody}`;
|
|
568
|
+
|
|
569
|
+
const envelopeFrom = isGroup ? `${ctx.chatId}:${ctx.senderOpenId}` : ctx.senderOpenId;
|
|
570
|
+
|
|
571
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
572
|
+
channel: "Feishu",
|
|
573
|
+
from: envelopeFrom,
|
|
574
|
+
timestamp: new Date(),
|
|
575
|
+
envelope: envelopeOptions,
|
|
576
|
+
body: messageBody,
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
let combinedBody = body;
|
|
580
|
+
const historyKey = isGroup ? ctx.chatId : undefined;
|
|
581
|
+
|
|
582
|
+
if (isGroup && historyKey && chatHistories) {
|
|
583
|
+
combinedBody = buildPendingHistoryContextFromMap({
|
|
584
|
+
historyMap: chatHistories,
|
|
585
|
+
historyKey,
|
|
586
|
+
limit: historyLimit,
|
|
587
|
+
currentMessage: combinedBody,
|
|
588
|
+
formatEntry: (entry) =>
|
|
589
|
+
core.channel.reply.formatAgentEnvelope({
|
|
590
|
+
channel: "Feishu",
|
|
591
|
+
// Preserve speaker identity in group history as well.
|
|
592
|
+
from: `${ctx.chatId}:${entry.sender}`,
|
|
593
|
+
timestamp: entry.timestamp,
|
|
594
|
+
body: entry.body,
|
|
595
|
+
envelope: envelopeOptions,
|
|
596
|
+
}),
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
601
|
+
Body: combinedBody,
|
|
602
|
+
RawBody: ctx.content,
|
|
603
|
+
CommandBody: ctx.content,
|
|
604
|
+
From: feishuFrom,
|
|
605
|
+
To: feishuTo,
|
|
606
|
+
SessionKey: route.sessionKey,
|
|
607
|
+
AccountId: route.accountId,
|
|
608
|
+
ChatType: isGroup ? "group" : "direct",
|
|
609
|
+
GroupSubject: isGroup ? ctx.chatId : undefined,
|
|
610
|
+
SenderName: ctx.senderName ?? ctx.senderOpenId,
|
|
611
|
+
SenderId: ctx.senderOpenId,
|
|
612
|
+
Provider: "feishu" as const,
|
|
613
|
+
Surface: "feishu" as const,
|
|
614
|
+
MessageSid: ctx.messageId,
|
|
615
|
+
Timestamp: Date.now(),
|
|
616
|
+
WasMentioned: ctx.mentionedBot,
|
|
617
|
+
CommandAuthorized: true,
|
|
618
|
+
OriginatingChannel: "feishu" as const,
|
|
619
|
+
OriginatingTo: feishuTo,
|
|
620
|
+
...mediaPayload,
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
|
624
|
+
cfg,
|
|
625
|
+
agentId: route.agentId,
|
|
626
|
+
runtime: runtime as RuntimeEnv,
|
|
627
|
+
chatId: ctx.chatId,
|
|
628
|
+
replyToMessageId: ctx.messageId,
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
log(`feishu: dispatching to agent (session=${route.sessionKey})`);
|
|
632
|
+
|
|
633
|
+
const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
|
|
634
|
+
ctx: ctxPayload,
|
|
635
|
+
cfg,
|
|
636
|
+
dispatcher,
|
|
637
|
+
replyOptions,
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
markDispatchIdle();
|
|
641
|
+
|
|
642
|
+
if (isGroup && historyKey && chatHistories) {
|
|
643
|
+
clearHistoryEntriesIfEnabled({
|
|
644
|
+
historyMap: chatHistories,
|
|
645
|
+
historyKey,
|
|
646
|
+
limit: historyLimit,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
log(`feishu: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`);
|
|
651
|
+
} catch (err) {
|
|
652
|
+
error(`feishu: failed to dispatch message: ${String(err)}`);
|
|
653
|
+
}
|
|
654
|
+
}
|