Files
CodePilot/src/lib/channels/feishu/outbound.ts
T
7418andClaude Opus 4.6 cdb4155771 feat: Feishu chat bridge — strip MCP residuals, enhance streaming cards, add tool progress
Phase 1: Strip Feishu MCP residuals
- Remove 'feishu' server entry from .mcp.json
- Remove @codepilot/feishu-mcp and packages/feishu-mcp from package-lock.json
- General MCP infrastructure (McpManager, /api/plugins/mcp) preserved

Phase 2: Streaming card improvements
- card-controller.ts: Add thinking state (💭 Thinking...), tool call progress
  display (🔄/✅/❌), better finalize with status footer (✅/⚠️/❌ + elapsed time),
  markdown optimization on final card, proper sequence numbering for setStreamingMode
- conversation-engine.ts: Add OnToolEvent callback type, fire tool_use/tool_result
  events during stream consumption
- bridge-manager.ts: Wire tool event tracking to card controller, pass proper status
  (completed/interrupted/error) to finalize, render errors in card when streaming

Phase 3: Typing indicator & UX
- Change reaction emoji from 'OnIt' to 'Typing' (OpenClaw convention)
- Improve /help and /start command output with grouped sections
- Export optimizeMarkdown() for reuse in card-controller

types.ts:
- Add ToolCallInfo interface for card tool progress
- Add updateToolCalls?() and setThinking?() to CardStreamController

Tests: 374 pass, 0 fail
TypeScript: clean (npx tsc --noEmit)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 11:30:58 +08:00

343 lines
11 KiB
TypeScript

/**
* Feishu outbound message rendering and sending.
*
* 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';
import type { OutboundMessage, SendResult } from '../../bridge/types';
const LOG_TAG = '[feishu/outbound]';
// ─── Markdown optimization for Feishu ────────────────────────────────────────
/**
* Optimize markdown for Feishu rendering compatibility.
*
* 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)
*/
export 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.
*
* - 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 {
const chatId = message.address.chatId.split(':thread:')[0];
const replyId = message.replyToMessageId;
// Interactive card for messages with buttons
if (message.inlineButtons && message.inlineButtons.length > 0) {
return sendAsInteractiveCard(client, chatId, message.text, message.inlineButtons, replyId);
}
// 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:
* - markdown element for text content
* - action element for buttons (Allow = primary, Deny = danger)
*/
async function sendAsInteractiveCard(
client: lark.Client,
chatId: string,
text: string,
inlineButtons: import('../../bridge/types').InlineButton[][],
replyToMessageId?: string,
): Promise<SendResult> {
try {
const mdText = htmlToFeishuMarkdown(text);
// 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: '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,
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' 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,
},
],
},
};
const cardContent = JSON.stringify(card);
let resp: any;
if (replyToMessageId) {
resp = await client.im.message.reply({
path: { message_id: replyToMessageId },
data: { content: cardContent, msg_type: 'interactive' },
});
} else {
resp = await client.im.message.create({
params: { receive_id_type: 'chat_id' },
data: { receive_id: chatId, content: cardContent, msg_type: 'interactive' },
});
}
const msgId = resp?.data?.message_id || '';
console.log(LOG_TAG, 'Sent interactive card:', msgId);
return { ok: true, messageId: msgId };
} catch (err: any) {
console.error(LOG_TAG, 'Interactive card send failed:', err?.message || err);
return { ok: false, error: err?.message || 'Unknown error' };
}
}
// ─── Reactions ──────────────────────────────────────────────────────────────
/**
* 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,
chatId: string,
cardContent: string,
replyToMessageId?: string,
): Promise<SendResult> {
try {
const realChatId = chatId.split(':thread:')[0];
let resp: any;
if (replyToMessageId) {
resp = await client.im.message.reply({
path: { message_id: replyToMessageId },
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' },
});
}
const msgId = resp?.data?.message_id || '';
return { ok: true, messageId: msgId };
} catch (err: any) {
console.error(LOG_TAG, 'Permission card send failed:', err?.message || err);
return { ok: false, error: err?.message || 'Unknown error' };
}
}