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:
1 parent
9ca9bce788
commit
647fa041d1
2 files changed
+265
-76
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user