diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 9bcfff147..95ec559d3 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -6,6 +6,7 @@ import { // Import feishu functions directly from source import { + getStartupChatIds, listFeishuAccountIds, resolveFeishuAccount, resolveDefaultFeishuAccountId, @@ -157,19 +158,19 @@ export const feishuPlugin: ChannelPlugin = { 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}`); } } diff --git a/package.json b/package.json index c9880eb49..054e2e594 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/config/types.feishu.ts b/src/config/types.feishu.ts index 61a070924..b21656067 100644 --- a/src/config/types.feishu.ts +++ b/src/config/types.feishu.ts @@ -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: diff --git a/src/feishu/accounts.ts b/src/feishu/accounts.ts index 21e50f39a..6afdf949b 100644 --- a/src/feishu/accounts.ts +++ b/src/feishu/accounts.ts @@ -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; 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: ![alt](url) + * - 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..c78dfc2d5 100644 --- a/src/feishu/index.ts +++ b/src/feishu/index.ts @@ -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"; diff --git a/src/feishu/message-dispatch.ts b/src/feishu/message-dispatch.ts index 937df3ca1..864c6e09b 100644 --- a/src/feishu/message-dispatch.ts +++ b/src/feishu/message-dispatch.ts @@ -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: mentions the user + replyText = ` ${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)}`)); } } diff --git a/src/feishu/monitor.ts b/src/feishu/monitor.ts index 357d609fb..82279a56c 100644 --- a/src/feishu/monitor.ts +++ b/src/feishu/monitor.ts @@ -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; + /** 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: 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)}`); + } } } diff --git a/src/feishu/send.ts b/src/feishu/send.ts index bbb3f3889..ad1e2ea39 100644 --- a/src/feishu/send.ts +++ b/src/feishu/send.ts @@ -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: 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. + * 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 { + 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 diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 9c8170631..dce6d9dd8 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -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( {