@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 +11 -6
- package/dist/feishu-handler.js +89 -329
- package/dist/feishu-types.js +9 -0
- package/dist/feishu-utils.js +151 -0
- package/dist/index.js +4 -12
- package/dist/mcp-server.js +38 -80
- package/package.json +3 -3
- package/src/feishu-handler.ts +126 -456
- package/src/feishu-types.ts +86 -0
- package/src/feishu-utils.ts +165 -0
- package/src/index.ts +4 -13
- package/src/mcp-server.ts +40 -94
package/README.md
CHANGED
|
@@ -1,29 +1,34 @@
|
|
|
1
1
|
# 飞书通道
|
|
2
2
|
|
|
3
|
-
|
|
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="
|
|
15
|
-
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")
|
|
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
|
-
|
|
31
|
+
AILO_WS_URL、AILO_TOKEN、AILO_MCP_NAME 由框架注入。
|
|
27
32
|
|
|
28
33
|
## 飞书开放平台
|
|
29
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,
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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,11 +555,19 @@ export class FeishuHandler {
|
|
|
601
555
|
start() {
|
|
602
556
|
// 获取 bot 自身的 open_id(异步,不阻塞启动)
|
|
603
557
|
this.fetchBotOpenId();
|
|
604
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
604
|
+
console.error("[feishu] content preview:", rawContent);
|
|
643
605
|
}
|
|
644
606
|
else if (rawContent.length > 500) {
|
|
645
|
-
console.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1183
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
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
|
+
};
|