diff --git a/src/lib/channels/feishu/index.ts b/src/lib/channels/feishu/index.ts index 522c412..9964baa 100644 --- a/src/lib/channels/feishu/index.ts +++ b/src/lib/channels/feishu/index.ts @@ -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 { 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(); + /** Track active reaction IDs per chatId so we can remove them on completion. */ + private activeReactions = new Map(); loadConfig(): FeishuConfig | null { this.config = loadFeishuConfig(); @@ -163,6 +167,10 @@ export class FeishuChannelPlugin implements ChannelPlugin { } 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 { 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; diff --git a/src/lib/channels/feishu/outbound.ts b/src/lib/channels/feishu/outbound.ts index 99a72ae..fb2520b 100644 --- a/src/lib/channels/feishu/outbound.ts +++ b/src/lib/channels/feishu/outbound.ts @@ -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
tags + * - Pad code blocks with
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
\n$2'); + + // 4. Table spacing — add
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
\n\n$1'); + r = r.replace(/((?:^\|.+\|[^\S\n]*\n?)+)/gm, '$1\n
\n'); + + // 5. Restore code blocks with
padding + codeBlocks.forEach((block, i) => { + r = r.replace(`${MARK}${i}___`, `\n
\n${block}\n
\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>/gi, '**$1**') + .replace(/(.*?)<\/code>/gi, '`$1`') + .replace(/
([\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 {
   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 {
+  // 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 {
   try {
-    // Strip HTML tags for Feishu (convert  to **,  to `, 
 to ```)
-    const mdText = text
-      .replace(/(.*?)<\/b>/gi, '**$1**')
-      .replace(/(.*?)<\/code>/gi, '`$1`')
-      .replace(/
([\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 {
+  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 {
+  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' },
       });
     }