Merge pull request #8 from QVerisAI/feishu

add markdown support to feishu
This commit is contained in:
Alex Xiang 2026-01-29 14:14:44 +08:00 committed by GitHub
commit 542362f585
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 860 additions and 43 deletions

View File

@ -6,6 +6,7 @@ import {
// Import feishu functions directly from source
import {
getStartupChatIds,
listFeishuAccountIds,
resolveFeishuAccount,
resolveDefaultFeishuAccountId,
@ -157,19 +158,19 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
onMessage,
});
// Send startup message if configured
const startupChatId = account.config.startupChatId;
if (startupChatId) {
// Send startup message to all configured startup chat IDs
const startupChatIds = getStartupChatIds(account.config);
const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" });
for (const chatId of startupChatIds) {
try {
const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" });
await client.sendTextMessage(
startupChatId,
chatId,
`🚀 Clawdbot 飞书网关已启动 (${timestamp})`,
"chat_id",
);
ctx.log?.info(`[${account.accountId}] sent startup message to ${startupChatId}`);
ctx.log?.info(`[${account.accountId}] sent startup message to ${chatId}`);
} catch (err) {
ctx.log?.warn(`[${account.accountId}] failed to send startup message: ${err}`);
ctx.log?.warn(`[${account.accountId}] failed to send startup message to ${chatId}: ${err}`);
}
}

View File

@ -1,6 +1,6 @@
{
"name": "moltbot",
"version": "2026.1.27-beta.1",
"version": "2026.1.28-qveris.1",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",

View File

@ -42,8 +42,17 @@ export type FeishuAccountConfig = {
/** Encrypt key for event decryption (optional). */
encryptKey?: string;
/** Chat ID to send a startup message to when gateway starts. */
startupChatId?: string;
/**
* Chat IDs to send a startup message to when gateway starts.
* Supports one or multiple group chat IDs (array or legacy single string).
*/
startupChatId?: string | string[];
/**
* When true, only allow messages from groups listed in startupChatId;
* private chats (DMs) are not allowed. Ignored if startupChatId is empty.
*/
allowOnlyStartupChats?: boolean;
/**
* Event subscription mode:

View File

@ -11,6 +11,18 @@ const debugAccounts = (...args: unknown[]) => {
}
};
/** Normalize startupChatId config (string or string[]) to a non-empty string array. */
export function getStartupChatIds(config: FeishuAccountConfig): string[] {
const raw = config.startupChatId;
if (Array.isArray(raw)) {
return raw.map((s) => String(s).trim()).filter(Boolean);
}
if (raw != null && String(raw).trim()) {
return [String(raw).trim()];
}
return [];
}
export type ResolvedFeishuAccount = {
accountId: string;
enabled: boolean;

View File

@ -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: ![alt](url)
* - Colors: <font color='red'>text</font>
* - @mention: <at id='all'></at>
*
* @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<FeishuSendMessageResult> {
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<string> {
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<FeishuSendMessageResult> {
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<FeishuSendMessageResult> {
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<void> {
// 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<string, string> = { 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
*/

View File

@ -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
*/

View File

@ -2,18 +2,37 @@
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 {
getStartupChatIds,
resolveFeishuAccount,
listFeishuAccountIds,
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";

View File

@ -7,7 +7,7 @@
import type { MoltbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import type { ResolvedFeishuAccount } from "./accounts.js";
import { getStartupChatIds, type ResolvedFeishuAccount } from "./accounts.js";
import type { FeishuMessageContext } from "./monitor.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { dispatchInboundMessageWithBufferedDispatcher } from "../auto-reply/dispatch.js";
@ -55,6 +55,21 @@ function isFeishuSenderAllowed(
const isGroup = ctx.chatType === "group";
const senderId = ctx.senderId;
// When allowOnlyStartupChats is true: only groups in startupChatId may send; no DMs
if (account.config.allowOnlyStartupChats) {
const allowedChatIds = getStartupChatIds(account.config);
if (allowedChatIds.length === 0) {
return { allowed: false, reason: "allow_only_startup_chats_no_list" };
}
if (!isGroup) {
return { allowed: false, reason: "allow_only_startup_chats_no_dm" };
}
if (!allowedChatIds.includes(ctx.chatId)) {
return { allowed: false, reason: "allow_only_startup_chats_group_not_allowed" };
}
return { allowed: true };
}
if (isGroup) {
// Check group policy
const groupPolicySetting = account.config.groupPolicy ?? "open";
@ -238,19 +253,31 @@ 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}`,
);
// Send response back to Feishu
// Send response back to Feishu; in groups, @mention the user who asked
if (payload.text) {
let replyText = payload.text;
if (ctx.chatType === "group" && ctx.senderId) {
// Feishu text/interactive: <at id="open_id"></at> mentions the user
replyText = `<at id="${ctx.senderId}"></at> ${replyText}`;
}
try {
log(`feishu: sending reply to ${ctx.chatId}...`);
await sendMessageFeishu({
to: ctx.chatId,
text: payload.text,
text: replyText,
accountId: account.accountId,
config: cfg,
receiveIdType: "chat_id",
autoRichText: true, // Enable markdown rendering
});
log(`feishu: reply sent successfully`);
} catch (sendErr) {
@ -261,12 +288,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)}`));
}
}

View File

@ -15,13 +15,14 @@ import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
import { formatErrorMessage } from "../infra/errors.js";
import { formatDurationMs } from "../infra/format-duration.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveFeishuAccount, type ResolvedFeishuAccount } from "./accounts.js";
import { getStartupChatIds, resolveFeishuAccount, type ResolvedFeishuAccount } from "./accounts.js";
import { createFeishuClient, type FeishuClient } from "./client.js";
import {
parseFeishuEvent,
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?: MoltbotConfig;
@ -42,6 +55,8 @@ export type MonitorFeishuOpts = {
webhookPath?: string;
/** Message handler callback */
onMessage?: (ctx: FeishuMessageContext) => void | Promise<void>;
/** Message recall handler callback */
onMessageRecalled?: (ctx: FeishuMessageRecallContext) => void | Promise<void>;
};
export type FeishuMessageContext = {
@ -63,8 +78,80 @@ export type FeishuMessageContext = {
runtime?: RuntimeEnv;
/** Reply to this message */
reply: (text: string) => Promise<void>;
/** Abort signal for this message processing (triggered on recall) */
abortSignal?: AbortSignal;
/** Mark this message as read */
markAsRead: () => Promise<void>;
};
/**
* 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<string, MessageProcessingState>();
/**
* 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: MoltbotConfig,
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
*/
@ -488,9 +693,9 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
throw new Error(`Feishu authentication failed: ${formatErrorMessage(err)}`);
}
// Send startup message if startupChatId is configured
const startupChatId = account.config.startupChatId?.trim();
if (startupChatId) {
// Send startup message to all configured startup chat IDs
const startupChatIds = getStartupChatIds(account.config);
if (startupChatIds.length > 0) {
const localIp = getLocalIPv4();
const hostname = os.hostname();
const version = process.env.CLAWDBOT_VERSION ?? process.env.npm_package_version ?? "unknown";
@ -507,11 +712,13 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
`启动时间: ${timestamp}`,
].join("\n");
try {
await client.sendTextMessage(startupChatId, startupMessage);
log(`feishu: sent startup message to chat ${startupChatId}`);
} catch (err) {
log(`feishu: failed to send startup message: ${formatErrorMessage(err)}`);
for (const chatId of startupChatIds) {
try {
await client.sendTextMessage(chatId, startupMessage);
log(`feishu: sent startup message to chat ${chatId}`);
} catch (err) {
log(`feishu: failed to send startup message to ${chatId}: ${formatErrorMessage(err)}`);
}
}
}

View File

@ -11,8 +11,249 @@ 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: ![alt](url) or img_key
* - Horizontal rule: ---
* - Colors: <font color='red'>text</font>
* - @mention: <at id='all'></at>, <at id='{user_id}'></at>
*
* 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<unknown>;
}>;
};
/**
* 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: <font color='green'>text</font> (green, red, grey)
* - Mention: <at id=open_id></at> or <at user_id="all"></at>
*
* 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 `<font color='grey'>${code}</font>`;
});
// Convert inline code to grey colored text (`code`)
result = result.replace(/`([^`]+)`/g, "<font color='grey'>$1</font>");
// 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.
* When true, sendMessageFeishu uses interactive card for full markdown support.
*/
export function hasMarkdown(text: string): boolean {
return (
/\*\*.+?\*\*/.test(text) || // bold
/\*.+?\*/.test(text) || // italic
/~~.+?~~/.test(text) || // strikethrough
/`.+?`/.test(text) || // inline code
/\[.+?\]\(.+?\)/.test(text) || // links
/^#{1,6}\s+/m.test(text) || // headings (# to ######)
/^[-*]\s+/m.test(text) || // unordered list
/^\d+\.\s+/m.test(text) || // ordered list
/```[\s\S]*?```/.test(text) // fenced code blocks
);
}
export type SendFeishuMessageParams = {
to: string;
text: string;
@ -21,12 +262,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: ![alt](url)
* - Bold/italic/strikethrough/links
*/
autoRichText?: boolean;
};
export type SendFeishuMessageResult = {
@ -68,27 +321,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 +393,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<SendFeishuMessageResult> {
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

View File

@ -101,10 +101,7 @@ export const CronOriginSchema = Type.Object(
);
/** Delivery routing mode for cron job replies */
export const CronDeliveryModeSchema = Type.Union([
Type.Literal("origin"),
Type.Literal("current"),
]);
export const CronDeliveryModeSchema = Type.Union([Type.Literal("origin"), Type.Literal("current")]);
export const CronJobStateSchema = Type.Object(
{