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")}
- - {t("feishu.step1")}
- - {t("feishu.step2")}
- - {t("feishu.step3")}
- - {t("feishu.step4")}
- - {t("feishu.step5")}
- - {t("feishu.step6")}
+ {mode === "webhook" ? (
+ <>
+ - {t("feishu.webhookStep1")}
+ - {t("feishu.webhookStep2")}
+ - {t("feishu.webhookStep3")}
+ - {t("feishu.webhookStep4")}
+ >
+ ) : (
+ <>
+ - {t("feishu.step1")}
+ - {t("feishu.step2")}
+ - {t("feishu.step3")}
+ - {t("feishu.step4")}
+ - {t("feishu.step5")}
+ - {t("feishu.step6")}
+ >
+ )}