@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.
@@ -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 cacheDir = path.join(workDir, "blobs");
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 safeMsg = messageId.replace(/[^a-zA-Z0-9_-]/g, "_");
507
- const safeKey = fileKey.replace(/[^a-zA-Z0-9_-]/g, "_");
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。优先下载到 cache 传 path;失败则传 ref
545
+ * 获取消息资源并转为 attachment。有 file_name 时下载到 cache 传 path;无则传 ref(不保存)。
539
546
  */
540
547
  async fetchMessageResourceAttachment(messageId, fileKey, resourceType, aidoType, fileName) {
541
- const pathOrNull = await this.downloadToCache(messageId, fileKey, resourceType, aidoType);
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
- const out = { type: aidoType, path: pathOrNull };
544
- if (fileName)
545
- out.name = fileName;
546
- return out;
553
+ return {
554
+ type: aidoType,
555
+ path: pathOrNull,
556
+ name: path.basename(pathOrNull),
557
+ };
547
558
  }
548
- const ref = `im:${messageId}:${fileKey}:${aidoType}`;
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 imageAttachments = (attachments ?? []).filter((a) => (a.type ?? "").toLowerCase() === "image");
912
- // 无内容且无图片时跳过
913
- if (!trimmed && imageAttachments.length === 0) {
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
- // 上传图片获取 image_key(失败时抛出错误,同步传播回 LLM tool result)
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
- // 构建 post content 行
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
- if (contentRows.length === 0) {
962
- return;
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) {
@@ -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.4",
3
+ "version": "0.0.6",
4
4
  "description": "Ailo 飞书/Lark 通道 MCP",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",