diff --git a/package.json b/package.json
index 4b8ae89e6..34144d83c 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
- "name": "clawdbot",
- "version": "2026.1.25",
+ "name": "moltbot",
+ "version": "2026.1.28-qveris.1",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
diff --git a/src/feishu/client.ts b/src/feishu/client.ts
index 2cced527a..70af7a6f1 100644
--- a/src/feishu/client.ts
+++ b/src/feishu/client.ts
@@ -297,6 +297,125 @@ export class FeishuClient {
});
}
+ /**
+ * Send an interactive card message with full markdown support
+ *
+ * Interactive cards support rich markdown formatting:
+ * - Basic: **bold**, *italic*, ~~strikethrough~~, [link](url)
+ * - Headings: # H1, ## H2
+ * - Lists: - item, 1. item
+ * - Code blocks: ```lang code```
+ * - Images: 
+ * - Colors: text
+ * - @mention:
+ *
+ * @param receiveId - Target chat/user ID
+ * @param card - Interactive card JSON structure
+ * @param receiveIdType - ID type (chat_id, open_id, etc.)
+ */
+ async sendInteractiveMessage(
+ receiveId: string,
+ card: unknown,
+ receiveIdType: FeishuSendMessageParams["receive_id_type"] = "chat_id",
+ ): Promise {
+ return this.sendMessage({
+ receive_id: receiveId,
+ receive_id_type: receiveIdType,
+ msg_type: "interactive",
+ content: JSON.stringify(card),
+ });
+ }
+
+ /**
+ * Upload an image to Feishu
+ * API: POST /im/v1/images
+ * @param image - Image data as Buffer or Uint8Array
+ * @param imageType - Image type: "message" (for chat) or "avatar" (for profile)
+ * @returns image_key to use when sending image messages
+ */
+ async uploadImage(
+ image: Buffer | Uint8Array,
+ imageType: "message" | "avatar" = "message",
+ ): Promise {
+ const token = await getTenantAccessToken(this.credentials);
+
+ // Create form data for multipart upload
+ // Convert to ArrayBuffer for Blob compatibility
+ const arrayBuffer = image.buffer.slice(
+ image.byteOffset,
+ image.byteOffset + image.byteLength,
+ ) as ArrayBuffer;
+ const formData = new FormData();
+ formData.append("image_type", imageType);
+ formData.append("image", new Blob([arrayBuffer]), "image.png");
+
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
+
+ try {
+ const response = await fetch(`${FEISHU_API_BASE}/im/v1/images`, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ body: formData,
+ signal: controller.signal,
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to upload image: ${response.status} ${response.statusText}`);
+ }
+
+ const result = (await response.json()) as FeishuApiResponse<{ image_key: string }>;
+
+ if (result.code !== 0) {
+ throw new Error(`Failed to upload image: ${result.code} ${result.msg}`);
+ }
+
+ if (!result.data?.image_key) {
+ throw new Error("No image_key in upload response");
+ }
+
+ return result.data.image_key;
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ }
+
+ /**
+ * Send an image message
+ * @param receiveId - Target chat/user ID
+ * @param imageKey - Image key from uploadImage()
+ * @param receiveIdType - ID type (chat_id, open_id, etc.)
+ */
+ async sendImageMessage(
+ receiveId: string,
+ imageKey: string,
+ receiveIdType: FeishuSendMessageParams["receive_id_type"] = "chat_id",
+ ): Promise {
+ return this.sendMessage({
+ receive_id: receiveId,
+ receive_id_type: receiveIdType,
+ msg_type: "image",
+ content: JSON.stringify({ image_key: imageKey }),
+ });
+ }
+
+ /**
+ * Upload and send an image in one call
+ * @param receiveId - Target chat/user ID
+ * @param image - Image data as Buffer or Uint8Array
+ * @param receiveIdType - ID type (chat_id, open_id, etc.)
+ */
+ async uploadAndSendImage(
+ receiveId: string,
+ image: Buffer | Uint8Array,
+ receiveIdType: FeishuSendMessageParams["receive_id_type"] = "chat_id",
+ ): Promise {
+ const imageKey = await this.uploadImage(image, "message");
+ return this.sendImageMessage(receiveId, imageKey, receiveIdType);
+ }
+
/**
* Reply to a message
*/
@@ -412,6 +531,52 @@ export class FeishuClient {
}
}
+ /**
+ * Mark a message as read by the bot
+ *
+ * NOTE: Feishu's API does not support bots marking messages as read.
+ * The message_read event exists for receiving notifications when users read messages,
+ * but there is no corresponding API for bots to mark messages as read.
+ * This method is a no-op kept for interface compatibility.
+ */
+ async markMessageRead(_messageId: string): Promise {
+ // No-op: Feishu doesn't have an API for bots to mark messages as read
+ // Bots receive messages via webhooks and don't have read status like human users
+ }
+
+ /**
+ * Get message read information
+ * Returns users who have read a specific message
+ */
+ async getMessageReadUsers(
+ messageId: string,
+ opts?: { pageSize?: number; pageToken?: string },
+ ): Promise<{
+ items: Array<{
+ user_id_type: string;
+ user_id: string;
+ timestamp: string;
+ }>;
+ has_more: boolean;
+ page_token?: string;
+ }> {
+ const params: Record = { user_id_type: "open_id" };
+ if (opts?.pageSize) params.page_size = String(opts.pageSize);
+ if (opts?.pageToken) params.page_token = opts.pageToken;
+
+ const result = await this.request<{
+ items: Array<{ user_id_type: string; user_id: string; timestamp: string }>;
+ has_more: boolean;
+ page_token?: string;
+ }>("GET", `/im/v1/messages/${messageId}/read_users`, { params });
+
+ if (result.code !== 0) {
+ throw new Error(`Failed to get message read users: ${result.code} ${result.msg}`);
+ }
+
+ return result.data ?? { items: [], has_more: false };
+ }
+
/**
* Update a message
*/
diff --git a/src/feishu/events.ts b/src/feishu/events.ts
index 22344113d..60ca4a9a7 100644
--- a/src/feishu/events.ts
+++ b/src/feishu/events.ts
@@ -161,6 +161,26 @@ export type FeishuMessageReactionCreatedEvent = FeishuEventBase & {
};
};
+/**
+ * Message recalled event (im.message.recalled_v1)
+ * Triggered when a user recalls (withdraws) a message
+ */
+export type FeishuMessageRecalledEvent = FeishuEventBase & {
+ header: FeishuEventBase["header"] & {
+ event_type: "im.message.recalled_v1";
+ };
+ event: {
+ /** The ID of the recalled message */
+ message_id: string;
+ /** The chat ID where the message was recalled */
+ chat_id: string;
+ /** Time when the message was recalled (unix timestamp in ms) */
+ recall_time: string;
+ /** Type of recall: user_recall or admin_recall */
+ recall_type?: string;
+ };
+};
+
/**
* URL verification challenge (webhook setup)
*/
@@ -178,7 +198,8 @@ export type FeishuEvent =
| FeishuMessageReadEvent
| FeishuChatMemberAddedEvent
| FeishuBotAddedEvent
- | FeishuMessageReactionCreatedEvent;
+ | FeishuMessageReactionCreatedEvent
+ | FeishuMessageRecalledEvent;
/**
* Raw event payload (may be encrypted)
@@ -277,6 +298,15 @@ export function isBotAddedEvent(event: FeishuRawEventPayload): event is FeishuBo
return "header" in event && event.header?.event_type === "im.chat.member.bot.added_v1";
}
+/**
+ * Check if event is a message recalled event
+ */
+export function isMessageRecalledEvent(
+ event: FeishuRawEventPayload,
+): event is FeishuMessageRecalledEvent {
+ return "header" in event && event.header?.event_type === "im.message.recalled_v1";
+}
+
/**
* Parse message content from event
*/
diff --git a/src/feishu/index.ts b/src/feishu/index.ts
index e67993908..3159a2f56 100644
--- a/src/feishu/index.ts
+++ b/src/feishu/index.ts
@@ -2,12 +2,25 @@
export { createFeishuBot, buildFeishuSessionKey, buildFeishuPeerId } from "./bot.js";
export { createFeishuClient, type FeishuClient } from "./client.js";
-export { monitorFeishuProvider, createFeishuWebhookHandler } from "./monitor.js";
+export {
+ monitorFeishuProvider,
+ createFeishuWebhookHandler,
+ registerMessageProcessing,
+ unregisterMessageProcessing,
+ updateMessageProcessingStatus,
+ abortMessageProcessing,
+} from "./monitor.js";
export {
sendMessageFeishu,
+ sendImageFeishu,
reactMessageFeishu,
deleteMessageFeishu,
editMessageFeishu,
+ markdownToFeishuText,
+ markdownToFeishuPost,
+ buildFeishuMarkdownCard,
+ hasMarkdown,
+ type FeishuInteractiveCard,
} from "./send.js";
export {
resolveFeishuAccount,
@@ -15,5 +28,10 @@ export {
listEnabledFeishuAccounts,
} from "./accounts.js";
export { resolveFeishuCredentials } from "./token.js";
-export type { FeishuMessageContext, MonitorFeishuOpts } from "./monitor.js";
+export type {
+ FeishuMessageContext,
+ MonitorFeishuOpts,
+ FeishuMessageRecallContext,
+} from "./monitor.js";
export type { FeishuBotOptions } from "./bot.js";
+export type { FeishuMessageRecalledEvent } from "./events.js";
diff --git a/src/feishu/message-dispatch.ts b/src/feishu/message-dispatch.ts
index 069dc410e..bb198b3a8 100644
--- a/src/feishu/message-dispatch.ts
+++ b/src/feishu/message-dispatch.ts
@@ -238,6 +238,12 @@ export async function dispatchFeishuMessage(params: DispatchFeishuMessageParams)
cfg,
dispatcherOptions: {
deliver: async (payload) => {
+ // Check if processing was aborted (message was recalled)
+ if (ctx.abortSignal?.aborted) {
+ log(`feishu: delivery skipped - message was recalled`);
+ return;
+ }
+
log(
`feishu: deliver callback called - hasText=${!!payload.text}, textLength=${payload.text?.length ?? 0}`,
);
@@ -251,6 +257,7 @@ export async function dispatchFeishuMessage(params: DispatchFeishuMessageParams)
accountId: account.accountId,
config: cfg,
receiveIdType: "chat_id",
+ autoRichText: true, // Enable markdown rendering
});
log(`feishu: reply sent successfully`);
} catch (sendErr) {
@@ -261,12 +268,22 @@ export async function dispatchFeishuMessage(params: DispatchFeishuMessageParams)
}
},
onError: (err) => {
+ // Don't report errors for aborted messages
+ if (ctx.abortSignal?.aborted) {
+ log(`feishu: error ignored - message was recalled`);
+ return;
+ }
runtime?.error?.(danger(`feishu: dispatch error: ${formatUncaughtError(err)}`));
},
},
});
log(`feishu: dispatch completed`);
} catch (err) {
+ // Don't report errors for aborted messages
+ if (ctx.abortSignal?.aborted) {
+ log(`feishu: dispatch aborted - message was recalled`);
+ return;
+ }
runtime?.error?.(danger(`feishu: message dispatch failed: ${formatUncaughtError(err)}`));
}
}
diff --git a/src/feishu/monitor.ts b/src/feishu/monitor.ts
index 5878c4dc2..59df8b6e8 100644
--- a/src/feishu/monitor.ts
+++ b/src/feishu/monitor.ts
@@ -22,6 +22,7 @@ import {
isUrlVerificationEvent,
isMessageReceiveEvent,
isBotAddedEvent,
+ isMessageRecalledEvent,
parseMessageContent,
extractMentionedText,
type FeishuMessageReceiveEvent,
@@ -29,6 +30,18 @@ import {
} from "./events.js";
import { resolveFeishuEncryptKey, resolveFeishuVerificationToken } from "./token.js";
+/** Callback context for message recall events */
+export type FeishuMessageRecallContext = {
+ /** The ID of the recalled message */
+ messageId: string;
+ /** The chat ID where the message was recalled */
+ chatId: string;
+ /** Whether the message was still being processed when recalled */
+ wasProcessing: boolean;
+ /** Current processing status if available */
+ processingStatus?: string;
+};
+
export type MonitorFeishuOpts = {
accountId?: string;
config?: ClawdbotConfig;
@@ -42,6 +55,8 @@ export type MonitorFeishuOpts = {
webhookPath?: string;
/** Message handler callback */
onMessage?: (ctx: FeishuMessageContext) => void | Promise;
+ /** Message recall handler callback */
+ onMessageRecalled?: (ctx: FeishuMessageRecallContext) => void | Promise;
};
export type FeishuMessageContext = {
@@ -63,8 +78,80 @@ export type FeishuMessageContext = {
runtime?: RuntimeEnv;
/** Reply to this message */
reply: (text: string) => Promise;
+ /** Abort signal for this message processing (triggered on recall) */
+ abortSignal?: AbortSignal;
+ /** Mark this message as read */
+ markAsRead: () => Promise;
};
+/**
+ * Track in-progress message processing for recall handling
+ * Maps messageId to { abortController, processingStatus }
+ */
+type MessageProcessingState = {
+ abortController: AbortController;
+ processingStatus: string;
+ chatId: string;
+ startedAt: number;
+};
+
+const inProgressMessages = new Map();
+
+/**
+ * Register a message as being processed
+ */
+export function registerMessageProcessing(
+ messageId: string,
+ chatId: string,
+): { abortController: AbortController; abortSignal: AbortSignal } {
+ const abortController = new AbortController();
+ inProgressMessages.set(messageId, {
+ abortController,
+ processingStatus: "processing",
+ chatId,
+ startedAt: Date.now(),
+ });
+ return { abortController, abortSignal: abortController.signal };
+}
+
+/**
+ * Update processing status for a message
+ */
+export function updateMessageProcessingStatus(messageId: string, status: string): void {
+ const state = inProgressMessages.get(messageId);
+ if (state) {
+ state.processingStatus = status;
+ }
+}
+
+/**
+ * Unregister a message from processing (call when done)
+ */
+export function unregisterMessageProcessing(messageId: string): void {
+ inProgressMessages.delete(messageId);
+}
+
+/**
+ * Abort processing for a recalled message
+ * Returns the processing state if the message was being processed
+ */
+export function abortMessageProcessing(messageId: string): {
+ wasProcessing: boolean;
+ processingStatus?: string;
+ chatId?: string;
+} {
+ const state = inProgressMessages.get(messageId);
+ if (!state) {
+ return { wasProcessing: false };
+ }
+
+ state.abortController.abort();
+ const { processingStatus, chatId } = state;
+ inProgressMessages.delete(messageId);
+
+ return { wasProcessing: true, processingStatus, chatId };
+}
+
const FEISHU_RESTART_POLICY = {
initialMs: 2000,
maxMs: 30_000,
@@ -99,6 +186,7 @@ function createMessageContext(
account: ResolvedFeishuAccount,
cfg: ClawdbotConfig,
runtime?: RuntimeEnv,
+ abortSignal?: AbortSignal,
): FeishuMessageContext {
const msg = event.event.message;
const sender = event.event.sender;
@@ -123,9 +211,18 @@ function createMessageContext(
account,
cfg,
runtime,
+ abortSignal,
reply: async (text: string) => {
await client.replyMessage(msg.message_id, "text", JSON.stringify({ text }));
},
+ markAsRead: async () => {
+ try {
+ await client.markMessageRead(msg.message_id);
+ } catch (err) {
+ // Mark as read is best-effort, don't fail the message processing
+ runtime?.log?.(`feishu: failed to mark message as read: ${formatErrorMessage(err)}`);
+ }
+ },
};
return ctx;
@@ -231,7 +328,21 @@ async function handleFeishuEvent(
if (isMessageReceiveEvent(event)) {
log(`feishu: handling message receive event`);
- const ctx = createMessageContext(event, opts.client, opts.account, cfg, opts.runtime);
+
+ const messageId = event.event.message.message_id;
+ const chatId = event.event.message.chat_id;
+
+ // Register message processing for recall handling
+ const { abortSignal } = registerMessageProcessing(messageId, chatId);
+
+ const ctx = createMessageContext(
+ event,
+ opts.client,
+ opts.account,
+ cfg,
+ opts.runtime,
+ abortSignal,
+ );
log(
`feishu: message context created - chatId=${ctx.chatId}, senderId=${ctx.senderId}, senderType=${ctx.senderType}, text="${ctx.text.substring(0, 100)}"`,
);
@@ -239,20 +350,78 @@ async function handleFeishuEvent(
// Skip bot's own messages
if (ctx.senderType === "bot") {
log(`feishu: skipping bot's own message`);
+ unregisterMessageProcessing(messageId);
return;
}
+ // Mark message as read (best-effort, non-blocking)
+ ctx.markAsRead().catch(() => {
+ // Silently ignore mark-as-read failures
+ });
+
// Call message handler
if (opts.onMessage) {
log(`feishu: calling onMessage handler`);
try {
+ updateMessageProcessingStatus(messageId, "calling agent");
await opts.onMessage(ctx);
log(`feishu: onMessage handler completed`);
} catch (err) {
- opts.runtime?.error?.(`feishu: message handler error: ${formatErrorMessage(err)}`);
+ // Check if this was an abort due to recall
+ if (abortSignal.aborted) {
+ log(`feishu: message processing aborted (message was recalled)`);
+ } else {
+ opts.runtime?.error?.(`feishu: message handler error: ${formatErrorMessage(err)}`);
+ }
+ } finally {
+ unregisterMessageProcessing(messageId);
}
} else {
log(`feishu: no onMessage handler registered`);
+ unregisterMessageProcessing(messageId);
+ }
+ } else if (isMessageRecalledEvent(event)) {
+ // Handle message recall event
+ const messageId = event.event.message_id;
+ const chatId = event.event.chat_id;
+ log(`feishu: message recalled - messageId=${messageId}, chatId=${chatId}`);
+
+ // Abort processing if the message was being processed
+ const {
+ wasProcessing,
+ processingStatus,
+ chatId: processingChatId,
+ } = abortMessageProcessing(messageId);
+
+ // Call recall handler if registered
+ if (opts.onMessageRecalled) {
+ try {
+ await opts.onMessageRecalled({
+ messageId,
+ chatId: processingChatId ?? chatId,
+ wasProcessing,
+ processingStatus,
+ });
+ } catch (err) {
+ opts.runtime?.error?.(`feishu: recall handler error: ${formatErrorMessage(err)}`);
+ }
+ }
+
+ // Send notification to chat if message was being processed
+ if (wasProcessing) {
+ try {
+ const statusText = processingStatus ? ` (状态: ${processingStatus})` : "";
+ await opts.client.sendTextMessage(
+ chatId,
+ `⚠️ 消息已被撤回,正在停止处理${statusText}`,
+ "chat_id",
+ );
+ log(`feishu: sent recall notification to chat ${chatId}`);
+ } catch (err) {
+ opts.runtime?.error?.(
+ `feishu: failed to send recall notification: ${formatErrorMessage(err)}`,
+ );
+ }
}
} else if (isBotAddedEvent(event)) {
opts.runtime?.log?.(`feishu: bot added to chat ${event.event.chat_id}`);
@@ -378,6 +547,17 @@ async function startWebSocketMode(
error(`feishu: WebSocket message handler error: ${formatErrorMessage(err)}`);
}
},
+ // Handle message recall events
+ "im.message.recalled_v1": async (data: { message_id: string; chat_id: string }) => {
+ try {
+ log(`feishu: message recalled - messageId=${data.message_id}, chatId=${data.chat_id}`);
+ // Convert to our internal event format and handle
+ const event = convertSdkRecalledEvent(data);
+ await handleFeishuEvent(event, opts);
+ } catch (err) {
+ error(`feishu: WebSocket recall handler error: ${formatErrorMessage(err)}`);
+ }
+ },
// Handle bot added to chat events
"im.chat.member.bot.added_v1": async (data: SdkBotAddedEventData) => {
log(`feishu: bot added to chat ${data.chat_id}`);
@@ -444,6 +624,31 @@ function convertSdkMessageEvent(sdkData: SdkMessageEventData): FeishuMessageRece
};
}
+/**
+ * Convert SDK message recalled event format to our internal format
+ */
+function convertSdkRecalledEvent(sdkData: {
+ message_id: string;
+ chat_id: string;
+}): FeishuRawEventPayload {
+ return {
+ schema: "2.0",
+ header: {
+ event_id: `ws_recall_${Date.now()}_${Math.random().toString(36).slice(2)}`,
+ event_type: "im.message.recalled_v1",
+ create_time: String(Date.now()),
+ token: "",
+ app_id: "",
+ tenant_key: "",
+ },
+ event: {
+ message_id: sdkData.message_id,
+ chat_id: sdkData.chat_id,
+ recall_time: String(Date.now()),
+ },
+ } as FeishuRawEventPayload;
+}
+
/**
* Main entry point for Feishu event monitoring
*/
diff --git a/src/feishu/send.ts b/src/feishu/send.ts
index 562ae83e8..e8e4bae05 100644
--- a/src/feishu/send.ts
+++ b/src/feishu/send.ts
@@ -11,8 +11,244 @@ import {
createFeishuClient,
type FeishuSendMessageResult,
type FeishuPostContent,
+ type FeishuPostElement,
} from "./client.js";
+/**
+ * Feishu Interactive Card structure for rich markdown messages
+ *
+ * Feishu supports full markdown in interactive cards (msg_type: "interactive"):
+ * - Basic: **bold**, *italic*, ~~strikethrough~~, [link](url)
+ * - Headings: # H1, ## H2 (only H1/H2 supported)
+ * - Lists: - item (unordered), 1. item (ordered)
+ * - Code: ```lang code``` (requires Feishu 7.6+)
+ * - Images:  or img_key
+ * - Horizontal rule: ---
+ * - Colors: text
+ * - @mention: ,
+ *
+ * See: https://www.feishu.cn/content/7gprunv5
+ */
+export type FeishuInteractiveCard = {
+ config?: {
+ wide_screen_mode?: boolean;
+ enable_forward?: boolean;
+ };
+ header?: {
+ title?: {
+ tag: "plain_text" | "lark_md";
+ content: string;
+ };
+ template?: string; // Color: blue, wathet, turquoise, green, yellow, orange, red, carmine, violet, purple, indigo, grey
+ };
+ elements: Array<{
+ tag: "markdown" | "div" | "hr" | "note" | "action";
+ content?: string;
+ text?: { tag: "plain_text" | "lark_md"; content: string };
+ elements?: Array<{ tag: string; content?: string }>;
+ actions?: Array;
+ }>;
+};
+
+/**
+ * Build a Feishu interactive card with markdown content
+ *
+ * @param markdown - Markdown content to display
+ * @param options - Optional card configuration
+ */
+export function buildFeishuMarkdownCard(
+ markdown: string,
+ options?: {
+ title?: string;
+ headerColor?: string;
+ wideScreen?: boolean;
+ },
+): FeishuInteractiveCard {
+ const card: FeishuInteractiveCard = {
+ config: {
+ wide_screen_mode: options?.wideScreen ?? true,
+ },
+ elements: [
+ {
+ tag: "markdown",
+ content: markdown,
+ },
+ ],
+ };
+
+ // Add header if title provided
+ if (options?.title) {
+ card.header = {
+ title: {
+ tag: "plain_text",
+ content: options.title,
+ },
+ };
+ if (options.headerColor) {
+ card.header.template = options.headerColor;
+ }
+ }
+
+ return card;
+}
+
+/**
+ * Convert markdown text to Feishu text format (for simple text messages)
+ *
+ * For simple text messages (msg_type: "text"), Feishu supports limited inline styles:
+ * - Bold: **text**
+ * - Italic: *text*
+ * - Strikethrough: ~~text~~
+ * - Link: [text](url)
+ * - Colored text: text (green, red, grey)
+ * - Mention: or
+ *
+ * Note: For full markdown support (headings, lists, code blocks, images),
+ * use buildFeishuMarkdownCard() with msg_type: "interactive" instead.
+ */
+export function markdownToFeishuText(markdown: string): string {
+ let result = markdown;
+
+ // Convert code blocks to grey colored text (```code```)
+ // Note: Full code block rendering requires interactive cards
+ result = result.replace(/```[\s\S]*?```/g, (match) => {
+ const code = match.slice(3, -3).trim();
+ return `${code}`;
+ });
+
+ // Convert inline code to grey colored text (`code`)
+ result = result.replace(/`([^`]+)`/g, "$1");
+
+ // Bold (**text**), italic (*text*), strikethrough (~~text~~), and links [text](url)
+ // are already supported natively by Feishu text format - no conversion needed
+
+ return result;
+}
+
+/**
+ * Convert markdown text to Feishu post format (rich text)
+ * Supports: bold, italic, code, links, and line breaks
+ *
+ * Note: For simple formatting, prefer markdownToFeishuText() with msg_type: "text"
+ * as it's simpler and supports native inline styles.
+ */
+export function markdownToFeishuPost(markdown: string): FeishuPostContent {
+ const lines = markdown.split("\n");
+ const content: FeishuPostElement[][] = [];
+ let currentParagraph: FeishuPostElement[] = [];
+
+ for (const line of lines) {
+ if (line.trim() === "") {
+ // Empty line - end current paragraph
+ if (currentParagraph.length > 0) {
+ content.push(currentParagraph);
+ currentParagraph = [];
+ }
+ continue;
+ }
+
+ // Parse inline markdown elements in the line
+ const elements = parseMarkdownLine(line);
+ currentParagraph.push(...elements);
+
+ // Each line becomes a paragraph in Feishu post
+ if (currentParagraph.length > 0) {
+ content.push(currentParagraph);
+ currentParagraph = [];
+ }
+ }
+
+ // Don't forget the last paragraph
+ if (currentParagraph.length > 0) {
+ content.push(currentParagraph);
+ }
+
+ return {
+ zh_cn: {
+ content,
+ },
+ };
+}
+
+/**
+ * Parse a single line of markdown into Feishu post elements
+ */
+function parseMarkdownLine(line: string): FeishuPostElement[] {
+ const elements: FeishuPostElement[] = [];
+
+ // Track position while parsing
+ let lastIndex = 0;
+
+ // Combined regex for all patterns
+ const combinedRegex = /(\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|\[(.+?)\]\((.+?)\))/g;
+ let match;
+
+ while ((match = combinedRegex.exec(line)) !== null) {
+ // Add text before this match
+ if (match.index > lastIndex) {
+ const textBefore = line.slice(lastIndex, match.index);
+ if (textBefore) {
+ elements.push({ tag: "text", text: textBefore });
+ }
+ }
+
+ const fullMatch = match[0];
+
+ if (fullMatch.startsWith("**")) {
+ // Bold: **text**
+ const boldText = fullMatch.slice(2, -2);
+ // Feishu doesn't have native bold in post, use text with prefix
+ elements.push({ tag: "text", text: boldText });
+ } else if (fullMatch.startsWith("`")) {
+ // Code: `code`
+ const codeText = fullMatch.slice(1, -1);
+ // Feishu doesn't have inline code in post, wrap in brackets
+ elements.push({ tag: "text", text: `「${codeText}」` });
+ } else if (fullMatch.startsWith("[")) {
+ // Link: [text](url)
+ const linkMatch = /\[(.+?)\]\((.+?)\)/.exec(fullMatch);
+ if (linkMatch) {
+ elements.push({ tag: "a", text: linkMatch[1], href: linkMatch[2] });
+ }
+ } else if (fullMatch.startsWith("*")) {
+ // Italic: *text*
+ const italicText = fullMatch.slice(1, -1);
+ // Feishu doesn't have native italic, use text
+ elements.push({ tag: "text", text: italicText });
+ }
+
+ lastIndex = match.index + fullMatch.length;
+ }
+
+ // Add remaining text after last match
+ if (lastIndex < line.length) {
+ const textAfter = line.slice(lastIndex);
+ if (textAfter) {
+ elements.push({ tag: "text", text: textAfter });
+ }
+ }
+
+ // If no elements were added (no markdown found), add the whole line as text
+ if (elements.length === 0) {
+ elements.push({ tag: "text", text: line });
+ }
+
+ return elements;
+}
+
+/**
+ * Check if text contains markdown that would benefit from rich text rendering
+ */
+export function hasMarkdown(text: string): boolean {
+ return (
+ /\*\*.+?\*\*/.test(text) || // bold
+ /\*.+?\*/.test(text) || // italic
+ /~~.+?~~/.test(text) || // strikethrough
+ /`.+?`/.test(text) || // code
+ /\[.+?\]\(.+?\)/.test(text) // links
+ );
+}
+
export type SendFeishuMessageParams = {
to: string;
text: string;
@@ -21,12 +257,24 @@ export type SendFeishuMessageParams = {
runtime?: RuntimeEnv;
/** Receive ID type: chat_id (default), open_id, user_id, union_id, email */
receiveIdType?: "chat_id" | "open_id" | "user_id" | "union_id" | "email";
- /** Message type: text (default) or post */
- msgType?: "text" | "post";
+ /** Message type: text (default), post, or interactive */
+ msgType?: "text" | "post" | "interactive";
/** For post messages, the post content */
postContent?: FeishuPostContent;
+ /** For interactive messages, the card content */
+ cardContent?: FeishuInteractiveCard;
/** Reply to a specific message */
replyToMessageId?: string;
+ /**
+ * Auto-convert markdown to rich format (default: false)
+ * When enabled, uses interactive card for full markdown support:
+ * - Headings: # H1, ## H2
+ * - Lists: - item, 1. item
+ * - Code blocks: ```lang code```
+ * - Images: 
+ * - Bold/italic/strikethrough/links
+ */
+ autoRichText?: boolean;
};
export type SendFeishuMessageResult = {
@@ -68,27 +316,57 @@ export async function sendMessageFeishu(
try {
let result: FeishuSendMessageResult;
+ // Determine if we should use interactive card for rich markdown
+ // Interactive cards support full markdown: headings, lists, code blocks, images, etc.
+ const useInteractiveCard = params.autoRichText && hasMarkdown(params.text);
+
if (params.replyToMessageId) {
// Reply to a specific message
- const content =
- params.msgType === "post" && params.postContent
- ? JSON.stringify({ post: params.postContent })
- : JSON.stringify({ text: params.text });
+ let content: string;
+ let msgType: "text" | "post" | "interactive" = params.msgType ?? "text";
- result = await client.replyMessage(
- params.replyToMessageId,
- params.msgType ?? "text",
- content,
+ if (params.msgType === "interactive" && params.cardContent) {
+ content = JSON.stringify(params.cardContent);
+ msgType = "interactive";
+ } else if (params.msgType === "post" && params.postContent) {
+ content = JSON.stringify({ post: params.postContent });
+ msgType = "post";
+ } else if (useInteractiveCard) {
+ // Auto-convert to interactive card for full markdown support
+ const card = buildFeishuMarkdownCard(params.text);
+ content = JSON.stringify(card);
+ msgType = "interactive";
+ } else {
+ content = JSON.stringify({ text: params.text });
+ msgType = "text";
+ }
+
+ result = await client.replyMessage(params.replyToMessageId, msgType, content);
+ } else if (params.msgType === "interactive" && params.cardContent) {
+ // Send an explicit interactive card message
+ result = await client.sendInteractiveMessage(
+ params.to,
+ params.cardContent,
+ params.receiveIdType ?? "chat_id",
);
} else if (params.msgType === "post" && params.postContent) {
- // Send a post (rich text) message
+ // Send a post (rich text) message - explicit post format requested
result = await client.sendPostMessage(
params.to,
params.postContent,
params.receiveIdType ?? "chat_id",
);
+ } else if (useInteractiveCard) {
+ // Auto-convert markdown to interactive card for full formatting support
+ // Supports: # headings, - lists, ```code```, ![images], **bold**, *italic*, etc.
+ const card = buildFeishuMarkdownCard(params.text);
+ result = await client.sendInteractiveMessage(
+ params.to,
+ card,
+ params.receiveIdType ?? "chat_id",
+ );
} else {
- // Send a text message
+ // Send a plain text message
result = await client.sendTextMessage(
params.to,
params.text,
@@ -110,6 +388,63 @@ export async function sendMessageFeishu(
}
}
+/**
+ * Send an image message via Feishu
+ * Handles both upload and send in one call
+ */
+export async function sendImageFeishu(params: {
+ to: string;
+ image: Buffer | Uint8Array;
+ accountId?: string | null;
+ config?: ClawdbotConfig;
+ runtime?: RuntimeEnv;
+ receiveIdType?: "chat_id" | "open_id" | "user_id" | "union_id" | "email";
+}): Promise {
+ const cfg = params.config ?? loadConfig();
+ const account = resolveFeishuAccount({
+ cfg,
+ accountId: params.accountId,
+ });
+
+ if (account.credentials.source === "none") {
+ return {
+ success: false,
+ error: `Feishu credentials missing for account "${account.accountId}".`,
+ };
+ }
+
+ if (!account.enabled) {
+ return {
+ success: false,
+ error: `Feishu account "${account.accountId}" is disabled.`,
+ };
+ }
+
+ const client = createFeishuClient(account.credentials, {
+ timeoutMs: (account.config.timeoutSeconds ?? 30) * 1000,
+ });
+
+ try {
+ const result = await client.uploadAndSendImage(
+ params.to,
+ params.image,
+ params.receiveIdType ?? "chat_id",
+ );
+
+ return {
+ success: true,
+ messageId: result.message_id,
+ };
+ } catch (err) {
+ const errorMsg = formatErrorMessage(err);
+ params.runtime?.error?.(`feishu: send image failed: ${errorMsg}`);
+ return {
+ success: false,
+ error: errorMsg,
+ };
+ }
+}
+
/**
* React to a message (Feishu supports reactions via emoji)
* Note: This requires additional API permissions