Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export {
sendFileLark,
sendAudioLark,
uploadAndSendMediaLark,
uploadAndSendImageLark,
} from './src/messaging/outbound/media';
export {
sendTextLark,
Expand Down
1 change: 1 addition & 0 deletions skills/feishu-channel-rules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
148 changes: 146 additions & 2 deletions src/messaging/outbound/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 ?? '',
Expand All @@ -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 ?? '',
Expand Down Expand Up @@ -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<string, 'opus' | 'mp4' | 'pdf' | 'doc' | 'xls' | 'ppt' | 'stream'> = {
Expand Down Expand Up @@ -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<typeof imageSize>;
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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<UploadAndSendImageResult> {
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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -903,8 +1048,7 @@ async function validateRemoteUrl(raw: string): Promise<void> {
// 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 {
Expand Down
4 changes: 4 additions & 0 deletions src/tools/tat/im/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
import { registerFeishuImBotImageTool } from './resource';
import { registerFeishuImBotSendImageTool } from './send-image';

/**
* 注册所有 IM 工具
Expand All @@ -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');
}
}
116 changes: 116 additions & 0 deletions src/tools/tat/im/send-image.ts
Original file line number Diff line number Diff line change
@@ -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' },
);
}
Loading