@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.
@@ -542,22 +542,26 @@ export class FeishuHandler {
542
542
  }
543
543
  }
544
544
  /**
545
- * 获取消息资源并转为 attachment。有 file_name 时下载到 cache 传 path;无则传 ref(不保存)。
545
+ * 获取消息资源并转为 attachment。有 file_name 时用原名;无则用默认名(如图片用 image_xxx.png)。
546
+ * 下载到本地后传绝对路径给 LLM,否则 LLM 无法访问。
546
547
  */
547
548
  async fetchMessageResourceAttachment(messageId, fileKey, resourceType, aidoType, fileName) {
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);
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 imageAttachments = (attachments ?? []).filter((a) => (a.type ?? "").toLowerCase() === "image");
922
- // 无内容且无图片时跳过
923
- if (!trimmed && imageAttachments.length === 0) {
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
- // 上传图片获取 image_key(失败时抛出错误,同步传播回 LLM tool result)
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
- // 构建 post content 行
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
- if (contentRows.length === 0) {
972
- return;
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) {
@@ -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 可选,图片用 type=image+path 或 base64);read_doc 读文档(需 url);set_nickname 设置备注(需 sender_id,nickname)。chat_id: ou_xxx 私聊/oc_xxx 群聊。",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lmcl/ailo-mcp-feishu",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Ailo 飞书/Lark 通道 MCP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -634,7 +634,8 @@ export class FeishuHandler implements BridgeHandler {
634
634
  }
635
635
 
636
636
  /**
637
- * 获取消息资源并转为 attachment。有 file_name 时下载到 cache 传 path;无则传 ref(不保存)。
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
- if (!fileName?.trim()) {
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
- fileName
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<{ type?: string; base64?: string; url?: string; mime?: string; file_path?: string }>
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 imageAttachments =
1067
- (attachments ?? []).filter((a) => (a.type ?? "").toLowerCase() === "image");
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
- // 上传图片获取 image_key(失败时抛出错误,同步传播回 LLM tool result)
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
- // 构建 post content 行
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
- if (contentRows.length === 0) {
1127
- return;
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
- const receiveIdType = chatId.startsWith("ou_") ? "open_id" : "chat_id";
1131
- await this.client.im.v1.message.create({
1132
- params: { receive_id_type: receiveIdType },
1133
- data: {
1134
- receive_id: chatId,
1135
- msg_type: "post",
1136
- content: JSON.stringify({
1137
- zh_cn: {
1138
- content: contentRows,
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 可选,图片用 type=image+path 或 base64);read_doc 读文档(需 url);set_nickname 设置备注(需 sender_id,nickname)。chat_id: ou_xxx 私聊/oc_xxx 群聊。",
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 };