Explorar o código

添加飞书官方openclaw插件及patch代码

kevin.yang hai 4 días
pai
achega
2b3c168fb5

+ 3 - 0
.gitmodules

@@ -1,3 +1,6 @@
 [submodule "vendor/opencode"]
 	path = vendor/opencode
 	url = https://github.com/anomalyco/opencode.git
+[submodule "gateway/core/channels/feishu/openclaw-lark"]
+	path = gateway/core/channels/feishu/openclaw-lark
+	url = https://github.com/larksuite/openclaw-lark.git

+ 33 - 0
docker-compose.yml

@@ -0,0 +1,33 @@
+services:
+  feishu:
+    build:
+      context: .
+      dockerfile: docker/Dockerfile.feishu
+    image: agent/feishu:latest
+    container_name: agent-feishu
+    restart: unless-stopped
+    volumes:
+      - ./gateway/core/channels/feishu/openclaw-lark/src/http/config.yml:/app/config.yml
+    env_file:
+      - .env
+    environment:
+      - FEISHU_HTTP_PORT=4380
+      - GATEWAY_FEISHU_WEBHOOK_URL=http://localhost:8000/api/channels/feishu/openclaw/webhook
+    networks:
+      - agent
+
+  gateway:
+    build:
+      context: .
+      dockerfile: docker/Dockerfile.gateway
+    image: agent/gateway:latest
+    container_name: agent-gateway
+    restart: unless-stopped
+    env_file:
+      - .env
+    networks:
+      - agent
+
+networks:
+  agent:
+    name: agent

+ 17 - 0
docker/.dockerignore

@@ -0,0 +1,17 @@
+node_modules
+npm-debug.log
+yarn.lock
+pnpm-lock.yaml
+
+dist
+
+.git
+.gitignore
+.DS_Store
+
+coverage
+logs
+*.log
+
+tmp
+temp

+ 31 - 0
docker/Dockerfile.feishu

@@ -0,0 +1,31 @@
+FROM node:22-slim AS builder
+
+WORKDIR /app
+
+RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources \
+    && apt-get update \
+    && apt-get install -y --no-install-recommends python3 make g++ cmake git \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY gateway/core/channels/feishu/openclaw-lark .
+
+RUN sed -i 's|git+ssh://git@github.com/|git+https://github.com/|g' package-lock.json \
+    && npm install \
+    && npx tsc -p tsconfig.json --noEmit false --noEmitOnError false --outDir dist
+
+FROM node:22-slim AS runtime
+
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV FEISHU_HTTP_PORT=4380
+
+COPY --from=builder /app/package.json ./package.json
+COPY --from=builder /app/package-lock.json ./package-lock.json
+COPY --from=builder /app/dist ./dist
+
+RUN npm ci --omit=dev
+
+EXPOSE 4380
+
+CMD ["node", "--experimental-loader", "./dist/src/http/esm-loader.js", "./dist/src/http/server.js"]

+ 12 - 0
docker/Dockerfile.gateway

@@ -0,0 +1,12 @@
+FROM python:3.11-slim
+
+WORKDIR /app
+
+COPY . .
+
+RUN pip config set global.index-url https://mirrors.ustc.edu.cn/pypi/simple \
+    && pip install -r requirements.txt --no-cache-dir
+
+EXPOSE 8000
+
+CMD [ "python", "/app/gateway_server.py" ]

+ 1 - 0
gateway/core/channels/feishu/openclaw-lark

@@ -0,0 +1 @@
+Subproject commit 52fc5c59937cf1c20402be253562566bcb058c33

+ 68 - 0
gateway/core/channels/feishu/openclaw-lark-patch/package.json

@@ -0,0 +1,68 @@
+{
+  "name": "@larksuite/openclaw-lark",
+  "version": "2026.3.17",
+  "description": "OpenClaw Lark/Feishu channel plugin",
+  "type": "module",
+  "bin": {
+    "openclaw-lark": "bin/openclaw-lark.js"
+  },
+  "files": [
+    "bin/",
+    "dist/",
+    "README.md",
+    "LICENSE"
+  ],
+  "scripts": {
+    "build": "node scripts/build.mjs",
+    "release": "node scripts/release.mjs",
+    "lint": "eslint src/ index.ts",
+    "lint:fix": "eslint src/ index.ts --fix",
+    "format": "prettier --write src/**/*.ts",
+    "format:check": "prettier --check src/**/*.ts"
+  },
+  "dependencies": {
+    "@larksuiteoapi/node-sdk": "^1.59.0",
+    "@sinclair/typebox": "0.34.48",
+    "image-size": "^2.0.2",
+    "yaml": "^2.6.0",
+    "zod": "^4.3.6"
+  },
+  "devDependencies": {
+    "@types/node": "^25.2.3",
+    "@types/qrcode": "^1.5.5",
+    "@typescript-eslint/eslint-plugin": "^8.56.1",
+    "@typescript-eslint/parser": "^8.56.1",
+    "eslint": "^9.39.3",
+    "eslint-plugin-import": "^2.32.0",
+    "eslint-plugin-n": "^17.24.0",
+    "execa": "^9.6.0",
+    "fs-extra": "^11.3.2",
+    "minimist": "^1.2.8",
+    "openclaw": "2026.2.26",
+    "prettier": "^3.8.1",
+    "typescript": "^5.9.3"
+  },
+  "openclaw": {
+    "extensions": [
+      "./index.ts"
+    ],
+    "channel": {
+      "id": "openclaw-lark",
+      "label": "Feishu",
+      "selectionLabel": "Lark/Feishu (飞书)",
+      "docsPath": "/channels/feishu",
+      "docsLabel": "feishu",
+      "blurb": "飞书/Lark enterprise messaging with doc/wiki/drive/task/calendar tools.",
+      "aliases": [
+        "lark"
+      ],
+      "order": 35,
+      "quickstartAllowFrom": true
+    },
+    "install": {
+      "npmSpec": "@larksuite/openclaw-lark",
+      "localPath": "extensions/feishu",
+      "defaultChoice": "npm"
+    }
+  }
+}

+ 70 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/core/owner-policy.ts

@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) 2026 ByteDance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ *
+ * owner-policy.ts — 应用 Owner 访问控制策略。
+ *
+ * 从 uat-client.ts 迁移 owner 检查逻辑到独立 policy 层。
+ * 提供 fail-close 策略(安全优先:授权发起路径)。
+ */
+
+import type { ConfiguredLarkAccount } from './types';
+import { getAppOwnerFallback } from './app-owner-fallback';
+
+// ---------------------------------------------------------------------------
+// Error class
+// ---------------------------------------------------------------------------
+
+/**
+ * 非应用 owner 尝试执行 owner-only 操作时抛出。
+ *
+ * 注意:`appOwnerId` 仅用于内部日志,不应序列化到用户可见的响应中,
+ * 以避免泄露 owner 的 open_id。
+ */
+export class OwnerAccessDeniedError extends Error {
+  readonly userOpenId: string;
+  readonly appOwnerId: string;
+
+  constructor(userOpenId: string, appOwnerId: string) {
+    super('Permission denied: Only the app owner is authorized to use this feature.');
+    this.name = 'OwnerAccessDeniedError';
+    this.userOpenId = userOpenId;
+    this.appOwnerId = appOwnerId;
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Policy functions
+// ---------------------------------------------------------------------------
+
+/**
+ * 校验用户是否为应用 owner(fail-close 版本)。
+ *
+ * - 获取 owner 失败时 → 拒绝(安全优先)
+ * - owner 不匹配时 → 拒绝
+ *
+ * 适用于:`executeAuthorize`(OAuth 授权发起)、`commands/auth.ts`(批量授权)等
+ * 赋予实质性权限的入口。
+ */
+export async function assertOwnerAccessStrict(
+  account: ConfiguredLarkAccount,
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  sdk: any,
+  userOpenId: string,
+): Promise<void> {
+  // ownerOnly === false 时放宽限制,允许所有用户发起授权
+  const ownerOnly = (account.config as any).ownerOnly;
+  if (ownerOnly === false) {
+    return;
+  }
+
+  const ownerOpenId = await getAppOwnerFallback(account, sdk);
+
+  if (!ownerOpenId) {
+    throw new OwnerAccessDeniedError(userOpenId, 'unknown');
+  }
+
+  if (ownerOpenId !== userOpenId) {
+    throw new OwnerAccessDeniedError(userOpenId, ownerOpenId);
+  }
+}

+ 17 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/http/.dockerignore

@@ -0,0 +1,17 @@
+node_modules
+npm-debug.log
+yarn.lock
+pnpm-lock.yaml
+
+dist
+
+.git
+.gitignore
+.DS_Store
+
+coverage
+logs
+*.log
+
+tmp
+temp

+ 31 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/http/Dockerfile

@@ -0,0 +1,31 @@
+FROM node:22-slim AS builder
+
+WORKDIR /app
+
+RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources \
+    && apt-get update \
+    && apt-get install -y --no-install-recommends python3 make g++ cmake git \
+    && rm -rf /var/lib/apt/lists/*
+
+COPY . .
+
+RUN sed -i 's|git+ssh://git@github.com/|git+https://github.com/|g' package-lock.json \
+    && npm install \
+    && npx tsc -p tsconfig.json --noEmit false --noEmitOnError false --outDir dist
+
+FROM node:22-slim AS runtime
+
+WORKDIR /app
+
+ENV NODE_ENV=production
+ENV FEISHU_HTTP_PORT=4380
+
+COPY --from=builder /app/package.json ./package.json
+COPY --from=builder /app/package-lock.json ./package-lock.json
+COPY --from=builder /app/dist ./dist
+
+RUN npm ci --omit=dev
+
+EXPOSE 4380
+
+CMD ["node", "--experimental-loader", "./dist/src/http/esm-loader.js", "./dist/src/http/server.js"]

+ 13 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/http/config.yml

@@ -0,0 +1,13 @@
+feishu:
+  domain: feishu
+  ownerOnly: false
+  accounts:
+    devops:
+      appId: cli_a928053b7378dcef
+      appSecret: 4YIofe166FBsCwgJ7zIRyeIYnukeg4pW
+    artist:
+      appId: cli_a9285ba4beb8dcc2
+      appSecret: WlX4D739zpLOakqYCTFhpgSLE3UUGmtU
+    crawler:
+      appId: cli_a9285bef9e789cee
+      appSecret: 0gBXVsw8J6MnSv28VXLRMf4RYZzUw7uf

+ 15 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/http/docker-compose.yml

@@ -0,0 +1,15 @@
+services:
+  feishu-http-server:
+    build:
+      context: ../..
+      dockerfile: src/http/Dockerfile
+    image: feishu-http-server:latest
+    container_name: feishu-http-server
+    volumes:
+      - ./config.yml:/app/config.yml
+    environment:
+      - FEISHU_HTTP_PORT=4380
+      - GATEWAY_FEISHU_WEBHOOK_URL=http://localhost:8000/api/channels/feishu/openclaw/webhook
+    ports:
+      - "4380:4380"
+    restart: unless-stopped

+ 31 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/http/esm-loader.ts

@@ -0,0 +1,31 @@
+export async function resolve(
+  specifier: string,
+  context: unknown,
+  defaultResolve: (
+    specifier: string,
+    context: unknown,
+    defaultResolve: typeof import('module').createRequire,
+  ) => Promise<{ url: string }>,
+) {
+  const spec = specifier;
+
+  if (spec === 'openclaw/plugin-sdk') {
+    const url = new URL('./openclaw-plugin-sdk.js', import.meta.url).href;
+    return { url, shortCircuit: true } as any;
+  }
+
+  const isRelative =
+    spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('/') || spec.startsWith('file:');
+
+  const hasExtension = /\.[a-zA-Z0-9]+$/.test((spec.split(/[?#]/)[0] ?? ''));
+
+  if (isRelative && !hasExtension) {
+    try {
+      return await (defaultResolve as any)(`${spec}.js`, context, defaultResolve);
+    } catch {
+      // fall through to default resolution below
+    }
+  }
+
+  return (defaultResolve as any)(spec, context, defaultResolve);
+}

+ 91 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/http/openclaw-plugin-sdk.ts

@@ -0,0 +1,91 @@
+import path from 'node:path';
+
+export const DEFAULT_ACCOUNT_ID = 'default';
+export const SILENT_REPLY_TOKEN = 'NO_REPLY';
+export const PAIRING_APPROVED_MESSAGE = 'Pairing approved.';
+export const DEFAULT_GROUP_HISTORY_LIMIT = 0;
+
+export function normalizeAccountId(raw: string | undefined | null): string | null {
+  if (!raw) return null;
+  const trimmed = raw.trim();
+  if (!trimmed) return null;
+  return trimmed.toLowerCase();
+}
+
+export function buildRandomTempFilePath(params: { prefix?: string; extension?: string }): string {
+  const prefix = params.prefix ?? 'im-resource';
+  const extension = params.extension ?? '';
+  const baseDir = process.env.OPENCLAW_TMP_DIR || '/tmp/openclaw';
+  const rand = Math.random().toString(36).slice(2);
+  const filename = `${prefix}-${Date.now()}-${rand}${extension}`;
+  return path.join(baseDir, filename);
+}
+
+export function createReplyPrefixContext(_params: { cfg: unknown; agentId: string }): { prefix: string } {
+  return { prefix: '' };
+}
+
+export function createTypingCallbacks(_params: {
+  cfg: unknown;
+  chatId: string;
+  accountId: string;
+  chatType?: string;
+  replyToMessageId?: string;
+}): { onStart: () => void; onStop: () => void } {
+  return {
+    onStart() {},
+    onStop() {},
+  };
+}
+
+export function logTypingFailure(_err: unknown): void {}
+
+export function clearHistoryEntriesIfEnabled(): void {}
+
+export function buildPendingHistoryContextFromMap(): Record<string, unknown> {
+  return {};
+}
+
+export function resolveThreadSessionKeys(_params: {
+  channel: string;
+  chatId: string;
+  threadId?: string;
+  messageId?: string;
+}): { sessionKey: string; threadSessionKey: string } {
+  return { sessionKey: '', threadSessionKey: '' };
+}
+
+export function formatDocsLink(path: string): string {
+  const base = process.env.OPENCLAW_DOCS_BASE || 'https://openclaw.dev';
+  return `${base}${path}`;
+}
+
+export function addWildcardAllowFrom(store: unknown, channelId: string): void {
+  void store;
+  void channelId;
+}
+
+export function recordPendingHistoryEntryIfEnabled(_params: {
+  historyMap?: Map<string, unknown[]>;
+  historyKey?: string;
+  entry?: unknown;
+  limit?: number;
+}): void {}
+
+export function resolveSenderCommandAuthorization(_params: {
+  cfg: unknown;
+  accountId: string;
+  chatId: string;
+  senderId: string;
+}): boolean {
+  return true;
+}
+
+export function isNormalizedSenderAllowed(_params: {
+  cfg: unknown;
+  accountId: string;
+  chatId: string;
+  senderId: string;
+}): boolean {
+  return true;
+}

+ 20 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/http/package.json

@@ -0,0 +1,20 @@
+{
+  "name": "feishu-http-server",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "main": "dist/server.js",
+  "scripts": {
+    "build": "tsc",
+    "start": "node dist/server.js",
+    "dev": "ts-node server.ts"
+  },
+  "dependencies": {
+    "@larksuiteoapi/node-sdk": "^1.59.0"
+  },
+  "devDependencies": {
+    "@types/node": "^22.0.0",
+    "ts-node": "^10.9.2",
+    "typescript": "^5.9.0"
+  }
+}

+ 832 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/http/server.ts

@@ -0,0 +1,832 @@
+import http from 'node:http'
+import { randomUUID } from 'node:crypto'
+import fs from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import * as Lark from '@larksuiteoapi/node-sdk'
+import { registerOapiTools } from '../tools/oapi/index.js'
+import { registerFeishuMcpDocTools } from '../tools/mcp/doc/index.js'
+import { withTicket, type LarkTicket } from '../core/lark-ticket.js'
+import { ToolClient } from '../core/tool-client.js'
+import { getStoredToken, setStoredToken, type StoredUAToken } from '../core/token-store.js'
+import { callWithUAT, NeedAuthorizationError } from '../core/uat-client.js'
+import { UserAuthRequiredError } from '../core/auth-errors.js'
+import { getLarkAccount, getEnabledLarkAccounts } from '../core/accounts.js'
+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 { addReactionFeishu, removeReactionFeishu, listReactionsFeishu } from '../messaging/outbound/reactions.js'
+import { requestDeviceAuthorization, pollDeviceToken } from '../core/device-flow.js'
+import { buildAuthCard } from '../tools/oauth-cards.js'
+import type { LarkBrand } from '../core/types.js'
+import YAML from 'yaml'
+
+interface RegisteredTool {
+  name: string
+  label?: string
+  description?: string
+  parameters?: unknown
+  execute: (toolCallId: string, params: unknown) => Promise<unknown>
+}
+
+interface Logger {
+  info?: (msg: string) => void
+  warn?: (msg: string) => void
+  error?: (msg: string) => void
+  debug?: (msg: string) => void
+}
+
+interface ToolCallRequest {
+  tool: string
+  params?: unknown
+  tool_call_id?: string
+  context?: {
+    message_id?: string
+    chat_id?: string
+    account_id?: string
+    sender_open_id?: string
+    chat_type?: 'p2p' | 'group'
+    thread_id?: string
+  }
+}
+
+interface ToolCallResponse {
+  ok: boolean
+  result?: unknown
+  error?: string
+  details?: unknown
+}
+
+const tools = new Map<string, RegisteredTool>()
+const globalConfigRef: { cfg: unknown | null } = { cfg: null }
+
+const logger: Logger = {
+  info: (msg: string) => console.log(msg),
+  warn: (msg: string) => console.warn(msg),
+  error: (msg: string) => console.error(msg),
+  debug: (msg: string) => console.debug(msg),
+}
+
+const GATEWAY_FEISHU_WEBHOOK_URL =
+  process.env.GATEWAY_FEISHU_WEBHOOK_URL ??
+  'http://localhost:8000/api/channels/feishu/openclaw/webhook'
+
+function patchToolClientOwnerCheck() {
+  const proto: any = (ToolClient as any).prototype
+  const original = proto.invokeAsUser
+  if (!original || (original as any).__patchedByHttpServer) {
+    return
+  }
+
+  proto.invokeAsUser = async function (
+    toolAction: unknown,
+    fn: (sdk: unknown, opts?: unknown, uat?: string) => Promise<unknown>,
+    requiredScopes: string[],
+    userOpenId: string | undefined,
+    appScopeVerified: boolean,
+  ): Promise<unknown> {
+    if (!userOpenId) {
+      throw new UserAuthRequiredError('unknown', {
+        apiName: toolAction as any,
+        scopes: requiredScopes,
+        appScopeVerified,
+        appId: (this as any).account.appId,
+      })
+    }
+
+    const stored = await getStoredToken((this as any).account.appId, userOpenId)
+    if (!stored) {
+      throw new UserAuthRequiredError(userOpenId, {
+        apiName: toolAction as any,
+        scopes: requiredScopes,
+        appScopeVerified,
+        appId: (this as any).account.appId,
+      })
+    }
+
+    if (appScopeVerified && stored.scope && requiredScopes.length > 0) {
+      const userGrantedScopes = new Set<string>(stored.scope.split(/\s+/).filter(Boolean))
+      const missingUserScopes = requiredScopes.filter((s) => !userGrantedScopes.has(s))
+      if (missingUserScopes.length > 0) {
+        throw new UserAuthRequiredError(userOpenId, {
+          apiName: toolAction as any,
+          scopes: missingUserScopes,
+          appScopeVerified,
+          appId: (this as any).account.appId,
+        })
+      }
+    }
+
+    try {
+      return await callWithUAT(
+        {
+          userOpenId,
+          appId: (this as any).account.appId,
+          appSecret: (this as any).account.appSecret,
+          domain: (this as any).account.brand,
+        },
+        (accessToken: string) =>
+          fn((this as any).sdk, Lark.withUserAccessToken(accessToken), accessToken),
+      )
+    } catch (err) {
+      if (err instanceof NeedAuthorizationError) {
+        throw new UserAuthRequiredError(userOpenId, {
+          apiName: toolAction as any,
+          scopes: requiredScopes,
+          appScopeVerified,
+        })
+      }
+      ; (this as any).rethrowStructuredError(
+        err,
+        toolAction,
+        requiredScopes,
+        userOpenId,
+        'user',
+      )
+      throw err
+    }
+  }
+
+    ; (proto.invokeAsUser as any).__patchedByHttpServer = true
+}
+
+function resolveConfigPath() {
+  const cwdConfig = path.resolve(process.cwd(), 'config.yml')
+  if (fs.existsSync(cwdConfig)) return cwdConfig
+  const srcConfig = path.resolve(process.cwd(), 'src/http/config.yml')
+  if (fs.existsSync(srcConfig)) return srcConfig
+  const filename = fileURLToPath(import.meta.url)
+  const dirname = path.dirname(filename)
+  const localConfig = path.join(dirname, 'config.yml')
+  return localConfig
+}
+
+function createConfig() {
+  const configPath = resolveConfigPath()
+  const raw = fs.readFileSync(configPath, 'utf8')
+  const parsed = YAML.parse(raw) as { feishu?: unknown }
+  if (!parsed || !parsed.feishu) {
+    throw new Error('config.yml missing "feishu" section')
+  }
+  return {
+    channels: {
+      feishu: parsed.feishu,
+    },
+  }
+}
+
+function createApi(config: unknown, apiLogger: Logger) {
+  return {
+    config,
+    logger: apiLogger,
+    registerTool(def: RegisteredTool) {
+      tools.set(def.name, def)
+      apiLogger.info?.(`Registered tool: ${def.name}`)
+    },
+    on() { },
+    registerChannel() { },
+    registerCli() { },
+  } as any
+}
+
+async function initTools() {
+  const config = createConfig()
+  globalConfigRef.cfg = config
+  const api = createApi(config, logger)
+
+  patchToolClientOwnerCheck()
+
+  registerOapiTools(api)
+  registerFeishuMcpDocTools(api)
+}
+
+function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
+  return new Promise((resolve, reject) => {
+    const chunks: Buffer[] = []
+    req.on('data', (chunk: Buffer | string) => {
+      chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk)
+    })
+    req.on('end', () => {
+      if (chunks.length === 0) {
+        resolve({})
+        return
+      }
+      const raw = Buffer.concat(chunks).toString('utf8')
+      try {
+        resolve(JSON.parse(raw))
+      } catch (err) {
+        reject(err)
+      }
+    })
+    req.on('error', (err: unknown) => {
+      reject(err)
+    })
+  })
+}
+
+function sendJson(res: http.ServerResponse, statusCode: number, body: unknown) {
+  const payload = JSON.stringify(body)
+  res.statusCode = statusCode
+  res.setHeader('Content-Type', 'application/json; charset=utf-8')
+  res.setHeader('Content-Length', Buffer.byteLength(payload))
+  res.end(payload)
+}
+
+function toToolCallRequest(body: unknown): ToolCallRequest {
+  if (!body || typeof body !== 'object') {
+    throw new Error('Request body must be an object')
+  }
+  const obj = body as Record<string, unknown>
+  const tool = obj.tool
+  if (!tool || typeof tool !== 'string') {
+    throw new Error('Missing field: tool')
+  }
+  const params = obj.params
+  const toolCallId =
+    typeof obj.tool_call_id === 'string' ? obj.tool_call_id : randomUUID()
+  const context =
+    obj.context && typeof obj.context === 'object'
+      ? (obj.context as ToolCallRequest['context'])
+      : {}
+  return { tool, params, tool_call_id: toolCallId, context }
+}
+
+function buildTicket(req: ToolCallRequest): LarkTicket {
+  const ctx = req.context ?? {}
+  return {
+    messageId: ctx.message_id ?? req.tool_call_id ?? randomUUID(),
+    chatId: ctx.chat_id ?? ctx.thread_id ?? 'http',
+    accountId: ctx.account_id ?? 'default',
+    startTime: Date.now(),
+    senderOpenId: ctx.sender_open_id,
+    chatType: ctx.chat_type,
+    threadId: ctx.thread_id,
+  }
+}
+
+async function triggerOAuthDeviceFlow(params: {
+  cfg: unknown
+  ticket: LarkTicket
+  accountId: string
+  senderOpenId: string
+  requiredScopes: string[]
+}): Promise<void> {
+  const { cfg, ticket, accountId, senderOpenId, requiredScopes } = params
+  const account = getLarkAccount(cfg as any, accountId)
+  if (!account.configured) {
+    logger.warn?.(`OAuth device flow skipped: account ${accountId} not configured`)
+    return
+  }
+
+  const scope = requiredScopes.join(' ')
+  const brand = account.brand as LarkBrand
+
+  let deviceAuth
+  try {
+    deviceAuth = await requestDeviceAuthorization({
+      appId: account.appId,
+      appSecret: account.appSecret,
+      brand,
+      scope,
+    })
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err)
+    logger.error?.(`Failed to start OAuth device flow: ${msg}`)
+    return
+  }
+
+  const chatId = ticket.chatId
+  if (chatId) {
+    const expiresMin = Math.round(deviceAuth.expiresIn / 60)
+    const card = buildAuthCard({
+      verificationUriComplete: deviceAuth.verificationUriComplete,
+      expiresMin,
+      scope,
+      brand,
+      appId: account.appId,
+    })
+
+    try {
+      await sendCardLark({
+        cfg: cfg as any,
+        to: chatId,
+        card,
+        replyToMessageId: ticket.messageId?.startsWith('om_') ? ticket.messageId : undefined,
+        replyInThread: Boolean(ticket.threadId),
+        accountId,
+      })
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err)
+      logger.error?.(`Failed to send OAuth card: ${msg}`)
+    }
+  }
+
+  void (async () => {
+    try {
+      const res = await pollDeviceToken({
+        appId: account.appId,
+        appSecret: account.appSecret,
+        brand,
+        deviceCode: deviceAuth.deviceCode,
+        interval: deviceAuth.interval,
+        expiresIn: deviceAuth.expiresIn,
+      })
+
+      if (!res.ok) {
+        logger.warn?.(
+          `OAuth device flow failed for user=${senderOpenId}, app=${account.appId}: ${res.error} ${res.message}`,
+        )
+        return
+      }
+
+      const now = Date.now()
+      const stored: StoredUAToken = {
+        userOpenId: senderOpenId,
+        appId: account.appId,
+        accessToken: res.token.accessToken,
+        refreshToken: res.token.refreshToken,
+        expiresAt: now + res.token.expiresIn * 1000,
+        refreshExpiresAt: now + res.token.refreshExpiresIn * 1000,
+        scope: res.token.scope,
+        grantedAt: now,
+      }
+
+      await setStoredToken(stored)
+      logger.info?.(
+        `OAuth device flow completed, token stored for user=${senderOpenId}, app=${account.appId}`,
+      )
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err)
+      logger.error?.(`OAuth device flow polling error: ${msg}`)
+    }
+  })()
+}
+
+async function handleToolCall(body: unknown): Promise<ToolCallResponse> {
+  let req: ToolCallRequest
+  try {
+    req = toToolCallRequest(body)
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err)
+    return { ok: false, error: msg }
+  }
+
+  const tool = tools.get(req.tool)
+  if (!tool) {
+    return { ok: false, error: `Tool not found: ${req.tool}` }
+  }
+
+  const ticket = buildTicket(req)
+
+  try {
+    const result = await withTicket(ticket, () =>
+      tool.execute(req.tool_call_id ?? randomUUID(), req.params),
+    )
+    return { ok: true, result }
+  } catch (err) {
+    if (err instanceof UserAuthRequiredError) {
+      logger.warn?.(
+        `Tool call requires user authorization: ${req.tool}, user=${err.userOpenId}, scopes=${err.requiredScopes.join(
+          ',',
+        )}`,
+      )
+      const cfg = globalConfigRef.cfg
+      if (!cfg) {
+        return { ok: false, error: 'need_user_authorization', details: { error: 'missing_config' } }
+      }
+
+      void triggerOAuthDeviceFlow({
+        cfg,
+        ticket,
+        accountId: ticket.accountId,
+        senderOpenId: err.userOpenId,
+        requiredScopes: err.requiredScopes,
+      })
+
+      return {
+        ok: false,
+        error: 'need_user_authorization',
+        details: {
+          api: err.apiName,
+          required_scopes: err.requiredScopes,
+          app_id: err.appId,
+        },
+      }
+    }
+    const msg = err instanceof Error ? err.message : String(err)
+    logger.error?.(`Tool call failed: ${req.tool}: ${msg}`)
+    return { ok: false, error: msg }
+  }
+}
+
+async function handleSendFeishuMessage(body: unknown): Promise<ToolCallResponse> {
+  if (!body || typeof body !== 'object') {
+    return { ok: false, error: 'Request body must be an object' }
+  }
+
+  const cfg = globalConfigRef.cfg
+  if (!cfg) {
+    return { ok: false, error: 'config_not_initialised' }
+  }
+
+  const obj = body as Record<string, unknown>
+  const kind = (obj.kind as string | undefined) ?? 'text'
+  const accountId = typeof obj.account_id === 'string' ? obj.account_id : undefined
+  const chatId = typeof obj.chat_id === 'string' ? obj.chat_id : undefined
+  const openId = typeof obj.open_id === 'string' ? obj.open_id : undefined
+  const explicitTo = typeof obj.to === 'string' ? obj.to : undefined
+  const replyToMessageId =
+    typeof obj.reply_to_message_id === 'string' ? obj.reply_to_message_id : undefined
+  const replyInThread =
+    typeof obj.reply_in_thread === 'boolean' ? obj.reply_in_thread : undefined
+
+  const to =
+    explicitTo ??
+    chatId ??
+    openId ??
+    ''
+
+  if (!to) {
+    return { ok: false, error: 'missing_target', details: { message: 'to/chat_id/open_id is required' } }
+  }
+
+  try {
+    switch (kind) {
+      case 'text': {
+        const text = typeof obj.text === 'string' ? obj.text : undefined
+        if (!text) {
+          return { ok: false, error: 'missing_text' }
+        }
+        const result = await sendTextLark({
+          cfg: cfg as any,
+          to,
+          text,
+          replyToMessageId,
+          replyInThread,
+          accountId,
+        })
+        return { ok: true, result }
+      }
+      case 'card': {
+        const card = obj.card as Record<string, unknown> | undefined
+        if (!card || typeof card !== 'object') {
+          return { ok: false, error: 'missing_card' }
+        }
+        const result = await sendCardLark({
+          cfg: cfg as any,
+          to,
+          card,
+          replyToMessageId,
+          replyInThread,
+          accountId,
+        })
+        return { ok: true, result }
+      }
+      case 'media': {
+        const mediaUrl = typeof obj.media_url === 'string' ? obj.media_url : undefined
+        if (!mediaUrl) {
+          return { ok: false, error: 'missing_media_url' }
+        }
+        const mediaLocalRoots = Array.isArray(obj.media_local_roots)
+          ? (obj.media_local_roots as string[])
+          : undefined
+        const result = await sendMediaLark({
+          cfg: cfg as any,
+          to,
+          mediaUrl,
+          replyToMessageId,
+          replyInThread,
+          accountId,
+          mediaLocalRoots,
+        })
+        return { ok: true, result }
+      }
+      default:
+        return { ok: false, error: 'unsupported_kind', details: { kind } }
+    }
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err)
+    return { ok: false, error: msg }
+  }
+}
+
+async function handleFeishuReaction(body: unknown): Promise<ToolCallResponse> {
+  if (!body || typeof body !== 'object') {
+    return { ok: false, error: 'Request body must be an object' }
+  }
+
+  const cfg = globalConfigRef.cfg
+  if (!cfg) {
+    return { ok: false, error: 'config_not_initialised' }
+  }
+
+  const obj = body as Record<string, unknown>
+  const action = (obj.action as string | undefined) ?? 'add'
+  const accountId = typeof obj.account_id === 'string' ? obj.account_id : undefined
+  const messageId = typeof obj.message_id === 'string' ? obj.message_id : undefined
+  const emoji = typeof obj.emoji === 'string' ? obj.emoji : undefined
+
+  if (!messageId) {
+    return { ok: false, error: 'missing_message_id' }
+  }
+
+  try {
+    if (action === 'list') {
+      const reactions = await listReactionsFeishu({
+        cfg: cfg as any,
+        messageId,
+        emojiType: emoji || undefined,
+        accountId,
+      })
+      return {
+        ok: true,
+        result: {
+          reactions: reactions.map((r) => ({
+            reactionId: r.reactionId,
+            emoji: r.emojiType,
+            operatorType: r.operatorType,
+            operatorId: r.operatorId,
+          })),
+        },
+      }
+    }
+
+    if (action === 'remove') {
+      const reactions = await listReactionsFeishu({
+        cfg: cfg as any,
+        messageId,
+        emojiType: emoji || undefined,
+        accountId,
+      })
+      const botReactions = reactions.filter((r) => r.operatorType === 'app')
+      for (const r of botReactions) {
+        await removeReactionFeishu({
+          cfg: cfg as any,
+          messageId,
+          reactionId: r.reactionId,
+          accountId,
+        })
+      }
+      return {
+        ok: true,
+        result: {
+          removed: botReactions.length,
+        },
+      }
+    }
+
+    if (!emoji) {
+      return { ok: false, error: 'missing_emoji' }
+    }
+
+    const { reactionId } = await addReactionFeishu({
+      cfg: cfg as any,
+      messageId,
+      emojiType: emoji,
+      accountId,
+    })
+
+    return {
+      ok: true,
+      result: {
+        reactionId,
+      },
+    }
+  } catch (err) {
+    const msg = err instanceof Error ? err.message : String(err)
+    return { ok: false, error: msg }
+  }
+}
+
+function forwardEventToGateway(normalized: unknown) {
+  return new Promise<void>((resolve, reject) => {
+    const req = http.request(
+      GATEWAY_FEISHU_WEBHOOK_URL,
+      {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+      },
+      (res) => {
+        res.on('data', () => { })
+        res.on('end', () => resolve())
+      },
+    )
+    req.on('error', (err) => {
+      reject(err)
+    })
+    req.end(JSON.stringify(normalized))
+  })
+}
+
+async function startFeishuLongConnections() {
+  const cfg = globalConfigRef.cfg
+  if (!cfg) {
+    logger.warn?.('feishu ws: missing config, skip long connections')
+    return
+  }
+
+  const accounts = getEnabledLarkAccounts(cfg as any)
+  if (accounts.length === 0) {
+    logger.warn?.('feishu ws: no enabled accounts, skip long connections')
+    return
+  }
+
+  for (const account of accounts) {
+    const mode = (account.config as any).connectionMode ?? 'websocket'
+    if (mode !== 'websocket') {
+      logger.info?.(`feishu[${account.accountId}]: connectionMode=${mode}, skip websocket`)
+      continue
+    }
+
+    const dedup = new MessageDedup()
+    const lark = LarkClient.fromAccount(account)
+
+    void lark
+      .startWS({
+        handlers: {
+          'im.message.receive_v1': async (data: unknown) => {
+            try {
+              const event = data as FeishuMessageEvent
+              const msgId = event.message?.message_id
+              if (!msgId) return
+              if (!dedup.tryRecord(msgId, account.accountId)) {
+                logger.debug?.(`feishu[${account.accountId}]: duplicate message ${msgId}, skip`)
+                return
+              }
+              const text = extractRawTextFromEvent(event) ?? ''
+              const normalized = {
+                event_type: 'message',
+                tenant_id: event.sender?.tenant_key,
+                app_id: account.appId,
+                account_id: account.accountId,
+                open_id: event.sender?.sender_id?.open_id,
+                chat_type: event.message?.chat_type,
+                chat_id: event.message?.chat_id,
+                message_id: msgId,
+                content: text,
+                raw: event,
+              }
+              await forwardEventToGateway(normalized)
+            } catch (err) {
+              const msg = err instanceof Error ? err.message : String(err)
+              logger.error?.(`feishu[${account.accountId}]: ws handler error: ${msg}`)
+            }
+          },
+          // 'im.message.reaction.created_v1': async (data: unknown) => {
+          //   try {
+          //     const event = data as FeishuReactionCreatedEvent
+          //     const msgId = event.message_id
+          //     const emojiType = event.reaction_type?.emoji_type ?? ''
+          //     const operatorOpenId = event.user_id?.open_id ?? ''
+          //     if (!msgId || !emojiType || !operatorOpenId) return
+
+          //     const dedupKey = `${msgId}:reaction:${emojiType}:${operatorOpenId}`
+          //     if (!dedup.tryRecord(dedupKey, account.accountId)) {
+          //       logger.debug?.(`feishu[${account.accountId}]: duplicate reaction ${dedupKey}, skip`)
+          //       return
+          //     }
+
+          //     if (isMessageExpired(event.action_time)) {
+          //       logger.debug?.(`feishu[${account.accountId}]: reaction on ${msgId} expired, skip`)
+          //       return
+          //     }
+
+          //     const normalized = {
+          //       event_type: 'reaction',
+          //       app_id: account.appId,
+          //       account_id: account.accountId,
+          //       open_id: operatorOpenId,
+          //       chat_type: event.chat_type,
+          //       chat_id: event.chat_id,
+          //       message_id: msgId,
+          //       emoji: emojiType,
+          //       action_time: event.action_time,
+          //       raw: data,
+          //     }
+          //     await forwardEventToGateway(normalized)
+          //   } catch (err) {
+          //     const msg = err instanceof Error ? err.message : String(err)
+          //     logger.error?.(`feishu[${account.accountId}]: ws reaction event error: ${msg}`)
+          //   }
+          // },
+          'card.action.trigger': async (data: unknown) => {
+            try {
+              const event = data as {
+                operator?: { open_id?: string }
+                action?: { value?: { action?: string; operation_id?: string } }
+              }
+              const openId = event.operator?.open_id
+              const action = event.action?.value?.action
+              const operationId = event.action?.value?.operation_id
+              const normalized = {
+                event_type: 'card_action',
+                app_id: account.appId,
+                account_id: account.accountId,
+                open_id: openId,
+                action,
+                operation_id: operationId,
+                raw: data,
+              }
+              await forwardEventToGateway(normalized)
+            } catch (err) {
+              const msg = err instanceof Error ? err.message : String(err)
+              logger.error?.(`feishu[${account.accountId}]: ws card.action.trigger error: ${msg}`)
+            }
+          },
+        },
+      })
+      .catch((err) => {
+        const msg = err instanceof Error ? err.message : String(err)
+        logger.error?.(`feishu[${account.accountId}]: failed to start websocket: ${msg}`)
+      })
+
+    logger.info?.(`feishu[${account.accountId}]: websocket long connection started`)
+  }
+}
+
+function createServer() {
+  const port = Number.parseInt(process.env.FEISHU_HTTP_PORT ?? '4380', 10)
+
+  const server = http.createServer(async (req: http.IncomingMessage, res: http.ServerResponse) => {
+    if (!req.url) {
+      sendJson(res, 400, { ok: false, error: 'Missing URL' })
+      return
+    }
+
+    if (req.method === 'GET' && req.url === '/healthz') {
+      sendJson(res, 200, { ok: true })
+      return
+    }
+
+    if (req.method === 'GET' && req.url === '/tools') {
+      const list = Array.from(tools.values()).map((t) => ({
+        name: t.name,
+        label: t.label,
+        description: t.description,
+        parameters: t.parameters,
+      }))
+      sendJson(res, 200, { ok: true, tools: list })
+      return
+    }
+
+    if (req.method === 'POST' && req.url === '/tool-call') {
+      try {
+        const body = await readJsonBody(req)
+        const resp = await handleToolCall(body)
+        const status = resp.ok ? 200 : 400
+        sendJson(res, status, resp)
+      } catch (err) {
+        const msg = err instanceof Error ? err.message : String(err)
+        sendJson(res, 500, { ok: false, error: msg })
+      }
+      return
+    }
+
+    if (req.method === 'POST' && req.url === '/feishu/send-message') {
+      try {
+        const body = await readJsonBody(req)
+        const resp = await handleSendFeishuMessage(body)
+        const status = resp.ok ? 200 : 400
+        sendJson(res, status, resp)
+      } catch (err) {
+        const msg = err instanceof Error ? err.message : String(err)
+        sendJson(res, 500, { ok: false, error: msg })
+      }
+      return
+    }
+
+    if (req.method === 'POST' && req.url === '/feishu/react') {
+      try {
+        const body = await readJsonBody(req)
+        const resp = await handleFeishuReaction(body)
+        const status = resp.ok ? 200 : 400
+        sendJson(res, status, resp)
+      } catch (err) {
+        const msg = err instanceof Error ? err.message : String(err)
+        sendJson(res, 500, { ok: false, error: msg })
+      }
+      return
+    }
+
+    sendJson(res, 404, { ok: false, error: 'Not Found' })
+  })
+
+  server.listen(port, () => {
+    logger.info?.(`Feishu HTTP adapter listening on port ${port}`)
+  })
+}
+
+initTools()
+  .then(() => {
+    createServer()
+    void startFeishuLongConnections()
+  })
+  .catch((err) => {
+    logger.error?.(
+      `Failed to initialise tools: ${err instanceof Error ? err.message : String(err)}`,
+    )
+    process.exitCode = 1
+  })

+ 18 - 0
gateway/core/channels/feishu/openclaw-lark-patch/src/http/tsconfig.json

@@ -0,0 +1,18 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ES2022",
+    "moduleResolution": "node",
+    "rootDir": ".",
+    "outDir": "dist",
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "resolveJsonModule": true,
+    "moduleDetection": "force",
+    "types": ["node"]
+  },
+  "include": ["server.ts"],
+  "exclude": ["node_modules", "dist"]
+}
+