Przeglądaj źródła

新增飞书消息类型,初步实现飞书connector

kevin.yang 4 dni temu
rodzic
commit
8f707e4d0f

+ 1 - 0
docker/Dockerfile.feishu

@@ -8,6 +8,7 @@ RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debia
     && rm -rf /var/lib/apt/lists/*
 
 COPY gateway/core/channels/feishu/openclaw-lark .
+# 同路径文件覆盖 vendor,定制逻辑只维护在 openclaw-lark-patch(勿改 openclaw-lark 子模块工作区)
 COPY gateway/core/channels/feishu/openclaw-lark-patch .
 
 RUN sed -i 's|git+ssh://git@github.com/|git+https://github.com/|g' package-lock.json \

+ 21 - 0
gateway/core/channels/__init__.py

@@ -0,0 +1,21 @@
+"""
+Gateway Channels:外部渠道接入(个人助理型飞书等)。
+
+与 openclaw-lark-patch HTTP 服务配合:`GATEWAY_FEISHU_WEBHOOK_URL` 指向
+`POST /api/channels/feishu/openclaw/webhook`。
+"""
+
+# from gateway.core.channels.channel_manager import ChannelManager, FeishuChannelConfig
+from gateway.core.channels.feishu.connector import FeishuConnector
+# from gateway.core.channels.router import MessageRouter, RouteResult
+from gateway.core.channels.types import FeishuReplyContext, IncomingFeishuEvent
+
+__all__ = [
+    # "ChannelManager",
+    # "FeishuChannelConfig",
+    "FeishuConnector",
+    "FeishuReplyContext",
+    "IncomingFeishuEvent",
+    # "MessageRouter",
+    # "RouteResult",
+]

+ 9 - 0
gateway/core/channels/backends/__init__.py

@@ -0,0 +1,9 @@
+from gateway.core.channels.backends.echo_executor import EchoExecutorBackend
+from gateway.core.channels.backends.memory_trace import MemoryTraceBackend
+from gateway.core.channels.backends.user_id import DefaultUserIdentityResolver
+
+__all__ = [
+    "DefaultUserIdentityResolver",
+    "EchoExecutorBackend",
+    "MemoryTraceBackend",
+]

+ 39 - 0
gateway/core/channels/backends/echo_executor.py

@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+import logging
+import uuid
+from typing import TYPE_CHECKING, Any
+
+from gateway.core.channels.types import FeishuReplyContext, IncomingFeishuEvent
+
+if TYPE_CHECKING:
+    from gateway.core.channels.feishu.connector import FeishuConnector
+
+logger = logging.getLogger(__name__)
+
+
+class EchoExecutorBackend:
+    """默认执行器:回显或固定话术,验证「Gateway → Node → 飞书」链路。"""
+
+    def __init__(self, *, prefix: str = "[Gateway] ", enabled: bool = True) -> None:
+        self._prefix = prefix
+        self._enabled = enabled
+
+    async def handle_inbound_message(
+        self,
+        trace_id: str,
+        text: str,
+        reply_context: FeishuReplyContext,
+        connector: Any,
+        *,
+        event: IncomingFeishuEvent,
+    ) -> str:
+        task_id = f"task-{uuid.uuid4()}"
+        if not self._enabled:
+            logger.info("EchoExecutor disabled, skip reply trace_id=%s", trace_id)
+            return task_id
+        reply_body = f"{self._prefix}{text}" if text else f"{self._prefix}(空消息)"
+        result = await connector.send_text(reply_context, reply_body)
+        if not result.get("ok"):
+            logger.error("send_text failed trace_id=%s result=%s", trace_id, result)
+        return task_id

+ 32 - 0
gateway/core/channels/backends/memory_trace.py

@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+import asyncio
+import uuid
+from typing import Any
+
+
+class MemoryTraceBackend:
+    """进程内 (channel, user_id) → trace_id;接入 Lifecycle 后可替换为 TraceManager。"""
+
+    def __init__(self) -> None:
+        self._map: dict[tuple[str, str], str] = {}
+        self._lock = asyncio.Lock()
+
+    async def get_or_create_trace(
+        self,
+        *,
+        channel: str,
+        user_id: str,
+        workspace_id: str,
+        agent_type: str,
+        metadata: dict[str, Any],
+    ) -> str:
+        _ = workspace_id, agent_type, metadata
+        key = (channel, user_id)
+        async with self._lock:
+            if key not in self._map:
+                self._map[key] = str(uuid.uuid4())
+            return self._map[key]
+
+    def clear(self) -> None:
+        self._map.clear()

+ 23 - 0
gateway/core/channels/backends/user_id.py

@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from gateway.core.channels.types import IncomingFeishuEvent
+
+
+class DefaultUserIdentityResolver:
+    """
+    默认 user_id:feishu:{tenant_key}:{app_id}:{open_id}。
+
+    无 open_id 时退化为 chat_id,便于群场景或异常事件仍可按会话隔离。
+    """
+
+    def __init__(self, *, tenant_fallback: str = "_") -> None:
+        self._tenant_fallback = tenant_fallback
+
+    def resolve_user_id(self, event: IncomingFeishuEvent) -> str:
+        tenant = event.tenant_id or self._tenant_fallback
+        app = event.app_id
+        if event.open_id:
+            return f"feishu:{tenant}:{app}:{event.open_id}"
+        if event.chat_id:
+            return f"feishu:{tenant}:{app}:chat:{event.chat_id}"
+        return f"feishu:{tenant}:{app}:unknown"

+ 3 - 0
gateway/core/channels/feishu/__init__.py

@@ -0,0 +1,3 @@
+from gateway.core.channels.feishu.connector import FeishuConnector
+
+__all__ = ["FeishuConnector"]

+ 41 - 0
gateway/core/channels/feishu/api.py

@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+import logging
+from collections.abc import Mapping
+from typing import Any
+
+import httpx
+
+logger = logging.getLogger(__name__)
+
+
+class FeishuHttpAdapterClient:
+    """调用 openclaw-lark-patch HTTP 服务(默认端口 4380)。"""
+
+    def __init__(self, base_url: str, *, timeout: float = 120.0) -> None:
+        self._base = base_url.rstrip("/")
+        self._timeout = timeout
+
+    async def post_json(self, path: str, body: Mapping[str, Any]) -> dict[str, Any]:
+        url = f"{self._base}{path if path.startswith('/') else '/' + path}"
+        async with httpx.AsyncClient(timeout=self._timeout) as client:
+            resp = await client.post(url, json=dict(body))
+            try:
+                data = resp.json()
+            except Exception:
+                data = {"ok": False, "error": "invalid_json", "status_code": resp.status_code}
+            if not isinstance(data, dict):
+                return {"ok": False, "error": "unexpected_response_shape"}
+            if resp.status_code >= 400 and "ok" not in data:
+                data = {**data, "ok": False, "status_code": resp.status_code}
+            return data
+
+    async def get_json(self, path: str) -> dict[str, Any]:
+        url = f"{self._base}{path if path.startswith('/') else '/' + path}"
+        async with httpx.AsyncClient(timeout=self._timeout) as client:
+            resp = await client.get(url)
+            try:
+                data = resp.json()
+            except Exception:
+                return {"ok": False, "error": "invalid_json", "status_code": resp.status_code}
+            return data if isinstance(data, dict) else {"ok": False, "error": "unexpected_response_shape"}

+ 223 - 0
gateway/core/channels/feishu/connector.py

@@ -0,0 +1,223 @@
+from __future__ import annotations
+
+import logging
+from collections.abc import Mapping, Sequence
+from typing import Any, Literal, assert_never
+
+from gateway.core.channels.feishu.api import FeishuHttpAdapterClient
+from gateway.core.channels.feishu.webhook import WebhookParseError, parse_openclaw_normalized
+from gateway.core.channels.types import FeishuReplyContext, IncomingFeishuEvent
+
+logger = logging.getLogger(__name__)
+
+FeishuHttpMessageKind = Literal["text", "card", "media", "raw"]
+
+
+class FeishuConnector:
+    """
+    飞书连接器:解析 openclaw 规范化事件,经 Feishu HTTP 适配器发送消息。
+
+    不持有 app_secret;鉴权与 token 均在 Node 侧完成。
+
+    发送能力与适配器 POST ``/feishu/send-message`` 一致:
+    ``text``(富文本 post)、``card``(交互卡片)、``media``(URL 上传后按类型发送)、
+    ``raw``(显式 ``msg_type`` + JSON ``content``,覆盖 text/post/image/分享名片/贴纸等开放平台类型)。
+    """
+
+    def __init__(
+        self,
+        *,
+        feishu_http_base_url: str = "http://127.0.0.1:4380",
+        timeout: float = 120.0,
+    ) -> None:
+        self._http = FeishuHttpAdapterClient(feishu_http_base_url, timeout=timeout)
+
+    def handle_webhook(self, event: Mapping[str, Any]) -> dict[str, Any]:
+        """校验 payload(对应 channels.md 的 handle_webhook)。"""
+        try:
+            ev: IncomingFeishuEvent = parse_openclaw_normalized(event)
+            return {"ok": True, "parsed": True, "event_type": ev.event_type}
+        except WebhookParseError as e:
+            return {"ok": False, "error": str(e)}
+
+    def parse_normalized_event(self, payload: Mapping[str, Any]) -> IncomingFeishuEvent:
+        return parse_openclaw_normalized(payload)
+
+    async def send_message(
+        self,
+        user_id: str,
+        text: str,
+        *,
+        reply_context: FeishuReplyContext | None = None,
+    ) -> dict[str, Any]:
+        """
+        channels.md 签名兼容:user_id 未使用时需传入 reply_context。
+
+        实际发送依赖 chat_id(与 server.ts /feishu/send-message 一致)。
+        """
+        if reply_context is None:
+            return {"ok": False, "error": "reply_context_required_for_openclaw_bridge"}
+        return await self.send_text(reply_context, text)
+
+    def _send_message_base(
+        self,
+        ctx: FeishuReplyContext,
+        *,
+        reply_in_thread: bool | None = None,
+    ) -> dict[str, Any]:
+        body: dict[str, Any] = {"chat_id": ctx.chat_id}
+        if ctx.account_id:
+            body["account_id"] = ctx.account_id
+        if ctx.message_id:
+            body["reply_to_message_id"] = ctx.message_id
+        if reply_in_thread is not None:
+            body["reply_in_thread"] = reply_in_thread
+        return body
+
+    async def post_send_message(self, body: Mapping[str, Any]) -> dict[str, Any]:
+        """POST ``/feishu/send-message``;``body`` 需含 ``kind`` 及对应字段(与适配器一致)。"""
+        payload = dict(body)
+        result = await self._http.post_json("/feishu/send-message", payload)
+        if not result.get("ok"):
+            logger.warning("feishu send-message failed: %s", result)
+        return result
+
+    async def send_text(
+        self,
+        ctx: FeishuReplyContext,
+        text: str,
+        *,
+        reply_in_thread: bool | None = None,
+    ) -> dict[str, Any]:
+        body = self._send_message_base(ctx, reply_in_thread=reply_in_thread)
+        body["kind"] = "text"
+        body["text"] = text
+        return await self.post_send_message(body)
+
+    async def send_card(
+        self,
+        ctx: FeishuReplyContext,
+        card: Mapping[str, Any],
+        *,
+        reply_in_thread: bool | None = None,
+    ) -> dict[str, Any]:
+        """发送交互卡片(``msg_type: interactive``)。"""
+        body = self._send_message_base(ctx, reply_in_thread=reply_in_thread)
+        body["kind"] = "card"
+        body["card"] = dict(card)
+        return await self.post_send_message(body)
+
+    async def send_media(
+        self,
+        ctx: FeishuReplyContext,
+        media_url: str,
+        *,
+        reply_in_thread: bool | None = None,
+        media_local_roots: Sequence[str] | None = None,
+        file_name: str | None = None,
+    ) -> dict[str, Any]:
+        """
+        发送图片 / 视频 / 普通文件 / 音频(opus/mp4 等由适配器根据扩展名与内容判定)。
+
+        ``media_url`` 可为 http(s) 或适配器允许的本地路径;本地路径需配合服务端
+        ``media_local_roots`` 白名单。
+        """
+        body = self._send_message_base(ctx, reply_in_thread=reply_in_thread)
+        body["kind"] = "media"
+        body["media_url"] = media_url
+        if media_local_roots is not None:
+            body["media_local_roots"] = list(media_local_roots)
+        if file_name:
+            body["file_name"] = file_name
+        return await self.post_send_message(body)
+
+    async def send_raw_im(
+        self,
+        ctx: FeishuReplyContext,
+        msg_type: str,
+        content: str | Mapping[str, Any],
+        *,
+        reply_in_thread: bool | None = None,
+        uuid: str | None = None,
+    ) -> dict[str, Any]:
+        """
+        使用机器人身份发送任意已支持的 ``msg_type``,``content`` 为合法 JSON 字符串或对象。
+
+        常用示例:``text`` → ``{"text": "你好"}``;``share_chat`` → ``{"chat_id": "oc_xxx"}``;
+        ``image`` → ``{"image_key": "img_xxx"}``(需先上传)。具体格式以飞书 IM 开放文档为准。
+        """
+        body = self._send_message_base(ctx, reply_in_thread=reply_in_thread)
+        body["kind"] = "raw"
+        body["msg_type"] = msg_type
+        body["content"] = content if isinstance(content, str) else dict(content)
+        if uuid:
+            body["uuid"] = uuid
+        return await self.post_send_message(body)
+
+    async def send(
+        self,
+        ctx: FeishuReplyContext,
+        *,
+        kind: FeishuHttpMessageKind = "text",
+        reply_in_thread: bool | None = None,
+        text: str | None = None,
+        card: Mapping[str, Any] | None = None,
+        media_url: str | None = None,
+        media_local_roots: Sequence[str] | None = None,
+        file_name: str | None = None,
+        msg_type: str | None = None,
+        content: str | Mapping[str, Any] | None = None,
+        uuid: str | None = None,
+    ) -> dict[str, Any]:
+        """与适配器 ``kind`` 对齐的统一发送入口。"""
+        if kind == "text":
+            if not text:
+                return {"ok": False, "error": "missing_text"}
+            return await self.send_text(ctx, text, reply_in_thread=reply_in_thread)
+        if kind == "card":
+            if card is None:
+                return {"ok": False, "error": "missing_card"}
+            return await self.send_card(ctx, card, reply_in_thread=reply_in_thread)
+        if kind == "media":
+            if not media_url:
+                return {"ok": False, "error": "missing_media_url"}
+            return await self.send_media(
+                ctx,
+                media_url,
+                reply_in_thread=reply_in_thread,
+                media_local_roots=media_local_roots,
+                file_name=file_name,
+            )
+        if kind == "raw":
+            if not msg_type or content is None:
+                return {"ok": False, "error": "raw_requires_msg_type_and_content"}
+            return await self.send_raw_im(
+                ctx,
+                msg_type,
+                content,
+                reply_in_thread=reply_in_thread,
+                uuid=uuid,
+            )
+        assert_never(kind)
+
+    async def list_feishu_app_accounts(self) -> dict[str, Any]:
+        """
+        拉取适配器 GET `/accounts`:已启用、已配置的飞书应用(机器人)列表。
+
+        与终端用户 open_id 无关;若要用户姓名/头像等需走开放平台 contact 等接口。
+        """
+        try:
+            data = await self._http.get_json("/accounts")
+            if data.get("ok") and "accounts" in data:
+                return {"ok": True, "accounts": data.get("accounts")}
+        except Exception as e:
+            logger.debug("list_feishu_app_accounts: %s", e)
+        return {"ok": True, "accounts": []}
+
+    async def get_user_info(self, user_id: str) -> dict[str, Any]:
+        """
+        飞书「用户」个人资料:当前 HTTP 适配器未暴露联系人查询,仅回传占位。
+
+        已配置应用列表请用 `list_feishu_app_accounts()`(勿与 `/accounts` 语义混淆)。
+        """
+        return {"ok": True, "user_id": user_id, "profile": None}

+ 192 - 0
gateway/core/channels/feishu/openclaw-lark-patch/index.ts

@@ -0,0 +1,192 @@
+/**
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ *
+ * OpenClaw Lark/Feishu plugin entry point.
+ *
+ * Registers the Feishu channel and all tool families:
+ * doc, wiki, drive, perm, bitable, task, calendar.
+ *
+ * NOTE: 本文件位于 openclaw-lark-patch,Docker 构建时覆盖 vendor 包(见 docker/Dockerfile.feishu)。
+ */
+
+import type { OpenClawPluginApi } from 'openclaw/plugin-sdk';
+import { emptyPluginConfigSchema } from 'openclaw/plugin-sdk';
+import { feishuPlugin } from './src/channel/plugin';
+import { LarkClient } from './src/core/lark-client';
+import { registerOapiTools } from './src/tools/oapi/index';
+import { registerFeishuMcpDocTools } from './src/tools/mcp/doc/index';
+import { registerFeishuOAuthTool } from './src/tools/oauth';
+import { registerFeishuOAuthBatchAuthTool } from './src/tools/oauth-batch-auth';
+import {
+  runDiagnosis,
+  formatDiagReportCli,
+  traceByMessageId,
+  formatTraceOutput,
+  analyzeTrace,
+} from './src/commands/diagnose';
+import { registerCommands } from './src/commands/index';
+import { larkLogger } from './src/core/lark-logger';
+import { emitSecurityWarnings } from './src/core/security-check';
+
+const log = larkLogger('plugin');
+
+// ---------------------------------------------------------------------------
+// Re-exports for external consumers
+// ---------------------------------------------------------------------------
+
+export { monitorFeishuProvider } from './src/channel/monitor';
+export { sendMessageFeishu, sendCardFeishu, updateCardFeishu, editMessageFeishu } from './src/messaging/outbound/send';
+export { getMessageFeishu } from './src/messaging/outbound/fetch';
+export {
+  uploadImageLark,
+  uploadFileLark,
+  sendImageLark,
+  sendFileLark,
+  sendAudioLark,
+  uploadAndSendMediaLark,
+} from './src/messaging/outbound/media';
+export {
+  sendTextLark,
+  sendCardLark,
+  sendMediaLark,
+  sendRawImLark,
+  type SendTextLarkParams,
+  type SendCardLarkParams,
+  type SendMediaLarkParams,
+  type SendRawImLarkParams,
+} from './src/messaging/outbound/deliver';
+export { type FeishuChannelData } from './src/messaging/outbound/outbound';
+export { probeFeishu } from './src/channel/probe';
+export {
+  addReactionFeishu,
+  removeReactionFeishu,
+  listReactionsFeishu,
+  FeishuEmoji,
+  VALID_FEISHU_EMOJI_TYPES,
+} from './src/messaging/outbound/reactions';
+export { forwardMessageFeishu } from './src/messaging/outbound/forward';
+export {
+  updateChatFeishu,
+  addChatMembersFeishu,
+  removeChatMembersFeishu,
+  listChatMembersFeishu,
+} from './src/messaging/outbound/chat-manage';
+export { feishuMessageActions } from './src/messaging/outbound/actions';
+export {
+  mentionedBot,
+  nonBotMentions,
+  extractMessageBody,
+  formatMentionForText,
+  formatMentionForCard,
+  formatMentionAllForText,
+  formatMentionAllForCard,
+  buildMentionedMessage,
+  buildMentionedCardContent,
+  type MentionInfo,
+} from './src/messaging/inbound/mention';
+export { feishuPlugin } from './src/channel/plugin';
+export type {
+  MessageContext,
+  RawMessage,
+  RawSender,
+  FeishuMessageContext,
+  FeishuReactionCreatedEvent,
+} from './src/messaging/types';
+export { handleFeishuReaction } from './src/messaging/inbound/reaction-handler';
+export { parseMessageEvent } from './src/messaging/inbound/parse';
+export { checkMessageGate } from './src/messaging/inbound/gate';
+export { isMessageExpired } from './src/messaging/inbound/dedup';
+
+// ---------------------------------------------------------------------------
+// Plugin definition
+// ---------------------------------------------------------------------------
+
+const plugin = {
+  id: 'openclaw-lark',
+  name: 'Feishu',
+  description: 'Lark/Feishu channel plugin with im/doc/wiki/drive/task/calendar tools',
+  configSchema: emptyPluginConfigSchema(),
+  register(api: OpenClawPluginApi) {
+    LarkClient.setRuntime(api.runtime);
+    api.registerChannel({ plugin: feishuPlugin });
+
+    // ========================================
+
+    // Register OAPI tools (calendar, task - using Feishu Open API directly)
+    registerOapiTools(api);
+
+    // Register MCP doc tools (using Model Context Protocol)
+    registerFeishuMcpDocTools(api);
+
+    // Register OAuth tool (UAT device flow authorization)
+    registerFeishuOAuthTool(api);
+
+    // Register OAuth batch auth tool (batch authorization for all app scopes)
+    registerFeishuOAuthBatchAuthTool(api);
+
+    // ---- Tool call hooks (auto-trace AI tool invocations) ----
+
+    api.on('before_tool_call', (event) => {
+      log.info(`tool call: ${event.toolName} params=${JSON.stringify(event.params)}`);
+    });
+
+    api.on('after_tool_call', (event) => {
+      if (event.error) {
+        log.error(`tool fail: ${event.toolName} ${event.error} (${event.durationMs ?? 0}ms)`);
+      } else {
+        log.info(`tool done: ${event.toolName} ok (${event.durationMs ?? 0}ms)`);
+      }
+    });
+
+    // ---- Diagnostic commands ----
+
+    // CLI: openclaw feishu-diagnose [--trace <messageId>]
+    api.registerCli(
+      (ctx) => {
+        ctx.program
+          .command('feishu-diagnose')
+          .description('运行飞书插件诊断,检查配置、连通性和权限状态')
+          .option('--trace <messageId>', '按 message_id 追踪完整处理链路')
+          .option('--analyze', '分析追踪日志(需配合 --trace 使用)')
+          .action(async (opts: { trace?: string; analyze?: boolean }) => {
+            try {
+              if (opts.trace) {
+                const lines = await traceByMessageId(opts.trace);
+                // eslint-disable-next-line no-console -- CLI 命令直接输出到终端
+                console.log(formatTraceOutput(lines, opts.trace));
+                if (opts.analyze && lines.length > 0) {
+                  // eslint-disable-next-line no-console -- CLI 命令直接输出到终端
+                  console.log(analyzeTrace(lines, opts.trace));
+                }
+              } else {
+                const report = await runDiagnosis({
+                  config: ctx.config,
+                  logger: ctx.logger,
+                });
+                // eslint-disable-next-line no-console -- CLI 命令直接输出到终端
+                console.log(formatDiagReportCli(report));
+                if (report.overallStatus === 'unhealthy') {
+                  process.exitCode = 1;
+                }
+              }
+            } catch (err) {
+              ctx.logger.error(`诊断命令执行失败: ${err}`);
+              process.exitCode = 1;
+            }
+          });
+      },
+      { commands: ['feishu-diagnose'] },
+    );
+
+    // Chat commands: /feishu_diagnose, /feishu_doctor, /feishu_auth, /feishu
+    registerCommands(api);
+
+    // ---- Multi-account security checks ----
+    if (api.config) {
+      emitSecurityWarnings(api.config, api.logger);
+    }
+  },
+};
+
+export default plugin;

+ 53 - 2
gateway/core/channels/feishu/openclaw-lark-patch/src/http/server.ts

@@ -4,7 +4,7 @@
  * 暴露给自建 Agent 的典型用法:
  * - GET /healthz、GET /readyz、GET /tools、GET /accounts
  * - POST /tool-call、POST /tool-calls/batch(OAPI + MCP 文档工具 + 可选 feishu_oauth)
- * - POST /feishu/send-message、POST /feishu/react、POST /feishu/revoke-uat
+ * - POST /feishu/send-message(kind: text | card | media | raw+msg_type)、POST /feishu/react、POST /feishu/revoke-uat
  * - WebSocket:将 im.message / 表情 / 卡片操作等事件 JSON 转发到 GATEWAY_FEISHU_WEBHOOK_URL
  *
  * 其它复用方式(不必跑本服务):
@@ -37,7 +37,21 @@ import { LarkClient } from '../core/lark-client.js'
 import { MessageDedup, isMessageExpired } from '../messaging/inbound/dedup.js'
 import type { FeishuMessageEvent, FeishuReactionCreatedEvent } from '../messaging/types.js'
 import { extractRawTextFromEvent } from '../channel/abort-detect.js'
-import { sendTextLark, sendCardLark, sendMediaLark } from '../messaging/outbound/deliver.js'
+import { sendTextLark, sendCardLark, sendMediaLark, sendRawImLark } from '../messaging/outbound/deliver.js'
+
+/** Bot IM `msg_type` values for POST /feishu/send-message when kind === 'raw'. */
+const FEISHU_HTTP_RAW_MSG_TYPES = new Set<string>([
+  'text',
+  'post',
+  'image',
+  'file',
+  'audio',
+  'media',
+  'interactive',
+  'share_chat',
+  'share_user',
+  'sticker',
+])
 import { addReactionFeishu, removeReactionFeishu, listReactionsFeishu } from '../messaging/outbound/reactions.js'
 import { requestDeviceAuthorization, pollDeviceToken } from '../core/device-flow.js'
 import { buildAuthCard } from '../tools/oauth-cards.js'
@@ -562,10 +576,12 @@ async function handleSendFeishuMessage(body: unknown): Promise<ToolCallResponse>
         const mediaLocalRoots = Array.isArray(obj.media_local_roots)
           ? (obj.media_local_roots as string[])
           : undefined
+        const fileName = typeof obj.file_name === 'string' ? obj.file_name : undefined
         const result = await sendMediaLark({
           cfg: cfg as any,
           to,
           mediaUrl,
+          fileName,
           replyToMessageId,
           replyInThread,
           accountId,
@@ -573,6 +589,41 @@ async function handleSendFeishuMessage(body: unknown): Promise<ToolCallResponse>
         })
         return { ok: true, result }
       }
+      case 'raw': {
+        const msgType = typeof obj.msg_type === 'string' ? obj.msg_type.trim() : ''
+        if (!msgType || !FEISHU_HTTP_RAW_MSG_TYPES.has(msgType)) {
+          return {
+            ok: false,
+            error: 'invalid_or_unsupported_msg_type',
+            details: { msg_type: msgType || undefined, allowed: [...FEISHU_HTTP_RAW_MSG_TYPES] },
+          }
+        }
+        let contentStr: string
+        if (typeof obj.content === 'string') {
+          contentStr = obj.content
+        } else if (obj.content != null && typeof obj.content === 'object') {
+          contentStr = JSON.stringify(obj.content)
+        } else {
+          return { ok: false, error: 'missing_content' }
+        }
+        try {
+          JSON.parse(contentStr)
+        } catch {
+          return { ok: false, error: 'content_must_be_valid_json' }
+        }
+        const uuid = typeof obj.uuid === 'string' ? obj.uuid : undefined
+        const result = await sendRawImLark({
+          cfg: cfg as any,
+          to,
+          msg_type: msgType,
+          content: contentStr,
+          replyToMessageId,
+          replyInThread,
+          accountId,
+          uuid,
+        })
+        return { ok: true, result }
+      }
       default:
         return { ok: false, error: 'unsupported_kind', details: { kind } }
     }

+ 530 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/messaging/outbound/deliver.ts

@@ -0,0 +1,530 @@
+// SPDX-License-Identifier: MIT
+
+/**
+ * Standalone text and media delivery functions for the Lark/Feishu channel.
+ *
+ * These functions operate directly on the Lark SDK without depending on
+ * {@link sendMessageFeishu} from `send.ts`. The outbound adapter delegates
+ * to these for its `sendText` and `sendMedia` implementations.
+ *
+ * NOTE: This file lives under openclaw-lark-patch and overlays the vendor
+ * package at Docker build time (see docker/Dockerfile.feishu).
+ */
+
+import type { ClawdbotConfig } from 'openclaw/plugin-sdk';
+import type { FeishuSendResult } from '../types';
+import { createAccountScopedConfig } from '../../core/accounts';
+import { LarkClient } from '../../core/lark-client';
+import { normalizeFeishuTarget, normalizeMessageId, resolveReceiveIdType } from '../../core/targets';
+import { runWithMessageUnavailableGuard } from '../../core/message-unavailable';
+import { optimizeMarkdownStyle } from '../../card/markdown-style';
+import { uploadAndSendMediaLark } from './media';
+import { formatLarkError } from '../../core/api-error';
+import { larkLogger } from '../../core/lark-logger';
+
+const log = larkLogger('outbound/deliver');
+
+// ---------------------------------------------------------------------------
+// Internal helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Build a Feishu post-format content envelope from processed text.
+ */
+function buildPostContent(text: string): string {
+  return JSON.stringify({
+    zh_cn: {
+      content: [[{ tag: 'md', text }]],
+    },
+  });
+}
+
+/**
+ * Normalise `<at>` mention tags that the AI frequently writes incorrectly.
+ *
+ * Correct Feishu syntax:
+ *   `<at user_id="ou_xxx">name</at>`   — mention a user
+ *   `<at user_id="all"></at>`           — mention everyone
+ *
+ * Common AI mistakes this function fixes:
+ *   `<at id=all></at>`           → `<at user_id="all"></at>`
+ *   `<at id="ou_xxx"></at>`      → `<at user_id="ou_xxx"></at>`
+ *   `<at open_id="ou_xxx"></at>` → `<at user_id="ou_xxx"></at>`
+ *   `<at user_id=ou_xxx></at>`   → `<at user_id="ou_xxx"></at>`
+ */
+function normalizeAtMentions(text: string): string {
+  return text.replace(/<at\s+(?:id|open_id|user_id)\s*=\s*"?([^">\s]+)"?\s*>/gi, '<at user_id="$1">');
+}
+
+/**
+ * Pre-process text for Lark rendering:
+ * mention normalisation + table conversion + style optimization.
+ */
+function prepareTextForLark(cfg: ClawdbotConfig, text: string, accountId?: string): string {
+  let processed = normalizeAtMentions(text);
+
+  // Convert markdown tables to Feishu-compatible format using per-account
+  // tableMode setting.
+  try {
+    const accountScopedCfg = createAccountScopedConfig(cfg, accountId);
+    const runtime = LarkClient.runtime;
+    if (runtime?.channel?.text?.convertMarkdownTables && runtime.channel.text.resolveMarkdownTableMode) {
+      const tableMode = runtime.channel.text.resolveMarkdownTableMode({
+        cfg: accountScopedCfg,
+        channel: 'feishu',
+      });
+      processed = runtime.channel.text.convertMarkdownTables(processed, tableMode);
+    }
+  } catch {
+    // Runtime not available -- use the text as-is.
+  }
+
+  return optimizeMarkdownStyle(processed, 1);
+}
+
+/**
+ * Unified IM message sender — handles both reply and create paths for any
+ * `msg_type`.  Replaces the former `replyPostMessage`, `createPostMessage`,
+ * `replyInteractiveMessage` and `createInteractiveMessage` helpers.
+ */
+async function sendImMessage(params: {
+  client: ReturnType<typeof LarkClient.fromCfg>['sdk'];
+  to: string;
+  content: string;
+  msgType: string;
+  replyToMessageId?: string;
+  replyInThread?: boolean;
+}): Promise<FeishuSendResult> {
+  const { client, to, content, msgType, replyToMessageId, replyInThread } = params;
+
+  // --- Reply path ---
+  if (replyToMessageId) {
+    log.info(`replying to message ${replyToMessageId} ` + `(msg_type=${msgType}, thread=${replyInThread ?? false})`);
+    const response = await client.im.message.reply({
+      path: { message_id: replyToMessageId },
+      data: { content, msg_type: msgType, reply_in_thread: replyInThread },
+    });
+
+    const result: FeishuSendResult = {
+      messageId: response?.data?.message_id ?? '',
+      chatId: response?.data?.chat_id ?? '',
+    };
+    log.debug(`reply sent: messageId=${result.messageId}`);
+    return result;
+  }
+
+  // --- Create path ---
+  const target = normalizeFeishuTarget(to);
+  if (!target) {
+    throw new Error(
+      `Cannot send message: "${to}" is not a valid target. ` + `Expected a chat_id (oc_*), open_id (ou_*), or user_id.`,
+    );
+  }
+
+  const receiveIdType = resolveReceiveIdType(target);
+  log.info(`creating message to ${target} (msg_type=${msgType})`);
+
+  const response = await client.im.message.create({
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    params: { receive_id_type: receiveIdType as any },
+    data: { receive_id: target, msg_type: msgType, content },
+  });
+
+  const result: FeishuSendResult = {
+    messageId: response?.data?.message_id ?? '',
+    chatId: response?.data?.chat_id ?? '',
+  };
+  log.debug(`message created: messageId=${result.messageId}`);
+  return result;
+}
+
+// ---------------------------------------------------------------------------
+// sendRawImLark — explicit msg_type + JSON content (bot)
+// ---------------------------------------------------------------------------
+
+/**
+ * Parameters for sending a bot IM message with a raw Feishu `msg_type`.
+ */
+export interface SendRawImLarkParams {
+  cfg: ClawdbotConfig;
+  to: string;
+  msg_type: string;
+  /** JSON string as required by the Feishu IM API for this `msg_type`. */
+  content: string;
+  replyToMessageId?: string;
+  replyInThread?: boolean;
+  accountId?: string;
+  /** Optional idempotency key (Feishu `uuid` field). */
+  uuid?: string;
+}
+
+/**
+ * Send using an explicit Feishu `msg_type` and serialised JSON `content`.
+ *
+ * Use this for `text`, `post`, `image`, `file`, `share_chat`, `share_user`, `sticker`, etc.
+ * when you already have the final payload. For markdown-as-post, prefer {@link sendTextLark};
+ * for upload-from-URL flows, prefer {@link sendMediaLark}.
+ */
+export async function sendRawImLark(params: SendRawImLarkParams): Promise<FeishuSendResult> {
+  const { cfg, to, msg_type, content, replyToMessageId, replyInThread, accountId, uuid } = params;
+
+  log.info(`sendRawImLark: target=${to}, msg_type=${msg_type}, reply=${Boolean(replyToMessageId)}`);
+  const client = LarkClient.fromCfg(cfg, accountId).sdk;
+
+  if (replyToMessageId) {
+    const normalizedId = normalizeMessageId(replyToMessageId);
+    const response = await runWithMessageUnavailableGuard({
+      messageId: normalizedId,
+      operation: `im.message.reply(${msg_type})`,
+      fn: () =>
+        client.im.message.reply({
+          path: { message_id: normalizedId! },
+          data: {
+            content,
+            msg_type,
+            reply_in_thread: replyInThread,
+            uuid,
+          },
+        }),
+    });
+
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    const replyData = (response as any)?.data;
+    return {
+      messageId: replyData?.message_id ?? '',
+      chatId: replyData?.chat_id ?? '',
+    };
+  }
+
+  const target = normalizeFeishuTarget(to);
+  if (!target) {
+    throw new Error(`[feishu-raw] Invalid target: "${to}"`);
+  }
+
+  const receiveIdType = resolveReceiveIdType(target);
+  const response = await client.im.message.create({
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    params: { receive_id_type: receiveIdType as any },
+    data: {
+      receive_id: target,
+      msg_type,
+      content,
+      uuid,
+    },
+  });
+
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const createData = (response as any)?.data;
+  return {
+    messageId: createData?.message_id ?? '',
+    chatId: createData?.chat_id ?? '',
+  };
+}
+
+/**
+ * Detect whether a text string is a complete Feishu card JSON (v1, v2, or template).
+ *
+ * Returns the parsed card object if the text is valid card JSON, or
+ * `undefined` if it is plain text. Detection is conservative — only
+ * triggers when the **entire** trimmed text is a JSON object with
+ * recognisable card structure markers.
+ *
+ * - **v2**: top-level `schema` equals `"2.0"`
+ * - **v1**: has an `elements` array AND at least `config` or `header`
+ * - **template**: `type` equals `"template"` with `data.template_id`
+ * - **wrapped**: `msg_type` or `type` equals `"interactive"` with a nested `card` object
+ */
+function detectCardJson(text: string): Record<string, unknown> | undefined {
+  const trimmed = text.trim();
+  if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) return undefined;
+
+  try {
+    const parsed: unknown = JSON.parse(trimmed);
+    if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
+      return undefined;
+    }
+
+    const obj = parsed as Record<string, unknown>;
+
+    // v2 CardKit — must declare schema "2.0"
+    if (obj.schema === '2.0') return obj;
+
+    // v1 Message Card — must have elements[] AND (config OR header)
+    if (Array.isArray(obj.elements) && (obj.config !== undefined || obj.header !== undefined)) {
+      return obj;
+    }
+
+    // Template card — type: "template" with data.template_id
+    if (
+      obj.type === 'template' &&
+      typeof obj.data === 'object' &&
+      obj.data !== null &&
+      typeof (obj.data as Record<string, unknown>).template_id === 'string'
+    ) {
+      return obj;
+    }
+
+    // Wrapped card — AI sometimes wraps card JSON with msg_type/type: "interactive"
+    if (
+      (obj.msg_type === 'interactive' || obj.type === 'interactive') &&
+      typeof obj.card === 'object' &&
+      obj.card !== null
+    ) {
+      return obj.card as Record<string, unknown>;
+    }
+
+    return undefined;
+  } catch {
+    return undefined;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// sendTextLark
+// ---------------------------------------------------------------------------
+
+/**
+ * Parameters for sending a text message via Feishu.
+ */
+export interface SendTextLarkParams {
+  /** Plugin configuration. */
+  cfg: ClawdbotConfig;
+  /** Target identifier (chat_id, open_id, or user_id). */
+  to: string;
+  /** Message text content (supports Feishu markdown subset). */
+  text: string;
+  /** When set, the message is sent as a threaded reply. */
+  replyToMessageId?: string;
+  /** When true, the reply appears in the thread instead of main chat. */
+  replyInThread?: boolean;
+  /** Optional account identifier for multi-account setups. */
+  accountId?: string;
+}
+
+/**
+ * Send a text message to a Feishu chat or user.
+ *
+ * Standalone implementation that directly operates the Lark SDK.
+ * The text is pre-processed (table conversion, style optimization)
+ * and sent as a Feishu "post" message with markdown rendering.
+ *
+ * If the entire text is a valid Feishu card JSON string (v1 or v2),
+ * it is automatically detected and routed to {@link sendCardLark}
+ * instead of being sent as plain text.
+ *
+ * @param params - See {@link SendTextLarkParams}.
+ * @returns The message ID and chat ID.
+ * @throws {Error} When the target is invalid or the API call fails.
+ *
+ * @example
+ * ```ts
+ * const result = await sendTextLark({
+ *   cfg,
+ *   to: "oc_xxx",
+ *   text: "Hello from Feishu",
+ * });
+ * ```
+ */
+export async function sendTextLark(params: SendTextLarkParams): Promise<FeishuSendResult> {
+  const { cfg, to, text, replyToMessageId, replyInThread, accountId } = params;
+
+  // Detect card JSON in text — route to card sending before text preprocessing.
+  const card = detectCardJson(text);
+  if (card) {
+    const version = card.schema === '2.0' ? 'v2' : 'v1';
+    log.info(`detected ${version} card JSON in text (target=${to}), routing to sendCardLark`);
+    return sendCardLark({ cfg, to, card, replyToMessageId, replyInThread, accountId });
+  }
+
+  log.info(`sendTextLark: target=${to}, textLength=${text.length}`);
+  const client = LarkClient.fromCfg(cfg, accountId).sdk;
+  const processedText = prepareTextForLark(cfg, text, accountId);
+  const content = buildPostContent(processedText);
+
+  return sendImMessage({ client, to, content, msgType: 'post', replyToMessageId, replyInThread });
+}
+
+// ---------------------------------------------------------------------------
+// sendCardLark
+// ---------------------------------------------------------------------------
+
+/**
+ * Parameters for sending an interactive card message via Feishu.
+ */
+export interface SendCardLarkParams {
+  /** Plugin configuration. */
+  cfg: ClawdbotConfig;
+  /** Target identifier (chat_id, open_id, or user_id). */
+  to: string;
+  /**
+   * Complete card JSON object (v1 Message Card or v2 CardKit).
+   *
+   * - **v1**: top-level `config`, `header`, `elements`.
+   * - **v2**: `schema: "2.0"`, `config`, `header`, `body.elements`.
+   *
+   * The Feishu server determines the version by the presence of
+   * `schema: "2.0"`.
+   */
+  card: Record<string, unknown>;
+  /** When set, the card is sent as a threaded reply. */
+  replyToMessageId?: string;
+  /** When true, the reply appears in the thread instead of main chat. */
+  replyInThread?: boolean;
+  /** Optional account identifier for multi-account setups. */
+  accountId?: string;
+}
+
+/**
+ * Send an interactive card message to a Feishu chat or user.
+ *
+ * Supports both v1 (Message Card) and v2 (CardKit) card formats.
+ * The card JSON is serialised and sent as `msg_type: "interactive"`.
+ *
+ * @param params - See {@link SendCardLarkParams}.
+ * @returns The message ID and chat ID.
+ * @throws {Error} When the target is invalid or the API call fails.
+ *
+ * @example
+ * ```ts
+ * // v1 card
+ * const result = await sendCardLark({
+ *   cfg,
+ *   to: "oc_xxx",
+ *   card: {
+ *     config: { wide_screen_mode: true },
+ *     header: { title: { tag: "plain_text", content: "Hello" }, template: "blue" },
+ *     elements: [{ tag: "div", text: { tag: "lark_md", content: "world" } }],
+ *   },
+ * });
+ *
+ * // v2 card
+ * const result2 = await sendCardLark({
+ *   cfg,
+ *   to: "oc_xxx",
+ *   card: {
+ *     schema: "2.0",
+ *     config: { wide_screen_mode: true },
+ *     body: { elements: [{ tag: "markdown", content: "Hello **world**" }] },
+ *   },
+ * });
+ * ```
+ */
+export async function sendCardLark(params: SendCardLarkParams): Promise<FeishuSendResult> {
+  const { cfg, to, card, replyToMessageId, replyInThread, accountId } = params;
+
+  const version = card.schema === '2.0' ? 'v2' : 'v1';
+  log.info(`sendCardLark: target=${to}, cardVersion=${version}`);
+
+  const client = LarkClient.fromCfg(cfg, accountId).sdk;
+  const content = JSON.stringify(card);
+
+  try {
+    return await sendImMessage({ client, to, content, msgType: 'interactive', replyToMessageId, replyInThread });
+  } catch (err) {
+    const detail = formatLarkError(err);
+    log.error(`sendCardLark failed: ${detail}`);
+
+    throw new Error(
+      `Card send failed: ${detail}\n\n` +
+        `Troubleshooting:\n` +
+        `- Do NOT use img/image elements with fabricated img_key values — Feishu rejects invalid keys.\n` +
+        `- Do NOT put URLs in img_key — it must be a real image_key from uploadImage.\n` +
+        `- Prefer text-only cards (markdown elements) which have 100% success rate.\n` +
+        `- If you need images, send them as separate media messages, not inside cards.`,
+    );
+  }
+}
+
+// ---------------------------------------------------------------------------
+// sendMediaLark
+// ---------------------------------------------------------------------------
+
+/**
+ * Parameters for sending a single media message via Feishu.
+ */
+export interface SendMediaLarkParams {
+  /** Plugin configuration. */
+  cfg: ClawdbotConfig;
+  /** Target identifier (chat_id, open_id, or user_id). */
+  to: string;
+  /** Media URL to upload and send. */
+  mediaUrl: string;
+  /** When set, the message is sent as a threaded reply. */
+  replyToMessageId?: string;
+  /** When true, the reply appears in the thread instead of main chat. */
+  replyInThread?: boolean;
+  /** Optional account identifier for multi-account setups. */
+  accountId?: string;
+  /** Allowed root directories for local file access (SSRF prevention). */
+  mediaLocalRoots?: readonly string[];
+  /**
+   * Optional display / detection name (e.g. `clip.mp4`) when the URL has no useful extension.
+   * Passed through to upload so image vs video vs file routing stays correct.
+   */
+  fileName?: string;
+}
+
+/**
+ * Send a single media message to a Feishu chat or user.
+ *
+ * Pure atomic operation — uploads the media and sends it. On upload
+ * failure, falls back to sending the URL as a clickable text link.
+ *
+ * This function does **not** handle leading text or multi-media
+ * orchestration; those concerns belong to the adapter's `sendMedia`
+ * and `sendPayload` methods.
+ *
+ * @param params - See {@link SendMediaLarkParams}.
+ * @returns The message ID and chat ID of the sent message.
+ * @throws {Error} When the target is invalid or all send attempts fail.
+ *
+ * @example
+ * ```ts
+ * const result = await sendMediaLark({
+ *   cfg,
+ *   to: "oc_xxx",
+ *   mediaUrl: "https://example.com/image.png",
+ * });
+ * ```
+ */
+export async function sendMediaLark(params: SendMediaLarkParams): Promise<FeishuSendResult> {
+  const { cfg, to, mediaUrl, replyToMessageId, replyInThread, accountId, mediaLocalRoots, fileName } = params;
+
+  log.info(`sendMediaLark: target=${to}, mediaUrl=${mediaUrl}`);
+
+  try {
+    const result = await uploadAndSendMediaLark({
+      cfg,
+      to,
+      mediaUrl,
+      fileName,
+      replyToMessageId,
+      replyInThread,
+      accountId,
+      mediaLocalRoots,
+    });
+    log.info(`media sent: messageId=${result.messageId}`);
+    return { messageId: result.messageId, chatId: result.chatId };
+  } catch (err) {
+    const errMsg = err instanceof Error ? err.message : String(err);
+    log.error(`sendMediaLark failed for "${mediaUrl}": ${errMsg}`);
+
+    // Fallback: send the URL as a clickable text link.
+    log.info(`falling back to text link for "${mediaUrl}"`);
+    const fallbackResult = await sendTextLark({
+      cfg,
+      to,
+      text: `\u{1F4CE} ${mediaUrl}`,
+      replyToMessageId,
+      replyInThread,
+      accountId,
+    });
+
+    return {
+      ...fallbackResult,
+      warning:
+        `Media upload failed for "${mediaUrl}" (${errMsg}). ` +
+        `A text link was sent instead. The user may need to open the link manually.`,
+    };
+  }
+}

+ 415 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/tools/oapi/im/message.ts

@@ -0,0 +1,415 @@
+/**
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ *
+ * feishu_im_user_message tool -- 以用户身份发送/回复 IM 消息
+ *
+ * Actions: send, reply
+ *
+ * Uses the Feishu IM API:
+ *   - send:  POST /open-apis/im/v1/messages?receive_id_type=...
+ *   - reply: POST /open-apis/im/v1/messages/:message_id/reply
+ *
+ * 全部以用户身份(user_access_token)调用,scope 来自 real-scope.json。
+ *
+ * NOTE: 本文件位于 openclaw-lark-patch,Docker 构建时覆盖 vendor 包(见 docker/Dockerfile.feishu)。
+ */
+
+import type { ClawdbotConfig, OpenClawPluginApi } from 'openclaw/plugin-sdk';
+import { Type } from '@sinclair/typebox';
+import { createAccountScopedConfig } from '../../../core/accounts';
+import { LarkClient } from '../../../core/lark-client';
+import {
+  json,
+  createToolContext,
+  assertLarkOk,
+  handleInvokeErrorWithAutoAuth,
+  registerTool,
+  StringEnum,
+} from '../helpers';
+
+interface FeishuPostContentBlock {
+  tag?: string;
+  text?: string;
+  [key: string]: unknown;
+}
+
+interface FeishuPostLocaleContent {
+  title?: string;
+  content?: FeishuPostContentBlock[][];
+  [key: string]: unknown;
+}
+
+const FEISHU_POST_LOCALE_PRIORITY = ['zh_cn', 'en_us', 'ja_jp'] as const;
+
+/**
+ * Check whether a value is a non-null object whose properties can be read.
+ *
+ * @param value - The value to check
+ * @returns Whether the value is a non-null object
+ */
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return value != null && typeof value === 'object';
+}
+
+/**
+ * Collect post content bodies from a parsed Feishu post payload.
+ * Handles both flat (title/content at root) and multi-locale wrapper structures.
+ *
+ * @param parsed - The parsed JSON object
+ * @returns List of post content bodies to process
+ */
+function collectPostContents(parsed: Record<string, unknown>): FeishuPostLocaleContent[] {
+  if ('title' in parsed || 'content' in parsed) {
+    return [parsed as FeishuPostLocaleContent];
+  }
+
+  const bodies: FeishuPostLocaleContent[] = [];
+  const seen = new Set<FeishuPostLocaleContent>();
+
+  // Process well-known locales first
+  for (const locale of FEISHU_POST_LOCALE_PRIORITY) {
+    const localeContent = parsed[locale];
+    if (!isRecord(localeContent)) {
+      continue;
+    }
+
+    const body = localeContent as FeishuPostLocaleContent;
+    if (!seen.has(body)) {
+      bodies.push(body);
+      seen.add(body);
+    }
+  }
+
+  // Process remaining locales
+  for (const value of Object.values(parsed)) {
+    if (!isRecord(value)) {
+      continue;
+    }
+
+    const body = value as FeishuPostLocaleContent;
+    if (!seen.has(body)) {
+      bodies.push(body);
+      seen.add(body);
+    }
+  }
+
+  return bodies;
+}
+
+/**
+ * Convert markdown tables to the Feishu-compatible list format.
+ *
+ * Reuses the channel runtime's existing converter so the tool send path
+ * behaves identically to the main reply path.
+ *
+ * @param cfg - Current tool configuration
+ * @param text - Raw markdown text
+ * @returns Converted text, or the original text when runtime is unavailable
+ */
+function convertMarkdownTablesForLark(cfg: ClawdbotConfig, text: string): string {
+  try {
+    const runtime = LarkClient.runtime;
+    if (runtime?.channel?.text?.convertMarkdownTables && runtime.channel.text.resolveMarkdownTableMode) {
+      const tableMode = runtime.channel.text.resolveMarkdownTableMode({
+        cfg,
+        channel: 'feishu',
+      });
+      return runtime.channel.text.convertMarkdownTables(text, tableMode);
+    }
+  } catch {
+    // Runtime converter unavailable -- keep text as-is.
+  }
+
+  return text;
+}
+
+/**
+ * Pre-process `tag="md"` text nodes inside `post` messages so the tool send
+ * path also renders markdown tables correctly.
+ *
+ * @param cfg - Current tool configuration
+ * @param msgType - Feishu message type
+ * @param content - The JSON string from tool parameters
+ * @returns Pre-processed JSON string
+ */
+function preprocessPostContent(cfg: ClawdbotConfig, msgType: string, content: string): string {
+  if (msgType !== 'post') {
+    return content;
+  }
+
+  try {
+    const parsed = JSON.parse(content) as unknown;
+    if (!isRecord(parsed)) {
+      return content;
+    }
+
+    const postContents = collectPostContents(parsed);
+    if (postContents.length === 0) {
+      return content;
+    }
+
+    let changed = false;
+
+    for (const postContent of postContents) {
+      if (!postContent.content || !Array.isArray(postContent.content)) {
+        continue;
+      }
+
+      for (const line of postContent.content) {
+        if (!Array.isArray(line)) {
+          continue;
+        }
+
+        for (const block of line) {
+          if (!isRecord(block) || block.tag !== 'md' || typeof block.text !== 'string') {
+            continue;
+          }
+
+          const convertedText = convertMarkdownTablesForLark(cfg, block.text);
+          if (convertedText !== block.text) {
+            block.text = convertedText;
+            changed = true;
+          }
+        }
+      }
+    }
+
+    return changed ? JSON.stringify(parsed) : content;
+  } catch {
+    return content;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Schema
+// ---------------------------------------------------------------------------
+
+const FeishuImMessageSchema = Type.Union([
+  // SEND
+  Type.Object({
+    action: Type.Literal('send'),
+    receive_id_type: StringEnum(['open_id', 'chat_id'], {
+      description: '接收者 ID 类型:open_id(私聊,ou_xxx)、chat_id(群聊,oc_xxx)',
+    }),
+    receive_id: Type.String({
+      description: "接收者 ID,与 receive_id_type 对应。open_id 填 'ou_xxx',chat_id 填 'oc_xxx'",
+    }),
+    msg_type: StringEnum(
+      [
+        'text',
+        'post',
+        'image',
+        'file',
+        'audio',
+        'media',
+        'interactive',
+        'share_chat',
+        'share_user',
+        'sticker',
+      ],
+      {
+        description:
+          '消息类型:text(纯文本)、post(富文本)、image(图片)、file(文件)、interactive(消息卡片)、share_chat(群名片)、share_user(个人名片)、sticker(表情贴纸)等',
+      },
+    ),
+    content: Type.String({
+      description:
+        '消息内容(JSON 字符串),格式取决于 msg_type。' +
+        '示例:text → \'{"text":"你好"}\',' +
+        'image → \'{"image_key":"img_xxx"}\',' +
+        'share_chat → \'{"chat_id":"oc_xxx"}\',' +
+        'post → \'{"zh_cn":{"title":"标题","content":[[{"tag":"text","text":"正文"}]]}}\'',
+    }),
+    uuid: Type.Optional(
+      Type.String({
+        description: '幂等唯一标识。同一 uuid 在 1 小时内只会发送一条消息,用于去重',
+      }),
+    ),
+  }),
+
+  // REPLY
+  Type.Object({
+    action: Type.Literal('reply'),
+    message_id: Type.String({
+      description: '被回复消息的 ID(om_xxx 格式)',
+    }),
+    msg_type: StringEnum(
+      [
+        'text',
+        'post',
+        'image',
+        'file',
+        'audio',
+        'media',
+        'interactive',
+        'share_chat',
+        'share_user',
+        'sticker',
+      ],
+      {
+        description:
+          '消息类型:text(纯文本)、post(富文本)、image(图片)、interactive(消息卡片)、share_chat、share_user、sticker 等',
+      },
+    ),
+    content: Type.String({
+      description: '回复消息内容(JSON 字符串),格式同 send 的 content',
+    }),
+    reply_in_thread: Type.Optional(
+      Type.Boolean({
+        description: '是否以话题形式回复。true 则消息出现在该消息的话题中,false(默认)则出现在聊天主流',
+      }),
+    ),
+    uuid: Type.Optional(
+      Type.String({
+        description: '幂等唯一标识',
+      }),
+    ),
+  }),
+]);
+
+// ---------------------------------------------------------------------------
+// Params type
+// ---------------------------------------------------------------------------
+
+type FeishuImMessageParams =
+  | {
+      action: 'send';
+      receive_id_type: 'open_id' | 'chat_id';
+      receive_id: string;
+      msg_type: string;
+      content: string;
+      uuid?: string;
+    }
+  | {
+      action: 'reply';
+      message_id: string;
+      msg_type: string;
+      content: string;
+      reply_in_thread?: boolean;
+      uuid?: string;
+    };
+
+// ---------------------------------------------------------------------------
+// Registration
+// ---------------------------------------------------------------------------
+
+export function registerFeishuImUserMessageTool(api: OpenClawPluginApi): boolean {
+  if (!api.config) return false;
+  const cfg = api.config;
+  const { toolClient, log } = createToolContext(api, 'feishu_im_user_message');
+
+  return registerTool(
+    api,
+    {
+      name: 'feishu_im_user_message',
+      label: 'Feishu: IM User Message',
+      description:
+        '飞书用户身份 IM 消息工具。**有且仅当用户明确要求以自己身份发消息、回复消息时使用,当没有明确要求时优先使用message系统工具**。' +
+        '\n\nActions:' +
+        '\n- send(发送消息):发送消息到私聊或群聊。私聊用 receive_id_type=open_id,群聊用 receive_id_type=chat_id' +
+        '\n- reply(回复消息):回复指定 message_id 的消息,支持话题回复(reply_in_thread=true)' +
+        '\n\n【重要】content 必须是合法 JSON 字符串,格式取决于 msg_type。' +
+        '最常用:text 类型 content 为 \'{"text":"消息内容"}\'。' +
+        '\n\n【安全约束】此工具以用户身份发送消息,发出后对方看到的发送者是用户本人。' +
+        '调用前必须先向用户确认:1) 发送对象(哪个人或哪个群)2) 消息内容。' +
+        '禁止在用户未明确同意的情况下自行发送消息。',
+      parameters: FeishuImMessageSchema,
+      async execute(_toolCallId: string, params: unknown) {
+        const p = params as FeishuImMessageParams;
+        try {
+          const client = toolClient();
+
+          switch (p.action) {
+            // -----------------------------------------------------------------
+            // SEND MESSAGE
+            // -----------------------------------------------------------------
+            case 'send': {
+              log.info(
+                `send: receive_id_type=${p.receive_id_type}, receive_id=${p.receive_id}, msg_type=${p.msg_type}`,
+              );
+              const accountScopedCfg = createAccountScopedConfig(cfg, client.account.accountId);
+              const processedContent = preprocessPostContent(accountScopedCfg, p.msg_type, p.content);
+
+              const res = await client.invoke(
+                'feishu_im_user_message.send',
+                (sdk, opts) =>
+                  sdk.im.v1.message.create(
+                    {
+                      params: { receive_id_type: p.receive_id_type },
+                      data: {
+                        receive_id: p.receive_id,
+                        msg_type: p.msg_type,
+                        content: processedContent,
+                        uuid: p.uuid,
+                      },
+                    },
+                    opts,
+                  ),
+                {
+                  as: 'user',
+                },
+              );
+              assertLarkOk(res);
+
+              // eslint-disable-next-line @typescript-eslint/no-explicit-any
+              const data = res.data as any;
+              log.info(`send: message sent, message_id=${data?.message_id}`);
+
+              return json({
+                message_id: data?.message_id,
+                chat_id: data?.chat_id,
+                create_time: data?.create_time,
+              });
+            }
+
+            // -----------------------------------------------------------------
+            // REPLY MESSAGE
+            // -----------------------------------------------------------------
+            case 'reply': {
+              log.info(
+                `reply: message_id=${p.message_id}, msg_type=${p.msg_type}, reply_in_thread=${p.reply_in_thread ?? false}`,
+              );
+              const accountScopedCfg = createAccountScopedConfig(cfg, client.account.accountId);
+              const processedContent = preprocessPostContent(accountScopedCfg, p.msg_type, p.content);
+
+              const res = await client.invoke(
+                'feishu_im_user_message.reply',
+                (sdk, opts) =>
+                  sdk.im.v1.message.reply(
+                    {
+                      path: { message_id: p.message_id },
+                      data: {
+                        content: processedContent,
+                        msg_type: p.msg_type,
+                        reply_in_thread: p.reply_in_thread,
+                        uuid: p.uuid,
+                      },
+                    },
+                    opts,
+                  ),
+                {
+                  as: 'user',
+                },
+              );
+              assertLarkOk(res);
+
+              // eslint-disable-next-line @typescript-eslint/no-explicit-any
+              const data = res.data as any;
+              log.info(`reply: message sent, message_id=${data?.message_id}`);
+
+              return json({
+                message_id: data?.message_id,
+                chat_id: data?.chat_id,
+                create_time: data?.create_time,
+              });
+            }
+          }
+        } catch (err) {
+          return await handleInvokeErrorWithAutoAuth(err, cfg);
+        }
+      },
+    },
+    { name: 'feishu_im_user_message' },
+  );
+}

+ 55 - 0
gateway/core/channels/feishu/webhook.py

@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+from typing import Any
+
+from gateway.core.channels.types import IncomingFeishuEvent, mapping_to_event
+
+
+class WebhookParseError(ValueError):
+    pass
+
+
+def parse_openclaw_normalized(body: Mapping[str, Any]) -> IncomingFeishuEvent:
+    """
+    校验并解析 Node 服务 `forwardEventToGateway` 的 JSON。
+
+    支持 event_type: message | reaction | card_action(与 server.ts 一致)。
+    """
+    if not body:
+        raise WebhookParseError("empty body")
+    event_type = body.get("event_type")
+    if not isinstance(event_type, str) or not event_type:
+        raise WebhookParseError("missing or invalid event_type")
+    app_id = body.get("app_id")
+    if not isinstance(app_id, str) or not app_id:
+        raise WebhookParseError("missing or invalid app_id")
+
+    event = mapping_to_event(body)
+
+    if event_type == "message":
+        if not event.chat_id:
+            raise WebhookParseError("message event requires chat_id")
+        if event.message_id is None or event.message_id == "":
+            raise WebhookParseError("message event requires message_id")
+    elif event_type == "reaction":
+        if not event.chat_id:
+            raise WebhookParseError("reaction event requires chat_id")
+        if not event.message_id:
+            raise WebhookParseError("reaction event requires message_id")
+    elif event_type == "card_action":
+        pass
+    else:
+        # 仍返回结构化事件,由上层决定是否忽略
+        pass
+
+    return event
+
+
+def handle_webhook_dict(body: Mapping[str, Any]) -> dict[str, Any]:
+    """FeishuConnector.handle_webhook 风格的同步封装(供非 async 调用方)。"""
+    try:
+        ev = parse_openclaw_normalized(body)
+        return {"ok": True, "event_type": ev.event_type, "app_id": ev.app_id}
+    except WebhookParseError as e:
+        return {"ok": False, "error": str(e)}

+ 46 - 0
gateway/core/channels/protocols.py

@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from typing import Any, Protocol, runtime_checkable
+
+from gateway.core.channels.types import FeishuReplyContext, IncomingFeishuEvent
+
+
+@runtime_checkable
+class TraceBackend(Protocol):
+    """与 Lifecycle.TraceManager 对接前的抽象:按渠道用户解析 trace_id。"""
+
+    async def get_or_create_trace(
+        self,
+        *,
+        channel: str,
+        user_id: str,
+        workspace_id: str,
+        agent_type: str,
+        metadata: dict[str, Any],
+    ) -> str:
+        ...
+
+
+@runtime_checkable
+class ExecutorBackend(Protocol):
+    """与 Executor 对接前的抽象:处理入站用户文本并负责回呼飞书。"""
+
+    async def handle_inbound_message(
+        self,
+        trace_id: str,
+        text: str,
+        reply_context: FeishuReplyContext,
+        connector: Any,
+        *,
+        event: IncomingFeishuEvent,
+    ) -> str:
+        """返回 task_id 或占位 id。"""
+        ...
+
+
+@runtime_checkable
+class UserIdentityResolver(Protocol):
+    """将飞书事件映射为网关内统一 user_id(后续可换 DB 映射表)。"""
+
+    def resolve_user_id(self, event: IncomingFeishuEvent) -> str:
+        ...

+ 84 - 0
gateway/core/channels/types.py

@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+from collections.abc import Mapping
+from dataclasses import dataclass, field
+from typing import Any
+
+
+@dataclass(slots=True)
+class IncomingFeishuEvent:
+    """openclaw-lark-patch `forwardEventToGateway` 写入的规范化结构(见 server.ts)。"""
+
+    event_type: str
+    app_id: str
+    account_id: str | None
+    open_id: str | None
+    tenant_id: str | None = None
+    chat_type: str | None = None
+    chat_id: str | None = None
+    message_id: str | None = None
+    content: str | None = None
+    emoji: str | None = None
+    action_time: str | None = None
+    action: str | None = None
+    operation_id: str | None = None
+    raw: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(slots=True)
+class FeishuReplyContext:
+    """调用 Feishu HTTP 适配器 `/feishu/send-message` 时需要的上下文。"""
+
+    account_id: str | None
+    app_id: str
+    chat_id: str
+    message_id: str | None = None
+    open_id: str | None = None
+
+
+def event_to_mapping(event: IncomingFeishuEvent) -> dict[str, Any]:
+    """供日志与扩展序列化使用。"""
+    return {
+        "event_type": event.event_type,
+        "app_id": event.app_id,
+        "account_id": event.account_id,
+        "open_id": event.open_id,
+        "tenant_id": event.tenant_id,
+        "chat_type": event.chat_type,
+        "chat_id": event.chat_id,
+        "message_id": event.message_id,
+        "content": event.content,
+        "emoji": event.emoji,
+        "action": event.action,
+        "operation_id": event.operation_id,
+    }
+
+
+def mapping_to_event(body: Mapping[str, Any]) -> IncomingFeishuEvent:
+    """从 JSON 反序列化(宽松,缺省字段为 None)。"""
+    raw = body.get("raw")
+    raw_dict: dict[str, Any] = raw if isinstance(raw, dict) else {}
+    return IncomingFeishuEvent(
+        event_type=str(body.get("event_type") or ""),
+        app_id=str(body.get("app_id") or ""),
+        account_id=_opt_str(body.get("account_id")),
+        open_id=_opt_str(body.get("open_id")),
+        tenant_id=_opt_str(body.get("tenant_id")),
+        chat_type=_opt_str(body.get("chat_type")),
+        chat_id=_opt_str(body.get("chat_id")),
+        message_id=_opt_str(body.get("message_id")),
+        content=_opt_str(body.get("content")),
+        emoji=_opt_str(body.get("emoji")),
+        action_time=_opt_str(body.get("action_time")),
+        action=_opt_str(body.get("action")),
+        operation_id=_opt_str(body.get("operation_id")),
+        raw=raw_dict,
+    )
+
+
+def _opt_str(v: Any) -> str | None:
+    if v is None:
+        return None
+    if isinstance(v, str):
+        return v if v else None
+    return str(v)