@lmcl/ailo-mcp-feishu 0.0.5 → 0.0.7
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 +137 -33
- package/dist/mcp-server.js +6 -2
- package/package.json +1 -1
- package/src/feishu-handler.ts +143 -39
- package/src/mcp-server.ts +6 -2
package/dist/feishu-handler.js
CHANGED
|
@@ -542,22 +542,26 @@ export class FeishuHandler {
|
|
|
542
542
|
}
|
|
543
543
|
}
|
|
544
544
|
/**
|
|
545
|
-
* 获取消息资源并转为 attachment。有 file_name
|
|
545
|
+
* 获取消息资源并转为 attachment。有 file_name 时用原名;无则用默认名(如图片用 image_xxx.png)。
|
|
546
|
+
* 下载到本地后传绝对路径给 LLM,否则 LLM 无法访问。
|
|
546
547
|
*/
|
|
547
548
|
async fetchMessageResourceAttachment(messageId, fileKey, resourceType, aidoType, fileName) {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
}
|
|
551
|
-
const pathOrNull = await this.downloadToCache(messageId, fileKey, resourceType, aidoType, fileName);
|
|
549
|
+
const effectiveName = fileName?.trim() || this.defaultFileNameForResource(aidoType, fileKey);
|
|
550
|
+
const pathOrNull = await this.downloadToCache(messageId, fileKey, resourceType, aidoType, effectiveName);
|
|
552
551
|
if (pathOrNull) {
|
|
553
552
|
return {
|
|
554
553
|
type: aidoType,
|
|
555
|
-
path: pathOrNull,
|
|
554
|
+
path: path.resolve(pathOrNull),
|
|
556
555
|
name: path.basename(pathOrNull),
|
|
557
556
|
};
|
|
558
557
|
}
|
|
559
558
|
return { type: aidoType, ref: `im:${messageId}:${fileKey}:${aidoType}`, channel: "feishu" };
|
|
560
559
|
}
|
|
560
|
+
/** 无 file_name 时的默认文件名(如图片、无名的文件等) */
|
|
561
|
+
defaultFileNameForResource(aidoType, fileKey) {
|
|
562
|
+
const ext = { image: "png", audio: "mp3", video: "mp4", file: "bin" }[aidoType] ?? "bin";
|
|
563
|
+
return `${aidoType}_${fileKey.slice(-12)}.${ext}`;
|
|
564
|
+
}
|
|
561
565
|
/**
|
|
562
566
|
* 使用飞书 WebSocket 长连接接收事件,无需配置回调地址。
|
|
563
567
|
* 需在飞书开放平台「事件与回调」中选择「使用长连接接收事件」并保存(本客户端需在线)。
|
|
@@ -873,6 +877,67 @@ export class FeishuHandler {
|
|
|
873
877
|
});
|
|
874
878
|
wsClient.start({ eventDispatcher });
|
|
875
879
|
}
|
|
880
|
+
/**
|
|
881
|
+
* 根据文件名/扩展名推断飞书 im.file.create 的 file_type。
|
|
882
|
+
* 支持: opus | mp4 | pdf | doc | xls | ppt | stream
|
|
883
|
+
*/
|
|
884
|
+
inferFileType(fileName, mime) {
|
|
885
|
+
const ext = path.extname(fileName || "").toLowerCase().slice(1);
|
|
886
|
+
const m = (mime ?? "").toLowerCase();
|
|
887
|
+
if (["mp4", "mov", "avi", "mkv", "webm", "m4v"].includes(ext) || m.includes("video"))
|
|
888
|
+
return "mp4";
|
|
889
|
+
if (["opus", "mp3", "wav", "m4a", "aac", "ogg", "flac"].includes(ext) || m.includes("audio"))
|
|
890
|
+
return "opus";
|
|
891
|
+
if (ext === "pdf" || m.includes("pdf"))
|
|
892
|
+
return "pdf";
|
|
893
|
+
if (["doc", "docx"].includes(ext) || m.includes("msword") || m.includes("document"))
|
|
894
|
+
return "doc";
|
|
895
|
+
if (["xls", "xlsx"].includes(ext) || m.includes("spreadsheet") || m.includes("excel"))
|
|
896
|
+
return "xls";
|
|
897
|
+
if (["ppt", "pptx"].includes(ext) || m.includes("presentation") || m.includes("powerpoint"))
|
|
898
|
+
return "ppt";
|
|
899
|
+
return "stream";
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* 上传文件到飞书 IM,获取 file_key。
|
|
903
|
+
* 支持 file_path 或 base64,用于发送文件/音频/视频消息。
|
|
904
|
+
* 飞书 API: POST /open-apis/im/v1/files (multipart)
|
|
905
|
+
*/
|
|
906
|
+
async uploadFileToFeishu(opts) {
|
|
907
|
+
let fileData;
|
|
908
|
+
if (opts.filePath) {
|
|
909
|
+
if (!fs.existsSync(opts.filePath)) {
|
|
910
|
+
throw new Error(`文件不存在: ${opts.filePath}`);
|
|
911
|
+
}
|
|
912
|
+
fileData = fs.readFileSync(opts.filePath);
|
|
913
|
+
}
|
|
914
|
+
else if (opts.base64) {
|
|
915
|
+
fileData = Buffer.from(opts.base64, "base64");
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
throw new Error("文件上传失败:未提供 file_path 或 base64");
|
|
919
|
+
}
|
|
920
|
+
const fileType = opts.fileType ?? this.inferFileType(opts.fileName, opts.mime);
|
|
921
|
+
let res;
|
|
922
|
+
try {
|
|
923
|
+
res = (await this.client.im.file.create({
|
|
924
|
+
data: {
|
|
925
|
+
file_type: fileType,
|
|
926
|
+
file_name: opts.fileName,
|
|
927
|
+
file: fileData,
|
|
928
|
+
...(opts.duration != null && opts.duration > 0 ? { duration: opts.duration } : {}),
|
|
929
|
+
},
|
|
930
|
+
}));
|
|
931
|
+
}
|
|
932
|
+
catch (e) {
|
|
933
|
+
const err = e;
|
|
934
|
+
const detail = err?.response?.data ? JSON.stringify(err.response.data) : err?.message;
|
|
935
|
+
throw new Error(`飞书文件上传失败 (${err?.response?.status ?? "unknown"}): ${detail}`);
|
|
936
|
+
}
|
|
937
|
+
if (res?.file_key)
|
|
938
|
+
return res.file_key;
|
|
939
|
+
throw new Error(`飞书文件上传失败(无 file_key): ${JSON.stringify(res)}`);
|
|
940
|
+
}
|
|
876
941
|
/**
|
|
877
942
|
* 上传图片到飞书,获取 image_key。
|
|
878
943
|
* 支持三种来源(优先级:file_path > base64 > url):
|
|
@@ -918,12 +983,23 @@ export class FeishuHandler {
|
|
|
918
983
|
}
|
|
919
984
|
async sendText(chatId, text, attachments) {
|
|
920
985
|
const trimmed = (text ?? "").trim();
|
|
921
|
-
const
|
|
922
|
-
|
|
923
|
-
|
|
986
|
+
const allAttachments = attachments ?? [];
|
|
987
|
+
const imageAttachments = allAttachments.filter((a) => (a.type ?? "").toLowerCase() === "image");
|
|
988
|
+
const fileAttachments = allAttachments.filter((a) => {
|
|
989
|
+
const t = (a.type ?? "").toLowerCase();
|
|
990
|
+
if (t === "image")
|
|
991
|
+
return false;
|
|
992
|
+
if (["file", "audio", "video"].includes(t))
|
|
993
|
+
return true;
|
|
994
|
+
// 未指定 type 但有 path/base64 时,视为通用文件
|
|
995
|
+
return !!(a.file_path || a.base64);
|
|
996
|
+
});
|
|
997
|
+
// 无内容且无任何附件时跳过
|
|
998
|
+
if (!trimmed && imageAttachments.length === 0 && fileAttachments.length === 0) {
|
|
924
999
|
return;
|
|
925
1000
|
}
|
|
926
|
-
|
|
1001
|
+
const receiveIdType = chatId.startsWith("ou_") ? "open_id" : "chat_id";
|
|
1002
|
+
// 1. 上传图片获取 image_key
|
|
927
1003
|
const imageKeys = [];
|
|
928
1004
|
for (const att of imageAttachments) {
|
|
929
1005
|
if (att.file_path || att.base64) {
|
|
@@ -934,22 +1010,18 @@ export class FeishuHandler {
|
|
|
934
1010
|
});
|
|
935
1011
|
imageKeys.push(key);
|
|
936
1012
|
}
|
|
937
|
-
// url 暂不支持(需先下载再上传,可后续扩展)
|
|
938
1013
|
}
|
|
939
|
-
//
|
|
1014
|
+
// 2. 先发送文本+图片(post),再发送文件/音频/视频
|
|
940
1015
|
const contentRows = [];
|
|
941
1016
|
if (trimmed) {
|
|
942
1017
|
const { cleanText, atElements } = extractMentionElements(trimmed, this.mentionNameToId);
|
|
943
1018
|
const adapted = adaptMarkdownForFeishu(cleanText);
|
|
944
1019
|
const processed = convertMarkdownTablesToCodeBlock(adapted);
|
|
945
|
-
// 按段落拆分为多个 content row(飞书富文本是多段落结构)
|
|
946
1020
|
const paragraphs = processed.split(/\n{2,}/).filter((p) => p.trim());
|
|
947
1021
|
if (paragraphs.length === 0) {
|
|
948
|
-
// 无有效段落,退化为单行
|
|
949
1022
|
contentRows.push([{ tag: "md", text: processed }]);
|
|
950
1023
|
}
|
|
951
1024
|
else {
|
|
952
|
-
// @mention 的 at 元素放在第一个段落的 row 中
|
|
953
1025
|
const firstRow = [];
|
|
954
1026
|
for (const at of atElements) {
|
|
955
1027
|
firstRow.push({ tag: "at", user_id: at.userId });
|
|
@@ -957,33 +1029,65 @@ export class FeishuHandler {
|
|
|
957
1029
|
}
|
|
958
1030
|
firstRow.push({ tag: "md", text: paragraphs[0].trim() });
|
|
959
1031
|
contentRows.push(firstRow);
|
|
960
|
-
// 后续段落各自独立一行
|
|
961
1032
|
for (let i = 1; i < paragraphs.length; i++) {
|
|
962
1033
|
contentRows.push([{ tag: "md", text: paragraphs[i].trim() }]);
|
|
963
1034
|
}
|
|
964
1035
|
}
|
|
965
1036
|
}
|
|
966
|
-
// 添加图片节点(每张图一行)
|
|
967
1037
|
for (const key of imageKeys) {
|
|
968
1038
|
contentRows.push([{ tag: "img", image_key: key }]);
|
|
969
1039
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1040
|
+
if (contentRows.length > 0) {
|
|
1041
|
+
await this.client.im.v1.message.create({
|
|
1042
|
+
params: { receive_id_type: receiveIdType },
|
|
1043
|
+
data: {
|
|
1044
|
+
receive_id: chatId,
|
|
1045
|
+
msg_type: "post",
|
|
1046
|
+
content: JSON.stringify({
|
|
1047
|
+
zh_cn: {
|
|
1048
|
+
content: contentRows,
|
|
1049
|
+
},
|
|
1050
|
+
}),
|
|
1051
|
+
},
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
// 3. 上传文件/音频/视频并发送对应消息
|
|
1055
|
+
for (const att of fileAttachments) {
|
|
1056
|
+
if (!att.file_path && !att.base64)
|
|
1057
|
+
continue;
|
|
1058
|
+
const fileName = att.name?.trim() ||
|
|
1059
|
+
(att.file_path ? path.basename(att.file_path) : `file_${Date.now()}`);
|
|
1060
|
+
const fileKey = await this.uploadFileToFeishu({
|
|
1061
|
+
filePath: att.file_path,
|
|
1062
|
+
base64: att.base64,
|
|
1063
|
+
fileName,
|
|
1064
|
+
mime: att.mime,
|
|
1065
|
+
duration: att.duration,
|
|
1066
|
+
});
|
|
1067
|
+
const msgType = (att.type ?? "file").toLowerCase();
|
|
1068
|
+
let content;
|
|
1069
|
+
if (msgType === "audio") {
|
|
1070
|
+
content = JSON.stringify({ file_key: fileKey, duration: att.duration ?? 0 });
|
|
1071
|
+
}
|
|
1072
|
+
else if (msgType === "video") {
|
|
1073
|
+
content = JSON.stringify({
|
|
1074
|
+
file_key: fileKey,
|
|
1075
|
+
file_name: fileName,
|
|
1076
|
+
duration: att.duration ?? 0,
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
else {
|
|
1080
|
+
content = JSON.stringify({ file_key: fileKey, file_name: fileName });
|
|
1081
|
+
}
|
|
1082
|
+
await this.client.im.v1.message.create({
|
|
1083
|
+
params: { receive_id_type: receiveIdType },
|
|
1084
|
+
data: {
|
|
1085
|
+
receive_id: chatId,
|
|
1086
|
+
msg_type: msgType === "video" ? "media" : msgType,
|
|
1087
|
+
content,
|
|
1088
|
+
},
|
|
1089
|
+
});
|
|
973
1090
|
}
|
|
974
|
-
const receiveIdType = chatId.startsWith("ou_") ? "open_id" : "chat_id";
|
|
975
|
-
await this.client.im.v1.message.create({
|
|
976
|
-
params: { receive_id_type: receiveIdType },
|
|
977
|
-
data: {
|
|
978
|
-
receive_id: chatId,
|
|
979
|
-
msg_type: "post",
|
|
980
|
-
content: JSON.stringify({
|
|
981
|
-
zh_cn: {
|
|
982
|
-
content: contentRows,
|
|
983
|
-
},
|
|
984
|
-
}),
|
|
985
|
-
},
|
|
986
|
-
});
|
|
987
1091
|
}
|
|
988
1092
|
/** 处理来自 MCP 工具 feishu action=set_nickname 的命令 */
|
|
989
1093
|
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 };
|
package/package.json
CHANGED
package/src/feishu-handler.ts
CHANGED
|
@@ -634,7 +634,8 @@ export class FeishuHandler implements BridgeHandler {
|
|
|
634
634
|
}
|
|
635
635
|
|
|
636
636
|
/**
|
|
637
|
-
* 获取消息资源并转为 attachment。有 file_name
|
|
637
|
+
* 获取消息资源并转为 attachment。有 file_name 时用原名;无则用默认名(如图片用 image_xxx.png)。
|
|
638
|
+
* 下载到本地后传绝对路径给 LLM,否则 LLM 无法访问。
|
|
638
639
|
*/
|
|
639
640
|
private async fetchMessageResourceAttachment(
|
|
640
641
|
messageId: string,
|
|
@@ -643,26 +644,30 @@ export class FeishuHandler implements BridgeHandler {
|
|
|
643
644
|
aidoType: "image" | "audio" | "video" | "file",
|
|
644
645
|
fileName?: string
|
|
645
646
|
): Promise<FeishuAttachment | null> {
|
|
646
|
-
|
|
647
|
-
return { type: aidoType, ref: `im:${messageId}:${fileKey}:${aidoType}`, channel: "feishu" };
|
|
648
|
-
}
|
|
647
|
+
const effectiveName = fileName?.trim() || this.defaultFileNameForResource(aidoType, fileKey);
|
|
649
648
|
const pathOrNull = await this.downloadToCache(
|
|
650
649
|
messageId,
|
|
651
650
|
fileKey,
|
|
652
651
|
resourceType,
|
|
653
652
|
aidoType,
|
|
654
|
-
|
|
653
|
+
effectiveName
|
|
655
654
|
);
|
|
656
655
|
if (pathOrNull) {
|
|
657
656
|
return {
|
|
658
657
|
type: aidoType,
|
|
659
|
-
path: pathOrNull,
|
|
658
|
+
path: path.resolve(pathOrNull),
|
|
660
659
|
name: path.basename(pathOrNull),
|
|
661
660
|
};
|
|
662
661
|
}
|
|
663
662
|
return { type: aidoType, ref: `im:${messageId}:${fileKey}:${aidoType}`, channel: "feishu" };
|
|
664
663
|
}
|
|
665
664
|
|
|
665
|
+
/** 无 file_name 时的默认文件名(如图片、无名的文件等) */
|
|
666
|
+
private defaultFileNameForResource(aidoType: string, fileKey: string): string {
|
|
667
|
+
const ext = { image: "png", audio: "mp3", video: "mp4", file: "bin" }[aidoType] ?? "bin";
|
|
668
|
+
return `${aidoType}_${fileKey.slice(-12)}.${ext}`;
|
|
669
|
+
}
|
|
670
|
+
|
|
666
671
|
/**
|
|
667
672
|
* 使用飞书 WebSocket 长连接接收事件,无需配置回调地址。
|
|
668
673
|
* 需在飞书开放平台「事件与回调」中选择「使用长连接接收事件」并保存(本客户端需在线)。
|
|
@@ -1009,6 +1014,66 @@ export class FeishuHandler implements BridgeHandler {
|
|
|
1009
1014
|
wsClient.start({ eventDispatcher });
|
|
1010
1015
|
}
|
|
1011
1016
|
|
|
1017
|
+
/**
|
|
1018
|
+
* 根据文件名/扩展名推断飞书 im.file.create 的 file_type。
|
|
1019
|
+
* 支持: opus | mp4 | pdf | doc | xls | ppt | stream
|
|
1020
|
+
*/
|
|
1021
|
+
private inferFileType(fileName: string, mime?: string): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
|
|
1022
|
+
const ext = path.extname(fileName || "").toLowerCase().slice(1);
|
|
1023
|
+
const m = (mime ?? "").toLowerCase();
|
|
1024
|
+
if (["mp4", "mov", "avi", "mkv", "webm", "m4v"].includes(ext) || m.includes("video")) return "mp4";
|
|
1025
|
+
if (["opus", "mp3", "wav", "m4a", "aac", "ogg", "flac"].includes(ext) || m.includes("audio")) return "opus";
|
|
1026
|
+
if (ext === "pdf" || m.includes("pdf")) return "pdf";
|
|
1027
|
+
if (["doc", "docx"].includes(ext) || m.includes("msword") || m.includes("document")) return "doc";
|
|
1028
|
+
if (["xls", "xlsx"].includes(ext) || m.includes("spreadsheet") || m.includes("excel")) return "xls";
|
|
1029
|
+
if (["ppt", "pptx"].includes(ext) || m.includes("presentation") || m.includes("powerpoint")) return "ppt";
|
|
1030
|
+
return "stream";
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* 上传文件到飞书 IM,获取 file_key。
|
|
1035
|
+
* 支持 file_path 或 base64,用于发送文件/音频/视频消息。
|
|
1036
|
+
* 飞书 API: POST /open-apis/im/v1/files (multipart)
|
|
1037
|
+
*/
|
|
1038
|
+
private async uploadFileToFeishu(opts: {
|
|
1039
|
+
filePath?: string;
|
|
1040
|
+
base64?: string;
|
|
1041
|
+
fileName: string;
|
|
1042
|
+
fileType?: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream";
|
|
1043
|
+
mime?: string;
|
|
1044
|
+
duration?: number;
|
|
1045
|
+
}): Promise<string> {
|
|
1046
|
+
let fileData: Buffer;
|
|
1047
|
+
if (opts.filePath) {
|
|
1048
|
+
if (!fs.existsSync(opts.filePath)) {
|
|
1049
|
+
throw new Error(`文件不存在: ${opts.filePath}`);
|
|
1050
|
+
}
|
|
1051
|
+
fileData = fs.readFileSync(opts.filePath);
|
|
1052
|
+
} else if (opts.base64) {
|
|
1053
|
+
fileData = Buffer.from(opts.base64, "base64");
|
|
1054
|
+
} else {
|
|
1055
|
+
throw new Error("文件上传失败:未提供 file_path 或 base64");
|
|
1056
|
+
}
|
|
1057
|
+
const fileType = opts.fileType ?? this.inferFileType(opts.fileName, opts.mime);
|
|
1058
|
+
let res: { file_key?: string };
|
|
1059
|
+
try {
|
|
1060
|
+
res = (await this.client.im.file.create({
|
|
1061
|
+
data: {
|
|
1062
|
+
file_type: fileType,
|
|
1063
|
+
file_name: opts.fileName,
|
|
1064
|
+
file: fileData,
|
|
1065
|
+
...(opts.duration != null && opts.duration > 0 ? { duration: opts.duration } : {}),
|
|
1066
|
+
},
|
|
1067
|
+
})) as { file_key?: string };
|
|
1068
|
+
} catch (e: unknown) {
|
|
1069
|
+
const err = e as { response?: { data?: unknown; status?: number }; message?: string };
|
|
1070
|
+
const detail = err?.response?.data ? JSON.stringify(err.response.data) : err?.message;
|
|
1071
|
+
throw new Error(`飞书文件上传失败 (${err?.response?.status ?? "unknown"}): ${detail}`);
|
|
1072
|
+
}
|
|
1073
|
+
if (res?.file_key) return res.file_key;
|
|
1074
|
+
throw new Error(`飞书文件上传失败(无 file_key): ${JSON.stringify(res)}`);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1012
1077
|
/**
|
|
1013
1078
|
* 上传图片到飞书,获取 image_key。
|
|
1014
1079
|
* 支持三种来源(优先级:file_path > base64 > url):
|
|
@@ -1060,18 +1125,35 @@ export class FeishuHandler implements BridgeHandler {
|
|
|
1060
1125
|
async sendText(
|
|
1061
1126
|
chatId: string,
|
|
1062
1127
|
text: string,
|
|
1063
|
-
attachments?: Array<{
|
|
1128
|
+
attachments?: Array<{
|
|
1129
|
+
type?: string;
|
|
1130
|
+
base64?: string;
|
|
1131
|
+
url?: string;
|
|
1132
|
+
mime?: string;
|
|
1133
|
+
file_path?: string;
|
|
1134
|
+
name?: string;
|
|
1135
|
+
duration?: number;
|
|
1136
|
+
}>
|
|
1064
1137
|
): Promise<void> {
|
|
1065
1138
|
const trimmed = (text ?? "").trim();
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1139
|
+
const allAttachments = attachments ?? [];
|
|
1140
|
+
const imageAttachments = allAttachments.filter((a) => (a.type ?? "").toLowerCase() === "image");
|
|
1141
|
+
const fileAttachments = allAttachments.filter((a) => {
|
|
1142
|
+
const t = (a.type ?? "").toLowerCase();
|
|
1143
|
+
if (t === "image") return false;
|
|
1144
|
+
if (["file", "audio", "video"].includes(t)) return true;
|
|
1145
|
+
// 未指定 type 但有 path/base64 时,视为通用文件
|
|
1146
|
+
return !!(a.file_path || a.base64);
|
|
1147
|
+
});
|
|
1068
1148
|
|
|
1069
|
-
//
|
|
1070
|
-
if (!trimmed && imageAttachments.length === 0) {
|
|
1149
|
+
// 无内容且无任何附件时跳过
|
|
1150
|
+
if (!trimmed && imageAttachments.length === 0 && fileAttachments.length === 0) {
|
|
1071
1151
|
return;
|
|
1072
1152
|
}
|
|
1073
1153
|
|
|
1074
|
-
|
|
1154
|
+
const receiveIdType = chatId.startsWith("ou_") ? "open_id" : "chat_id";
|
|
1155
|
+
|
|
1156
|
+
// 1. 上传图片获取 image_key
|
|
1075
1157
|
const imageKeys: string[] = [];
|
|
1076
1158
|
for (const att of imageAttachments) {
|
|
1077
1159
|
if (att.file_path || att.base64) {
|
|
@@ -1082,12 +1164,10 @@ export class FeishuHandler implements BridgeHandler {
|
|
|
1082
1164
|
});
|
|
1083
1165
|
imageKeys.push(key);
|
|
1084
1166
|
}
|
|
1085
|
-
// url 暂不支持(需先下载再上传,可后续扩展)
|
|
1086
1167
|
}
|
|
1087
1168
|
|
|
1088
|
-
//
|
|
1169
|
+
// 2. 先发送文本+图片(post),再发送文件/音频/视频
|
|
1089
1170
|
const contentRows: Array<Record<string, string>[]> = [];
|
|
1090
|
-
|
|
1091
1171
|
if (trimmed) {
|
|
1092
1172
|
const { cleanText, atElements } = extractMentionElements(
|
|
1093
1173
|
trimmed,
|
|
@@ -1095,14 +1175,10 @@ export class FeishuHandler implements BridgeHandler {
|
|
|
1095
1175
|
);
|
|
1096
1176
|
const adapted = adaptMarkdownForFeishu(cleanText);
|
|
1097
1177
|
const processed = convertMarkdownTablesToCodeBlock(adapted);
|
|
1098
|
-
|
|
1099
|
-
// 按段落拆分为多个 content row(飞书富文本是多段落结构)
|
|
1100
1178
|
const paragraphs = processed.split(/\n{2,}/).filter((p) => p.trim());
|
|
1101
1179
|
if (paragraphs.length === 0) {
|
|
1102
|
-
// 无有效段落,退化为单行
|
|
1103
1180
|
contentRows.push([{ tag: "md", text: processed }]);
|
|
1104
1181
|
} else {
|
|
1105
|
-
// @mention 的 at 元素放在第一个段落的 row 中
|
|
1106
1182
|
const firstRow: Record<string, string>[] = [];
|
|
1107
1183
|
for (const at of atElements) {
|
|
1108
1184
|
firstRow.push({ tag: "at", user_id: at.userId });
|
|
@@ -1110,36 +1186,64 @@ export class FeishuHandler implements BridgeHandler {
|
|
|
1110
1186
|
}
|
|
1111
1187
|
firstRow.push({ tag: "md", text: paragraphs[0].trim() });
|
|
1112
1188
|
contentRows.push(firstRow);
|
|
1113
|
-
// 后续段落各自独立一行
|
|
1114
1189
|
for (let i = 1; i < paragraphs.length; i++) {
|
|
1115
1190
|
contentRows.push([{ tag: "md", text: paragraphs[i].trim() }]);
|
|
1116
1191
|
}
|
|
1117
1192
|
}
|
|
1118
1193
|
}
|
|
1119
|
-
|
|
1120
|
-
// 添加图片节点(每张图一行)
|
|
1121
1194
|
for (const key of imageKeys) {
|
|
1122
1195
|
contentRows.push([{ tag: "img", image_key: key }]);
|
|
1123
1196
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1197
|
+
if (contentRows.length > 0) {
|
|
1198
|
+
await this.client.im.v1.message.create({
|
|
1199
|
+
params: { receive_id_type: receiveIdType },
|
|
1200
|
+
data: {
|
|
1201
|
+
receive_id: chatId,
|
|
1202
|
+
msg_type: "post",
|
|
1203
|
+
content: JSON.stringify({
|
|
1204
|
+
zh_cn: {
|
|
1205
|
+
content: contentRows,
|
|
1206
|
+
},
|
|
1207
|
+
}),
|
|
1208
|
+
},
|
|
1209
|
+
});
|
|
1128
1210
|
}
|
|
1129
1211
|
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1212
|
+
// 3. 上传文件/音频/视频并发送对应消息
|
|
1213
|
+
for (const att of fileAttachments) {
|
|
1214
|
+
if (!att.file_path && !att.base64) continue;
|
|
1215
|
+
const fileName =
|
|
1216
|
+
att.name?.trim() ||
|
|
1217
|
+
(att.file_path ? path.basename(att.file_path) : `file_${Date.now()}`);
|
|
1218
|
+
const fileKey = await this.uploadFileToFeishu({
|
|
1219
|
+
filePath: att.file_path,
|
|
1220
|
+
base64: att.base64,
|
|
1221
|
+
fileName,
|
|
1222
|
+
mime: att.mime,
|
|
1223
|
+
duration: att.duration,
|
|
1224
|
+
});
|
|
1225
|
+
const msgType = (att.type ?? "file").toLowerCase();
|
|
1226
|
+
let content: string;
|
|
1227
|
+
if (msgType === "audio") {
|
|
1228
|
+
content = JSON.stringify({ file_key: fileKey, duration: att.duration ?? 0 });
|
|
1229
|
+
} else if (msgType === "video") {
|
|
1230
|
+
content = JSON.stringify({
|
|
1231
|
+
file_key: fileKey,
|
|
1232
|
+
file_name: fileName,
|
|
1233
|
+
duration: att.duration ?? 0,
|
|
1234
|
+
});
|
|
1235
|
+
} else {
|
|
1236
|
+
content = JSON.stringify({ file_key: fileKey, file_name: fileName });
|
|
1237
|
+
}
|
|
1238
|
+
await this.client.im.v1.message.create({
|
|
1239
|
+
params: { receive_id_type: receiveIdType },
|
|
1240
|
+
data: {
|
|
1241
|
+
receive_id: chatId,
|
|
1242
|
+
msg_type: msgType === "video" ? "media" : msgType,
|
|
1243
|
+
content,
|
|
1244
|
+
},
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1143
1247
|
}
|
|
1144
1248
|
|
|
1145
1249
|
/** 处理来自 MCP 工具 feishu action=set_nickname 的命令 */
|
package/src/mcp-server.ts
CHANGED
|
@@ -6,10 +6,12 @@ import { z } from "zod";
|
|
|
6
6
|
import type { FeishuHandler } from "./feishu-handler.js";
|
|
7
7
|
|
|
8
8
|
const attachmentSchema = z.object({
|
|
9
|
-
type: z.string().optional(),
|
|
9
|
+
type: z.string().optional().describe("image|file|audio|video"),
|
|
10
10
|
path: z.string().optional(),
|
|
11
11
|
base64: z.string().optional(),
|
|
12
12
|
mime: z.string().optional(),
|
|
13
|
+
name: z.string().optional().describe("文件名,用于 file/audio/video"),
|
|
14
|
+
duration: z.number().optional().describe("音视频时长(毫秒),用于 audio/video"),
|
|
13
15
|
});
|
|
14
16
|
|
|
15
17
|
const feishuSchema = {
|
|
@@ -28,7 +30,7 @@ export function createFeishuMcpServer(handler: FeishuHandler): McpServer {
|
|
|
28
30
|
server.registerTool(
|
|
29
31
|
"feishu",
|
|
30
32
|
{
|
|
31
|
-
description: "飞书操作。action=send 发消息(需 chat_id,text;attachments
|
|
33
|
+
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 群聊。",
|
|
32
34
|
inputSchema: feishuSchema,
|
|
33
35
|
},
|
|
34
36
|
async (args) => {
|
|
@@ -39,6 +41,8 @@ export function createFeishuMcpServer(handler: FeishuHandler): McpServer {
|
|
|
39
41
|
file_path: a.path,
|
|
40
42
|
base64: a.base64,
|
|
41
43
|
mime: a.mime,
|
|
44
|
+
name: a.name,
|
|
45
|
+
duration: a.duration,
|
|
42
46
|
}));
|
|
43
47
|
await handler.sendText(args.chat_id!, args.text ?? "", atts);
|
|
44
48
|
return { content: [{ type: "text" as const, text: `已发送到 ${args.chat_id}` }], isError: false };
|