@lmcl/ailo-mcp-feishu 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,29 +1,34 @@
1
1
  # 飞书通道
2
2
 
3
- 飞书/Lark 感知通道,WebSocket 长连接,双向收发。
3
+ 飞书感知通道,WebSocket 长连接,双向收发。
4
4
 
5
5
  ## 安装与添加
6
6
 
7
7
  ```bash
8
+ # 从 npm(需先 publish)
8
9
  npm install -g @lmcl/ailo-mcp-feishu
10
+
11
+ # 或本地开发:在项目目录下
12
+ npm run build && npm install -g .
9
13
  ```
10
14
 
11
- 然后通过 mcp_manage 创建并启动:
15
+ 然后通过 mcp_manage 创建并启动。**name 只能含字母、汉字、下划线**(无标点无数字),推荐纯英文尽量短:
12
16
 
13
17
  ```
14
- mcp_manage(action=create, name="channel:feishu", command="ailo-mcp-feishu", env={FEISHU_APP_ID: "cli_xxx", FEISHU_APP_SECRET: "xxx"})
15
- mcp_manage(action=start, name="channel:feishu")
18
+ mcp_manage(action=create, name="feishu", command="npx", args=["@lmcl/ailo-mcp-feishu"], env={FEISHU_APP_ID: "cli_xxx", FEISHU_APP_SECRET: "xxx"})
19
+ mcp_manage(action=start, name="feishu")
16
20
  ```
17
21
 
22
+ 若已全局安装(`npm install -g @lmcl/ailo-mcp-feishu`),可用 `command="ailo-mcp-feishu"`,args 留空。
23
+
18
24
  ## 环境变量
19
25
 
20
26
  | 变量 | 必填 | 说明 |
21
27
  |-----|-----|-----|
22
28
  | FEISHU_APP_ID | 是 | 飞书应用 App ID |
23
29
  | FEISHU_APP_SECRET | 是 | 飞书应用 App Secret |
24
- | FEISHU_DOMAIN | 否 | feishu \| lark,默认 feishu |
25
30
 
26
- AIDO_WS_URLAIDO_TOKENAIDO_MCP_NAME 由框架注入。
31
+ AILO_WS_URLAILO_TOKENAILO_MCP_NAME 由框架注入。
27
32
 
28
33
  ## 飞书开放平台
29
34
 
@@ -1,73 +1,9 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as Lark from "@larksuiteoapi/node-sdk";
4
- /** 历史消息阈值:发送时间超过此值视为重发,直接丢弃(毫秒) */
5
- const STALE_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000;
6
- /** 失败缓存 TTL:getUserInfo/getChatInfo 调用失败后缓存此时长,避免重复调用(毫秒) */
7
- const NEGATIVE_CACHE_TTL = 5 * 60 * 1000;
8
- /** 飞书 message_type 与「获取消息中的资源文件」API 的 type、Aido attachment type 的映射 */
9
- const MEDIA_MESSAGE_CONFIG = {
10
- image: { resourceType: "image", aidoType: "image", contentKey: "image_key" },
11
- file: { resourceType: "file", aidoType: "file", contentKey: "file_key" },
12
- audio: { resourceType: "audio", aidoType: "audio", contentKey: "file_key" },
13
- media: { resourceType: "video", aidoType: "video", contentKey: "file_key" },
14
- video: { resourceType: "video", aidoType: "video", contentKey: "file_key" },
15
- };
16
- function streamToBuffer(stream) {
17
- return new Promise((resolve, reject) => {
18
- const chunks = [];
19
- stream.on("data", (chunk) => chunks.push(chunk));
20
- stream.on("end", () => resolve(Buffer.concat(chunks)));
21
- stream.on("error", reject);
22
- });
23
- }
24
- function getPostContentRows(contentJson) {
25
- try {
26
- const root = JSON.parse(contentJson);
27
- let content = root?.content;
28
- if (!Array.isArray(content) && root?.post) {
29
- const lang = root.post.zh_cn ?? root.post.en;
30
- content = lang?.content;
31
- }
32
- return Array.isArray(content) ? content : null;
33
- }
34
- catch {
35
- return null;
36
- }
37
- }
38
- function extractTextFromPostContent(contentJson) {
39
- const content = getPostContentRows(contentJson);
40
- if (!content)
41
- return "";
42
- const parts = [];
43
- for (const row of content) {
44
- if (!Array.isArray(row))
45
- continue;
46
- for (const node of row) {
47
- if (node?.tag === "text" && typeof node.text === "string") {
48
- parts.push(node.text);
49
- }
50
- }
51
- }
52
- return parts.join("\n");
53
- }
54
- /** 从 post 消息 content 中解析出所有 img 节点的 image_key,用于拉取图片附件 */
55
- function extractImageKeysFromPostContent(contentJson) {
56
- const content = getPostContentRows(contentJson);
57
- if (!content)
58
- return [];
59
- const keys = [];
60
- for (const row of content) {
61
- if (!Array.isArray(row))
62
- continue;
63
- for (const node of row) {
64
- if (node?.tag === "img" && typeof node.image_key === "string") {
65
- keys.push(node.image_key);
66
- }
67
- }
68
- }
69
- return keys;
70
- }
4
+ import { getWorkDir } from "@lmcl/ailo-mcp-sdk";
5
+ import { MEDIA_MESSAGE_CONFIG, NEGATIVE_CACHE_TTL, STALE_MESSAGE_THRESHOLD_MS, } from "./feishu-types.js";
6
+ import { adaptMarkdownForFeishu, convertMarkdownTablesToCodeBlock, extractImageKeysFromPostContent, extractMentionElements, extractTextFromPostContent, streamToBuffer, } from "./feishu-utils.js";
71
7
  export class FeishuHandler {
72
8
  config;
73
9
  client;
@@ -95,12 +31,11 @@ export class FeishuHandler {
95
31
  CACHE_TTL = 24 * 60 * 60 * 1000;
96
32
  constructor(config) {
97
33
  this.config = config;
98
- const domain = config.domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu;
99
34
  this.client = new Lark.Client({
100
35
  appId: config.appId,
101
36
  appSecret: config.appSecret,
102
37
  appType: Lark.AppType.SelfBuild,
103
- domain,
38
+ domain: Lark.Domain.Feishu,
104
39
  // 抑制 SDK 内部的 error 日志输出(如外部用户 41050 等预期失败),
105
40
  // 应用层已在 getUserInfo/getChatInfo 等方法中自行处理错误并输出可读日志。
106
41
  loggerLevel: Lark.LoggerLevel.fatal,
@@ -134,7 +69,7 @@ export class FeishuHandler {
134
69
  if (k !== "_counter" && typeof v === "string")
135
70
  this.externalUserLabels.set(k, v);
136
71
  }
137
- console.log(`[feishu] 已加载外部用户映射: ${this.externalUserLabels.size} 条, counter=${this.externalUserCounter}`);
72
+ console.error(`[feishu] 已加载外部用户映射: ${this.externalUserLabels.size} 条, counter=${this.externalUserCounter}`);
138
73
  }
139
74
  catch {
140
75
  console.warn("[feishu] 解析外部用户数据失败,将从零开始");
@@ -326,7 +261,7 @@ export class FeishuHandler {
326
261
  const bot = res.data?.bot;
327
262
  this.botOpenId = bot?.open_id ?? bot?.user_id ?? "";
328
263
  if (this.botOpenId) {
329
- console.log(`[feishu] bot open_id: ${this.botOpenId}`);
264
+ console.error(`[feishu] bot open_id: ${this.botOpenId}`);
330
265
  }
331
266
  // 获取失败时仅 debug 级别,不影响核心功能(仅群聊 @检测 会失效)
332
267
  }
@@ -372,6 +307,34 @@ export class FeishuHandler {
372
307
  setOnMessageRead(handler) {
373
308
  this.onMessageRead = handler;
374
309
  }
310
+ buildBridgeMessage(opts) {
311
+ const { chatId, text, chatType, senderId = "", senderName = "", chatName, mentionsSelf, timestamp, attachments } = opts;
312
+ const isPrivate = chatType === "私聊";
313
+ const tags = [
314
+ { desc: "类型", value: chatType, core: true },
315
+ { desc: "会话", value: chatId, core: true },
316
+ ];
317
+ if (!isPrivate && chatName) {
318
+ tags.push({ desc: "群名", value: chatName, core: true });
319
+ }
320
+ else if (!isPrivate && chatId) {
321
+ tags.push({ desc: "群名", value: `群${chatId.slice(-8)}`, core: true });
322
+ }
323
+ tags.push({ desc: "昵称", value: senderName, core: isPrivate }, { desc: "用户", value: senderId, core: isPrivate });
324
+ if (mentionsSelf) {
325
+ tags.push({ desc: "@我", value: "是", core: isPrivate });
326
+ }
327
+ if (timestamp != null && timestamp > 0) {
328
+ const d = new Date(timestamp);
329
+ const pad = (n) => String(n).padStart(2, "0");
330
+ tags.push({
331
+ desc: "时间",
332
+ value: `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`,
333
+ core: false,
334
+ });
335
+ }
336
+ return { text, contextTags: tags, attachments };
337
+ }
375
338
  /**
376
339
  * 从飞书 SDK 抛出的错误中提取业务错误码。
377
340
  * SDK 的错误结构通常为 [AxiosError, { code, msg, ... }] 数组,
@@ -433,7 +396,7 @@ export class FeishuHandler {
433
396
  const errCode = this.extractFeishuErrorCode(err);
434
397
  if (errCode === 41050) {
435
398
  // 41050 = no user authority:外部用户(非本租户成员),无权访问通讯录信息,属预期情况
436
- console.log(`[feishu] getUserInfo(${userId}): 外部用户,无权限获取通讯录信息 (41050)`);
399
+ console.error(`[feishu] getUserInfo(${userId}): 外部用户,无权限获取通讯录信息 (41050)`);
437
400
  }
438
401
  else {
439
402
  const detail = err?.response?.data ?? err.message;
@@ -482,7 +445,7 @@ export class FeishuHandler {
482
445
  const errCode = this.extractFeishuErrorCode(err);
483
446
  if (errCode === 41050) {
484
447
  // 41050 = no user authority:外部群可能无权获取群信息
485
- console.log(`[feishu] getChatInfo(${chatId}): 无权限获取群信息 (41050)`);
448
+ console.error(`[feishu] getChatInfo(${chatId}): 无权限获取群信息 (41050)`);
486
449
  }
487
450
  else {
488
451
  const detail = err?.response?.data ?? err.message;
@@ -512,7 +475,7 @@ export class FeishuHandler {
512
475
  this.chatCache.delete(key);
513
476
  }
514
477
  }
515
- console.log(`[feishu] cache cleaned: users=${this.userCache.size}, chats=${this.chatCache.size}`);
478
+ console.error(`[feishu] cache cleaned: users=${this.userCache.size}, chats=${this.chatCache.size}`);
516
479
  }
517
480
  /**
518
481
  * 推断 chatId 对应的聊天类型(撤回/Reaction 等事件不返回 chat_type 时使用)
@@ -526,23 +489,14 @@ export class FeishuHandler {
526
489
  return "group";
527
490
  return "p2p";
528
491
  }
529
- getBlobCacheDir() {
530
- // 优先使用 AIDO_BLOB_CACHE(迁出生命目录);否则回退 AIDO_HOME/cache/blobs
531
- const blobCache = process.env.AIDO_BLOB_CACHE;
532
- if (blobCache)
533
- return blobCache;
534
- const home = process.env.AIDO_HOME;
535
- if (!home)
536
- return null;
537
- return path.join(home, "cache", "blobs");
538
- }
539
492
  /**
540
493
  * 下载消息资源到本地缓存,返回 path。失败时返回 null(调用方传 ref)。
541
494
  */
542
495
  async downloadToCache(messageId, fileKey, resourceType, aidoType) {
543
- const cacheDir = this.getBlobCacheDir();
544
- if (!cacheDir)
496
+ const workDir = getWorkDir();
497
+ if (!workDir)
545
498
  return null;
499
+ const cacheDir = path.join(workDir, "blobs");
546
500
  try {
547
501
  await fs.promises.mkdir(cacheDir, { recursive: true });
548
502
  }
@@ -601,11 +555,19 @@ export class FeishuHandler {
601
555
  start() {
602
556
  // 获取 bot 自身的 open_id(异步,不阻塞启动)
603
557
  this.fetchBotOpenId();
604
- const domain = this.config.domain === "lark" ? Lark.Domain.Lark : Lark.Domain.Feishu;
558
+ // MCP stdio 占用 stdout,Lark SDK logger 必须写到 stderr
559
+ const stderrLogger = {
560
+ error: (...args) => console.error(...args),
561
+ warn: (...args) => console.error(...args),
562
+ info: (...args) => console.error(...args),
563
+ debug: (...args) => console.error(...args),
564
+ trace: (...args) => console.error(...args),
565
+ };
605
566
  const wsClient = new Lark.WSClient({
606
567
  appId: this.config.appId,
607
568
  appSecret: this.config.appSecret,
608
- domain,
569
+ domain: Lark.Domain.Feishu,
570
+ logger: stderrLogger,
609
571
  loggerLevel: Lark.LoggerLevel.info,
610
572
  });
611
573
  this.wsClient = wsClient;
@@ -634,22 +596,22 @@ export class FeishuHandler {
634
596
  // 历史消息过滤:发送时间超过 5 分钟视为重发,直接丢弃
635
597
  const createTimeMs = msg.create_time ? parseInt(msg.create_time, 10) : NaN;
636
598
  if (!isNaN(createTimeMs) && Date.now() - createTimeMs > STALE_MESSAGE_THRESHOLD_MS) {
637
- console.log(`[feishu] dropped stale message ${messageId} (create_time ${createTimeMs}, age ${Math.round((Date.now() - createTimeMs) / 60000)}min)`);
599
+ console.error(`[feishu] dropped stale message ${messageId} (create_time ${createTimeMs}, age ${Math.round((Date.now() - createTimeMs) / 60000)}min)`);
638
600
  return;
639
601
  }
640
- console.log(`[feishu] received ${messageType} ${chatType} ${chatId} from ${senderId} (content length ${rawContent.length})`);
602
+ console.error(`[feishu] received ${messageType} ${chatType} ${chatId} from ${senderId} (content length ${rawContent.length})`);
641
603
  if (rawContent.length > 0 && rawContent.length <= 500) {
642
- console.log("[feishu] content preview:", rawContent);
604
+ console.error("[feishu] content preview:", rawContent);
643
605
  }
644
606
  else if (rawContent.length > 500) {
645
- console.log("[feishu] content preview:", rawContent.slice(0, 500) + "...");
607
+ console.error("[feishu] content preview:", rawContent.slice(0, 500) + "...");
646
608
  }
647
609
  // 并行获取用户信息和群聊信息
648
610
  const [userInfo, chatInfo] = await Promise.all([
649
611
  senderId ? this.getUserInfo(senderId) : Promise.resolve(null),
650
612
  chatType === "group" ? this.getChatInfo(chatId) : Promise.resolve(null),
651
613
  ]);
652
- console.log(`[feishu] sender=${senderId} name=${userInfo?.name ?? "(empty)"} chatType=${chatType} chatName=${chatInfo?.name ?? "(none)"}`);
614
+ console.error(`[feishu] sender=${senderId} name=${userInfo?.name ?? "(empty)"} chatType=${chatType} chatName=${chatInfo?.name ?? "(none)"}`);
653
615
  // 缓存 senderName → senderId,供出站消息 @Name 转换使用
654
616
  if (senderId && userInfo?.name) {
655
617
  this.mentionNameToId.set(userInfo.name, senderId);
@@ -683,12 +645,12 @@ export class FeishuHandler {
683
645
  text = extractTextFromPostContent(rawContent);
684
646
  const postImageKeys = [...new Set(extractImageKeysFromPostContent(rawContent))];
685
647
  if (postImageKeys.length > 0) {
686
- console.log(`[feishu] post: fetching ${postImageKeys.length} image(s) from message ${messageId}`);
648
+ console.error(`[feishu] post: fetching ${postImageKeys.length} image(s) from message ${messageId}`);
687
649
  for (const imageKey of postImageKeys) {
688
650
  const attach = await this.fetchMessageResourceAttachment(messageId, imageKey, "image", "image");
689
651
  if (attach) {
690
652
  attachments.push(attach);
691
- console.log(`[feishu] post: fetched image ok (${attach.path ? "path" : "ref"})`);
653
+ console.error(`[feishu] post: fetched image ok (${attach.path ? "path" : "ref"})`);
692
654
  }
693
655
  else {
694
656
  console.warn(`[feishu] post: failed to fetch image ${imageKey}`);
@@ -729,22 +691,19 @@ export class FeishuHandler {
729
691
  if (msg.parent_id) {
730
692
  text = `[回复消息 ${msg.parent_id}] ${text}`;
731
693
  }
732
- // 组装完整消息并回调(符合 BridgeMessage 接口,chatType 用中文标签,isPrivate 供 SDK 生成 coreForSenseContext)
733
694
  if (chatId || messageId) {
734
695
  const isP2p = chatType === "p2p";
735
- this.onMessageHandler({
736
- chatId,
737
- messageId,
696
+ this.onMessageHandler(this.buildBridgeMessage({
697
+ chatId: chatId ?? messageId ?? "",
698
+ text,
699
+ chatType: isP2p ? "私聊" : "群聊",
738
700
  senderId,
739
701
  senderName: userInfo?.name || "获取昵称失败",
740
- chatType: isP2p ? "私聊" : "群聊",
741
702
  chatName: chatInfo?.name,
742
- text,
743
703
  mentionsSelf,
744
704
  attachments,
745
705
  timestamp,
746
- isPrivate: isP2p,
747
- });
706
+ }));
748
707
  }
749
708
  },
750
709
  "im.chat.access_event.bot_p2p_chat_entered_v1": async (data) => {
@@ -779,7 +738,7 @@ export class FeishuHandler {
779
738
  const meta = this.msgMetaMap.get(messageId);
780
739
  const chatId = meta?.chatId ?? event.chat_id ?? "";
781
740
  if (!chatId) {
782
- console.log(`[feishu] recalled unknown message ${messageId}, skipping`);
741
+ console.error(`[feishu] recalled unknown message ${messageId}, skipping`);
783
742
  return;
784
743
  }
785
744
  // 尝试获取被撤回消息的发送者信息
@@ -836,18 +795,16 @@ export class FeishuHandler {
836
795
  const actor = recallLabel[recallType] ?? "某人";
837
796
  text = `[${actor}撤回了一条消息]`;
838
797
  }
839
- console.log(`[feishu] message recalled: ${messageId} by ${msgSenderName || "(unknown)"}(${msgSenderId}) recall_type=${recallType} in ${chatId}`);
798
+ console.error(`[feishu] message recalled: ${messageId} by ${msgSenderName || "(unknown)"}(${msgSenderId}) recall_type=${recallType} in ${chatId}`);
840
799
  const isP2pRecall = chatType === "p2p";
841
- this.onMessageHandler({
800
+ this.onMessageHandler(this.buildBridgeMessage({
842
801
  chatId,
843
- messageId,
802
+ text,
803
+ chatType: isP2pRecall ? "私聊" : "群聊",
844
804
  senderId: msgSenderId,
845
805
  senderName: msgSenderName,
846
- chatType: isP2pRecall ? "私聊" : "群聊",
847
- text,
848
806
  timestamp: event.recall_time ? parseInt(event.recall_time, 10) : Date.now(),
849
- isPrivate: isP2pRecall,
850
- });
807
+ }));
851
808
  },
852
809
  "im.message.reaction.created_v1": async (data) => {
853
810
  if (!this.onMessageHandler)
@@ -859,24 +816,22 @@ export class FeishuHandler {
859
816
  const meta = this.msgMetaMap.get(messageId);
860
817
  const chatId = meta?.chatId ?? "";
861
818
  if (!chatId) {
862
- console.log(`[feishu] reaction on unknown message ${messageId}, skipping`);
819
+ console.error(`[feishu] reaction on unknown message ${messageId}, skipping`);
863
820
  return;
864
821
  }
865
822
  const reactorInfo = reactorId ? await this.getUserInfo(reactorId) : null;
866
823
  const reactorName = reactorInfo?.name || reactorId || "某人";
867
824
  const chatType = this.inferChatType(chatId);
868
- console.log(`[feishu] reaction ${emoji} on ${messageId} by ${reactorName}(${reactorId})`);
825
+ console.error(`[feishu] reaction ${emoji} on ${messageId} by ${reactorName}(${reactorId})`);
869
826
  const isP2pReaction = chatType === "p2p";
870
- this.onMessageHandler({
827
+ this.onMessageHandler(this.buildBridgeMessage({
871
828
  chatId,
872
- messageId,
829
+ text: `[${reactorName} 对一条消息贴了表情 ${emoji} (message_id: ${messageId})]`,
830
+ chatType: isP2pReaction ? "私聊" : "群聊",
873
831
  senderId: reactorId,
874
832
  senderName: reactorName,
875
- chatType: isP2pReaction ? "私聊" : "群聊",
876
- text: `[${reactorName} 对一条消息贴了表情 ${emoji} (message_id: ${messageId})]`,
877
833
  timestamp: event.action_time ? parseInt(event.action_time, 10) : Date.now(),
878
- isPrivate: isP2pReaction,
879
- });
834
+ }));
880
835
  },
881
836
  "im.message.reaction.deleted_v1": async (data) => {
882
837
  if (!this.onMessageHandler)
@@ -888,24 +843,22 @@ export class FeishuHandler {
888
843
  const meta2 = this.msgMetaMap.get(messageId);
889
844
  const chatId = meta2?.chatId ?? "";
890
845
  if (!chatId) {
891
- console.log(`[feishu] reaction-delete on unknown message ${messageId}, skipping`);
846
+ console.error(`[feishu] reaction-delete on unknown message ${messageId}, skipping`);
892
847
  return;
893
848
  }
894
849
  const reactorInfo = reactorId ? await this.getUserInfo(reactorId) : null;
895
850
  const reactorName = reactorInfo?.name || reactorId || "某人";
896
851
  const chatType = this.inferChatType(chatId);
897
- console.log(`[feishu] reaction removed ${emoji} on ${messageId} by ${reactorName}(${reactorId})`);
852
+ console.error(`[feishu] reaction removed ${emoji} on ${messageId} by ${reactorName}(${reactorId})`);
898
853
  const isP2pDel = chatType === "p2p";
899
- this.onMessageHandler({
854
+ this.onMessageHandler(this.buildBridgeMessage({
900
855
  chatId,
901
- messageId,
856
+ text: `[${reactorName} 移除了表情 ${emoji} (message_id: ${messageId})]`,
857
+ chatType: isP2pDel ? "私聊" : "群聊",
902
858
  senderId: reactorId,
903
859
  senderName: reactorName,
904
- chatType: isP2pDel ? "私聊" : "群聊",
905
- text: `[${reactorName} 移除了表情 ${emoji} (message_id: ${messageId})]`,
906
860
  timestamp: event.action_time ? parseInt(event.action_time, 10) : Date.now(),
907
- isPrivate: isP2pDel,
908
- });
861
+ }));
909
862
  },
910
863
  });
911
864
  wsClient.start({ eventDispatcher });
@@ -1022,210 +975,17 @@ export class FeishuHandler {
1022
975
  },
1023
976
  });
1024
977
  }
1025
- /**
1026
- * 处理来自 LLM 的结构化命令。
1027
- *
1028
- * 当前支持的命令:
1029
- * - set_nickname: 更新外部用户的昵称映射
1030
- * params: { sender_id: "ou_xxx", nickname: "小红" }
1031
- */
978
+ /** 处理来自 MCP 工具 feishu action=set_nickname 的命令 */
1032
979
  async onCommand(command, params) {
1033
- switch (command) {
1034
- case "set_nickname": {
1035
- const senderId = String(params.sender_id ?? "");
1036
- const nickname = String(params.nickname ?? "").trim();
1037
- if (!senderId) {
1038
- console.warn("[feishu] set_nickname: sender_id is required");
1039
- return;
1040
- }
1041
- if (!nickname) {
1042
- console.warn("[feishu] set_nickname: nickname is required");
1043
- return;
1044
- }
1045
- const oldLabel = this.externalUserLabels.get(senderId);
1046
- // 更新外部用户映射(无论之前是否存在)
1047
- this.externalUserLabels.set(senderId, nickname);
1048
- // 同步更新 userCache,使后续消息立即使用新昵称
1049
- this.userCache.set(senderId, { value: { name: nickname, openId: senderId }, ts: Date.now() });
1050
- // 同步更新 mentionNameToId,使出站消息 @新昵称 立即可用
1051
- this.mentionNameToId.set(nickname, senderId);
1052
- this.saveExternalUserLabel(senderId, nickname);
1053
- console.log(`[feishu] set_nickname: ${senderId} ${oldLabel ? oldLabel + " → " : "→ "}${nickname}`);
1054
- break;
1055
- }
1056
- case "add_reaction": {
1057
- const messageId = String(params.message_id ?? "");
1058
- const emojiType = String(params.emoji_type ?? "").trim();
1059
- if (!messageId) {
1060
- console.warn("[feishu] add_reaction: message_id is required");
1061
- return;
1062
- }
1063
- if (!emojiType) {
1064
- console.warn("[feishu] add_reaction: emoji_type is required");
1065
- return;
1066
- }
1067
- try {
1068
- await this.client.request({
1069
- method: "POST",
1070
- url: `/open-apis/im/v1/messages/${messageId}/reactions`,
1071
- data: { reaction_type: { emoji_type: emojiType } },
1072
- });
1073
- console.log(`[feishu] add_reaction: ${emojiType} on ${messageId}`);
1074
- }
1075
- catch (err) {
1076
- console.warn(`[feishu] add_reaction failed:`, err);
1077
- }
1078
- break;
1079
- }
1080
- default:
1081
- console.log(`[feishu] unknown command: ${command}`);
1082
- }
1083
- }
1084
- }
1085
- /**
1086
- * 从出站文本中提取 @提及,返回独立的 at 元素列表和清理后的文本。
1087
- * 飞书 post 消息的 md 标签不支持内联 <at> 语法,
1088
- * 必须将 @提及 作为独立的 { tag: "at", user_id } 元素放在 content 数组中。
1089
- *
1090
- * 两种匹配策略:
1091
- * 1. @Name(open_id) 格式 —— AI 保留了完整 ID 信息
1092
- * 2. @Name 格式 —— AI 只写了名字,通过 nameToIdCache 查找 open_id
1093
- */
1094
- function extractMentionElements(text, nameToIdCache) {
1095
- const atElements = [];
1096
- const seenIds = new Set();
1097
- // Step 1: 提取 @Name(open_id) 格式并从文本中移除
1098
- let cleanText = text.replace(/@([^@(]+?)\(([a-zA-Z0-9][a-zA-Z0-9_]{9,})\)/g, (_, displayName, userId) => {
1099
- if (!seenIds.has(userId)) {
1100
- atElements.push({ userId, name: displayName.trim() });
1101
- seenIds.add(userId);
1102
- }
1103
- return ""; // 从文本中移除
1104
- });
1105
- // Step 2: 通过名称缓存提取剩余的纯 @Name
1106
- if (nameToIdCache && nameToIdCache.size > 0) {
1107
- // 按名称长度降序排列,避免短名称部分匹配长名称
1108
- const names = [...nameToIdCache.keys()].sort((a, b) => b.length - a.length);
1109
- for (const name of names) {
1110
- const openId = nameToIdCache.get(name);
1111
- const atMention = `@${name}`;
1112
- if (cleanText.includes(atMention)) {
1113
- if (!seenIds.has(openId)) {
1114
- atElements.push({ userId: openId, name });
1115
- seenIds.add(openId);
1116
- }
1117
- cleanText = cleanText.replaceAll(atMention, "");
1118
- }
1119
- }
1120
- }
1121
- // 清理多余空格(仅压缩行内水平空白,保留换行结构)
1122
- cleanText = cleanText
1123
- .replace(/[^\S\n]+/g, " ") // 行内连续空白 → 单个空格
1124
- .replace(/\n{3,}/g, "\n\n") // 3+ 连续换行 → 双换行(段落分隔)
1125
- .trim();
1126
- return { cleanText, atElements };
1127
- }
1128
- // ─── Markdown 适配:飞书 md 标签受限子集 ───
1129
- /**
1130
- * 将标准 Markdown 适配为飞书 md 标签支持的子集。
1131
- *
1132
- * 飞书 md 标签的已知限制:
1133
- * - 标题仅支持 # 和 ##,###+ 不渲染(被当纯文本显示)
1134
- * - 列表不支持缩进/嵌套
1135
- * - 列表项仅支持文本和链接
1136
- * - 不支持表格(由 convertMarkdownTablesToCodeBlock 另行处理)
1137
- */
1138
- function adaptMarkdownForFeishu(text) {
1139
- return text
1140
- // ###+ 标题 → **加粗文本**(飞书仅支持 # 和 ##)
1141
- .replace(/^#{3,}\s+(.+)$/gm, "**$1**")
1142
- // 移除嵌套列表的前导缩进(飞书不支持缩进)
1143
- .replace(/^[ \t]+([-*])\s/gm, "$1 ")
1144
- .replace(/^[ \t]+(\d+\.)\s/gm, "$1 ");
1145
- }
1146
- // ─── Markdown 表格转换工具函数 ───
1147
- /** 获取字符串显示宽度(CJK 字符算 2,其他算 1) */
1148
- function getDisplayWidth(str) {
1149
- let width = 0;
1150
- for (const ch of str) {
1151
- const code = ch.codePointAt(0) ?? 0;
1152
- if ((code >= 0x4e00 && code <= 0x9fff) ||
1153
- (code >= 0x3000 && code <= 0x303f) ||
1154
- (code >= 0xff00 && code <= 0xffef) ||
1155
- (code >= 0x3400 && code <= 0x4dbf) ||
1156
- (code >= 0x20000 && code <= 0x2a6df)) {
1157
- width += 2;
1158
- }
1159
- else {
1160
- width += 1;
1161
- }
1162
- }
1163
- return width;
1164
- }
1165
- /** 按显示宽度右填充空格 */
1166
- function padEnd(str, targetWidth) {
1167
- const padding = Math.max(0, targetWidth - getDisplayWidth(str));
1168
- return str + " ".repeat(padding);
1169
- }
1170
- /**
1171
- * 将 Markdown 中的表格块转换为代码块(等宽对齐文本),
1172
- * 飞书 md 标签不支持表格语法,会被静默丢弃。
1173
- */
1174
- function convertMarkdownTablesToCodeBlock(text) {
1175
- const lines = text.split("\n");
1176
- const result = [];
1177
- let tableLines = [];
1178
- const flushTable = () => {
1179
- if (tableLines.length === 0)
980
+ if (command !== "set_nickname")
1180
981
  return;
1181
- // 解析表格:提取单元格,跳过分隔行(|---|---|)
1182
- const rows = [];
1183
- for (const line of tableLines) {
1184
- const trimmed = line.replace(/^\|/, "").replace(/\|$/, "");
1185
- const cells = trimmed.split("|").map((c) => c.trim());
1186
- if (cells.every((c) => /^[-:]+$/.test(c)))
1187
- continue;
1188
- rows.push(cells);
1189
- }
1190
- if (rows.length === 0) {
1191
- result.push(...tableLines);
1192
- tableLines = [];
982
+ const senderId = String(params.sender_id ?? "");
983
+ const nickname = String(params.nickname ?? "").trim();
984
+ if (!senderId || !nickname)
1193
985
  return;
1194
- }
1195
- // 计算每列最大显示宽度
1196
- const colCount = Math.max(...rows.map((r) => r.length));
1197
- const colWidths = Array(colCount).fill(0);
1198
- for (const row of rows) {
1199
- for (let i = 0; i < row.length; i++) {
1200
- colWidths[i] = Math.max(colWidths[i], getDisplayWidth(row[i]));
1201
- }
1202
- }
1203
- // 格式化为对齐文本
1204
- const formatted = [];
1205
- for (let ri = 0; ri < rows.length; ri++) {
1206
- const row = rows[ri];
1207
- const cells = row.map((cell, ci) => padEnd(cell, colWidths[ci]));
1208
- formatted.push(cells.join(" | "));
1209
- if (ri === 0 && rows.length > 1) {
1210
- formatted.push(colWidths.map((w) => "-".repeat(w)).join("-+-"));
1211
- }
1212
- }
1213
- result.push("```");
1214
- result.push(...formatted);
1215
- result.push("```");
1216
- tableLines = [];
1217
- };
1218
- for (const line of lines) {
1219
- if (/^\s*\|/.test(line)) {
1220
- tableLines.push(line);
1221
- }
1222
- else {
1223
- if (tableLines.length > 0)
1224
- flushTable();
1225
- result.push(line);
1226
- }
986
+ this.externalUserLabels.set(senderId, nickname);
987
+ this.userCache.set(senderId, { value: { name: nickname, openId: senderId }, ts: Date.now() });
988
+ this.mentionNameToId.set(nickname, senderId);
989
+ this.saveExternalUserLabel(senderId, nickname);
1227
990
  }
1228
- if (tableLines.length > 0)
1229
- flushTable();
1230
- return result.join("\n");
1231
991
  }
@@ -0,0 +1,9 @@
1
+ export const STALE_MESSAGE_THRESHOLD_MS = 5 * 60 * 1000;
2
+ export const NEGATIVE_CACHE_TTL = 5 * 60 * 1000;
3
+ export const MEDIA_MESSAGE_CONFIG = {
4
+ image: { resourceType: "image", aidoType: "image", contentKey: "image_key" },
5
+ file: { resourceType: "file", aidoType: "file", contentKey: "file_key" },
6
+ audio: { resourceType: "audio", aidoType: "audio", contentKey: "file_key" },
7
+ media: { resourceType: "video", aidoType: "video", contentKey: "file_key" },
8
+ video: { resourceType: "video", aidoType: "video", contentKey: "file_key" },
9
+ };