feat: improve Feishu messaging — markdown rendering, card styling, reaction lifecycle

outbound.ts:
- Switch from msg_type 'text' to 'post' with {tag: 'md'} for markdown rendering
- Add optimizeMarkdown(): heading demotion, table spacing, code block padding, image key stripping
- Redesign permission card: blue header with lock icon, notation-sized timeout hint,
  column_set button layout (Schema V2 compatible, 'action' tag deprecated)
- Add addReaction/removeReaction for emoji acknowledgment lifecycle

index.ts:
- Implement onMessageStart: add 'OnIt' emoji on message receipt
- Implement onMessageEnd: remove reaction after response sent
- Track lastMessageIdByChat for reaction targeting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
7418andClaude Opus 4.6 committed 2026-03-14 11:10:01 +08:00
1 parent 9ca9bce788
commit 647fa041d1
2 files changed
+265 -76

No files matched your search

+31 -1
View File
@@ -11,7 +11,7 @@ import type { FeishuConfig } from './types';
import { loadFeishuConfig, validateFeishuConfig } from './config';
import { FeishuGateway } from './gateway';
import { parseInboundMessage } from './inbound';
import { sendMessage } from './outbound';
import { sendMessage, addReaction, removeReaction } from './outbound';
import { isUserAuthorized } from './policy';
import { createCardStreamController } from './card-controller';
@@ -25,6 +25,10 @@ export class FeishuChannelPlugin implements ChannelPlugin<FeishuConfig> {
private gateway: FeishuGateway | null = null;
private messageQueue: InboundMessage[] = [];
private waitResolve: ((msg: InboundMessage | null) => void) | null = null;
/** Track last received messageId per chatId for reaction acknowledgment. */
private lastMessageIdByChat = new Map<string, string>();
/** Track active reaction IDs per chatId so we can remove them on completion. */
private activeReactions = new Map<string, { messageId: string; reactionId: string }>();
loadConfig(): FeishuConfig | null {
this.config = loadFeishuConfig();
@@ -163,6 +167,10 @@ export class FeishuChannelPlugin implements ChannelPlugin<FeishuConfig> {
}
private enqueueMessage(msg: InboundMessage): void {
// Track messageId for reaction acknowledgment (skip callback messages)
if (msg.messageId && !msg.callbackData) {
this.lastMessageIdByChat.set(msg.address.chatId, msg.messageId);
}
if (this.waitResolve) {
const resolve = this.waitResolve;
this.waitResolve = null;
@@ -192,6 +200,28 @@ export class FeishuChannelPlugin implements ChannelPlugin<FeishuConfig> {
return isUserAuthorized(this.config, userId, chatId);
}
/** Add emoji reaction to acknowledge message receipt. */
onMessageStart(chatId: string): void {
const client = this.gateway?.getRestClient();
const messageId = this.lastMessageIdByChat.get(chatId);
if (!client || !messageId) return;
// Fire-and-forget — don't block message processing
addReaction(client, messageId, 'OnIt').then((reactionId) => {
if (reactionId) {
this.activeReactions.set(chatId, { messageId, reactionId });
}
}).catch(() => {});
}
/** Remove the "processing" reaction after response is sent. */
onMessageEnd(chatId: string): void {
const client = this.gateway?.getRestClient();
const reaction = this.activeReactions.get(chatId);
if (!client || !reaction) return;
this.activeReactions.delete(chatId);
removeReaction(client, reaction.messageId, reaction.reactionId).catch(() => {});
}
getCardStreamController(): CardStreamController | null {
const client = this.gateway?.getRestClient();
if (!client || !this.config?.cardStreamConfig) return null;
+234 -75
View File
@@ -1,10 +1,11 @@
/**
* Feishu outbound message rendering and sending.
*
* Missing capabilities compared to OpenClaw's ChannelMessageActionAdapter:
* - No react/unreact support (add/remove emoji reactions)
* - No message delete/unsend support
* - No user identity (user_access_token) sending — bot identity only
* Text messages use `post` format with `md` tag for markdown rendering.
* Card messages (buttons, permissions) use Schema V2 interactive cards.
*
* Markdown is optimized for Feishu: heading demotion, table spacing,
* code block padding, and invalid image key stripping.
*/
import type * as lark from '@larksuiteoapi/node-sdk';
@@ -12,66 +13,156 @@ import type { OutboundMessage, SendResult } from '../../bridge/types';
const LOG_TAG = '[feishu/outbound]';
// ─── Markdown optimization for Feishu ────────────────────────────────────────
/**
* Send a message to Feishu using bot identity (app_access_token).
* Optimize markdown for Feishu rendering compatibility.
*
* If the message contains inlineButtons, it is rendered as an interactive card
* (Schema V2 with column_set + button layout). Otherwise it's sent as plain text.
* Based on OpenClaw's markdown-style.ts:
* - Demote headings: H1 → H4, H2-H6 → H5 (Feishu renders H1-H3 too large)
* - Add spacing around tables with <br> tags
* - Pad code blocks with <br> for visual separation
* - Strip invalid image keys (prevent CardKit error 200570)
* - Compress excessive newlines (3+ → 2)
*/
function optimizeMarkdown(text: string): string {
try {
return _optimizeMarkdown(text);
} catch {
return text;
}
}
function _optimizeMarkdown(text: string): string {
// 1. Extract code blocks — protect from transformation
const MARK = '___CB_';
const codeBlocks: string[] = [];
let r = text.replace(/```[\s\S]*?```/g, (m) => {
return `${MARK}${codeBlocks.push(m) - 1}___`;
});
// 2. Heading demotion (only if H1-H3 exist)
const hasLargeHeadings = /^#{1,3} /m.test(r);
if (hasLargeHeadings) {
r = r.replace(/^#{2,6} (.+)$/gm, '##### $1'); // H2-H6 → H5
r = r.replace(/^# (.+)$/gm, '#### $1'); // H1 → H4
}
// 3. Spacing between consecutive headings
r = r.replace(/^(#{4,5} .+)\n{1,2}(#{4,5} )/gm, '$1\n<br>\n$2');
// 4. Table spacing — add <br> before/after table blocks
r = r.replace(/^([^|\n].*)\n(\|.+\|)/gm, '$1\n\n$2');
r = r.replace(/\n\n((?:\|.+\|[^\S\n]*\n?)+)/g, '\n\n<br>\n\n$1');
r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, '$1\n<br>\n');
// 5. Restore code blocks with <br> padding
codeBlocks.forEach((block, i) => {
r = r.replace(`${MARK}${i}___`, `\n<br>\n${block}\n<br>\n`);
});
// 6. Strip invalid image keys (only allow img_xxx and http(s) URLs)
if (r.includes('![')) {
r = r.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_full, _alt, value) => {
if (value.startsWith('img_') || value.startsWith('http://') || value.startsWith('https://')) {
return _full;
}
return value;
});
}
// 7. Compress excessive newlines
r = r.replace(/\n{3,}/g, '\n\n');
return r;
}
/**
* Convert HTML-formatted text (from permission-broker) to Feishu markdown.
*/
function htmlToFeishuMarkdown(text: string): string {
return text
.replace(/<b>(.*?)<\/b>/gi, '**$1**')
.replace(/<code>(.*?)<\/code>/gi, '`$1`')
.replace(/<pre>([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
.replace(/<\/?[^>]+>/g, '');
}
// ─── Message sending ─────────────────────────────────────────────────────────
/**
* Send a message to Feishu.
*
* OpenClaw supports both bot and user identity via its ChannelMessageActionAdapter,
* allowing messages to be sent as the authenticated user when user_access_token is available.
*
* This handles static message delivery — card streaming is handled upstream by bridge-manager.
* - With inlineButtons → interactive card (Schema V2)
* - Without → post format with md tag (supports markdown rendering)
*/
export async function sendMessage(
client: lark.Client,
message: OutboundMessage,
): Promise<SendResult> {
try {
// Extract real chat ID (strip thread prefix if present)
const chatId = message.address.chatId.split(':thread:')[0];
const replyId = message.replyToMessageId;
// If message has inline buttons, send as interactive card
// Interactive card for messages with buttons
if (message.inlineButtons && message.inlineButtons.length > 0) {
return sendAsInteractiveCard(client, chatId, message.text, message.inlineButtons, replyId);
}
const content = JSON.stringify({ text: message.text });
let resp: any;
if (replyId) {
resp = await client.im.message.reply({
path: { message_id: replyId },
data: {
content,
msg_type: 'text',
},
});
} else {
resp = await client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: chatId,
content,
msg_type: 'text',
},
});
}
const msgId = resp?.data?.message_id || '';
return { ok: true, messageId: msgId };
// Post format with md tag for markdown support
return sendAsPost(client, chatId, message.text, message.parseMode, replyId);
} catch (err: any) {
console.error(LOG_TAG, 'Send failed:', err?.message || err);
return { ok: false, error: err?.message || 'Unknown error' };
}
}
/**
* Send a message using post format with md tag.
* This enables markdown rendering in Feishu (bold, code, lists, tables, etc).
*/
async function sendAsPost(
client: lark.Client,
chatId: string,
text: string,
parseMode?: string,
replyToMessageId?: string,
): Promise<SendResult> {
// Convert HTML to markdown if needed, then optimize for Feishu
let mdText = parseMode === 'HTML' ? htmlToFeishuMarkdown(text) : text;
mdText = optimizeMarkdown(mdText);
const content = JSON.stringify({
zh_cn: {
content: [[{ tag: 'md', text: mdText }]],
},
});
let resp: any;
if (replyToMessageId) {
resp = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: { content, msg_type: 'post' },
});
} else {
resp = await client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: { receive_id: chatId, content, msg_type: 'post' },
});
}
const msgId = resp?.data?.message_id || '';
return { ok: true, messageId: msgId };
}
// ─── Interactive cards ───────────────────────────────────────────────────────
/**
* Build and send a Feishu interactive card with buttons.
*
* Uses Schema V2 with column_set + column + button layout because
* Schema V2 does NOT support the `action` tag directly.
* Uses Schema V2 with:
* - markdown element for text content
* - action element for buttons (Allow = primary, Deny = danger)
*/
async function sendAsInteractiveCard(
client: lark.Client,
@@ -81,37 +172,72 @@ async function sendAsInteractiveCard(
replyToMessageId?: string,
): Promise<SendResult> {
try {
// Strip HTML tags for Feishu (convert <b> to **, <code> to `, <pre> to ```)
const mdText = text
.replace(/<b>(.*?)<\/b>/gi, '**$1**')
.replace(/<code>(.*?)<\/code>/gi, '`$1`')
.replace(/<pre>([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
.replace(/<\/?[^>]+>/g, '');
const mdText = htmlToFeishuMarkdown(text);
// Build button elements using column_set layout (Schema V2 compatible)
const buttonRows = inlineButtons.map((row) => ({
tag: 'column_set' as const,
flex_mode: 'stretch' as const,
columns: row.map((btn) => ({
// Build button columns — Schema V2 doesn't support 'action' tag,
// use column_set with individual button elements instead
const buttonColumns = inlineButtons.flat().map((btn) => {
let btnType: 'primary' | 'danger' | 'default' = 'default';
const lowerText = btn.text.toLowerCase();
if (lowerText.includes('deny') || lowerText.includes('拒绝')) {
btnType = 'danger';
} else if (lowerText.includes('allow') || lowerText.includes('允许')) {
btnType = 'primary';
}
return {
tag: 'column' as const,
width: 'weighted' as const,
weight: 1,
elements: [{
tag: 'button' as const,
text: { tag: 'plain_text' as const, content: btn.text },
type: 'primary' as const,
value: { callback_data: btn.callbackData, chatId },
}],
})),
}));
width: 'auto' as const,
elements: [
{
tag: 'button' as const,
text: { tag: 'plain_text' as const, content: btn.text },
type: btnType,
size: 'medium' as const,
value: { callback_data: btn.callbackData, chatId },
},
],
};
});
const card = {
schema: '2.0',
config: { wide_screen_mode: true },
config: {
wide_screen_mode: true,
style: {
color: {
'light-orange-bg': {
light_mode: 'rgba(255, 214, 102, 0.12)',
dark_mode: 'rgba(255, 214, 102, 0.08)',
},
},
},
},
header: {
title: { tag: 'plain_text' as const, content: 'Permission Required' },
template: 'blue' as const,
icon: { tag: 'standard_icon' as const, token: 'lock-chat_filled' },
padding: '12px 12px 12px 12px',
},
body: {
elements: [
{ tag: 'markdown', content: mdText },
...buttonRows,
{
tag: 'markdown' as const,
content: mdText,
text_size: 'normal' as const,
},
{
tag: 'markdown' as const,
content: '⏱ This request will expire in 5 minutes',
text_size: 'notation' as const,
},
{ tag: 'hr' as const },
{
tag: 'column_set' as const,
flex_mode: 'none' as const,
horizontal_align: 'left' as const,
columns: buttonColumns,
},
],
},
};
@@ -140,10 +266,50 @@ async function sendAsInteractiveCard(
}
}
// ─── Reactions ──────────────────────────────────────────────────────────────
/**
* Send a permission card with interactive buttons using bot identity (app_access_token).
*
* OpenClaw supports both bot and user identity for card delivery.
* Add an emoji reaction to a Feishu message.
* Used as a "message received" acknowledgment before processing starts.
*/
export async function addReaction(
client: lark.Client,
messageId: string,
emojiType: string = 'OnIt',
): Promise<string | null> {
try {
const resp = await client.im.messageReaction.create({
path: { message_id: messageId },
data: { reaction_type: { emoji_type: emojiType } },
});
return (resp?.data as any)?.reaction_id || null;
} catch (err: any) {
// Non-critical — log and swallow
console.warn(LOG_TAG, 'Failed to add reaction:', err?.message || err);
return null;
}
}
/**
* Remove an emoji reaction from a Feishu message.
* Used to clear the "processing" indicator after response is sent.
*/
export async function removeReaction(
client: lark.Client,
messageId: string,
reactionId: string,
): Promise<void> {
try {
await client.im.messageReaction.delete({
path: { message_id: messageId, reaction_id: reactionId },
});
} catch (err: any) {
console.warn(LOG_TAG, 'Failed to remove reaction:', err?.message || err);
}
}
/**
* Send a pre-built card (for permission cards or custom cards).
*/
export async function sendPermissionCard(
client: lark.Client,
@@ -158,19 +324,12 @@ export async function sendPermissionCard(
if (replyToMessageId) {
resp = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: {
content: cardContent,
msg_type: 'interactive',
},
data: { content: cardContent, msg_type: 'interactive' },
});
} else {
resp = await client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: {
receive_id: realChatId,
content: cardContent,
msg_type: 'interactive',
},
data: { receive_id: realChatId, content: cardContent, msg_type: 'interactive' },
});
}