Merge pull request #8 from QVerisAI/feishu
add markdown support to feishu
This commit is contained in:
commit
542362f585
@ -6,6 +6,7 @@ import {
|
|||||||
|
|
||||||
// Import feishu functions directly from source
|
// Import feishu functions directly from source
|
||||||
import {
|
import {
|
||||||
|
getStartupChatIds,
|
||||||
listFeishuAccountIds,
|
listFeishuAccountIds,
|
||||||
resolveFeishuAccount,
|
resolveFeishuAccount,
|
||||||
resolveDefaultFeishuAccountId,
|
resolveDefaultFeishuAccountId,
|
||||||
@ -157,19 +158,19 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
|||||||
onMessage,
|
onMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send startup message if configured
|
// Send startup message to all configured startup chat IDs
|
||||||
const startupChatId = account.config.startupChatId;
|
const startupChatIds = getStartupChatIds(account.config);
|
||||||
if (startupChatId) {
|
const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" });
|
||||||
|
for (const chatId of startupChatIds) {
|
||||||
try {
|
try {
|
||||||
const timestamp = new Date().toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" });
|
|
||||||
await client.sendTextMessage(
|
await client.sendTextMessage(
|
||||||
startupChatId,
|
chatId,
|
||||||
`🚀 Clawdbot 飞书网关已启动 (${timestamp})`,
|
`🚀 Clawdbot 飞书网关已启动 (${timestamp})`,
|
||||||
"chat_id",
|
"chat_id",
|
||||||
);
|
);
|
||||||
ctx.log?.info(`[${account.accountId}] sent startup message to ${startupChatId}`);
|
ctx.log?.info(`[${account.accountId}] sent startup message to ${chatId}`);
|
||||||
} catch (err) {
|
} 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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "moltbot",
|
"name": "moltbot",
|
||||||
"version": "2026.1.27-beta.1",
|
"version": "2026.1.28-qveris.1",
|
||||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@ -42,8 +42,17 @@ export type FeishuAccountConfig = {
|
|||||||
/** Encrypt key for event decryption (optional). */
|
/** Encrypt key for event decryption (optional). */
|
||||||
encryptKey?: string;
|
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:
|
* Event subscription mode:
|
||||||
|
|||||||
@ -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 = {
|
export type ResolvedFeishuAccount = {
|
||||||
accountId: string;
|
accountId: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
@ -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: <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
|
* 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
|
* Update a message
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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)
|
* URL verification challenge (webhook setup)
|
||||||
*/
|
*/
|
||||||
@ -178,7 +198,8 @@ export type FeishuEvent =
|
|||||||
| FeishuMessageReadEvent
|
| FeishuMessageReadEvent
|
||||||
| FeishuChatMemberAddedEvent
|
| FeishuChatMemberAddedEvent
|
||||||
| FeishuBotAddedEvent
|
| FeishuBotAddedEvent
|
||||||
| FeishuMessageReactionCreatedEvent;
|
| FeishuMessageReactionCreatedEvent
|
||||||
|
| FeishuMessageRecalledEvent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw event payload (may be encrypted)
|
* 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";
|
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
|
* Parse message content from event
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -2,18 +2,37 @@
|
|||||||
|
|
||||||
export { createFeishuBot, buildFeishuSessionKey, buildFeishuPeerId } from "./bot.js";
|
export { createFeishuBot, buildFeishuSessionKey, buildFeishuPeerId } from "./bot.js";
|
||||||
export { createFeishuClient, type FeishuClient } from "./client.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 {
|
export {
|
||||||
sendMessageFeishu,
|
sendMessageFeishu,
|
||||||
|
sendImageFeishu,
|
||||||
reactMessageFeishu,
|
reactMessageFeishu,
|
||||||
deleteMessageFeishu,
|
deleteMessageFeishu,
|
||||||
editMessageFeishu,
|
editMessageFeishu,
|
||||||
|
markdownToFeishuText,
|
||||||
|
markdownToFeishuPost,
|
||||||
|
buildFeishuMarkdownCard,
|
||||||
|
hasMarkdown,
|
||||||
|
type FeishuInteractiveCard,
|
||||||
} from "./send.js";
|
} from "./send.js";
|
||||||
export {
|
export {
|
||||||
|
getStartupChatIds,
|
||||||
resolveFeishuAccount,
|
resolveFeishuAccount,
|
||||||
listFeishuAccountIds,
|
listFeishuAccountIds,
|
||||||
listEnabledFeishuAccounts,
|
listEnabledFeishuAccounts,
|
||||||
} from "./accounts.js";
|
} from "./accounts.js";
|
||||||
export { resolveFeishuCredentials } from "./token.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 { FeishuBotOptions } from "./bot.js";
|
||||||
|
export type { FeishuMessageRecalledEvent } from "./events.js";
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import type { MoltbotConfig } from "../config/config.js";
|
import type { MoltbotConfig } from "../config/config.js";
|
||||||
import type { RuntimeEnv } from "../runtime.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 type { FeishuMessageContext } from "./monitor.js";
|
||||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||||
import { dispatchInboundMessageWithBufferedDispatcher } from "../auto-reply/dispatch.js";
|
import { dispatchInboundMessageWithBufferedDispatcher } from "../auto-reply/dispatch.js";
|
||||||
@ -55,6 +55,21 @@ function isFeishuSenderAllowed(
|
|||||||
const isGroup = ctx.chatType === "group";
|
const isGroup = ctx.chatType === "group";
|
||||||
const senderId = ctx.senderId;
|
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) {
|
if (isGroup) {
|
||||||
// Check group policy
|
// Check group policy
|
||||||
const groupPolicySetting = account.config.groupPolicy ?? "open";
|
const groupPolicySetting = account.config.groupPolicy ?? "open";
|
||||||
@ -238,19 +253,31 @@ export async function dispatchFeishuMessage(params: DispatchFeishuMessageParams)
|
|||||||
cfg,
|
cfg,
|
||||||
dispatcherOptions: {
|
dispatcherOptions: {
|
||||||
deliver: async (payload) => {
|
deliver: async (payload) => {
|
||||||
|
// Check if processing was aborted (message was recalled)
|
||||||
|
if (ctx.abortSignal?.aborted) {
|
||||||
|
log(`feishu: delivery skipped - message was recalled`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
log(
|
log(
|
||||||
`feishu: deliver callback called - hasText=${!!payload.text}, textLength=${payload.text?.length ?? 0}`,
|
`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) {
|
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 {
|
try {
|
||||||
log(`feishu: sending reply to ${ctx.chatId}...`);
|
log(`feishu: sending reply to ${ctx.chatId}...`);
|
||||||
await sendMessageFeishu({
|
await sendMessageFeishu({
|
||||||
to: ctx.chatId,
|
to: ctx.chatId,
|
||||||
text: payload.text,
|
text: replyText,
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
receiveIdType: "chat_id",
|
receiveIdType: "chat_id",
|
||||||
|
autoRichText: true, // Enable markdown rendering
|
||||||
});
|
});
|
||||||
log(`feishu: reply sent successfully`);
|
log(`feishu: reply sent successfully`);
|
||||||
} catch (sendErr) {
|
} catch (sendErr) {
|
||||||
@ -261,12 +288,22 @@ export async function dispatchFeishuMessage(params: DispatchFeishuMessageParams)
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
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)}`));
|
runtime?.error?.(danger(`feishu: dispatch error: ${formatUncaughtError(err)}`));
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
log(`feishu: dispatch completed`);
|
log(`feishu: dispatch completed`);
|
||||||
} catch (err) {
|
} 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)}`));
|
runtime?.error?.(danger(`feishu: message dispatch failed: ${formatUncaughtError(err)}`));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,13 +15,14 @@ import { computeBackoff, sleepWithAbort } from "../infra/backoff.js";
|
|||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
import { formatDurationMs } from "../infra/format-duration.js";
|
import { formatDurationMs } from "../infra/format-duration.js";
|
||||||
import type { RuntimeEnv } from "../runtime.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 { createFeishuClient, type FeishuClient } from "./client.js";
|
||||||
import {
|
import {
|
||||||
parseFeishuEvent,
|
parseFeishuEvent,
|
||||||
isUrlVerificationEvent,
|
isUrlVerificationEvent,
|
||||||
isMessageReceiveEvent,
|
isMessageReceiveEvent,
|
||||||
isBotAddedEvent,
|
isBotAddedEvent,
|
||||||
|
isMessageRecalledEvent,
|
||||||
parseMessageContent,
|
parseMessageContent,
|
||||||
extractMentionedText,
|
extractMentionedText,
|
||||||
type FeishuMessageReceiveEvent,
|
type FeishuMessageReceiveEvent,
|
||||||
@ -29,6 +30,18 @@ import {
|
|||||||
} from "./events.js";
|
} from "./events.js";
|
||||||
import { resolveFeishuEncryptKey, resolveFeishuVerificationToken } from "./token.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 = {
|
export type MonitorFeishuOpts = {
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
config?: MoltbotConfig;
|
config?: MoltbotConfig;
|
||||||
@ -42,6 +55,8 @@ export type MonitorFeishuOpts = {
|
|||||||
webhookPath?: string;
|
webhookPath?: string;
|
||||||
/** Message handler callback */
|
/** Message handler callback */
|
||||||
onMessage?: (ctx: FeishuMessageContext) => void | Promise<void>;
|
onMessage?: (ctx: FeishuMessageContext) => void | Promise<void>;
|
||||||
|
/** Message recall handler callback */
|
||||||
|
onMessageRecalled?: (ctx: FeishuMessageRecallContext) => void | Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FeishuMessageContext = {
|
export type FeishuMessageContext = {
|
||||||
@ -63,8 +78,80 @@ export type FeishuMessageContext = {
|
|||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
/** Reply to this message */
|
/** Reply to this message */
|
||||||
reply: (text: string) => Promise<void>;
|
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 = {
|
const FEISHU_RESTART_POLICY = {
|
||||||
initialMs: 2000,
|
initialMs: 2000,
|
||||||
maxMs: 30_000,
|
maxMs: 30_000,
|
||||||
@ -99,6 +186,7 @@ function createMessageContext(
|
|||||||
account: ResolvedFeishuAccount,
|
account: ResolvedFeishuAccount,
|
||||||
cfg: MoltbotConfig,
|
cfg: MoltbotConfig,
|
||||||
runtime?: RuntimeEnv,
|
runtime?: RuntimeEnv,
|
||||||
|
abortSignal?: AbortSignal,
|
||||||
): FeishuMessageContext {
|
): FeishuMessageContext {
|
||||||
const msg = event.event.message;
|
const msg = event.event.message;
|
||||||
const sender = event.event.sender;
|
const sender = event.event.sender;
|
||||||
@ -123,9 +211,18 @@ function createMessageContext(
|
|||||||
account,
|
account,
|
||||||
cfg,
|
cfg,
|
||||||
runtime,
|
runtime,
|
||||||
|
abortSignal,
|
||||||
reply: async (text: string) => {
|
reply: async (text: string) => {
|
||||||
await client.replyMessage(msg.message_id, "text", JSON.stringify({ text }));
|
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;
|
return ctx;
|
||||||
@ -231,7 +328,21 @@ async function handleFeishuEvent(
|
|||||||
|
|
||||||
if (isMessageReceiveEvent(event)) {
|
if (isMessageReceiveEvent(event)) {
|
||||||
log(`feishu: handling message receive 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(
|
log(
|
||||||
`feishu: message context created - chatId=${ctx.chatId}, senderId=${ctx.senderId}, senderType=${ctx.senderType}, text="${ctx.text.substring(0, 100)}"`,
|
`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
|
// Skip bot's own messages
|
||||||
if (ctx.senderType === "bot") {
|
if (ctx.senderType === "bot") {
|
||||||
log(`feishu: skipping bot's own message`);
|
log(`feishu: skipping bot's own message`);
|
||||||
|
unregisterMessageProcessing(messageId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark message as read (best-effort, non-blocking)
|
||||||
|
ctx.markAsRead().catch(() => {
|
||||||
|
// Silently ignore mark-as-read failures
|
||||||
|
});
|
||||||
|
|
||||||
// Call message handler
|
// Call message handler
|
||||||
if (opts.onMessage) {
|
if (opts.onMessage) {
|
||||||
log(`feishu: calling onMessage handler`);
|
log(`feishu: calling onMessage handler`);
|
||||||
try {
|
try {
|
||||||
|
updateMessageProcessingStatus(messageId, "calling agent");
|
||||||
await opts.onMessage(ctx);
|
await opts.onMessage(ctx);
|
||||||
log(`feishu: onMessage handler completed`);
|
log(`feishu: onMessage handler completed`);
|
||||||
} catch (err) {
|
} 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 {
|
} else {
|
||||||
log(`feishu: no onMessage handler registered`);
|
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)) {
|
} else if (isBotAddedEvent(event)) {
|
||||||
opts.runtime?.log?.(`feishu: bot added to chat ${event.event.chat_id}`);
|
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)}`);
|
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
|
// Handle bot added to chat events
|
||||||
"im.chat.member.bot.added_v1": async (data: SdkBotAddedEventData) => {
|
"im.chat.member.bot.added_v1": async (data: SdkBotAddedEventData) => {
|
||||||
log(`feishu: bot added to chat ${data.chat_id}`);
|
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
|
* 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)}`);
|
throw new Error(`Feishu authentication failed: ${formatErrorMessage(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send startup message if startupChatId is configured
|
// Send startup message to all configured startup chat IDs
|
||||||
const startupChatId = account.config.startupChatId?.trim();
|
const startupChatIds = getStartupChatIds(account.config);
|
||||||
if (startupChatId) {
|
if (startupChatIds.length > 0) {
|
||||||
const localIp = getLocalIPv4();
|
const localIp = getLocalIPv4();
|
||||||
const hostname = os.hostname();
|
const hostname = os.hostname();
|
||||||
const version = process.env.CLAWDBOT_VERSION ?? process.env.npm_package_version ?? "unknown";
|
const version = process.env.CLAWDBOT_VERSION ?? process.env.npm_package_version ?? "unknown";
|
||||||
@ -507,11 +712,13 @@ export async function monitorFeishuProvider(opts: MonitorFeishuOpts = {}): Promi
|
|||||||
`启动时间: ${timestamp}`,
|
`启动时间: ${timestamp}`,
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
try {
|
for (const chatId of startupChatIds) {
|
||||||
await client.sendTextMessage(startupChatId, startupMessage);
|
try {
|
||||||
log(`feishu: sent startup message to chat ${startupChatId}`);
|
await client.sendTextMessage(chatId, startupMessage);
|
||||||
} catch (err) {
|
log(`feishu: sent startup message to chat ${chatId}`);
|
||||||
log(`feishu: failed to send startup message: ${formatErrorMessage(err)}`);
|
} catch (err) {
|
||||||
|
log(`feishu: failed to send startup message to ${chatId}: ${formatErrorMessage(err)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,8 +11,249 @@ import {
|
|||||||
createFeishuClient,
|
createFeishuClient,
|
||||||
type FeishuSendMessageResult,
|
type FeishuSendMessageResult,
|
||||||
type FeishuPostContent,
|
type FeishuPostContent,
|
||||||
|
type FeishuPostElement,
|
||||||
} from "./client.js";
|
} 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: <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 = {
|
export type SendFeishuMessageParams = {
|
||||||
to: string;
|
to: string;
|
||||||
text: string;
|
text: string;
|
||||||
@ -21,12 +262,24 @@ export type SendFeishuMessageParams = {
|
|||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
/** Receive ID type: chat_id (default), open_id, user_id, union_id, email */
|
/** Receive ID type: chat_id (default), open_id, user_id, union_id, email */
|
||||||
receiveIdType?: "chat_id" | "open_id" | "user_id" | "union_id" | "email";
|
receiveIdType?: "chat_id" | "open_id" | "user_id" | "union_id" | "email";
|
||||||
/** Message type: text (default) or post */
|
/** Message type: text (default), post, or interactive */
|
||||||
msgType?: "text" | "post";
|
msgType?: "text" | "post" | "interactive";
|
||||||
/** For post messages, the post content */
|
/** For post messages, the post content */
|
||||||
postContent?: FeishuPostContent;
|
postContent?: FeishuPostContent;
|
||||||
|
/** For interactive messages, the card content */
|
||||||
|
cardContent?: FeishuInteractiveCard;
|
||||||
/** Reply to a specific message */
|
/** Reply to a specific message */
|
||||||
replyToMessageId?: string;
|
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 = {
|
export type SendFeishuMessageResult = {
|
||||||
@ -68,27 +321,57 @@ export async function sendMessageFeishu(
|
|||||||
try {
|
try {
|
||||||
let result: FeishuSendMessageResult;
|
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) {
|
if (params.replyToMessageId) {
|
||||||
// Reply to a specific message
|
// Reply to a specific message
|
||||||
const content =
|
let content: string;
|
||||||
params.msgType === "post" && params.postContent
|
let msgType: "text" | "post" | "interactive" = params.msgType ?? "text";
|
||||||
? JSON.stringify({ post: params.postContent })
|
|
||||||
: JSON.stringify({ text: params.text });
|
|
||||||
|
|
||||||
result = await client.replyMessage(
|
if (params.msgType === "interactive" && params.cardContent) {
|
||||||
params.replyToMessageId,
|
content = JSON.stringify(params.cardContent);
|
||||||
params.msgType ?? "text",
|
msgType = "interactive";
|
||||||
content,
|
} 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) {
|
} 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(
|
result = await client.sendPostMessage(
|
||||||
params.to,
|
params.to,
|
||||||
params.postContent,
|
params.postContent,
|
||||||
params.receiveIdType ?? "chat_id",
|
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 {
|
} else {
|
||||||
// Send a text message
|
// Send a plain text message
|
||||||
result = await client.sendTextMessage(
|
result = await client.sendTextMessage(
|
||||||
params.to,
|
params.to,
|
||||||
params.text,
|
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)
|
* React to a message (Feishu supports reactions via emoji)
|
||||||
* Note: This requires additional API permissions
|
* Note: This requires additional API permissions
|
||||||
|
|||||||
@ -101,10 +101,7 @@ export const CronOriginSchema = Type.Object(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** Delivery routing mode for cron job replies */
|
/** Delivery routing mode for cron job replies */
|
||||||
export const CronDeliveryModeSchema = Type.Union([
|
export const CronDeliveryModeSchema = Type.Union([Type.Literal("origin"), Type.Literal("current")]);
|
||||||
Type.Literal("origin"),
|
|
||||||
Type.Literal("current"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const CronJobStateSchema = Type.Object(
|
export const CronJobStateSchema = Type.Object(
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user