@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 +7 -6
- package/dist/feishu-handler.js +62 -311
- package/dist/feishu-types.js +9 -0
- package/dist/feishu-utils.js +151 -0
- package/dist/index.js +3 -11
- package/dist/mcp-server.js +38 -80
- package/package.json +2 -2
- package/src/feishu-handler.ts +99 -438
- package/src/feishu-types.ts +86 -0
- package/src/feishu-utils.ts +165 -0
- package/src/index.ts +3 -12
- package/src/mcp-server.ts +40 -94
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 飞书通道
|
|
2
2
|
|
|
3
|
-
|
|
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="
|
|
19
|
-
mcp_manage(action=start, name="
|
|
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
|
-
|
|
31
|
+
AILO_WS_URL、AILO_TOKEN、AILO_MCP_NAME 由框架注入。
|
|
31
32
|
|
|
32
33
|
## 飞书开放平台
|
|
33
34
|
|
package/dist/feishu-handler.js
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
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
|
|
544
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1192
|
-
|
|
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
|
-
|
|
1206
|
-
|
|
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
|
+
}
|