@lmcl/ailo-mcp-feishu 0.0.2 → 0.0.4

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,6 +1,6 @@
1
1
  # 飞书通道
2
2
 
3
- 飞书/Lark 感知通道,WebSocket 长连接,双向收发。
3
+ 飞书感知通道,WebSocket 长连接,双向收发。
4
4
 
5
5
  ## 安装与添加
6
6
 
@@ -12,22 +12,23 @@ npm install -g @lmcl/ailo-mcp-feishu
12
12
  npm run build && npm install -g .
13
13
  ```
14
14
 
15
- 然后通过 mcp_manage 创建并启动:
15
+ 然后通过 mcp_manage 创建并启动。**name 只能含字母、汉字、下划线**(无标点无数字),推荐纯英文尽量短:
16
16
 
17
17
  ```
18
- mcp_manage(action=create, name="channel:feishu", command="ailo-mcp-feishu", env={FEISHU_APP_ID: "cli_xxx", FEISHU_APP_SECRET: "xxx"})
19
- 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")
20
20
  ```
21
21
 
22
+ 若已全局安装(`npm install -g @lmcl/ailo-mcp-feishu`),可用 `command="ailo-mcp-feishu"`,args 留空。
23
+
22
24
  ## 环境变量
23
25
 
24
26
  | 变量 | 必填 | 说明 |
25
27
  |-----|-----|-----|
26
28
  | FEISHU_APP_ID | 是 | 飞书应用 App ID |
27
29
  | FEISHU_APP_SECRET | 是 | 飞书应用 App Secret |
28
- | FEISHU_DOMAIN | 否 | feishu \| lark,默认 feishu |
29
30
 
30
- AIDO_WS_URLAIDO_TOKENAIDO_MCP_NAME 由框架注入。
31
+ AILO_WS_URLAILO_TOKENAILO_MCP_NAME 由框架注入。
31
32
 
32
33
  ## 飞书开放平台
33
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,
@@ -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, ... }] 数组,
@@ -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,7 +555,6 @@ 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;
605
558
  // MCP stdio 占用 stdout,Lark SDK 的 logger 必须写到 stderr
606
559
  const stderrLogger = {
607
560
  error: (...args) => console.error(...args),
@@ -613,7 +566,7 @@ export class FeishuHandler {
613
566
  const wsClient = new Lark.WSClient({
614
567
  appId: this.config.appId,
615
568
  appSecret: this.config.appSecret,
616
- domain,
569
+ domain: Lark.Domain.Feishu,
617
570
  logger: stderrLogger,
618
571
  loggerLevel: Lark.LoggerLevel.info,
619
572
  });
@@ -738,22 +691,19 @@ export class FeishuHandler {
738
691
  if (msg.parent_id) {
739
692
  text = `[回复消息 ${msg.parent_id}] ${text}`;
740
693
  }
741
- // 组装完整消息并回调(符合 BridgeMessage 接口,chatType 用中文标签,isPrivate 供 SDK 生成 coreForSenseContext)
742
694
  if (chatId || messageId) {
743
695
  const isP2p = chatType === "p2p";
744
- this.onMessageHandler({
745
- chatId,
746
- messageId,
696
+ this.onMessageHandler(this.buildBridgeMessage({
697
+ chatId: chatId ?? messageId ?? "",
698
+ text,
699
+ chatType: isP2p ? "私聊" : "群聊",
747
700
  senderId,
748
701
  senderName: userInfo?.name || "获取昵称失败",
749
- chatType: isP2p ? "私聊" : "群聊",
750
702
  chatName: chatInfo?.name,
751
- text,
752
703
  mentionsSelf,
753
704
  attachments,
754
705
  timestamp,
755
- isPrivate: isP2p,
756
- });
706
+ }));
757
707
  }
758
708
  },
759
709
  "im.chat.access_event.bot_p2p_chat_entered_v1": async (data) => {
@@ -847,16 +797,14 @@ export class FeishuHandler {
847
797
  }
848
798
  console.error(`[feishu] message recalled: ${messageId} by ${msgSenderName || "(unknown)"}(${msgSenderId}) recall_type=${recallType} in ${chatId}`);
849
799
  const isP2pRecall = chatType === "p2p";
850
- this.onMessageHandler({
800
+ this.onMessageHandler(this.buildBridgeMessage({
851
801
  chatId,
852
- messageId,
802
+ text,
803
+ chatType: isP2pRecall ? "私聊" : "群聊",
853
804
  senderId: msgSenderId,
854
805
  senderName: msgSenderName,
855
- chatType: isP2pRecall ? "私聊" : "群聊",
856
- text,
857
806
  timestamp: event.recall_time ? parseInt(event.recall_time, 10) : Date.now(),
858
- isPrivate: isP2pRecall,
859
- });
807
+ }));
860
808
  },
861
809
  "im.message.reaction.created_v1": async (data) => {
862
810
  if (!this.onMessageHandler)
@@ -876,16 +824,14 @@ export class FeishuHandler {
876
824
  const chatType = this.inferChatType(chatId);
877
825
  console.error(`[feishu] reaction ${emoji} on ${messageId} by ${reactorName}(${reactorId})`);
878
826
  const isP2pReaction = chatType === "p2p";
879
- this.onMessageHandler({
827
+ this.onMessageHandler(this.buildBridgeMessage({
880
828
  chatId,
881
- messageId,
829
+ text: `[${reactorName} 对一条消息贴了表情 ${emoji} (message_id: ${messageId})]`,
830
+ chatType: isP2pReaction ? "私聊" : "群聊",
882
831
  senderId: reactorId,
883
832
  senderName: reactorName,
884
- chatType: isP2pReaction ? "私聊" : "群聊",
885
- text: `[${reactorName} 对一条消息贴了表情 ${emoji} (message_id: ${messageId})]`,
886
833
  timestamp: event.action_time ? parseInt(event.action_time, 10) : Date.now(),
887
- isPrivate: isP2pReaction,
888
- });
834
+ }));
889
835
  },
890
836
  "im.message.reaction.deleted_v1": async (data) => {
891
837
  if (!this.onMessageHandler)
@@ -905,16 +851,14 @@ export class FeishuHandler {
905
851
  const chatType = this.inferChatType(chatId);
906
852
  console.error(`[feishu] reaction removed ${emoji} on ${messageId} by ${reactorName}(${reactorId})`);
907
853
  const isP2pDel = chatType === "p2p";
908
- this.onMessageHandler({
854
+ this.onMessageHandler(this.buildBridgeMessage({
909
855
  chatId,
910
- messageId,
856
+ text: `[${reactorName} 移除了表情 ${emoji} (message_id: ${messageId})]`,
857
+ chatType: isP2pDel ? "私聊" : "群聊",
911
858
  senderId: reactorId,
912
859
  senderName: reactorName,
913
- chatType: isP2pDel ? "私聊" : "群聊",
914
- text: `[${reactorName} 移除了表情 ${emoji} (message_id: ${messageId})]`,
915
860
  timestamp: event.action_time ? parseInt(event.action_time, 10) : Date.now(),
916
- isPrivate: isP2pDel,
917
- });
861
+ }));
918
862
  },
919
863
  });
920
864
  wsClient.start({ eventDispatcher });
@@ -1031,210 +975,17 @@ export class FeishuHandler {
1031
975
  },
1032
976
  });
1033
977
  }
1034
- /**
1035
- * 处理来自 LLM 的结构化命令。
1036
- *
1037
- * 当前支持的命令:
1038
- * - set_nickname: 更新外部用户的昵称映射
1039
- * params: { sender_id: "ou_xxx", nickname: "小红" }
1040
- */
978
+ /** 处理来自 MCP 工具 feishu action=set_nickname 的命令 */
1041
979
  async onCommand(command, params) {
1042
- switch (command) {
1043
- case "set_nickname": {
1044
- const senderId = String(params.sender_id ?? "");
1045
- const nickname = String(params.nickname ?? "").trim();
1046
- if (!senderId) {
1047
- console.warn("[feishu] set_nickname: sender_id is required");
1048
- return;
1049
- }
1050
- if (!nickname) {
1051
- console.warn("[feishu] set_nickname: nickname is required");
1052
- return;
1053
- }
1054
- const oldLabel = this.externalUserLabels.get(senderId);
1055
- // 更新外部用户映射(无论之前是否存在)
1056
- this.externalUserLabels.set(senderId, nickname);
1057
- // 同步更新 userCache,使后续消息立即使用新昵称
1058
- this.userCache.set(senderId, { value: { name: nickname, openId: senderId }, ts: Date.now() });
1059
- // 同步更新 mentionNameToId,使出站消息 @新昵称 立即可用
1060
- this.mentionNameToId.set(nickname, senderId);
1061
- this.saveExternalUserLabel(senderId, nickname);
1062
- console.error(`[feishu] set_nickname: ${senderId} ${oldLabel ? oldLabel + " → " : "→ "}${nickname}`);
1063
- break;
1064
- }
1065
- case "add_reaction": {
1066
- const messageId = String(params.message_id ?? "");
1067
- const emojiType = String(params.emoji_type ?? "").trim();
1068
- if (!messageId) {
1069
- console.warn("[feishu] add_reaction: message_id is required");
1070
- return;
1071
- }
1072
- if (!emojiType) {
1073
- console.warn("[feishu] add_reaction: emoji_type is required");
1074
- return;
1075
- }
1076
- try {
1077
- await this.client.request({
1078
- method: "POST",
1079
- url: `/open-apis/im/v1/messages/${messageId}/reactions`,
1080
- data: { reaction_type: { emoji_type: emojiType } },
1081
- });
1082
- console.error(`[feishu] add_reaction: ${emojiType} on ${messageId}`);
1083
- }
1084
- catch (err) {
1085
- console.warn(`[feishu] add_reaction failed:`, err);
1086
- }
1087
- break;
1088
- }
1089
- default:
1090
- console.error(`[feishu] unknown command: ${command}`);
1091
- }
1092
- }
1093
- }
1094
- /**
1095
- * 从出站文本中提取 @提及,返回独立的 at 元素列表和清理后的文本。
1096
- * 飞书 post 消息的 md 标签不支持内联 <at> 语法,
1097
- * 必须将 @提及 作为独立的 { tag: "at", user_id } 元素放在 content 数组中。
1098
- *
1099
- * 两种匹配策略:
1100
- * 1. @Name(open_id) 格式 —— AI 保留了完整 ID 信息
1101
- * 2. @Name 格式 —— AI 只写了名字,通过 nameToIdCache 查找 open_id
1102
- */
1103
- function extractMentionElements(text, nameToIdCache) {
1104
- const atElements = [];
1105
- const seenIds = new Set();
1106
- // Step 1: 提取 @Name(open_id) 格式并从文本中移除
1107
- let cleanText = text.replace(/@([^@(]+?)\(([a-zA-Z0-9][a-zA-Z0-9_]{9,})\)/g, (_, displayName, userId) => {
1108
- if (!seenIds.has(userId)) {
1109
- atElements.push({ userId, name: displayName.trim() });
1110
- seenIds.add(userId);
1111
- }
1112
- return ""; // 从文本中移除
1113
- });
1114
- // Step 2: 通过名称缓存提取剩余的纯 @Name
1115
- if (nameToIdCache && nameToIdCache.size > 0) {
1116
- // 按名称长度降序排列,避免短名称部分匹配长名称
1117
- const names = [...nameToIdCache.keys()].sort((a, b) => b.length - a.length);
1118
- for (const name of names) {
1119
- const openId = nameToIdCache.get(name);
1120
- const atMention = `@${name}`;
1121
- if (cleanText.includes(atMention)) {
1122
- if (!seenIds.has(openId)) {
1123
- atElements.push({ userId: openId, name });
1124
- seenIds.add(openId);
1125
- }
1126
- cleanText = cleanText.replaceAll(atMention, "");
1127
- }
1128
- }
1129
- }
1130
- // 清理多余空格(仅压缩行内水平空白,保留换行结构)
1131
- cleanText = cleanText
1132
- .replace(/[^\S\n]+/g, " ") // 行内连续空白 → 单个空格
1133
- .replace(/\n{3,}/g, "\n\n") // 3+ 连续换行 → 双换行(段落分隔)
1134
- .trim();
1135
- return { cleanText, atElements };
1136
- }
1137
- // ─── Markdown 适配:飞书 md 标签受限子集 ───
1138
- /**
1139
- * 将标准 Markdown 适配为飞书 md 标签支持的子集。
1140
- *
1141
- * 飞书 md 标签的已知限制:
1142
- * - 标题仅支持 # 和 ##,###+ 不渲染(被当纯文本显示)
1143
- * - 列表不支持缩进/嵌套
1144
- * - 列表项仅支持文本和链接
1145
- * - 不支持表格(由 convertMarkdownTablesToCodeBlock 另行处理)
1146
- */
1147
- function adaptMarkdownForFeishu(text) {
1148
- return text
1149
- // ###+ 标题 → **加粗文本**(飞书仅支持 # 和 ##)
1150
- .replace(/^#{3,}\s+(.+)$/gm, "**$1**")
1151
- // 移除嵌套列表的前导缩进(飞书不支持缩进)
1152
- .replace(/^[ \t]+([-*])\s/gm, "$1 ")
1153
- .replace(/^[ \t]+(\d+\.)\s/gm, "$1 ");
1154
- }
1155
- // ─── Markdown 表格转换工具函数 ───
1156
- /** 获取字符串显示宽度(CJK 字符算 2,其他算 1) */
1157
- function getDisplayWidth(str) {
1158
- let width = 0;
1159
- for (const ch of str) {
1160
- const code = ch.codePointAt(0) ?? 0;
1161
- if ((code >= 0x4e00 && code <= 0x9fff) ||
1162
- (code >= 0x3000 && code <= 0x303f) ||
1163
- (code >= 0xff00 && code <= 0xffef) ||
1164
- (code >= 0x3400 && code <= 0x4dbf) ||
1165
- (code >= 0x20000 && code <= 0x2a6df)) {
1166
- width += 2;
1167
- }
1168
- else {
1169
- width += 1;
1170
- }
1171
- }
1172
- return width;
1173
- }
1174
- /** 按显示宽度右填充空格 */
1175
- function padEnd(str, targetWidth) {
1176
- const padding = Math.max(0, targetWidth - getDisplayWidth(str));
1177
- return str + " ".repeat(padding);
1178
- }
1179
- /**
1180
- * 将 Markdown 中的表格块转换为代码块(等宽对齐文本),
1181
- * 飞书 md 标签不支持表格语法,会被静默丢弃。
1182
- */
1183
- function convertMarkdownTablesToCodeBlock(text) {
1184
- const lines = text.split("\n");
1185
- const result = [];
1186
- let tableLines = [];
1187
- const flushTable = () => {
1188
- if (tableLines.length === 0)
980
+ if (command !== "set_nickname")
1189
981
  return;
1190
- // 解析表格:提取单元格,跳过分隔行(|---|---|)
1191
- const rows = [];
1192
- for (const line of tableLines) {
1193
- const trimmed = line.replace(/^\|/, "").replace(/\|$/, "");
1194
- const cells = trimmed.split("|").map((c) => c.trim());
1195
- if (cells.every((c) => /^[-:]+$/.test(c)))
1196
- continue;
1197
- rows.push(cells);
1198
- }
1199
- if (rows.length === 0) {
1200
- result.push(...tableLines);
1201
- tableLines = [];
982
+ const senderId = String(params.sender_id ?? "");
983
+ const nickname = String(params.nickname ?? "").trim();
984
+ if (!senderId || !nickname)
1202
985
  return;
1203
- }
1204
- // 计算每列最大显示宽度
1205
- const colCount = Math.max(...rows.map((r) => r.length));
1206
- const colWidths = Array(colCount).fill(0);
1207
- for (const row of rows) {
1208
- for (let i = 0; i < row.length; i++) {
1209
- colWidths[i] = Math.max(colWidths[i], getDisplayWidth(row[i]));
1210
- }
1211
- }
1212
- // 格式化为对齐文本
1213
- const formatted = [];
1214
- for (let ri = 0; ri < rows.length; ri++) {
1215
- const row = rows[ri];
1216
- const cells = row.map((cell, ci) => padEnd(cell, colWidths[ci]));
1217
- formatted.push(cells.join(" | "));
1218
- if (ri === 0 && rows.length > 1) {
1219
- formatted.push(colWidths.map((w) => "-".repeat(w)).join("-+-"));
1220
- }
1221
- }
1222
- result.push("```");
1223
- result.push(...formatted);
1224
- result.push("```");
1225
- tableLines = [];
1226
- };
1227
- for (const line of lines) {
1228
- if (/^\s*\|/.test(line)) {
1229
- tableLines.push(line);
1230
- }
1231
- else {
1232
- if (tableLines.length > 0)
1233
- flushTable();
1234
- result.push(line);
1235
- }
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);
1236
990
  }
1237
- if (tableLines.length > 0)
1238
- flushTable();
1239
- return result.join("\n");
1240
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
+ };
@@ -0,0 +1,151 @@
1
+ export function streamToBuffer(stream) {
2
+ return new Promise((resolve, reject) => {
3
+ const chunks = [];
4
+ stream.on("data", (chunk) => chunks.push(chunk));
5
+ stream.on("end", () => resolve(Buffer.concat(chunks)));
6
+ stream.on("error", reject);
7
+ });
8
+ }
9
+ function getPostContentRows(contentJson) {
10
+ try {
11
+ const root = JSON.parse(contentJson);
12
+ let content = root?.content;
13
+ if (!Array.isArray(content) && root?.post) {
14
+ content = (root.post.zh_cn ?? root.post.en)?.content ?? null;
15
+ }
16
+ return Array.isArray(content) ? content : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ export function extractTextFromPostContent(contentJson) {
23
+ const content = getPostContentRows(contentJson);
24
+ if (!content)
25
+ return "";
26
+ const parts = [];
27
+ for (const row of content) {
28
+ if (!Array.isArray(row))
29
+ continue;
30
+ for (const node of row) {
31
+ if (node?.tag === "text" && typeof node.text === "string")
32
+ parts.push(node.text);
33
+ }
34
+ }
35
+ return parts.join("\n");
36
+ }
37
+ export function extractImageKeysFromPostContent(contentJson) {
38
+ const content = getPostContentRows(contentJson);
39
+ if (!content)
40
+ return [];
41
+ const keys = [];
42
+ for (const row of content) {
43
+ if (!Array.isArray(row))
44
+ continue;
45
+ for (const node of row) {
46
+ if (node?.tag === "img" && typeof node.image_key === "string")
47
+ keys.push(node.image_key);
48
+ }
49
+ }
50
+ return keys;
51
+ }
52
+ export function extractMentionElements(text, nameToIdCache) {
53
+ const atElements = [];
54
+ const seenIds = new Set();
55
+ let cleanText = text.replace(/@([^@(]+?)\(([a-zA-Z0-9][a-zA-Z0-9_]{9,})\)/g, (_, displayName, userId) => {
56
+ if (!seenIds.has(userId)) {
57
+ atElements.push({ userId, name: displayName.trim() });
58
+ seenIds.add(userId);
59
+ }
60
+ return "";
61
+ });
62
+ if (nameToIdCache?.size) {
63
+ const names = [...nameToIdCache.keys()].sort((a, b) => b.length - a.length);
64
+ for (const name of names) {
65
+ const openId = nameToIdCache.get(name);
66
+ const atMention = `@${name}`;
67
+ if (cleanText.includes(atMention)) {
68
+ if (!seenIds.has(openId)) {
69
+ atElements.push({ userId: openId, name });
70
+ seenIds.add(openId);
71
+ }
72
+ cleanText = cleanText.replaceAll(atMention, "");
73
+ }
74
+ }
75
+ }
76
+ cleanText = cleanText.replace(/[^\S\n]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
77
+ return { cleanText, atElements };
78
+ }
79
+ export function adaptMarkdownForFeishu(text) {
80
+ return text
81
+ .replace(/^#{3,}\s+(.+)$/gm, "**$1**")
82
+ .replace(/^[ \t]+([-*])\s/gm, "$1 ")
83
+ .replace(/^[ \t]+(\d+\.)\s/gm, "$1 ");
84
+ }
85
+ function getDisplayWidth(str) {
86
+ let width = 0;
87
+ for (const ch of str) {
88
+ const code = ch.codePointAt(0) ?? 0;
89
+ if ((code >= 0x4e00 && code <= 0x9fff) ||
90
+ (code >= 0x3000 && code <= 0x303f) ||
91
+ (code >= 0xff00 && code <= 0xffef) ||
92
+ (code >= 0x3400 && code <= 0x4dbf) ||
93
+ (code >= 0x20000 && code <= 0x2a6df)) {
94
+ width += 2;
95
+ }
96
+ else {
97
+ width += 1;
98
+ }
99
+ }
100
+ return width;
101
+ }
102
+ function padEnd(str, targetWidth) {
103
+ return str + " ".repeat(Math.max(0, targetWidth - getDisplayWidth(str)));
104
+ }
105
+ export function convertMarkdownTablesToCodeBlock(text) {
106
+ const lines = text.split("\n");
107
+ const result = [];
108
+ let tableLines = [];
109
+ const flushTable = () => {
110
+ if (tableLines.length === 0)
111
+ return;
112
+ const rows = [];
113
+ for (const line of tableLines) {
114
+ const cells = line.replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
115
+ if (cells.every((c) => /^[-:]+$/.test(c)))
116
+ continue;
117
+ rows.push(cells);
118
+ }
119
+ if (rows.length === 0) {
120
+ result.push(...tableLines);
121
+ tableLines = [];
122
+ return;
123
+ }
124
+ const colCount = Math.max(...rows.map((r) => r.length));
125
+ const colWidths = Array(colCount).fill(0);
126
+ for (const row of rows) {
127
+ for (let i = 0; i < row.length; i++)
128
+ colWidths[i] = Math.max(colWidths[i], getDisplayWidth(row[i]));
129
+ }
130
+ const formatted = [];
131
+ for (let ri = 0; ri < rows.length; ri++) {
132
+ formatted.push(rows[ri].map((cell, ci) => padEnd(cell, colWidths[ci])).join(" | "));
133
+ if (ri === 0 && rows.length > 1)
134
+ formatted.push(colWidths.map((w) => "-".repeat(w)).join("-+-"));
135
+ }
136
+ result.push("```", ...formatted, "```");
137
+ tableLines = [];
138
+ };
139
+ for (const line of lines) {
140
+ if (/^\s*\|/.test(line))
141
+ tableLines.push(line);
142
+ else {
143
+ if (tableLines.length > 0)
144
+ flushTable();
145
+ result.push(line);
146
+ }
147
+ }
148
+ if (tableLines.length > 0)
149
+ flushTable();
150
+ return result.join("\n");
151
+ }