Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/__tests__/unit/feishu-webhook.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Record<string, unknown> | null {
if (payload.event && typeof payload.event === 'object') {
return payload.event as Record<string, unknown>;
}
return null;
}

/** Check if a payload is a URL verification challenge. */
function isUrlVerification(payload: Record<string, unknown>): 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);
});
});
15 changes: 13 additions & 2 deletions src/app/api/settings/feishu/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -57,6 +64,10 @@ export async function PUT(request: NextRequest) {
continue;
}

if (key === 'bridge_feishu_webhook_verification_token' && strValue.startsWith('***')) {
continue;
}

setSetting(key, strValue);
}

Expand Down
104 changes: 97 additions & 7 deletions src/components/bridge/FeishuBridgeSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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: "",
Expand All @@ -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("");
Expand All @@ -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);
Expand Down Expand Up @@ -103,10 +115,15 @@ export function FeishuBridgeSection() {
const updates: Partial<FeishuBridgeSettings> = {
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);
};

Expand Down Expand Up @@ -220,6 +237,68 @@ export function FeishuBridgeSection() {
{t("feishu.domainHint")}
</p>
</div>

{/* Connection Mode */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.mode")}
</label>
<Select value={mode} onValueChange={(v) => {
setMode(v);
if (v === 'webhook' && domain === 'feishu') {
setDomain('lark');
}
}}>
<SelectTrigger className="w-full text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="websocket">
{t("feishu.modeWebsocket")}
</SelectItem>
<SelectItem value="webhook">
{t("feishu.modeWebhook")}
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground mt-1">
{t("feishu.modeHint")}
</p>
</div>

{mode === "webhook" && (
<>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.webhookPort")}
</label>
<Input
value={webhookPort}
onChange={(e) => setWebhookPort(e.target.value)}
placeholder="9898"
className="font-mono text-sm w-32"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("feishu.webhookPortDesc")}
</p>
</div>
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
{t("feishu.verificationToken")}
</label>
<Input
type="password"
value={verificationToken}
onChange={(e) => setVerificationToken(e.target.value)}
placeholder="xxxxxxxxxxxxxxxxxxxxxxxx"
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground mt-1">
{t("feishu.verificationTokenDesc")}
</p>
</div>
</>
)}
</div>

<div className="flex items-center gap-2">
Expand Down Expand Up @@ -356,15 +435,26 @@ export function FeishuBridgeSection() {
{/* Setup Guide */}
<div className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm">
<h2 className="text-sm font-medium mb-2">
{t("feishu.setupGuide")}
{mode === "webhook" ? t("feishu.webhookSetupGuide") : t("feishu.setupGuide")}
</h2>
<ol className="text-xs text-muted-foreground space-y-1.5 list-decimal pl-4">
<li>{t("feishu.step1")}</li>
<li>{t("feishu.step2")}</li>
<li>{t("feishu.step3")}</li>
<li>{t("feishu.step4")}</li>
<li>{t("feishu.step5")}</li>
<li>{t("feishu.step6")}</li>
{mode === "webhook" ? (
<>
<li>{t("feishu.webhookStep1")}</li>
<li>{t("feishu.webhookStep2")}</li>
<li>{t("feishu.webhookStep3")}</li>
<li>{t("feishu.webhookStep4")}</li>
</>
) : (
<>
<li>{t("feishu.step1")}</li>
<li>{t("feishu.step2")}</li>
<li>{t("feishu.step3")}</li>
<li>{t("feishu.step4")}</li>
<li>{t("feishu.step5")}</li>
<li>{t("feishu.step6")}</li>
</>
)}
</ol>
</div>
</div>
Expand Down
13 changes: 13 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
Expand Down
13 changes: 13 additions & 0 deletions src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,19 @@ const zh: Record<TranslationKey, string> = {
'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}',
Expand Down
Loading