diff --git a/docs/design/feishu-tools.md b/docs/design/feishu-tools.md index c089e8d..8383702 100644 --- a/docs/design/feishu-tools.md +++ b/docs/design/feishu-tools.md @@ -1,8 +1,8 @@ --- -summary: "飞书 MCP 工具集:doc/wiki/drive/bitable/chat/calendar/contact/task" +summary: "飞书 MCP 工具集:doc/wiki/drive/bitable/chat/calendar/contact/task + 主聊天/图片发送/消息下载" related_paths: - src/feishu/tools/** -last_updated: "2026-04-02" +last_updated: "2026-06-14" --- # 飞书 MCP 工具集 @@ -25,10 +25,17 @@ last_updated: "2026-04-02" | `feishu_calendar` | `calendar.ts` | 日历操作 | | `feishu_contact` | `contact.ts` | 通讯录查询 | | `feishu_task` | `task.ts` | 任务管理 | +| `feishu_send_to_chat` | `main-chat.ts` | 话题内将消息发送到群主聊天(仅 chatId 存在时注册) | +| `feishu_send_image` | `send-image.ts` | 把工作区本地图片发到当前会话/话题(仅 chatId 存在时注册) | +| `feishu_download_message_file` | `message.ts` | 按需下载历史消息中的文件(始终注册) | +| `feishu_download_message_image` | `image.ts` | 按需下载历史消息中的图片(始终注册) | + +> 说明:上表前 8 个工具受 `config.feishu.tools.*` 子开关控制;后 4 个不走子开关——`feishu_send_to_chat`/`feishu_send_image` 在 `chatId` 存在时注册,两个下载工具始终注册。 ### 关键设计决策 - **Action-based dispatch**:每个工具内含多个 action(read/write/create/...),用 Zod discriminatedUnion 定义 +- **图片发送落点**:`feishu_send_image` 上传图片拿 `image_key` 后,依据闭包绑定的 `threadReplyMsgId` 决定回复到话题(`replyImageInThread`)或发送到会话(`sendImage`);`threadReplyMsgId` 由 executor 透传 `ExecuteInput.threadRootMessageId`。注意 `im.image.create` 直接返回已解包的 `{ image_key }`,与其它 message 接口的 `{ code, msg, data }` 结构不同 - **权限控制**:`permissions.ts` 按 read-only 模式过滤写操作 - **参数校验**:`validation.ts` 统一校验 token 格式 - **Markdown 转换**:`markdown-to-blocks.ts` 将 Markdown 转为飞书 Block 结构 diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 9ed3429..38ec1b4 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -683,7 +683,7 @@ export class ClaudeExecutor { const keyParts = sessionKey.split(':'); const chatId = keyParts.length >= 4 ? keyParts[2] : keyParts[0] || undefined; const userId = keyParts.length >= 4 ? keyParts[3] : keyParts[1] || undefined; - const feishuMcp = createFeishuToolsMcpServer(chatId, userId); + const feishuMcp = createFeishuToolsMcpServer(chatId, userId, input.threadRootMessageId); if (feishuMcp) { mcpServers['feishu-tools'] = feishuMcp; } diff --git a/src/feishu/client.ts b/src/feishu/client.ts index 95228f2..8511655 100644 --- a/src/feishu/client.ts +++ b/src/feishu/client.ts @@ -1,4 +1,5 @@ import * as lark from '@larksuiteoapi/node-sdk'; +import type { ReadStream } from 'node:fs'; import { logger } from '../utils/logger.js'; import { formatMergeForwardSubMessage } from './message-parser.js'; @@ -494,6 +495,83 @@ export class FeishuClient { * 下载消息中的图片资源 * 使用 im.messageResource.get API(支持下载用户发送的图片) */ + /** + * 上传图片到飞书,返回 image_key(用于发送图片消息) + * + * @param image 图片文件流(如 fs.createReadStream(path))或 Buffer。 + * 注意:im.image.create 直接返回已解包的 { image_key }, + * 而非其它 message 接口的 { code, msg, data } 结构。 + */ + async uploadImage(image: Buffer | ReadStream): Promise { + try { + const resp = await this.client.im.image.create({ + data: { image_type: 'message', image }, + }); + + if (!resp?.image_key) { + logger.error({ resp }, 'Failed to upload image (no image_key returned)'); + return undefined; + } + + return resp.image_key; + } catch (err) { + logger.error({ err }, 'Error uploading image'); + return undefined; + } + } + + /** + * 发送图片消息到指定群/会话 + */ + async sendImage(chatId: string, imageKey: string): Promise { + try { + const resp = await this.client.im.message.create({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: chatId, + msg_type: 'image', + content: JSON.stringify({ image_key: imageKey }), + }, + }); + + if (resp.code !== 0) { + logger.error({ code: resp.code, msg: resp.msg }, 'Failed to send image message'); + return undefined; + } + + return resp.data?.message_id; + } catch (err) { + logger.error({ err }, 'Error sending image message'); + return undefined; + } + } + + /** + * 在话题中回复图片(通过回复话题内的消息) + */ + async replyImageInThread(threadMessageId: string, imageKey: string): Promise { + try { + const resp = await this.client.im.message.reply({ + path: { message_id: threadMessageId }, + data: { + msg_type: 'image', + content: JSON.stringify({ image_key: imageKey }), + reply_in_thread: true, + }, + }); + + if (resp.code !== 0) { + logger.error({ code: resp.code, msg: resp.msg }, 'Failed to reply image in thread'); + return undefined; + } + + return resp.data?.message_id; + } catch (err) { + logger.error({ err }, 'Error replying image in thread'); + return undefined; + } + } + async downloadMessageImage(messageId: string, imageKey: string): Promise { try { const resp = await this.client.im.messageResource.get({ diff --git a/src/feishu/tools/__tests__/index.test.ts b/src/feishu/tools/__tests__/index.test.ts index 74643b4..cc831c8 100644 --- a/src/feishu/tools/__tests__/index.test.ts +++ b/src/feishu/tools/__tests__/index.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; // vi.hoisted runs before vi.mock factories — safe to reference in factory -const { mockConfig, mockDocTool, mockWikiTool, mockDriveTool, mockBitableTool, mockChatTool, mockContactTool, mockTaskTool, mockCalendarTool, mockMainChatTool, mockMessageFileTool, mockMessageImageTool, mockCreateSdkMcpServer } = vi.hoisted(() => { +const { mockConfig, mockDocTool, mockWikiTool, mockDriveTool, mockBitableTool, mockChatTool, mockContactTool, mockTaskTool, mockCalendarTool, mockMainChatTool, mockSendImageTool, mockMessageFileTool, mockMessageImageTool, mockCreateSdkMcpServer } = vi.hoisted(() => { const mockConfig = { feishu: { tools: { @@ -29,6 +29,7 @@ const { mockConfig, mockDocTool, mockWikiTool, mockDriveTool, mockBitableTool, m mockTaskTool: vi.fn(() => ({ name: 'feishu_task' })), mockCalendarTool: vi.fn(() => ({ name: 'feishu_calendar' })), mockMainChatTool: vi.fn(() => ({ name: 'feishu_send_to_chat' })), + mockSendImageTool: vi.fn(() => ({ name: 'feishu_send_image' })), mockMessageFileTool: vi.fn(() => ({ name: 'feishu_download_message_file' })), mockMessageImageTool: vi.fn(() => ({ name: 'feishu_download_message_image' })), mockCreateSdkMcpServer: vi.fn((opts: unknown) => ({ ...(opts as object), type: 'mcp-server' })), @@ -46,6 +47,7 @@ vi.mock('../contact.js', () => ({ feishuContactTool: () => mockContactTool() })) vi.mock('../task.js', () => ({ feishuTaskTool: () => mockTaskTool() })); vi.mock('../calendar.js', () => ({ feishuCalendarTool: () => mockCalendarTool() })); vi.mock('../main-chat.js', () => ({ feishuMainChatTool: () => mockMainChatTool() })); +vi.mock('../send-image.js', () => ({ feishuSendImageTool: () => mockSendImageTool() })); vi.mock('../message.js', () => ({ feishuMessageFileTool: () => mockMessageFileTool() })); vi.mock('../image.js', () => ({ feishuMessageImageTool: () => mockMessageImageTool() })); vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ @@ -122,13 +124,19 @@ describe('createFeishuToolsMcpServer', () => { expect(mockMainChatTool).not.toHaveBeenCalled(); }); - it('should include main-chat tool when chatId is provided', () => { + it('should include main-chat and send-image tools when chatId is provided', () => { const result = createFeishuToolsMcpServer('chat_123'); expect(result).toBeDefined(); const call = mockCreateSdkMcpServer.mock.calls[0][0]; - // 8 config-gated + 1 main-chat + 1 message file + 1 message image = 11 - expect(call.tools).toHaveLength(11); + // 8 config-gated + 1 main-chat + 1 send-image + 1 message file + 1 message image = 12 + expect(call.tools).toHaveLength(12); expect(mockMainChatTool).toHaveBeenCalledTimes(1); + expect(mockSendImageTool).toHaveBeenCalledTimes(1); + }); + + it('should not include send-image tool when chatId is undefined', () => { + createFeishuToolsMcpServer(undefined); + expect(mockSendImageTool).not.toHaveBeenCalled(); }); it('should not include task tool when task switch is false', () => { diff --git a/src/feishu/tools/__tests__/send-image.test.ts b/src/feishu/tools/__tests__/send-image.test.ts new file mode 100644 index 0000000..8e2ed8e --- /dev/null +++ b/src/feishu/tools/__tests__/send-image.test.ts @@ -0,0 +1,172 @@ +// @ts-nocheck — test file, vitest uses esbuild transform +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ============================================================ +// Mocks +// ============================================================ + +vi.mock('../../../utils/logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +const mockUploadImage = vi.fn(); +const mockSendImage = vi.fn(); +const mockReplyImageInThread = vi.fn(); + +vi.mock('../../client.js', () => ({ + feishuClient: { + uploadImage: (...args: unknown[]) => mockUploadImage(...args), + sendImage: (...args: unknown[]) => mockSendImage(...args), + replyImageInThread: (...args: unknown[]) => mockReplyImageInThread(...args), + }, +})); + +const mockExistsSync = vi.fn(); +const mockStatSync = vi.fn(); +const mockOpenSync = vi.fn(() => 3); +const mockReadSync = vi.fn(); +const mockCloseSync = vi.fn(); +const mockCreateReadStream = vi.fn(() => ({ __fakeStream: true })); + +vi.mock('node:fs', () => ({ + existsSync: (...args: unknown[]) => mockExistsSync(...args), + statSync: (...args: unknown[]) => mockStatSync(...args), + openSync: (...args: unknown[]) => mockOpenSync(...args), + readSync: (...args: unknown[]) => mockReadSync(...args), + closeSync: (...args: unknown[]) => mockCloseSync(...args), + createReadStream: (...args: unknown[]) => mockCreateReadStream(...args), +})); + +// Mock the SDK tool() — capture the handler function for direct testing +let capturedHandler: (args: Record) => Promise; +vi.mock('@anthropic-ai/claude-agent-sdk', () => ({ + tool: (_name: string, _desc: string, _schema: unknown, handler: unknown) => { + capturedHandler = handler as typeof capturedHandler; + return { name: _name, handler }; + }, +})); + +import { feishuSendImageTool } from '../send-image.js'; + +// PNG magic bytes for the happy path +const PNG_SIG = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + +function writeHeader(sig: Buffer) { + mockReadSync.mockImplementation((_fd: number, buf: Buffer) => { + sig.copy(buf); + return sig.length; + }); +} + +// ============================================================ +// Tests +// ============================================================ + +describe('feishu_send_image tool', () => { + beforeEach(() => { + vi.clearAllMocks(); + // default happy-path file system state: existing 2KB PNG + mockExistsSync.mockReturnValue(true); + mockStatSync.mockReturnValue({ isFile: () => true, size: 2048 }); + writeHeader(PNG_SIG); + mockUploadImage.mockResolvedValue('img_key_1'); + mockSendImage.mockResolvedValue('msg_1'); + mockReplyImageInThread.mockResolvedValue('thread_msg_1'); + }); + + it('should return error when chatId is undefined', async () => { + feishuSendImageTool(); + const result = await capturedHandler({ file_path: '/abs/out.png' }) as any; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('当前不在会话中'); + expect(mockUploadImage).not.toHaveBeenCalled(); + }); + + it('should upload and send image to chat when no thread', async () => { + feishuSendImageTool('chat_123'); + const result = await capturedHandler({ file_path: '/abs/out.png' }) as any; + expect(result.isError).toBeUndefined(); + expect(mockUploadImage).toHaveBeenCalledWith({ __fakeStream: true }); + expect(mockSendImage).toHaveBeenCalledWith('chat_123', 'img_key_1'); + expect(mockReplyImageInThread).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain('会话'); + }); + + it('should reply image in thread when threadReplyMsgId provided', async () => { + feishuSendImageTool('chat_123', 'thread_root_1'); + const result = await capturedHandler({ file_path: '/abs/out.png' }) as any; + expect(result.isError).toBeUndefined(); + expect(mockReplyImageInThread).toHaveBeenCalledWith('thread_root_1', 'img_key_1'); + expect(mockSendImage).not.toHaveBeenCalled(); + expect(result.content[0].text).toContain('话题'); + }); + + it('should return error when file does not exist', async () => { + mockExistsSync.mockReturnValue(false); + feishuSendImageTool('chat_123'); + const result = await capturedHandler({ file_path: '/abs/missing.png' }) as any; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('不存在'); + expect(mockUploadImage).not.toHaveBeenCalled(); + }); + + it('should return error when file is too large', async () => { + mockStatSync.mockReturnValue({ isFile: () => true, size: 11 * 1024 * 1024 }); + feishuSendImageTool('chat_123'); + const result = await capturedHandler({ file_path: '/abs/huge.png' }) as any; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('过大'); + expect(mockUploadImage).not.toHaveBeenCalled(); + }); + + it('should return error when file is empty', async () => { + mockStatSync.mockReturnValue({ isFile: () => true, size: 0 }); + feishuSendImageTool('chat_123'); + const result = await capturedHandler({ file_path: '/abs/empty.png' }) as any; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('为空'); + expect(mockUploadImage).not.toHaveBeenCalled(); + }); + + it('should return error for unsupported (non-image) format', async () => { + writeHeader(Buffer.from([0x00, 0x01, 0x02, 0x03])); // not an image + feishuSendImageTool('chat_123'); + const result = await capturedHandler({ file_path: '/abs/data.bin' }) as any; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('无法识别'); + expect(mockUploadImage).not.toHaveBeenCalled(); + }); + + it('should return error when upload returns no image_key', async () => { + mockUploadImage.mockResolvedValue(undefined); + feishuSendImageTool('chat_123'); + const result = await capturedHandler({ file_path: '/abs/out.png' }) as any; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('image_key'); + expect(mockSendImage).not.toHaveBeenCalled(); + }); + + it('should return error when send returns no message_id', async () => { + mockSendImage.mockResolvedValue(undefined); + feishuSendImageTool('chat_123'); + const result = await capturedHandler({ file_path: '/abs/out.png' }) as any; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('发送失败'); + }); + + it('should return error when upload throws', async () => { + mockUploadImage.mockRejectedValue(new Error('network down')); + feishuSendImageTool('chat_123'); + const result = await capturedHandler({ file_path: '/abs/out.png' }) as any; + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('network down'); + }); + + it('should accept jpg via magic bytes', async () => { + writeHeader(Buffer.from([0xFF, 0xD8, 0xFF, 0xE0])); + feishuSendImageTool('chat_123'); + const result = await capturedHandler({ file_path: '/abs/photo.jpg' }) as any; + expect(result.isError).toBeUndefined(); + expect(mockSendImage).toHaveBeenCalledWith('chat_123', 'img_key_1'); + }); +}); diff --git a/src/feishu/tools/index.ts b/src/feishu/tools/index.ts index a717e95..bc6b100 100644 --- a/src/feishu/tools/index.ts +++ b/src/feishu/tools/index.ts @@ -9,6 +9,7 @@ import { feishuTaskTool } from './task.js'; import { feishuContactTool } from './contact.js'; import { feishuCalendarTool } from './calendar.js'; import { feishuMainChatTool } from './main-chat.js'; +import { feishuSendImageTool } from './send-image.js'; import { feishuMessageFileTool } from './message.js'; import { feishuMessageImageTool } from './image.js'; import { getValidUserToken } from '../oauth.js'; @@ -18,10 +19,11 @@ import { getValidUserToken } from '../oauth.js'; * * 根据配置子开关组装工具列表,始终包含消息文件按需下载工具。 * - * @param chatId 当前会话的群 chat_id,用于创建文档后自动授权群成员 - * @param userId 当前用户的 open_id,用于获取 user_access_token + * @param chatId 当前会话的群 chat_id,用于创建文档后自动授权群成员 + * @param userId 当前用户的 open_id,用于获取 user_access_token + * @param threadReplyMsgId 话题根消息 ID,传入时 feishu_send_image 会把图片回复到话题内 */ -export function createFeishuToolsMcpServer(chatId?: string, userId?: string) { +export function createFeishuToolsMcpServer(chatId?: string, userId?: string, threadReplyMsgId?: string) { const tools = []; if (config.feishu.tools.doc) tools.push(feishuDocTool(chatId)); if (config.feishu.tools.wiki) tools.push(feishuWikiTool()); @@ -38,6 +40,9 @@ export function createFeishuToolsMcpServer(chatId?: string, userId?: string) { // 主聊天发送工具:agent 在话题内时可自主决定将重要结果发到群主聊天 if (chatId) tools.push(feishuMainChatTool(chatId)); + // 图片发送工具:agent 把工作区里的本地图片发回当前会话/话题 + if (chatId) tools.push(feishuSendImageTool(chatId, threadReplyMsgId)); + // 消息文件按需下载工具:配合 lazy loading,agent 可按需获取历史消息中的文件 tools.push(feishuMessageFileTool()); // 消息图片按需下载工具:父群历史图片 / 工作区切换后落盘图片皆通过此工具按需读取 diff --git a/src/feishu/tools/send-image.ts b/src/feishu/tools/send-image.ts new file mode 100644 index 0000000..ce3943e --- /dev/null +++ b/src/feishu/tools/send-image.ts @@ -0,0 +1,179 @@ +import { tool } from '@anthropic-ai/claude-agent-sdk'; +import { z } from 'zod'; +import { createReadStream, existsSync, openSync, readSync, closeSync, statSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { feishuClient } from '../client.js'; +import { logger } from '../../utils/logger.js'; + +// 飞书图片消息上限为 10MB +const MAX_IMAGE_SIZE = 10 * 1024 * 1024; + +/** + * 通过 magic bytes 识别图片类型,返回扩展名;非受支持图片返回 null。 + * 覆盖 agent 常见产物:PNG 截图 / JPG / GIF / WebP / BMP。 + */ +function detectImageType(buf: Buffer): string | null { + if (buf.length >= 2 && buf[0] === 0xFF && buf[1] === 0xD8) return 'jpg'; + if ( + buf.length >= 4 + && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47 + ) return 'png'; + if (buf.length >= 3 && buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46) return 'gif'; + if ( + buf.length >= 12 + && buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 + && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50 + ) return 'webp'; + if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4D) return 'bmp'; + return null; +} + +/** 读取文件头部若干字节用于类型识别(避免将整图载入内存)。 */ +function readHeader(filePath: string, length = 12): Buffer { + const buf = Buffer.alloc(length); + const fd = openSync(filePath, 'r'); + try { + const bytesRead = readSync(fd, buf, 0, length, 0); + return buf.subarray(0, bytesRead); + } finally { + closeSync(fd); + } +} + +/** + * 飞书图片发送 MCP 工具 + * + * 让 agent 把工作区里的本地图片文件(截图、生成的图表等)发回当前会话。 + * chatId / threadReplyMsgId 通过闭包绑定:话题内回复到话题,否则发到群/会话。 + * + * @param chatId 当前会话的 chat_id + * @param threadReplyMsgId 话题内时传入的话题根消息 ID,使图片回复到话题内 + */ +export function feishuSendImageTool(chatId?: string, threadReplyMsgId?: string) { + return tool( + 'feishu_send_image', + [ + '把本地图片文件发送到当前飞书会话。', + '', + '用于将工作区中生成的图片(截图、图表、渲染结果等)发回给用户。', + '在话题中工作时图片回复到话题内,否则发送到当前群/会话。', + '', + '参数:', + '- file_path: 图片文件的本地绝对路径(如 /root/dev/.workspaces/xxx/out.png)', + '', + '限制: 支持 png/jpg/gif/webp/bmp,大小不超过 10MB。', + ].join('\n'), + { + file_path: z.string().describe('图片文件的本地绝对路径'), + }, + async (args) => { + if (!chatId) { + return { + content: [{ type: 'text' as const, text: '无法发送图片:当前不在会话中' }], + isError: true, + }; + } + + const filePath = resolve(args.file_path); + + if (!existsSync(filePath)) { + return { + content: [{ type: 'text' as const, text: `图片文件不存在: ${filePath}(请提供绝对路径)` }], + isError: true, + }; + } + + let size: number; + try { + const stat = statSync(filePath); + if (!stat.isFile()) { + return { + content: [{ type: 'text' as const, text: `不是文件: ${filePath}` }], + isError: true, + }; + } + size = stat.size; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + content: [{ type: 'text' as const, text: `读取文件失败: ${msg}` }], + isError: true, + }; + } + + if (size === 0) { + return { + content: [{ type: 'text' as const, text: '图片文件为空' }], + isError: true, + }; + } + if (size > MAX_IMAGE_SIZE) { + return { + content: [{ + type: 'text' as const, + text: `图片过大 (${(size / 1024 / 1024).toFixed(1)}MB),超过 ${MAX_IMAGE_SIZE / 1024 / 1024}MB 限制`, + }], + isError: true, + }; + } + + const imageType = detectImageType(readHeader(filePath)); + if (!imageType) { + return { + content: [{ + type: 'text' as const, + text: '无法识别为受支持的图片格式(png/jpg/gif/webp/bmp)', + }], + isError: true, + }; + } + + try { + const imageKey = await feishuClient.uploadImage(createReadStream(filePath)); + if (!imageKey) { + return { + content: [{ type: 'text' as const, text: '图片上传失败(飞书未返回 image_key)' }], + isError: true, + }; + } + + const messageId = threadReplyMsgId + ? await feishuClient.replyImageInThread(threadReplyMsgId, imageKey) + : await feishuClient.sendImage(chatId, imageKey); + + if (!messageId) { + return { + content: [{ type: 'text' as const, text: '图片发送失败' }], + isError: true, + }; + } + + logger.info( + { chatId, threadReplyMsgId, filePath, imageType, sizeBytes: size, imageKey, messageId }, + 'feishu_send_image tool invoked', + ); + + return { + content: [{ + type: 'text' as const, + text: `图片已发送到${threadReplyMsgId ? '话题' : '会话'} (${(size / 1024).toFixed(1)}KB)`, + }], + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logger.error({ err: msg, chatId, filePath }, 'feishu_send_image failed'); + return { + content: [{ type: 'text' as const, text: `发送图片失败: ${msg}` }], + isError: true, + }; + } + }, + { + annotations: { + readOnlyHint: false, + destructiveHint: false, + openWorldHint: true, + }, + }, + ); +}