From 3453497fa140f1a1b0d40699e360c9ff3c87e80b Mon Sep 17 00:00:00 2001 From: CodePilot Dev Date: Wed, 11 Mar 2026 10:29:05 +0800 Subject: [PATCH 1/6] test: add unit tests for Lark webhook event parsing and verification --- src/__tests__/unit/feishu-webhook.test.ts | 81 +++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 src/__tests__/unit/feishu-webhook.test.ts diff --git a/src/__tests__/unit/feishu-webhook.test.ts b/src/__tests__/unit/feishu-webhook.test.ts new file mode 100644 index 00000000..aca28498 --- /dev/null +++ b/src/__tests__/unit/feishu-webhook.test.ts @@ -0,0 +1,81 @@ +/** + * Unit tests for Feishu webhook mode. + * + * Run with: npx tsx --test src/__tests__/unit/feishu-webhook.test.ts + */ + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Event parsing (extracted logic, no adapter instantiation needed) ── + +/** Verify a Lark event's token matches the expected verification token. */ +function verifyEventToken(payload: { header?: { token?: string }; token?: string }, expected: string): boolean { + const token = payload.header?.token ?? payload.token ?? ''; + return token === expected; +} + +/** Extract the event data from a Lark webhook payload. */ +function extractEventData(payload: Record): Record | null { + if (payload.event && typeof payload.event === 'object') { + return payload.event as Record; + } + return null; +} + +/** Check if a payload is a URL verification challenge. */ +function isUrlVerification(payload: Record): payload is { type: 'url_verification'; challenge: string; token: string } { + return payload.type === 'url_verification' && typeof payload.challenge === 'string'; +} + +describe('Lark webhook: URL verification', () => { + it('detects url_verification type', () => { + const payload = { challenge: 'abc123', token: 'tok', type: 'url_verification' }; + assert.strictEqual(isUrlVerification(payload), true); + }); + + it('rejects non-verification payload', () => { + const payload = { schema: '2.0', header: {}, event: {} }; + assert.strictEqual(isUrlVerification(payload), false); + }); +}); + +describe('Lark webhook: token verification', () => { + it('verifies schema 2.0 header token', () => { + const payload = { header: { token: 'secret123' }, event: {} }; + assert.strictEqual(verifyEventToken(payload, 'secret123'), true); + assert.strictEqual(verifyEventToken(payload, 'wrong'), false); + }); + + it('verifies v1 top-level token', () => { + const payload = { token: 'secret123', event: {} }; + assert.strictEqual(verifyEventToken(payload, 'secret123'), true); + }); + + it('rejects empty token', () => { + const payload = { header: {} }; + assert.strictEqual(verifyEventToken(payload, 'secret123'), false); + }); +}); + +describe('Lark webhook: event extraction', () => { + it('extracts event from schema 2.0 payload', () => { + const payload = { + schema: '2.0', + header: { event_type: 'im.message.receive_v1', token: 'tok' }, + event: { + sender: { sender_id: { open_id: 'ou_xxx' }, sender_type: 'user' }, + message: { message_id: 'om_xxx', chat_id: 'oc_xxx', message_type: 'text', content: '{"text":"hi"}' }, + }, + }; + const event = extractEventData(payload); + assert.ok(event); + assert.ok('sender' in event); + assert.ok('message' in event); + }); + + it('returns null for missing event', () => { + const payload = { schema: '2.0', header: {} }; + assert.strictEqual(extractEventData(payload), null); + }); +}); From 291d973bf7dcba44d0bf908e1832796b3ee988fa Mon Sep 17 00:00:00 2001 From: CodePilot Dev Date: Wed, 11 Mar 2026 10:31:53 +0800 Subject: [PATCH 2/6] feat(feishu): add webhook mode with local HTTP server for Lark international support Lark (international) does not support WebSocket long connection. This adds an alternative 'webhook' mode that starts a local HTTP server on 127.0.0.1 to receive events forwarded via Cloudflare Tunnel. All existing message processing, auth, and send logic is reused unchanged. Mode is controlled by bridge_feishu_mode setting (default: websocket). Co-Authored-By: Claude Opus 4.6 --- src/lib/bridge/adapters/feishu-adapter.ts | 144 +++++++++++++++++++--- 1 file changed, 125 insertions(+), 19 deletions(-) diff --git a/src/lib/bridge/adapters/feishu-adapter.ts b/src/lib/bridge/adapters/feishu-adapter.ts index 9f9fe6e7..1b6186d2 100644 --- a/src/lib/bridge/adapters/feishu-adapter.ts +++ b/src/lib/bridge/adapters/feishu-adapter.ts @@ -16,6 +16,7 @@ */ import crypto from 'crypto'; +import http from 'http'; import * as lark from '@larksuiteoapi/node-sdk'; import type { ChannelType, @@ -44,6 +45,20 @@ const MAX_FILE_SIZE = 20 * 1024 * 1024; /** Feishu emoji type for typing indicator (same as Openclaw). */ const TYPING_EMOJI = 'Typing'; +/** Default port for webhook mode local server. */ +const DEFAULT_WEBHOOK_PORT = 9898; + +/** Verify a Lark event's token matches the expected verification token. */ +function verifyEventToken(payload: { header?: { token?: string }; token?: string }, expected: string): boolean { + const token = payload.header?.token ?? payload.token ?? ''; + return token === expected; +} + +/** Check if a payload is a URL verification challenge. */ +function isUrlVerification(payload: Record): payload is { type: 'url_verification'; challenge: string; token: string } { + return payload.type === 'url_verification' && typeof payload.challenge === 'string'; +} + /** Shape of the SDK's im.message.receive_v1 event data. */ type FeishuMessageEventData = { sender: { @@ -96,6 +111,7 @@ export class FeishuAdapter extends BaseChannelAdapter { private lastIncomingMessageId = new Map(); /** Track active typing reaction IDs per chat for cleanup. */ private typingReactions = new Map(); + private webhookServer: http.Server | null = null; // ── Lifecycle ─────────────────────────────────────────────── @@ -114,8 +130,9 @@ export class FeishuAdapter extends BaseChannelAdapter { const domain = domainSetting === 'lark' ? lark.Domain.Lark : lark.Domain.Feishu; + const mode = getSetting('bridge_feishu_mode') || 'websocket'; - // Create REST client + // Create REST client (needed for both modes — sends replies via REST API) this.restClient = new lark.Client({ appId, appSecret, @@ -127,25 +144,27 @@ export class FeishuAdapter extends BaseChannelAdapter { this.running = true; - // Create EventDispatcher and register event handlers. - // NOTE: card.action.trigger requires HTTP webhook (not supported via WSClient). - // Openclaw uses an HTTP server for card callbacks — CodePilot is a desktop app - // without a public endpoint, so we rely on text-based /perm commands instead. - const dispatcher = new lark.EventDispatcher({}).register({ - 'im.message.receive_v1': async (data) => { - await this.handleIncomingEvent(data as FeishuMessageEventData); - }, - }); - - // Create and start WSClient - this.wsClient = new lark.WSClient({ - appId, - appSecret, - domain, - }); - this.wsClient.start({ eventDispatcher: dispatcher }); + if (mode === 'webhook') { + // Webhook mode: start local HTTP server to receive forwarded events + const port = parseInt(getSetting('bridge_feishu_webhook_port') || '', 10) || DEFAULT_WEBHOOK_PORT; + await this.startWebhookServer(port); + console.log('[feishu-adapter] Started in WEBHOOK mode on port', port, '(botOpenId:', this.botOpenId || 'unknown', ')'); + } else { + // WebSocket mode (default): use SDK's WSClient + const dispatcher = new lark.EventDispatcher({}).register({ + 'im.message.receive_v1': async (data) => { + await this.handleIncomingEvent(data as FeishuMessageEventData); + }, + }); - console.log('[feishu-adapter] Started (botOpenId:', this.botOpenId || 'unknown', ')'); + this.wsClient = new lark.WSClient({ + appId, + appSecret, + domain, + }); + this.wsClient.start({ eventDispatcher: dispatcher }); + console.log('[feishu-adapter] Started in WEBSOCKET mode (botOpenId:', this.botOpenId || 'unknown', ')'); + } } async stop(): Promise { @@ -161,6 +180,15 @@ export class FeishuAdapter extends BaseChannelAdapter { } this.wsClient = null; } + + // Close webhook HTTP server + if (this.webhookServer) { + await new Promise((resolve) => { + this.webhookServer!.close(() => resolve()); + }); + this.webhookServer = null; + } + this.restClient = null; // Reject all waiting consumers @@ -181,6 +209,84 @@ export class FeishuAdapter extends BaseChannelAdapter { return this.running; } + /** + * Start a local HTTP server to receive Lark webhook events. + * Only listens on 127.0.0.1 — traffic arrives via Cloudflare Tunnel. + */ + private startWebhookServer(port: number): Promise { + const verificationToken = getSetting('bridge_feishu_webhook_verification_token') || ''; + + return new Promise((resolve, reject) => { + this.webhookServer = http.createServer(async (req, res) => { + // Only accept POST + if (req.method !== 'POST') { + res.writeHead(405); + res.end('Method Not Allowed'); + return; + } + + // Read body + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + } + const body = Buffer.concat(chunks).toString('utf-8'); + + let payload: Record; + try { + payload = JSON.parse(body); + } catch { + res.writeHead(400); + res.end('Bad Request'); + return; + } + + // Handle URL verification challenge (for direct webhook without Worker relay) + if (isUrlVerification(payload)) { + if (verificationToken && payload.token !== verificationToken) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ challenge: payload.challenge })); + return; + } + + // Verify token if configured + if (verificationToken && !verifyEventToken(payload as { header?: { token?: string }; token?: string }, verificationToken)) { + console.warn('[feishu-adapter] Webhook token verification failed'); + res.writeHead(403); + res.end('Forbidden'); + return; + } + + // Extract event data and process + const eventData = payload.event as FeishuMessageEventData | undefined; + if (eventData) { + // Fire-and-forget — respond 200 immediately to Lark/relay + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ code: 0, msg: 'ok' })); + + // Process asynchronously (same handler as WSClient mode) + await this.handleIncomingEvent(eventData); + } else { + res.writeHead(200); + res.end('OK'); + } + }); + + this.webhookServer.listen(port, '127.0.0.1', () => { + resolve(); + }); + + this.webhookServer.on('error', (err) => { + console.error('[feishu-adapter] Webhook server error:', err.message); + reject(err); + }); + }); + } + // ── Queue ─────────────────────────────────────────────────── consumeOne(): Promise { From cdeefc98957003d396ba211620abc156911047b8 Mon Sep 17 00:00:00 2001 From: CodePilot Dev Date: Wed, 11 Mar 2026 10:31:57 +0800 Subject: [PATCH 3/6] feat(feishu): add webhook mode settings to API route Adds bridge_feishu_mode, bridge_feishu_webhook_port, and bridge_feishu_webhook_verification_token to the settings allowlist. Verification token is masked in GET responses like app_secret. Co-Authored-By: Claude Opus 4.6 --- src/app/api/settings/feishu/route.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/app/api/settings/feishu/route.ts b/src/app/api/settings/feishu/route.ts index b0254e59..87aab2db 100644 --- a/src/app/api/settings/feishu/route.ts +++ b/src/app/api/settings/feishu/route.ts @@ -11,6 +11,9 @@ const FEISHU_KEYS = [ 'bridge_feishu_app_id', 'bridge_feishu_app_secret', 'bridge_feishu_domain', + 'bridge_feishu_mode', + 'bridge_feishu_webhook_port', + 'bridge_feishu_webhook_verification_token', 'bridge_feishu_allowed_users', 'bridge_feishu_group_policy', 'bridge_feishu_group_allow_from', @@ -26,9 +29,13 @@ export async function GET() { // Mask app secret for security if (key === 'bridge_feishu_app_secret' && value.length > 8) { result[key] = '***' + value.slice(-8); - } else { - result[key] = value; + continue; } + if (key === 'bridge_feishu_webhook_verification_token' && value.length > 8) { + result[key] = '***' + value.slice(-8); + continue; + } + result[key] = value; } } @@ -57,6 +64,10 @@ export async function PUT(request: NextRequest) { continue; } + if (key === 'bridge_feishu_webhook_verification_token' && strValue.startsWith('***')) { + continue; + } + setSetting(key, strValue); } From 0505d560cb94865cfe5b14209bf366c0b1756a91 Mon Sep 17 00:00:00 2001 From: CodePilot Dev Date: Wed, 11 Mar 2026 10:33:34 +0800 Subject: [PATCH 4/6] i18n: add Feishu webhook mode translations (en + zh) Co-Authored-By: Claude Sonnet 4.6 --- src/i18n/en.ts | 13 +++++++++++++ src/i18n/zh.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/i18n/en.ts b/src/i18n/en.ts index a5d7e9cf..0c527276 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -513,6 +513,19 @@ const en = { 'feishu.domainFeishu': 'Feishu (feishu.cn)', 'feishu.domainLark': 'Lark (larksuite.com)', 'feishu.domainHint': 'Choose Feishu for China mainland or Lark for international', + 'feishu.mode': 'Connection Mode', + 'feishu.modeWebsocket': 'WebSocket (Feishu China)', + 'feishu.modeWebhook': 'Webhook (Lark International)', + 'feishu.modeHint': 'Lark international does not support WebSocket. Use Webhook mode with Cloudflare Tunnel.', + 'feishu.webhookPort': 'Local Webhook Port', + 'feishu.webhookPortDesc': 'Port for the local HTTP server that receives forwarded events', + 'feishu.verificationToken': 'Verification Token', + 'feishu.verificationTokenDesc': 'From your Lark app\'s Event Subscription page', + 'feishu.webhookSetupGuide': 'Webhook Mode Setup', + 'feishu.webhookStep1': 'Deploy the Cloudflare Worker from tools/lark-webhook-worker/', + 'feishu.webhookStep2': 'Install cloudflared and start tunnel: cloudflared tunnel --url localhost:9898', + 'feishu.webhookStep3': 'Set the Worker\'s TUNNEL_URL to your tunnel address', + 'feishu.webhookStep4': 'In Lark developer console, set webhook URL to your Worker URL and subscribe to im.message.receive_v1', 'feishu.verify': 'Test Connection', 'feishu.verified': 'Connection verified', 'feishu.verifiedAs': 'Connected as {name}', diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts index 04a5e668..a49c0e88 100644 --- a/src/i18n/zh.ts +++ b/src/i18n/zh.ts @@ -510,6 +510,19 @@ const zh: Record = { 'feishu.domainFeishu': '飞书 (feishu.cn)', 'feishu.domainLark': 'Lark (larksuite.com)', 'feishu.domainHint': '中国大陆选择飞书,海外选择 Lark', + 'feishu.mode': '连接模式', + 'feishu.modeWebsocket': 'WebSocket(飞书国内版)', + 'feishu.modeWebhook': 'Webhook(Lark 国际版)', + 'feishu.modeHint': 'Lark 国际版不支持 WebSocket 长连接,请使用 Webhook 模式配合 Cloudflare Tunnel', + 'feishu.webhookPort': '本地 Webhook 端口', + 'feishu.webhookPortDesc': '接收转发事件的本地 HTTP 服务端口', + 'feishu.verificationToken': 'Verification Token', + 'feishu.verificationTokenDesc': '在 Lark 应用的事件订阅页面获取', + 'feishu.webhookSetupGuide': 'Webhook 模式设置', + 'feishu.webhookStep1': '部署 tools/lark-webhook-worker/ 中的 Cloudflare Worker', + 'feishu.webhookStep2': '安装 cloudflared 并启动隧道:cloudflared tunnel --url localhost:9898', + 'feishu.webhookStep3': '将 Worker 的 TUNNEL_URL 设为你的隧道地址', + 'feishu.webhookStep4': '在 Lark 开发者后台设置 Webhook URL 为 Worker 地址,订阅 im.message.receive_v1 事件', 'feishu.verify': '测试连接', 'feishu.verified': '连接验证成功', 'feishu.verifiedAs': '已连接为 {name}', From 99ebeeb5b75f131636f3917a563ae31954821c60 Mon Sep 17 00:00:00 2001 From: CodePilot Dev Date: Wed, 11 Mar 2026 10:34:11 +0800 Subject: [PATCH 5/6] feat: add Cloudflare Worker relay for Lark webhook events Standalone Worker project that handles Lark URL verification and forwards events to CodePilot via Cloudflare Tunnel. Free tier: 100k requests/day, zero cost. --- tools/lark-webhook-worker/README.md | 77 +++++++++++++++++++++++++ tools/lark-webhook-worker/package.json | 15 +++++ tools/lark-webhook-worker/src/index.ts | 74 ++++++++++++++++++++++++ tools/lark-webhook-worker/tsconfig.json | 14 +++++ tools/lark-webhook-worker/wrangler.toml | 7 +++ 5 files changed, 187 insertions(+) create mode 100644 tools/lark-webhook-worker/README.md create mode 100644 tools/lark-webhook-worker/package.json create mode 100644 tools/lark-webhook-worker/src/index.ts create mode 100644 tools/lark-webhook-worker/tsconfig.json create mode 100644 tools/lark-webhook-worker/wrangler.toml diff --git a/tools/lark-webhook-worker/README.md b/tools/lark-webhook-worker/README.md new file mode 100644 index 00000000..bb75b4c9 --- /dev/null +++ b/tools/lark-webhook-worker/README.md @@ -0,0 +1,77 @@ +# Lark Webhook Worker + +Cloudflare Worker that relays Lark (international) webhook events to your local CodePilot instance via Cloudflare Tunnel. + +## Why? + +Lark international does not support WebSocket long connections for event subscriptions (only domestic Feishu does). This Worker bridges the gap by: + +1. Receiving Lark webhook events at a public HTTPS endpoint +2. Handling URL verification (challenge-response) +3. Forwarding events to your local machine via Cloudflare Tunnel + +## Setup + +### 1. Install Cloudflare Tunnel + +```bash +# macOS +brew install cloudflared + +# Or download from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/ +``` + +### 2. Start Tunnel + +```bash +# Point tunnel to CodePilot's webhook port (default 9898) +cloudflared tunnel --url localhost:9898 +``` + +Note the tunnel URL (e.g., `https://xxx-xxx-xxx.cfargotunnel.com`). + +### 3. Deploy Worker + +```bash +cd tools/lark-webhook-worker +npm install +npx wrangler deploy +``` + +### 4. Set Worker Secrets + +```bash +# Set your tunnel URL +npx wrangler secret put TUNNEL_URL +# Paste: https://xxx-xxx-xxx.cfargotunnel.com + +# Optional: set verification token for extra security +npx wrangler secret put VERIFICATION_TOKEN +# Paste: your Lark app's Verification Token +``` + +### 5. Configure Lark + +1. Go to [Lark Open Platform](https://open.larksuite.com) +2. Open your app → Event Subscriptions +3. Set Request URL to your Worker URL (e.g., `https://lark-webhook-relay.your-account.workers.dev`) +4. Add event: `im.message.receive_v1` + +### 6. Configure CodePilot + +1. Open CodePilot → Settings → Remote Bridge → Feishu +2. Set Connection Mode to **Webhook** +3. Enter your App ID, App Secret, and Verification Token +4. Enable the bridge + +## Architecture + +``` +Lark Message → Lark Platform → Worker (CF Edge) → Tunnel → localhost:9898 → CodePilot → Claude +``` + +## Cost + +- Cloudflare Workers: Free tier includes 100,000 requests/day +- Cloudflare Tunnel: Free +- Total: **$0** diff --git a/tools/lark-webhook-worker/package.json b/tools/lark-webhook-worker/package.json new file mode 100644 index 00000000..5df5d684 --- /dev/null +++ b/tools/lark-webhook-worker/package.json @@ -0,0 +1,15 @@ +{ + "name": "lark-webhook-worker", + "version": "1.0.0", + "private": true, + "description": "Cloudflare Worker relay for Lark webhook events to CodePilot local server", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20241205.0", + "typescript": "^5.7.0", + "wrangler": "^4.0.0" + } +} diff --git a/tools/lark-webhook-worker/src/index.ts b/tools/lark-webhook-worker/src/index.ts new file mode 100644 index 00000000..d26e9e9d --- /dev/null +++ b/tools/lark-webhook-worker/src/index.ts @@ -0,0 +1,74 @@ +/** + * Cloudflare Worker: Lark Webhook → CodePilot relay. + * + * Handles: + * 1. Lark URL verification (challenge-response) + * 2. Forwards event payloads to CodePilot via Cloudflare Tunnel + * + * Environment variables: + * - TUNNEL_URL: Cloudflare Tunnel URL pointing to CodePilot's local webhook server + * - VERIFICATION_TOKEN (optional): Lark app's Verification Token for signature check + */ + +interface Env { + TUNNEL_URL: string; + VERIFICATION_TOKEN?: string; +} + +export default { + async fetch(request: Request, env: Env): Promise { + // Only accept POST + if (request.method !== 'POST') { + return new Response('Method Not Allowed', { status: 405 }); + } + + // Validate TUNNEL_URL is configured + if (!env.TUNNEL_URL) { + return new Response('TUNNEL_URL not configured', { status: 500 }); + } + + let payload: Record; + try { + payload = await request.json() as Record; + } catch { + return new Response('Bad Request', { status: 400 }); + } + + // Handle Lark URL verification challenge + if (payload.type === 'url_verification' && typeof payload.challenge === 'string') { + // Optionally verify token + if (env.VERIFICATION_TOKEN && payload.token !== env.VERIFICATION_TOKEN) { + return new Response('Forbidden', { status: 403 }); + } + return Response.json({ challenge: payload.challenge }); + } + + // Optionally verify event token + if (env.VERIFICATION_TOKEN) { + const header = payload.header as { token?: string } | undefined; + const token = header?.token ?? (payload as { token?: string }).token; + if (token !== env.VERIFICATION_TOKEN) { + return new Response('Forbidden', { status: 403 }); + } + } + + // Forward event to CodePilot via Cloudflare Tunnel + try { + const tunnelUrl = env.TUNNEL_URL.replace(/\/$/, ''); + const resp = await fetch(tunnelUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!resp.ok) { + console.error(`Tunnel relay failed: ${resp.status} ${resp.statusText}`); + } + } catch (err) { + console.error('Failed to forward to tunnel:', err); + } + + // Always return 200 to Lark to acknowledge receipt + return Response.json({ code: 0, msg: 'ok' }); + }, +}; diff --git a/tools/lark-webhook-worker/tsconfig.json b/tools/lark-webhook-worker/tsconfig.json new file mode 100644 index 00000000..51c1b446 --- /dev/null +++ b/tools/lark-webhook-worker/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src"] +} diff --git a/tools/lark-webhook-worker/wrangler.toml b/tools/lark-webhook-worker/wrangler.toml new file mode 100644 index 00000000..1e818833 --- /dev/null +++ b/tools/lark-webhook-worker/wrangler.toml @@ -0,0 +1,7 @@ +name = "lark-webhook-relay" +main = "src/index.ts" +compatibility_date = "2024-12-01" + +[vars] +# Set via: npx wrangler secret put TUNNEL_URL +# TUNNEL_URL = "https://your-tunnel-id.cfargotunnel.com" From 5646be4ceebb4492695c2cc919c5bc9f935fde29 Mon Sep 17 00:00:00 2001 From: CodePilot Dev Date: Wed, 11 Mar 2026 10:34:20 +0800 Subject: [PATCH 6/6] feat(feishu): add webhook mode selector and settings UI Co-Authored-By: Claude Sonnet 4.6 --- src/components/bridge/FeishuBridgeSection.tsx | 104 ++++++++++++++++-- 1 file changed, 97 insertions(+), 7 deletions(-) diff --git a/src/components/bridge/FeishuBridgeSection.tsx b/src/components/bridge/FeishuBridgeSection.tsx index 8e623a06..b83e887e 100644 --- a/src/components/bridge/FeishuBridgeSection.tsx +++ b/src/components/bridge/FeishuBridgeSection.tsx @@ -23,6 +23,9 @@ interface FeishuBridgeSettings { bridge_feishu_app_id: string; bridge_feishu_app_secret: string; bridge_feishu_domain: string; + bridge_feishu_mode: string; + bridge_feishu_webhook_port: string; + bridge_feishu_webhook_verification_token: string; bridge_feishu_allowed_users: string; bridge_feishu_group_policy: string; bridge_feishu_group_allow_from: string; @@ -33,6 +36,9 @@ const DEFAULT_SETTINGS: FeishuBridgeSettings = { bridge_feishu_app_id: "", bridge_feishu_app_secret: "", bridge_feishu_domain: "feishu", + bridge_feishu_mode: "websocket", + bridge_feishu_webhook_port: "9898", + bridge_feishu_webhook_verification_token: "", bridge_feishu_allowed_users: "", bridge_feishu_group_policy: "open", bridge_feishu_group_allow_from: "", @@ -45,6 +51,9 @@ export function FeishuBridgeSection() { const [appId, setAppId] = useState(""); const [appSecret, setAppSecret] = useState(""); const [domain, setDomain] = useState("feishu"); + const [mode, setMode] = useState("websocket"); + const [webhookPort, setWebhookPort] = useState("9898"); + const [verificationToken, setVerificationToken] = useState(""); const [allowedUsers, setAllowedUsers] = useState(""); const [groupPolicy, setGroupPolicy] = useState("open"); const [groupAllowFrom, setGroupAllowFrom] = useState(""); @@ -67,6 +76,9 @@ export function FeishuBridgeSection() { setAppId(s.bridge_feishu_app_id); setAppSecret(s.bridge_feishu_app_secret); setDomain(s.bridge_feishu_domain || "feishu"); + setMode(s.bridge_feishu_mode || "websocket"); + setWebhookPort(s.bridge_feishu_webhook_port || "9898"); + setVerificationToken(s.bridge_feishu_webhook_verification_token || ""); setAllowedUsers(s.bridge_feishu_allowed_users); setGroupPolicy(s.bridge_feishu_group_policy || "open"); setGroupAllowFrom(s.bridge_feishu_group_allow_from); @@ -103,10 +115,15 @@ export function FeishuBridgeSection() { const updates: Partial = { bridge_feishu_app_id: appId, bridge_feishu_domain: domain, + bridge_feishu_mode: mode, + bridge_feishu_webhook_port: webhookPort, }; if (appSecret && !appSecret.startsWith("***")) { updates.bridge_feishu_app_secret = appSecret; } + if (verificationToken && !verificationToken.startsWith("***")) { + updates.bridge_feishu_webhook_verification_token = verificationToken; + } saveSettings(updates); }; @@ -220,6 +237,68 @@ export function FeishuBridgeSection() { {t("feishu.domainHint")}

+ + {/* Connection Mode */} +
+ + +

+ {t("feishu.modeHint")} +

+
+ + {mode === "webhook" && ( + <> +
+ + setWebhookPort(e.target.value)} + placeholder="9898" + className="font-mono text-sm w-32" + /> +

+ {t("feishu.webhookPortDesc")} +

+
+
+ + setVerificationToken(e.target.value)} + placeholder="xxxxxxxxxxxxxxxxxxxxxxxx" + className="font-mono text-sm" + /> +

+ {t("feishu.verificationTokenDesc")} +

+
+ + )}
@@ -356,15 +435,26 @@ export function FeishuBridgeSection() { {/* Setup Guide */}

- {t("feishu.setupGuide")} + {mode === "webhook" ? t("feishu.webhookSetupGuide") : t("feishu.setupGuide")}

    -
  1. {t("feishu.step1")}
  2. -
  3. {t("feishu.step2")}
  4. -
  5. {t("feishu.step3")}
  6. -
  7. {t("feishu.step4")}
  8. -
  9. {t("feishu.step5")}
  10. -
  11. {t("feishu.step6")}
  12. + {mode === "webhook" ? ( + <> +
  13. {t("feishu.webhookStep1")}
  14. +
  15. {t("feishu.webhookStep2")}
  16. +
  17. {t("feishu.webhookStep3")}
  18. +
  19. {t("feishu.webhookStep4")}
  20. + + ) : ( + <> +
  21. {t("feishu.step1")}
  22. +
  23. {t("feishu.step2")}
  24. +
  25. {t("feishu.step3")}
  26. +
  27. {t("feishu.step4")}
  28. +
  29. {t("feishu.step5")}
  30. +
  31. {t("feishu.step6")}
  32. + + )}