Files
moltbot/src/feishu/send.ts
Josh Palmer 4fc4c5256a 🤖 Feishu: expand channel support
What:
- add post parsing, doc link extraction, routing, replies, reactions, typing, and user lookup
- fix media download/send flows and make doc fetches domain-aware
- update Feishu docs and clawtributor credits

Why:
- raise Feishu parity with other channels and avoid dropped group messages
- keep replies threaded while supporting Lark domains
- document new configuration and credit the contributor

Tests:
- pnpm build
- pnpm check
- pnpm test (gateway suite timed out; reran pnpm vitest run --config vitest.gateway.config.ts)

Co-authored-by: 九灵云 <server@jiulingyun.cn>
2026-02-05 12:29:04 -08:00

375 lines
12 KiB
TypeScript

import type { Client } from "@larksuiteoapi/node-sdk";
import { formatErrorMessage } from "../infra/errors.js";
import { getChildLogger } from "../logging.js";
import { mediaKindFromMime } from "../media/constants.js";
import { loadWebMedia } from "../web/media.js";
import { containsMarkdown, markdownToFeishuPost } from "./format.js";
const logger = getChildLogger({ module: "feishu-send" });
export type FeishuMsgType = "text" | "image" | "file" | "audio" | "media" | "post" | "interactive";
export type FeishuSendOpts = {
msgType?: FeishuMsgType;
receiveIdType?: "open_id" | "user_id" | "union_id" | "email" | "chat_id";
/** URL of media to upload and send (for image/file/audio/media types) */
mediaUrl?: string;
/** Max bytes for media download */
maxBytes?: number;
/** Whether to auto-convert Markdown to rich text (post). Default: true */
autoRichText?: boolean;
/** Message ID to reply to (uses reply API instead of create) */
replyToMessageId?: string;
/** Whether to reply in thread mode. Default: false */
replyInThread?: boolean;
};
export type FeishuSendResult = {
message_id?: string;
};
type FeishuMessageContent = ({ text?: string } & Record<string, unknown>) | string;
/**
* Upload an image to Feishu and get image_key
*/
export async function uploadImageFeishu(client: Client, imageBuffer: Buffer): Promise<string> {
const res = await client.im.image.create({
data: {
image_type: "message",
image: imageBuffer,
},
});
if (!res?.image_key) {
throw new Error(`Feishu image upload failed: no image_key returned`);
}
return res.image_key;
}
/**
* Upload a file to Feishu and get file_key
* @param fileType - opus (audio), mp4 (video), pdf, doc, xls, ppt, stream (other)
*/
export async function uploadFileFeishu(
client: Client,
fileBuffer: Buffer,
fileName: string,
fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream",
duration?: number,
): Promise<string> {
logger.info(
`Uploading file to Feishu: name=${fileName}, type=${fileType}, size=${fileBuffer.length}`,
);
let res: Awaited<ReturnType<typeof client.im.file.create>>;
try {
res = await client.im.file.create({
data: {
file_type: fileType,
file_name: fileName,
file: fileBuffer,
...(duration ? { duration } : {}),
},
});
} catch (err) {
const errMsg = formatErrorMessage(err);
// Log the full error details
logger.error(`Feishu file upload exception: ${errMsg}`);
if (err && typeof err === "object") {
const response = (err as { response?: { data?: unknown; status?: number } }).response;
if (response?.data) {
logger.error(`Response data: ${JSON.stringify(response.data)}`);
}
if (response?.status) {
logger.error(`Response status: ${response.status}`);
}
}
throw new Error(`Feishu file upload failed: ${errMsg}`, { cause: err });
}
// Log full response for debugging
logger.info(`Feishu file upload response: ${JSON.stringify(res)}`);
const responseMeta =
res && typeof res === "object" ? (res as { code?: number; msg?: string }) : {};
// Check for API error code (if provided by SDK)
if (typeof responseMeta.code === "number" && responseMeta.code !== 0) {
const code = responseMeta.code;
const msg = responseMeta.msg || "unknown error";
logger.error(`Feishu file upload API error: code=${code}, msg=${msg}`);
throw new Error(`Feishu file upload failed: ${msg} (code: ${code})`);
}
const fileKey = res?.file_key;
if (!fileKey) {
logger.error(`Feishu file upload failed - no file_key in response: ${JSON.stringify(res)}`);
throw new Error(`Feishu file upload failed: no file_key returned`);
}
logger.info(`Feishu file upload successful: file_key=${fileKey}`);
return fileKey;
}
/**
* Determine Feishu file_type from content type
*/
function resolveFeishuFileType(
contentType?: string,
fileName?: string,
): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
const ct = contentType?.toLowerCase() ?? "";
const fn = fileName?.toLowerCase() ?? "";
// Audio - Feishu only supports opus for audio messages
if (ct.includes("audio/") || fn.endsWith(".opus") || fn.endsWith(".ogg")) {
return "opus";
}
// Video
if (ct.includes("video/") || fn.endsWith(".mp4") || fn.endsWith(".mov")) {
return "mp4";
}
// Documents
if (ct.includes("pdf") || fn.endsWith(".pdf")) {
return "pdf";
}
if (
ct.includes("msword") ||
ct.includes("wordprocessingml") ||
fn.endsWith(".doc") ||
fn.endsWith(".docx")
) {
return "doc";
}
if (
ct.includes("excel") ||
ct.includes("spreadsheetml") ||
fn.endsWith(".xls") ||
fn.endsWith(".xlsx")
) {
return "xls";
}
if (
ct.includes("powerpoint") ||
ct.includes("presentationml") ||
fn.endsWith(".ppt") ||
fn.endsWith(".pptx")
) {
return "ppt";
}
return "stream";
}
/**
* Send a message to Feishu
*/
export async function sendMessageFeishu(
client: Client,
receiveId: string,
content: FeishuMessageContent,
opts: FeishuSendOpts = {},
): Promise<FeishuSendResult | null> {
const receiveIdType = opts.receiveIdType || "chat_id";
let msgType = opts.msgType || "text";
let finalContent = content;
const contentText =
typeof content === "object" && content !== null && "text" in content
? (content as { text?: string }).text
: undefined;
// Handle media URL - upload first, then send
if (opts.mediaUrl) {
try {
logger.info(`Loading media from: ${opts.mediaUrl}`);
const media = await loadWebMedia(opts.mediaUrl, opts.maxBytes);
const kind = mediaKindFromMime(media.contentType ?? undefined);
const fileName = media.fileName ?? "file";
logger.info(
`Media loaded: kind=${kind}, contentType=${media.contentType}, fileName=${fileName}, size=${media.buffer.length}`,
);
if (kind === "image") {
// Upload image and send as image message
const imageKey = await uploadImageFeishu(client, media.buffer);
msgType = "image";
finalContent = { image_key: imageKey };
} else if (kind === "video") {
// Upload video file and send as media message
const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "mp4");
msgType = "media";
finalContent = { file_key: fileKey };
} else if (kind === "audio") {
// Feishu audio messages (msg_type: "audio") only support opus format
// For other audio formats (mp3, wav, etc.), send as file instead
const isOpus =
media.contentType?.includes("opus") ||
media.contentType?.includes("ogg") ||
fileName.toLowerCase().endsWith(".opus") ||
fileName.toLowerCase().endsWith(".ogg");
if (isOpus) {
logger.info(`Uploading opus audio: ${fileName}`);
const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "opus");
logger.info(`Opus upload successful, file_key: ${fileKey}`);
msgType = "audio";
finalContent = { file_key: fileKey };
} else {
// Send non-opus audio as file attachment
logger.info(`Uploading non-opus audio as file: ${fileName}`);
const fileKey = await uploadFileFeishu(client, media.buffer, fileName, "stream");
logger.info(`File upload successful, file_key: ${fileKey}`);
msgType = "file";
finalContent = { file_key: fileKey };
}
} else {
// Upload as file
const fileType = resolveFeishuFileType(media.contentType, fileName);
const fileKey = await uploadFileFeishu(client, media.buffer, fileName, fileType);
msgType = "file";
finalContent = { file_key: fileKey };
}
// If there's text alongside media, we need to send two messages
// First send the media, then send text as a follow-up
if (typeof contentText === "string" && contentText.trim()) {
// Send media first
const mediaContent = JSON.stringify(finalContent);
if (opts.replyToMessageId) {
await replyMessageFeishu(client, opts.replyToMessageId, mediaContent, msgType, {
replyInThread: opts.replyInThread,
});
} else {
const mediaRes = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: msgType,
content: mediaContent,
},
});
if (mediaRes.code !== 0) {
logger.error(`Feishu media send failed: ${mediaRes.code} - ${mediaRes.msg}`);
throw new Error(`Feishu API Error: ${mediaRes.msg}`);
}
}
// Then send text
const textRes = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: "text",
content: JSON.stringify({ text: contentText }),
},
});
return textRes.data ?? null;
}
} catch (err) {
const errMsg = formatErrorMessage(err);
const errStack = err instanceof Error ? err.stack : undefined;
logger.error(`Feishu media upload/send error: ${errMsg}`);
if (errStack) {
logger.error(`Stack: ${errStack}`);
}
// Re-throw the error instead of falling back to text
// This makes debugging easier and prevents silent failures
throw new Error(`Feishu media upload failed: ${errMsg}`, { cause: err });
}
}
// Auto-convert Markdown to rich text if enabled and content is text with Markdown
const autoRichText = opts.autoRichText !== false;
const finalText =
typeof finalContent === "object" && finalContent !== null && "text" in finalContent
? (finalContent as { text?: string }).text
: undefined;
if (
autoRichText &&
msgType === "text" &&
typeof finalText === "string" &&
containsMarkdown(finalText)
) {
try {
const postContent = markdownToFeishuPost(finalText);
msgType = "post";
finalContent = postContent;
logger.debug(`Converted Markdown to Feishu post format`);
} catch (err) {
logger.warn(
`Failed to convert Markdown to post, falling back to text: ${formatErrorMessage(err)}`,
);
// Fall back to plain text
}
}
const contentStr = typeof finalContent === "string" ? finalContent : JSON.stringify(finalContent);
// Use reply API if replyToMessageId is provided
if (opts.replyToMessageId) {
return replyMessageFeishu(client, opts.replyToMessageId, contentStr, msgType, {
replyInThread: opts.replyInThread,
});
}
try {
const res = await client.im.message.create({
params: { receive_id_type: receiveIdType },
data: {
receive_id: receiveId,
msg_type: msgType,
content: contentStr,
},
});
if (res.code !== 0) {
logger.error(`Feishu send failed: ${res.code} - ${res.msg}`);
throw new Error(`Feishu API Error: ${res.msg}`);
}
return res.data ?? null;
} catch (err) {
logger.error(`Feishu send error: ${formatErrorMessage(err)}`);
throw err;
}
}
export type FeishuReplyOpts = {
/** Whether to reply in thread mode. Default: false */
replyInThread?: boolean;
};
/**
* Reply to a specific message in Feishu
* Uses the Feishu reply API: POST /open-apis/im/v1/messages/:message_id/reply
*/
export async function replyMessageFeishu(
client: Client,
messageId: string,
content: string,
msgType: FeishuMsgType,
opts: FeishuReplyOpts = {},
): Promise<FeishuSendResult | null> {
try {
const res = await client.im.message.reply({
path: { message_id: messageId },
data: {
msg_type: msgType,
content: content,
reply_in_thread: opts.replyInThread ?? false,
},
});
if (res.code !== 0) {
logger.error(`Feishu reply failed: ${res.code} - ${res.msg}`);
throw new Error(`Feishu API Error: ${res.msg}`);
}
return res.data ?? null;
} catch (err) {
logger.error(`Feishu reply error: ${formatErrorMessage(err)}`);
throw err;
}
}