@lmcl/ailo-mcp-feishu 0.0.4 → 0.0.6
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/dist/feishu-handler.js +150 -40
- package/dist/mcp-server.js +6 -2
- package/package.json +1 -1
- package/src/feishu-handler.ts +1256 -1140
- package/src/mcp-server.ts +6 -2
package/dist/feishu-handler.js
CHANGED
|
@@ -491,21 +491,28 @@ export class FeishuHandler {
|
|
|
491
491
|
}
|
|
492
492
|
/**
|
|
493
493
|
* 下载消息资源到本地缓存,返回 path。失败时返回 null(调用方传 ref)。
|
|
494
|
+
* 路径按年/月组织:blobs/YYYY/MM/
|
|
495
|
+
* 文件名:时间戳_原始file_name(必须提供 file_name,否则不保存,走 ref)
|
|
494
496
|
*/
|
|
495
|
-
async downloadToCache(messageId, fileKey, resourceType, aidoType) {
|
|
497
|
+
async downloadToCache(messageId, fileKey, resourceType, aidoType, fileName) {
|
|
498
|
+
const trimmed = fileName?.trim();
|
|
499
|
+
if (!trimmed)
|
|
500
|
+
return null;
|
|
496
501
|
const workDir = getWorkDir();
|
|
497
502
|
if (!workDir)
|
|
498
503
|
return null;
|
|
499
|
-
const
|
|
504
|
+
const now = new Date();
|
|
505
|
+
const year = String(now.getFullYear());
|
|
506
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
507
|
+
const cacheDir = path.join(workDir, "blobs", year, month);
|
|
500
508
|
try {
|
|
501
509
|
await fs.promises.mkdir(cacheDir, { recursive: true });
|
|
502
510
|
}
|
|
503
511
|
catch {
|
|
504
512
|
return null;
|
|
505
513
|
}
|
|
506
|
-
const
|
|
507
|
-
const
|
|
508
|
-
const filename = `ref_feishu_${safeMsg}_${safeKey}_${aidoType}`;
|
|
514
|
+
const sanitized = trimmed.replace(/[/\\?*:|"<>]/g, "_").slice(0, 200);
|
|
515
|
+
const filename = `${Date.now()}_${sanitized}`;
|
|
509
516
|
const outPath = path.join(cacheDir, filename);
|
|
510
517
|
const tryMessageResource = async () => {
|
|
511
518
|
const res = await this.client.im.v1.messageResource.get({
|
|
@@ -535,18 +542,21 @@ export class FeishuHandler {
|
|
|
535
542
|
}
|
|
536
543
|
}
|
|
537
544
|
/**
|
|
538
|
-
* 获取消息资源并转为 attachment
|
|
545
|
+
* 获取消息资源并转为 attachment。有 file_name 时下载到 cache 传 path;无则传 ref(不保存)。
|
|
539
546
|
*/
|
|
540
547
|
async fetchMessageResourceAttachment(messageId, fileKey, resourceType, aidoType, fileName) {
|
|
541
|
-
|
|
548
|
+
if (!fileName?.trim()) {
|
|
549
|
+
return { type: aidoType, ref: `im:${messageId}:${fileKey}:${aidoType}`, channel: "feishu" };
|
|
550
|
+
}
|
|
551
|
+
const pathOrNull = await this.downloadToCache(messageId, fileKey, resourceType, aidoType, fileName);
|
|
542
552
|
if (pathOrNull) {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
553
|
+
return {
|
|
554
|
+
type: aidoType,
|
|
555
|
+
path: pathOrNull,
|
|
556
|
+
name: path.basename(pathOrNull),
|
|
557
|
+
};
|
|
547
558
|
}
|
|
548
|
-
|
|
549
|
-
return { type: aidoType, ref, channel: "feishu", name: fileName };
|
|
559
|
+
return { type: aidoType, ref: `im:${messageId}:${fileKey}:${aidoType}`, channel: "feishu" };
|
|
550
560
|
}
|
|
551
561
|
/**
|
|
552
562
|
* 使用飞书 WebSocket 长连接接收事件,无需配置回调地址。
|
|
@@ -863,6 +873,67 @@ export class FeishuHandler {
|
|
|
863
873
|
});
|
|
864
874
|
wsClient.start({ eventDispatcher });
|
|
865
875
|
}
|
|
876
|
+
/**
|
|
877
|
+
* 根据文件名/扩展名推断飞书 im.file.create 的 file_type。
|
|
878
|
+
* 支持: opus | mp4 | pdf | doc | xls | ppt | stream
|
|
879
|
+
*/
|
|
880
|
+
inferFileType(fileName, mime) {
|
|
881
|
+
const ext = path.extname(fileName || "").toLowerCase().slice(1);
|
|
882
|
+
const m = (mime ?? "").toLowerCase();
|
|
883
|
+
if (["mp4", "mov", "avi", "mkv", "webm", "m4v"].includes(ext) || m.includes("video"))
|
|
884
|
+
return "mp4";
|
|
885
|
+
if (["opus", "mp3", "wav", "m4a", "aac", "ogg", "flac"].includes(ext) || m.includes("audio"))
|
|
886
|
+
return "opus";
|
|
887
|
+
if (ext === "pdf" || m.includes("pdf"))
|
|
888
|
+
return "pdf";
|
|
889
|
+
if (["doc", "docx"].includes(ext) || m.includes("msword") || m.includes("document"))
|
|
890
|
+
return "doc";
|
|
891
|
+
if (["xls", "xlsx"].includes(ext) || m.includes("spreadsheet") || m.includes("excel"))
|
|
892
|
+
return "xls";
|
|
893
|
+
if (["ppt", "pptx"].includes(ext) || m.includes("presentation") || m.includes("powerpoint"))
|
|
894
|
+
return "ppt";
|
|
895
|
+
return "stream";
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* 上传文件到飞书 IM,获取 file_key。
|
|
899
|
+
* 支持 file_path 或 base64,用于发送文件/音频/视频消息。
|
|
900
|
+
* 飞书 API: POST /open-apis/im/v1/files (multipart)
|
|
901
|
+
*/
|
|
902
|
+
async uploadFileToFeishu(opts) {
|
|
903
|
+
let fileData;
|
|
904
|
+
if (opts.filePath) {
|
|
905
|
+
if (!fs.existsSync(opts.filePath)) {
|
|
906
|
+
throw new Error(`文件不存在: ${opts.filePath}`);
|
|
907
|
+
}
|
|
908
|
+
fileData = fs.readFileSync(opts.filePath);
|
|
909
|
+
}
|
|
910
|
+
else if (opts.base64) {
|
|
911
|
+
fileData = Buffer.from(opts.base64, "base64");
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
throw new Error("文件上传失败:未提供 file_path 或 base64");
|
|
915
|
+
}
|
|
916
|
+
const fileType = opts.fileType ?? this.inferFileType(opts.fileName, opts.mime);
|
|
917
|
+
let res;
|
|
918
|
+
try {
|
|
919
|
+
res = (await this.client.im.file.create({
|
|
920
|
+
data: {
|
|
921
|
+
file_type: fileType,
|
|
922
|
+
file_name: opts.fileName,
|
|
923
|
+
file: fileData,
|
|
924
|
+
...(opts.duration != null && opts.duration > 0 ? { duration: opts.duration } : {}),
|
|
925
|
+
},
|
|
926
|
+
}));
|
|
927
|
+
}
|
|
928
|
+
catch (e) {
|
|
929
|
+
const err = e;
|
|
930
|
+
const detail = err?.response?.data ? JSON.stringify(err.response.data) : err?.message;
|
|
931
|
+
throw new Error(`飞书文件上传失败 (${err?.response?.status ?? "unknown"}): ${detail}`);
|
|
932
|
+
}
|
|
933
|
+
if (res?.file_key)
|
|
934
|
+
return res.file_key;
|
|
935
|
+
throw new Error(`飞书文件上传失败(无 file_key): ${JSON.stringify(res)}`);
|
|
936
|
+
}
|
|
866
937
|
/**
|
|
867
938
|
* 上传图片到飞书,获取 image_key。
|
|
868
939
|
* 支持三种来源(优先级:file_path > base64 > url):
|
|
@@ -908,12 +979,23 @@ export class FeishuHandler {
|
|
|
908
979
|
}
|
|
909
980
|
async sendText(chatId, text, attachments) {
|
|
910
981
|
const trimmed = (text ?? "").trim();
|
|
911
|
-
const
|
|
912
|
-
|
|
913
|
-
|
|
982
|
+
const allAttachments = attachments ?? [];
|
|
983
|
+
const imageAttachments = allAttachments.filter((a) => (a.type ?? "").toLowerCase() === "image");
|
|
984
|
+
const fileAttachments = allAttachments.filter((a) => {
|
|
985
|
+
const t = (a.type ?? "").toLowerCase();
|
|
986
|
+
if (t === "image")
|
|
987
|
+
return false;
|
|
988
|
+
if (["file", "audio", "video"].includes(t))
|
|
989
|
+
return true;
|
|
990
|
+
// 未指定 type 但有 path/base64 时,视为通用文件
|
|
991
|
+
return !!(a.file_path || a.base64);
|
|
992
|
+
});
|
|
993
|
+
// 无内容且无任何附件时跳过
|
|
994
|
+
if (!trimmed && imageAttachments.length === 0 && fileAttachments.length === 0) {
|
|
914
995
|
return;
|
|
915
996
|
}
|
|
916
|
-
|
|
997
|
+
const receiveIdType = chatId.startsWith("ou_") ? "open_id" : "chat_id";
|
|
998
|
+
// 1. 上传图片获取 image_key
|
|
917
999
|
const imageKeys = [];
|
|
918
1000
|
for (const att of imageAttachments) {
|
|
919
1001
|
if (att.file_path || att.base64) {
|
|
@@ -924,22 +1006,18 @@ export class FeishuHandler {
|
|
|
924
1006
|
});
|
|
925
1007
|
imageKeys.push(key);
|
|
926
1008
|
}
|
|
927
|
-
// url 暂不支持(需先下载再上传,可后续扩展)
|
|
928
1009
|
}
|
|
929
|
-
//
|
|
1010
|
+
// 2. 先发送文本+图片(post),再发送文件/音频/视频
|
|
930
1011
|
const contentRows = [];
|
|
931
1012
|
if (trimmed) {
|
|
932
1013
|
const { cleanText, atElements } = extractMentionElements(trimmed, this.mentionNameToId);
|
|
933
1014
|
const adapted = adaptMarkdownForFeishu(cleanText);
|
|
934
1015
|
const processed = convertMarkdownTablesToCodeBlock(adapted);
|
|
935
|
-
// 按段落拆分为多个 content row(飞书富文本是多段落结构)
|
|
936
1016
|
const paragraphs = processed.split(/\n{2,}/).filter((p) => p.trim());
|
|
937
1017
|
if (paragraphs.length === 0) {
|
|
938
|
-
// 无有效段落,退化为单行
|
|
939
1018
|
contentRows.push([{ tag: "md", text: processed }]);
|
|
940
1019
|
}
|
|
941
1020
|
else {
|
|
942
|
-
// @mention 的 at 元素放在第一个段落的 row 中
|
|
943
1021
|
const firstRow = [];
|
|
944
1022
|
for (const at of atElements) {
|
|
945
1023
|
firstRow.push({ tag: "at", user_id: at.userId });
|
|
@@ -947,33 +1025,65 @@ export class FeishuHandler {
|
|
|
947
1025
|
}
|
|
948
1026
|
firstRow.push({ tag: "md", text: paragraphs[0].trim() });
|
|
949
1027
|
contentRows.push(firstRow);
|
|
950
|
-
// 后续段落各自独立一行
|
|
951
1028
|
for (let i = 1; i < paragraphs.length; i++) {
|
|
952
1029
|
contentRows.push([{ tag: "md", text: paragraphs[i].trim() }]);
|
|
953
1030
|
}
|
|
954
1031
|
}
|
|
955
1032
|
}
|
|
956
|
-
// 添加图片节点(每张图一行)
|
|
957
1033
|
for (const key of imageKeys) {
|
|
958
1034
|
contentRows.push([{ tag: "img", image_key: key }]);
|
|
959
1035
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1036
|
+
if (contentRows.length > 0) {
|
|
1037
|
+
await this.client.im.v1.message.create({
|
|
1038
|
+
params: { receive_id_type: receiveIdType },
|
|
1039
|
+
data: {
|
|
1040
|
+
receive_id: chatId,
|
|
1041
|
+
msg_type: "post",
|
|
1042
|
+
content: JSON.stringify({
|
|
1043
|
+
zh_cn: {
|
|
1044
|
+
content: contentRows,
|
|
1045
|
+
},
|
|
1046
|
+
}),
|
|
1047
|
+
},
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
// 3. 上传文件/音频/视频并发送对应消息
|
|
1051
|
+
for (const att of fileAttachments) {
|
|
1052
|
+
if (!att.file_path && !att.base64)
|
|
1053
|
+
continue;
|
|
1054
|
+
const fileName = att.name?.trim() ||
|
|
1055
|
+
(att.file_path ? path.basename(att.file_path) : `file_${Date.now()}`);
|
|
1056
|
+
const fileKey = await this.uploadFileToFeishu({
|
|
1057
|
+
filePath: att.file_path,
|
|
1058
|
+
base64: att.base64,
|
|
1059
|
+
fileName,
|
|
1060
|
+
mime: att.mime,
|
|
1061
|
+
duration: att.duration,
|
|
1062
|
+
});
|
|
1063
|
+
const msgType = (att.type ?? "file").toLowerCase();
|
|
1064
|
+
let content;
|
|
1065
|
+
if (msgType === "audio") {
|
|
1066
|
+
content = JSON.stringify({ file_key: fileKey, duration: att.duration ?? 0 });
|
|
1067
|
+
}
|
|
1068
|
+
else if (msgType === "video") {
|
|
1069
|
+
content = JSON.stringify({
|
|
1070
|
+
file_key: fileKey,
|
|
1071
|
+
file_name: fileName,
|
|
1072
|
+
duration: att.duration ?? 0,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
else {
|
|
1076
|
+
content = JSON.stringify({ file_key: fileKey, file_name: fileName });
|
|
1077
|
+
}
|
|
1078
|
+
await this.client.im.v1.message.create({
|
|
1079
|
+
params: { receive_id_type: receiveIdType },
|
|
1080
|
+
data: {
|
|
1081
|
+
receive_id: chatId,
|
|
1082
|
+
msg_type: msgType === "video" ? "media" : msgType,
|
|
1083
|
+
content,
|
|
1084
|
+
},
|
|
1085
|
+
});
|
|
963
1086
|
}
|
|
964
|
-
const receiveIdType = chatId.startsWith("ou_") ? "open_id" : "chat_id";
|
|
965
|
-
await this.client.im.v1.message.create({
|
|
966
|
-
params: { receive_id_type: receiveIdType },
|
|
967
|
-
data: {
|
|
968
|
-
receive_id: chatId,
|
|
969
|
-
msg_type: "post",
|
|
970
|
-
content: JSON.stringify({
|
|
971
|
-
zh_cn: {
|
|
972
|
-
content: contentRows,
|
|
973
|
-
},
|
|
974
|
-
}),
|
|
975
|
-
},
|
|
976
|
-
});
|
|
977
1087
|
}
|
|
978
1088
|
/** 处理来自 MCP 工具 feishu action=set_nickname 的命令 */
|
|
979
1089
|
async onCommand(command, params) {
|
package/dist/mcp-server.js
CHANGED
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
const attachmentSchema = z.object({
|
|
7
|
-
type: z.string().optional(),
|
|
7
|
+
type: z.string().optional().describe("image|file|audio|video"),
|
|
8
8
|
path: z.string().optional(),
|
|
9
9
|
base64: z.string().optional(),
|
|
10
10
|
mime: z.string().optional(),
|
|
11
|
+
name: z.string().optional().describe("文件名,用于 file/audio/video"),
|
|
12
|
+
duration: z.number().optional().describe("音视频时长(毫秒),用于 audio/video"),
|
|
11
13
|
});
|
|
12
14
|
const feishuSchema = {
|
|
13
15
|
action: z.enum(["send", "read_doc", "set_nickname"]).describe("send=发消息; read_doc=读文档; set_nickname=设置备注"),
|
|
@@ -21,7 +23,7 @@ const feishuSchema = {
|
|
|
21
23
|
export function createFeishuMcpServer(handler) {
|
|
22
24
|
const server = new McpServer({ name: "feishu", version: "0.1.0" });
|
|
23
25
|
server.registerTool("feishu", {
|
|
24
|
-
description: "飞书操作。action=send 发消息(需 chat_id,text;attachments
|
|
26
|
+
description: "飞书操作。action=send 发消息(需 chat_id,text;attachments 可选):type=image 图片;type=file 文件;type=audio 音频;type=video 视频。附件用 path 或 base64,可选 name/duration。read_doc 读文档(需 url);set_nickname 设置备注(需 sender_id,nickname)。chat_id: ou_xxx 私聊/oc_xxx 群聊。",
|
|
25
27
|
inputSchema: feishuSchema,
|
|
26
28
|
}, async (args) => {
|
|
27
29
|
const { action } = args;
|
|
@@ -31,6 +33,8 @@ export function createFeishuMcpServer(handler) {
|
|
|
31
33
|
file_path: a.path,
|
|
32
34
|
base64: a.base64,
|
|
33
35
|
mime: a.mime,
|
|
36
|
+
name: a.name,
|
|
37
|
+
duration: a.duration,
|
|
34
38
|
}));
|
|
35
39
|
await handler.sendText(args.chat_id, args.text ?? "", atts);
|
|
36
40
|
return { content: [{ type: "text", text: `已发送到 ${args.chat_id}` }], isError: false };
|