diff --git a/index.ts b/index.ts index 9ff1c581..b77611c2 100644 --- a/index.ts +++ b/index.ts @@ -46,6 +46,7 @@ export { sendFileLark, sendAudioLark, uploadAndSendMediaLark, + uploadAndSendImageLark, } from './src/messaging/outbound/media'; export { sendTextLark, diff --git a/skills/feishu-channel-rules/SKILL.md b/skills/feishu-channel-rules/SKILL.md index 51d6ed94..0285c30b 100644 --- a/skills/feishu-channel-rules/SKILL.md +++ b/skills/feishu-channel-rules/SKILL.md @@ -16,3 +16,4 @@ alwaysActive: true ## Note - Lark Markdown differs from standard Markdown in some ways; when unsure, refer to `references/markdown-syntax.md` +- When the user asks to send an image and it must appear as an actual image message, use `feishu_im_bot_send_image` with an image URL or a local file under `/tmp/openclaw` instead of embedding image URLs in cards or markdown. Use it only when the destination is the current conversation or was explicitly provided by the user. The tool returns `ok=true` only after Feishu accepts a real `msg_type=image` message. diff --git a/src/messaging/outbound/media.ts b/src/messaging/outbound/media.ts index f7f3c648..688b3598 100644 --- a/src/messaging/outbound/media.ts +++ b/src/messaging/outbound/media.ts @@ -17,6 +17,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { Readable } from 'node:stream'; +import { imageSize } from 'image-size'; import type { OpenClawConfig } from 'openclaw/plugin-sdk'; import { LarkClient } from '../../core/lark-client'; import { normalizeFeishuTarget, resolveReceiveIdType } from '../../core/targets'; @@ -83,6 +84,20 @@ export interface SendMediaResult { chatId: string; } +/** + * Result of uploading and sending an image in one strict operation. + */ +export interface UploadAndSendImageResult extends SendMediaResult { + /** The image_key assigned by Feishu. */ + imageKey: string; + /** Detected image format, e.g. "png" or "jpg". */ + imageFormat: string; + /** Detected image width, when available. */ + width?: number; + /** Detected image height, when available. */ + height?: number; +} + // --------------------------------------------------------------------------- // Response extraction helpers // --------------------------------------------------------------------------- @@ -184,6 +199,13 @@ async function extractBufferFromResponse(response: unknown): Promise<{ buffer: B throw new Error('[feishu-media] Unable to extract binary data from response: unrecognised format'); } +function assertLarkResponseOk(response: unknown, context: string): void { + const res = response as { code?: number; msg?: string } | undefined; + if (res?.code !== undefined && res.code !== 0) { + throw new Error(`[feishu-media] ${context} failed: code=${res.code}, msg=${res.msg ?? 'unknown error'}`); + } +} + /** * Consume a Readable stream into a Buffer. */ @@ -292,6 +314,7 @@ export async function uploadImageLark(params: { const response = await client.im.image.create({ data: { image_type: imageType, image: imageStream as any }, }); + assertLarkResponseOk(response, 'Image upload'); const imageKey = (response as any)?.data?.image_key ?? (response as any)?.image_key; if (!imageKey) { @@ -341,6 +364,7 @@ export async function uploadFileLark(params: { ...(duration !== undefined ? { duration: String(duration) } : {}), } as any, }); + assertLarkResponseOk(response, 'File upload'); const fileKey = (response as any)?.data?.file_key ?? (response as any)?.file_key; if (!fileKey) { @@ -379,6 +403,7 @@ async function sendMediaMessage(params: { path: { message_id: replyToMessageId }, data: { content, msg_type: msgType, reply_in_thread: replyInThread }, }); + assertLarkResponseOk(response, `Send ${msgType} reply`); return { messageId: response?.data?.message_id ?? '', chatId: response?.data?.chat_id ?? '', @@ -398,6 +423,7 @@ async function sendMediaMessage(params: { params: { receive_id_type: receiveIdType as any }, data: { receive_id: target, msg_type: msgType, content }, }); + assertLarkResponseOk(response, `Send ${msgType} message`); return { messageId: response?.data?.message_id ?? '', @@ -541,6 +567,10 @@ export async function sendAudioLark(params: { /** Known image extensions. */ const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.ico', '.tiff', '.tif', '.heic']); +const FEISHU_IMAGE_MAX_BYTES = 10 * 1024 * 1024; +const FEISHU_SUPPORTED_IMAGE_FORMATS = new Set(['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp', 'ico', 'tiff', 'heic']); +const FEISHU_GIF_MAX_DIMENSION = 2000; +const FEISHU_IMAGE_MAX_DIMENSION = 12000; /** Extension-to-Feishu-file-type mapping. */ const EXTENSION_TYPE_MAP: Record = { @@ -717,6 +747,56 @@ function isImageFileName(fileName: string): boolean { return IMAGE_EXTENSIONS.has(ext); } +function assertSupportedFeishuImage(buffer: Buffer): { format: string; width?: number; height?: number } { + if (buffer.length === 0) { + throw new Error('[feishu-media] Image send failed: image size is 0 bytes.'); + } + + if (buffer.length > FEISHU_IMAGE_MAX_BYTES) { + throw new Error( + `[feishu-media] Image send failed: image size ${buffer.length} bytes exceeds Feishu's 10 MB image limit. ` + + 'Send it as a file instead.', + ); + } + + let dimensions: ReturnType; + try { + dimensions = imageSize(buffer); + } catch { + throw new Error( + '[feishu-media] Image send failed: unable to recognise the image format. ' + + 'Feishu image messages require JPG, JPEG, PNG, WEBP, GIF, BMP, ICO, TIFF, or HEIC.', + ); + } + + const format = dimensions.type?.toLowerCase(); + if (!format || !FEISHU_SUPPORTED_IMAGE_FORMATS.has(format)) { + throw new Error( + `[feishu-media] Image send failed: unsupported image format "${format ?? 'unknown'}". ` + + 'Feishu image messages require JPG, JPEG, PNG, WEBP, GIF, BMP, ICO, TIFF, or HEIC.', + ); + } + + const { width, height } = dimensions; + if (width !== undefined && height !== undefined) { + const maxDimension = format === 'gif' ? FEISHU_GIF_MAX_DIMENSION : FEISHU_IMAGE_MAX_DIMENSION; + if (width > maxDimension || height > maxDimension) { + throw new Error( + `[feishu-media] Image send failed: image resolution ${width}x${height} exceeds Feishu's ` + + `${maxDimension}x${maxDimension} ${format === 'gif' ? 'GIF ' : ''}image limit. Send it as a file instead.`, + ); + } + } + + return { format, width, height }; +} + +function assertSentImageResult(result: SendMediaResult): void { + if (!result.messageId) { + throw new Error('[feishu-media] Image send failed: Feishu returned success without a message_id.'); + } +} + // --------------------------------------------------------------------------- // uploadAndSendMediaLark // --------------------------------------------------------------------------- @@ -843,6 +923,71 @@ export async function uploadAndSendMediaLark(params: { }); } +/** + * Upload and send an image message in one strict operation. + * + * This follows Feishu's documented image path: upload the image with + * `image_type=message`, then send `msg_type=image` with the returned + * `image_key`. It never falls back to a text link or file message. + */ +export async function uploadAndSendImageLark(params: { + cfg: OpenClawConfig; + to: string; + imageUrl?: string; + imageBuffer?: Buffer; + fileName?: string; + replyToMessageId?: string; + replyInThread?: boolean; + accountId?: string; + /** Allowed root directories for local file access (SSRF prevention). */ + mediaLocalRoots?: readonly string[]; +}): Promise { + const { cfg, to, imageUrl, imageBuffer, replyToMessageId, replyInThread, accountId, mediaLocalRoots } = params; + + log.info( + `uploadAndSendImageLark: target=${to || replyToMessageId || '(none)'}, ` + + `source=${imageBuffer ? 'buffer' : (imageUrl ?? '(none)')}`, + ); + + let buffer: Buffer; + if (imageBuffer) { + buffer = imageBuffer; + } else if (imageUrl) { + buffer = await fetchMediaBuffer(imageUrl, mediaLocalRoots); + } else { + throw new Error( + '[feishu-media] uploadAndSendImageLark requires either imageUrl or imageBuffer. ' + + 'Provide a URL, local file path, or raw Buffer to send an image.', + ); + } + + const imageInfo = assertSupportedFeishuImage(buffer); + const { imageKey } = await uploadImageLark({ + cfg, + image: buffer, + imageType: 'message', + accountId, + }); + + const sent = await sendImageLark({ + cfg, + to, + imageKey, + replyToMessageId, + replyInThread, + accountId, + }); + assertSentImageResult(sent); + + return { + ...sent, + imageKey, + imageFormat: imageInfo.format, + width: imageInfo.width, + height: imageInfo.height, + }; +} + // --------------------------------------------------------------------------- // fetchRemoteImageBuffer — public wrapper for remote-only image downloads // --------------------------------------------------------------------------- @@ -903,8 +1048,7 @@ async function validateRemoteUrl(raw: string): Promise { // URL contains a literal IP address — check it directly. if (isPrivateIP(hostname)) { throw new Error( - `[feishu-media] Access to private/reserved IP "${hostname}" is denied (SSRF protection). ` + - `URL: "${raw}"`, + `[feishu-media] Access to private/reserved IP "${hostname}" is denied (SSRF protection). ` + `URL: "${raw}"`, ); } } else { diff --git a/src/tools/tat/im/index.ts b/src/tools/tat/im/index.ts index 8f248d06..ef098f0d 100644 --- a/src/tools/tat/im/index.ts +++ b/src/tools/tat/im/index.ts @@ -8,6 +8,7 @@ import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'; import { registerFeishuImBotImageTool } from './resource'; +import { registerFeishuImBotSendImageTool } from './send-image'; /** * 注册所有 IM 工具 @@ -19,4 +20,7 @@ export function registerFeishuImTools(api: OpenClawPluginApi): void { if (registerFeishuImBotImageTool(api)) { api.logger.debug?.('feishu_im: Registered feishu_im_bot_image'); } + if (registerFeishuImBotSendImageTool(api)) { + api.logger.debug?.('feishu_im: Registered feishu_im_bot_send_image'); + } } diff --git a/src/tools/tat/im/send-image.ts b/src/tools/tat/im/send-image.ts new file mode 100644 index 00000000..5b90999b --- /dev/null +++ b/src/tools/tat/im/send-image.ts @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates + * SPDX-License-Identifier: MIT + * + * feishu_im_bot_send_image 工具 + * + * 以机器人身份可靠发送飞书 IM 图片消息。 + * + * 飞书 API: + * - 上传图片: POST /open-apis/im/v1/images + * - 发送消息: POST /open-apis/im/v1/messages + * - 回复消息: POST /open-apis/im/v1/messages/:message_id/reply + * 权限: im:resource, im:message:send_as_bot + * 凭证: tenant_access_token + */ + +import type { OpenClawPluginApi } from 'openclaw/plugin-sdk'; +import { Type } from '@sinclair/typebox'; +import { uploadAndSendImageLark } from '../../../messaging/outbound/media'; +import { createToolContext, formatLarkError, json, registerTool } from '../../oapi/helpers'; + +const BOT_SEND_IMAGE_LOCAL_ROOTS = ['/tmp/openclaw'] as const; + +const FeishuImBotSendImageSchema = Type.Object({ + to: Type.Optional( + Type.String({ + description: '接收者 ID。群聊用 oc_xxx,用户用 ou_xxx。回复消息时可省略,但直接发送新消息时必填。', + }), + ), + image: Type.String({ + description: + '图片 URL、file:// URL 或本地路径。优先使用 http/https URL;本地路径默认只允许 /tmp/openclaw 下的文件。', + }), + file_name: Type.Optional( + Type.String({ + description: '可选文件名,仅用于日志和调用方可读性;图片格式以二进制内容检测为准。', + }), + ), + reply_to_message_id: Type.Optional( + Type.String({ + description: '要回复的消息 ID(om_xxx)。提供后将通过回复接口发送图片。', + }), + ), + reply_in_thread: Type.Optional( + Type.Boolean({ + description: '是否以话题形式回复。仅在 reply_to_message_id 存在时生效。', + }), + ), +}); + +interface FeishuImBotSendImageParams { + to?: string; + image: string; + file_name?: string; + reply_to_message_id?: string; + reply_in_thread?: boolean; +} + +export function registerFeishuImBotSendImageTool(api: OpenClawPluginApi): boolean { + if (!api.config) return false; + + const { toolClient, log } = createToolContext(api, 'feishu_im_bot_send_image'); + + return registerTool( + api, + { + name: 'feishu_im_bot_send_image', + label: 'Feishu: IM Bot Image Send', + description: + '【以机器人身份】可靠发送飞书图片消息。' + + '\n\n固定执行官方两步流程:先上传图片获取 image_key,再发送 msg_type=image 消息。' + + '本工具只在真正发出图片消息后返回 ok=true;不会把失败静默降级成文本链接或文件消息。' + + '\n\n适用场景:需要保证对方看到的是图片,而不是卡片占位、链接或附件。' + + '\n\n【安全约束】只有在用户明确要求发送图片,并且发送对象来自当前对话上下文或用户明确提供时才能调用。禁止自行选择新接收者、扩大接收范围或批量发送。' + + '\n\n限制:图片需符合飞书官方限制:JPG/JPEG/PNG/WEBP/GIF/BMP/ICO/TIFF/HEIC,大小不超过 10 MB;GIF 不超过 2000x2000,其他图片不超过 12000x12000。', + parameters: FeishuImBotSendImageSchema, + async execute(_toolCallId: string, params: unknown) { + const p = params as FeishuImBotSendImageParams; + + try { + if (!p.to && !p.reply_to_message_id) { + return json({ error: 'to 或 reply_to_message_id 至少需要提供一个。' }); + } + + const currentClient = toolClient(); + log.info(`send_image: to="${p.to ?? ''}", reply_to="${p.reply_to_message_id ?? ''}", image="${p.image}"`); + + const result = await uploadAndSendImageLark({ + cfg: currentClient.config, + to: p.to ?? '', + imageUrl: p.image, + fileName: p.file_name, + replyToMessageId: p.reply_to_message_id, + replyInThread: p.reply_in_thread, + accountId: currentClient.account.accountId, + mediaLocalRoots: BOT_SEND_IMAGE_LOCAL_ROOTS, + }); + + return json({ + ok: true, + message_id: result.messageId, + chat_id: result.chatId, + image_key: result.imageKey, + image_format: result.imageFormat, + width: result.width, + height: result.height, + }); + } catch (err) { + log.error(`send_image error: ${formatLarkError(err)}`); + return json({ ok: false, error: formatLarkError(err) }); + } + }, + }, + { name: 'feishu_im_bot_send_image' }, + ); +} diff --git a/tests/media-image-send.test.ts b/tests/media-image-send.test.ts new file mode 100644 index 00000000..c166e8e6 --- /dev/null +++ b/tests/media-image-send.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + fromCfg: vi.fn(), + imageCreate: vi.fn(), + messageCreate: vi.fn(), + messageReply: vi.fn(), +})); + +vi.mock('../src/core/lark-client', () => ({ + LarkClient: { + fromCfg: mocks.fromCfg, + }, +})); + +vi.mock('../src/core/lark-logger', () => ({ + larkLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), +})); + +import { uploadAndSendImageLark } from '../src/messaging/outbound/media'; + +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/l4p2bQAAAABJRU5ErkJggg==', + 'base64', +); + +function installSdkMock(): void { + mocks.fromCfg.mockReturnValue({ + sdk: { + im: { + image: { + create: mocks.imageCreate, + }, + message: { + create: mocks.messageCreate, + reply: mocks.messageReply, + }, + }, + }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + installSdkMock(); + mocks.imageCreate.mockResolvedValue({ code: 0, data: { image_key: 'img_test' } }); + mocks.messageCreate.mockResolvedValue({ code: 0, data: { message_id: 'om_test', chat_id: 'oc_test' } }); + mocks.messageReply.mockResolvedValue({ code: 0, data: { message_id: 'om_reply', chat_id: 'oc_test' } }); +}); + +describe('uploadAndSendImageLark', () => { + it('uploads and sends a real image message from a buffer', async () => { + const result = await uploadAndSendImageLark({ + cfg: {} as never, + to: 'oc_test', + imageBuffer: PNG_1X1, + fileName: 'no-extension', + }); + + expect(mocks.imageCreate).toHaveBeenCalledTimes(1); + expect(mocks.imageCreate.mock.calls[0][0].data.image_type).toBe('message'); + expect(mocks.messageCreate).toHaveBeenCalledWith({ + params: { receive_id_type: 'chat_id' }, + data: { + receive_id: 'oc_test', + msg_type: 'image', + content: JSON.stringify({ image_key: 'img_test' }), + }, + }); + expect(result).toMatchObject({ + messageId: 'om_test', + chatId: 'oc_test', + imageKey: 'img_test', + imageFormat: 'png', + width: 1, + height: 1, + }); + }); + + it('rejects non-image buffers before upload', async () => { + await expect( + uploadAndSendImageLark({ + cfg: {} as never, + to: 'oc_test', + imageBuffer: Buffer.from('not an image'), + }), + ).rejects.toThrow('unable to recognise the image format'); + + expect(mocks.imageCreate).not.toHaveBeenCalled(); + expect(mocks.messageCreate).not.toHaveBeenCalled(); + }); + + it('fails when Feishu does not return a message_id', async () => { + mocks.messageCreate.mockResolvedValueOnce({ code: 0, data: { chat_id: 'oc_test' } }); + + await expect( + uploadAndSendImageLark({ + cfg: {} as never, + to: 'oc_test', + imageBuffer: PNG_1X1, + }), + ).rejects.toThrow('without a message_id'); + }); +});