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