diff --git a/README.md b/README.md index 753f69c..b6c39c6 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Self-hosted personal AI assistant with Telegram and Terminal interfaces. - **Multi-Frontend**: Telegram bot + Terminal UI (minimal & fullscreen modes) + Web UI dashboard - **Multi-Model**: Anthropic Claude, OpenAI, GitHub Copilot, Gemini, Bedrock, Zhipu AI (GLM), xAI (Grok), Ollama, llama.cpp with intelligent routing -- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, Mattermost, Microsoft Teams, Google Chat, LINE, and iMessage (BlueBubbles) with unified adapter interface +- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, Mattermost, Microsoft Teams, Google Chat, LINE, Feishu/Lark, and iMessage (BlueBubbles) with unified adapter interface - **Web Dashboard**: SPA control panel with health monitoring, chat, session browser, usage stats, and settings editor - **Model Switching**: Switch between cloud/local models on demand - **Session Persistence**: SQLite-backed conversation history @@ -206,6 +206,19 @@ line: # LINE webhook endpoint should point to: # POST https:///line/events +# Optional: Feishu / Lark +feishu: + app_id: "${FEISHU_APP_ID}" + app_secret: "${FEISHU_APP_SECRET}" + webhook_token: "${FEISHU_WEBHOOK_TOKEN}" + allowed_chat_ids: [] + require_mention: true + mention_name: "flynn" + endpoint: "https://open.feishu.cn" + +# Feishu webhook endpoint should point to: +# POST https:///feishu/events + models: default: provider: anthropic diff --git a/config/default.yaml b/config/default.yaml index 3b2b890..4ee0a74 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -62,6 +62,16 @@ telegram: # require_mention: true # mention_name: flynn +# Optional: Feishu / Lark +# feishu: +# app_id: ${FEISHU_APP_ID} +# app_secret: ${FEISHU_APP_SECRET} +# webhook_token: ${FEISHU_WEBHOOK_TOKEN} +# allowed_chat_ids: [] # Empty = allow all chats +# require_mention: true +# mention_name: flynn +# endpoint: https://open.feishu.cn + server: # Tailscale Serve config (optional). Enable `serve: true` to expose the # gateway to your tailnet via `tailscale serve`. diff --git a/docs/architecture/CONTRIBUTOR_MAP.md b/docs/architecture/CONTRIBUTOR_MAP.md index 1bfed3c..a7c304e 100644 --- a/docs/architecture/CONTRIBUTOR_MAP.md +++ b/docs/architecture/CONTRIBUTOR_MAP.md @@ -20,7 +20,7 @@ src/ hooks/ Confirm/log/silent policy + autonomy resolution sandbox/ Docker sandbox manager + sandboxed tool wrappers models/ Provider clients + model router + retry/cost/capabilities - channels/ Chat adapters + pairing gate (Telegram/Discord/Slack/WhatsApp/Matrix/Signal/Mattermost/etc.) + channels/ Chat adapters + pairing gate (Telegram/Discord/Slack/WhatsApp/Matrix/Signal/Mattermost/LINE/Feishu/etc.) gateway/ WebSocket JSON-RPC server + web UI + handlers memory/ Hybrid search + embeddings + persistence session/ SQLite store + session mgmt diff --git a/docs/plans/2026-02-16-feishu-channel-adapter-checklist.md b/docs/plans/2026-02-16-feishu-channel-adapter-checklist.md new file mode 100644 index 0000000..e558d23 --- /dev/null +++ b/docs/plans/2026-02-16-feishu-channel-adapter-checklist.md @@ -0,0 +1,55 @@ +# Feishu Channel Adapter Checklist + +**Date:** 2026-02-16 +**Scope:** Implement Feishu/Lark adapter as the second item in the LINE/Feishu/Zalo channel gap set. + +## Goal + +Add a Feishu channel adapter with webhook ingress, outbound send path, mention/allowlist gating, and runtime wiring through daemon + gateway. + +## Implemented + +- Added Feishu adapter: + - `src/channels/feishu/adapter.ts` + - `src/channels/feishu/index.ts` +- Inbound webhook handling: + - endpoint: `POST /feishu/events` + - supports Feishu URL verification challenge flow. + - validates `header.token` when `webhook_token` is configured. +- Message normalization: + - handles `im.message.receive_v1` text messages. + - parses `message.content` JSON `{ "text": "..." }`. + - emits normalized `InboundMessage` with `metadata.replyPeerId`. +- Outbound messaging: + - obtains tenant access token via internal app credentials. + - sends text messages via Feishu IM v1 messages API. +- Gating: + - optional chat allowlist (`allowed_chat_ids`). + - mention policy (`require_mention`, `mention_name`) with DM bypass. +- Runtime integration: + - config schema + defaults for `feishu`. + - daemon registration and gateway handler binding. + - gateway route wiring + services discovery visibility. + +## Tests + +- `src/channels/feishu/adapter.test.ts` + - name/status + - outbound token + send path + - url verification handling + - inbound event normalization + - webhook token enforcement +- `src/daemon/channels.test.ts` + - adapter registration + gateway `setFeishuHandler` binding +- `src/config/schema.test.ts` + - feishu config parsing/defaults +- `src/gateway/handlers/services.test.ts` + - `feishu` service presence + +## Validation Run + +```bash +pnpm test:run src/channels/feishu/adapter.test.ts src/daemon/channels.test.ts src/config/schema.test.ts src/gateway/handlers/services.test.ts +pnpm typecheck +pnpm build +``` diff --git a/docs/plans/state.json b/docs/plans/state.json index ccfea79..a3db3d7 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -620,6 +620,33 @@ ], "test_status": "pnpm test:run src/channels/line/adapter.test.ts src/daemon/channels.test.ts src/config/schema.test.ts src/gateway/handlers/services.test.ts + pnpm typecheck + pnpm build passing" }, + "feishu-channel-adapter": { + "file": "2026-02-16-feishu-channel-adapter-checklist.md", + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Implemented Feishu/Lark channel adapter with webhook ingress (`/feishu/events`), URL verification challenge handling, webhook token validation, mention/allowlist gating, outbound IM send path via tenant access token, daemon/gateway wiring, and services visibility.", + "files_created": [ + "docs/plans/2026-02-16-feishu-channel-adapter-checklist.md", + "src/channels/feishu/adapter.ts", + "src/channels/feishu/adapter.test.ts", + "src/channels/feishu/index.ts" + ], + "files_modified": [ + "src/channels/index.ts", + "src/daemon/channels.ts", + "src/daemon/channels.test.ts", + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/gateway/server.ts", + "src/gateway/handlers/services.ts", + "src/gateway/handlers/services.test.ts", + "README.md", + "config/default.yaml", + "docs/architecture/CONTRIBUTOR_MAP.md" + ], + "test_status": "pnpm test:run src/channels/feishu/adapter.test.ts src/daemon/channels.test.ts src/config/schema.test.ts src/gateway/handlers/services.test.ts + pnpm typecheck + pnpm build passing" + }, "qmd-backend": { "file": "2026-02-16-qmd-backend-checklist.md", "status": "completed", @@ -3181,7 +3208,7 @@ } }, "overall_progress": { - "total_test_count": 1802, + "total_test_count": 1808, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -3196,12 +3223,12 @@ "tier2_completion": "4/4 (100%) — inbound webhooks, vector memory search, Dockerfile, heartbeat monitor", "tier3_completion": "5/5 (100%) — lane queue, credential redaction, web UI token dashboard, xAI (Grok) provider, Voyage AI embeddings", "tier4_completion": "4/4 (100%) — gateway lock, shell completion, Tailscale Serve/Funnel, DM pairing codes", - "feature_gap_scorecard": "124/128 match (97%), 0 partial (0%), 4 missing (3%)", + "feature_gap_scorecard": "125/128 match (98%), 0 partial (0%), 3 missing (2%)", "operator_dx_milestone": "Phase 3 (Live Ops Dashboard): 2/2 plans complete — milestone done", "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "OpenClaw gap: Feishu channel adapter (open next scoped implementation checklist)" + "next_up": "OpenClaw gap: Zalo channel adapter (open next scoped implementation checklist)" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/channels/feishu/adapter.test.ts b/src/channels/feishu/adapter.test.ts new file mode 100644 index 0000000..6a85a8a --- /dev/null +++ b/src/channels/feishu/adapter.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { IncomingMessage, ServerResponse } from 'http'; + +import { FeishuAdapter } from './adapter.js'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => JSON.stringify(body), + } as Response; +} + +function mockReq(body: string): IncomingMessage { + const req = { + headers: {}, + on(event: string, handler: (...args: unknown[]) => void) { + if (event === 'data') { + handler(Buffer.from(body, 'utf8')); + } + if (event === 'end') { + handler(); + } + return this; + }, + off: () => req, + destroy: () => undefined, + } as unknown as IncomingMessage; + return req; +} + +function mockRes() { + const state = { statusCode: 0, body: '' }; + const res = { + writeHead: (code: number) => { + state.statusCode = code; + }, + end: (chunk?: string) => { + state.body = chunk ?? ''; + }, + } as unknown as ServerResponse; + return { res, state }; +} + +describe('FeishuAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockReset(); + }); + + it('has name feishu and starts disconnected', () => { + const adapter = new FeishuAdapter({ + appId: 'app-id', + appSecret: 'app-secret', + }); + expect(adapter.name).toBe('feishu'); + expect(adapter.status).toBe('disconnected'); + }); + + it('send fetches token and posts message', async () => { + const adapter = new FeishuAdapter({ + appId: 'app-id', + appSecret: 'app-secret', + }); + await adapter.connect(); + + mockFetch.mockImplementation(async (url: string) => { + if (url.includes('/tenant_access_token/internal')) { + return jsonResponse({ code: 0, tenant_access_token: 'tenant-token', expire: 7200 }); + } + if (url.includes('/im/v1/messages?receive_id_type=chat_id')) { + return jsonResponse({ code: 0, msg: 'ok' }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); + + await adapter.send('oc_xxx_chat', { text: 'hello feishu' }); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('handleRequest returns challenge for url verification', async () => { + const adapter = new FeishuAdapter({ + appId: 'app-id', + appSecret: 'app-secret', + webhookToken: 'verify-token', + }); + const body = JSON.stringify({ + type: 'url_verification', + challenge: 'challenge-token', + }); + const req = mockReq(body); + const { res, state } = mockRes(); + + await adapter.handleRequest(req, res); + expect(state.statusCode).toBe(200); + expect(state.body).toContain('challenge-token'); + }); + + it('handleEvent forwards text message with reply metadata', async () => { + const adapter = new FeishuAdapter({ + appId: 'app-id', + appSecret: 'app-secret', + webhookToken: 'verify-token', + requireMention: false, + }); + const inbound: Array<{ channel: string; senderId: string; text: string }> = []; + adapter.onMessage((msg) => inbound.push({ channel: msg.channel, senderId: msg.senderId, text: msg.text })); + + await adapter.handleEvent({ + header: { event_type: 'im.message.receive_v1', token: 'verify-token' }, + event: { + sender: { sender_id: { open_id: 'ou_123' } }, + message: { + message_id: 'om_1', + chat_id: 'oc_123', + chat_type: 'group', + message_type: 'text', + content: JSON.stringify({ text: 'ping' }), + }, + }, + }); + + expect(inbound).toEqual([{ channel: 'feishu', senderId: 'ou_123', text: 'ping' }]); + }); + + it('enforces webhook token on event ingress', async () => { + const adapter = new FeishuAdapter({ + appId: 'app-id', + appSecret: 'app-secret', + webhookToken: 'verify-token', + }); + + const body = JSON.stringify({ + header: { event_type: 'im.message.receive_v1', token: 'wrong-token' }, + event: { + sender: { sender_id: { open_id: 'ou_123' } }, + message: { + message_id: 'om_1', + chat_id: 'oc_123', + chat_type: 'group', + message_type: 'text', + content: JSON.stringify({ text: 'ping' }), + }, + }, + }); + const req = mockReq(body); + const { res, state } = mockRes(); + + await adapter.handleRequest(req, res); + expect(state.statusCode).toBe(401); + }); +}); diff --git a/src/channels/feishu/adapter.ts b/src/channels/feishu/adapter.ts new file mode 100644 index 0000000..5ba6fea --- /dev/null +++ b/src/channels/feishu/adapter.ts @@ -0,0 +1,269 @@ +import type { IncomingMessage, ServerResponse } from 'http'; + +import type { + InboundMessage, + OutboundMessage, + ChannelAdapter, + ChannelStatus, +} from '../types.js'; +import { shouldIgnoreForMissingMention, splitMessage } from '../utils.js'; +import { readRequestBody } from '../../utils/httpBody.js'; + +export interface FeishuAdapterConfig { + appId: string; + appSecret: string; + webhookToken?: string; + allowedChatIds?: string[]; + requireMention?: boolean; + mentionName?: string; + endpoint?: string; +} + +interface FeishuTenantTokenResponse { + code?: number; + tenant_access_token?: string; + expire?: number; + msg?: string; +} + +interface FeishuMessageSendResponse { + code?: number; + msg?: string; +} + +interface FeishuEventEnvelope { + type?: string; + challenge?: string; + header?: { + event_type?: string; + token?: string; + }; + event?: { + sender?: { + sender_id?: { + open_id?: string; + user_id?: string; + }; + sender_type?: string; + }; + message?: { + message_id?: string; + chat_id?: string; + chat_type?: string; + message_type?: string; + content?: string; + }; + }; +} + +const MAX_MESSAGE_LENGTH = 3500; + +export class FeishuAdapter implements ChannelAdapter { + readonly name = 'feishu'; + private _status: ChannelStatus = 'disconnected'; + private messageHandler?: (msg: InboundMessage) => void; + private tokenCache: { token: string; expiresAt: number } | null = null; + + constructor(private readonly config: FeishuAdapterConfig) {} + + get status(): ChannelStatus { + return this._status; + } + + onMessage(handler: (msg: InboundMessage) => void): void { + this.messageHandler = handler; + } + + async connect(): Promise { + this._status = 'connected'; + } + + async disconnect(): Promise { + this._status = 'disconnected'; + } + + async send(peerId: string, message: OutboundMessage): Promise { + if (this._status !== 'connected') { + throw new Error('Feishu adapter not connected'); + } + + const text = message.text.trim(); + if (!text) { + return; + } + + const chunks = text.length > MAX_MESSAGE_LENGTH ? splitMessage(text, MAX_MESSAGE_LENGTH) : [text]; + for (const chunk of chunks) { + await this.sendMessage(peerId, chunk); + } + } + + async handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + let body = ''; + try { + body = await readRequestBody(req, { maxBytes: 1_048_576 }); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid request body' })); + return; + } + + let payload: FeishuEventEnvelope; + try { + payload = JSON.parse(body) as FeishuEventEnvelope; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + + // URL verification flow + if (payload.type === 'url_verification' && payload.challenge) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ challenge: payload.challenge })); + return; + } + + if (this.config.webhookToken && payload.header?.token !== this.config.webhookToken) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid webhook token' })); + return; + } + + await this.handleEvent(payload); + res.writeHead(202, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ accepted: true })); + } + + async handleEvent(payload: FeishuEventEnvelope): Promise { + if (!this.messageHandler) { + return; + } + if (payload.header?.event_type !== 'im.message.receive_v1') { + return; + } + + const message = payload.event?.message; + if (!message || message.message_type !== 'text') { + return; + } + const chatId = message.chat_id?.trim(); + if (!chatId) { + return; + } + if (this.config.allowedChatIds && this.config.allowedChatIds.length > 0) { + if (!this.config.allowedChatIds.includes(chatId)) { + return; + } + } + + const senderId = payload.event?.sender?.sender_id?.open_id?.trim() + || payload.event?.sender?.sender_id?.user_id?.trim(); + if (!senderId) { + return; + } + + const text = parseFeishuText(message.content); + if (!text) { + return; + } + + const mentionName = this.config.mentionName ?? 'flynn'; + const mentionRegex = new RegExp(`(?:^|\\s)@?${escapeRegex(mentionName)}(?:\\b|:)`, 'i'); + const isDm = (message.chat_type ?? '').toLowerCase() === 'p2p'; + const mentionsBot = mentionRegex.test(text); + if (shouldIgnoreForMissingMention({ + requireMention: this.config.requireMention, + defaultRequireMention: true, + mentionsBot: isDm || mentionsBot, + })) { + return; + } + + const cleaned = text.replace(new RegExp(`^\\s*@?${escapeRegex(mentionName)}(?:\\b|:)\\s*`, 'i'), '').trim(); + if (!cleaned) { + return; + } + + this.messageHandler({ + id: message.message_id ?? `feishu-${Date.now()}`, + channel: 'feishu', + senderId, + text: cleaned, + timestamp: Date.now(), + metadata: { + chatId, + chatType: message.chat_type, + replyPeerId: chatId, + }, + }); + } + + private async sendMessage(chatId: string, text: string): Promise { + const token = await this.getTenantAccessToken(); + const endpoint = `${this.baseEndpoint()}/open-apis/im/v1/messages?receive_id_type=chat_id`; + const response = await fetch(endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json; charset=utf-8', + }, + body: JSON.stringify({ + receive_id: chatId, + msg_type: 'text', + content: JSON.stringify({ text }), + }), + }); + const payload = await response.json().catch(() => ({})) as FeishuMessageSendResponse; + if (!response.ok || payload.code !== 0) { + throw new Error(`Feishu send failed (${response.status}): ${payload.msg ?? 'unknown error'}`); + } + } + + private async getTenantAccessToken(): Promise { + const now = Date.now(); + if (this.tokenCache && this.tokenCache.expiresAt > now + 30_000) { + return this.tokenCache.token; + } + + const endpoint = `${this.baseEndpoint()}/open-apis/auth/v3/tenant_access_token/internal`; + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify({ + app_id: this.config.appId, + app_secret: this.config.appSecret, + }), + }); + const payload = await response.json().catch(() => ({})) as FeishuTenantTokenResponse; + if (!response.ok || payload.code !== 0 || !payload.tenant_access_token) { + throw new Error(`Feishu auth failed (${response.status}): ${payload.msg ?? 'missing token'}`); + } + const expireSecs = typeof payload.expire === 'number' ? payload.expire : 3600; + this.tokenCache = { + token: payload.tenant_access_token, + expiresAt: now + expireSecs * 1000, + }; + return payload.tenant_access_token; + } + + private baseEndpoint(): string { + return (this.config.endpoint ?? 'https://open.feishu.cn').replace(/\/+$/, ''); + } +} + +function parseFeishuText(content: string | undefined): string { + if (!content) { + return ''; + } + try { + const parsed = JSON.parse(content) as { text?: string }; + return (parsed.text ?? '').trim(); + } catch { + return ''; + } +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/channels/feishu/index.ts b/src/channels/feishu/index.ts new file mode 100644 index 0000000..f59d047 --- /dev/null +++ b/src/channels/feishu/index.ts @@ -0,0 +1 @@ +export { FeishuAdapter, type FeishuAdapterConfig } from './adapter.js'; diff --git a/src/channels/index.ts b/src/channels/index.ts index f670e28..51c9c9e 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -22,4 +22,5 @@ export { TeamsAdapter, type TeamsAdapterConfig } from './teams/index.js'; export { GoogleChatAdapter, type GoogleChatAdapterConfig } from './googleChat/index.js'; export { BlueBubblesAdapter, type BlueBubblesAdapterConfig } from './bluebubbles/index.js'; export { LineAdapter, type LineAdapterConfig } from './line/index.js'; +export { FeishuAdapter, type FeishuAdapterConfig } from './feishu/index.js'; export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js'; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 88111ef..cf2f670 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -608,6 +608,33 @@ describe('configSchema — line', () => { }); }); +describe('configSchema — feishu', () => { + const minimalConfig = { + telegram: { bot_token: 'test', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + }; + + it('accepts feishu config and defaults optional fields', () => { + const result = configSchema.parse({ + ...minimalConfig, + feishu: { + app_id: 'cli_a1b2c3', + app_secret: 'secret', + }, + }); + + expect(result.feishu).toBeDefined(); + if (!result.feishu) { + throw new Error('Expected feishu config'); + } + expect(result.feishu.app_id).toBe('cli_a1b2c3'); + expect(result.feishu.app_secret).toBe('secret'); + expect(result.feishu.allowed_chat_ids).toEqual([]); + expect(result.feishu.require_mention).toBe(true); + expect(result.feishu.mention_name).toBe('flynn'); + }); +}); + describe('configSchema — whatsapp', () => { const minimalConfig = { telegram: { bot_token: 'test', allowed_chat_ids: [1] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index ee6711e..7b5939c 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -511,6 +511,16 @@ const lineSchema = z.object({ mention_name: z.string().default('flynn'), }).optional(); +const feishuSchema = z.object({ + app_id: z.string().min(1, 'Feishu app_id is required'), + app_secret: z.string().min(1, 'Feishu app_secret is required'), + webhook_token: z.string().optional(), + allowed_chat_ids: z.array(z.string()).default([]), + require_mention: z.boolean().default(true), + mention_name: z.string().default('flynn'), + endpoint: z.string().url('Feishu endpoint must be a valid URL').optional(), +}).optional(); + const browserSchema = z.object({ enabled: z.boolean().default(false), executable_path: z.string().optional(), @@ -691,6 +701,7 @@ export const configSchema = z.object({ google_chat: googleChatSchema, bluebubbles: bluebubblesSchema, line: lineSchema, + feishu: feishuSchema, server: serverSchema.default({}), models: modelsSchema, backends: backendsSchema.default({}), diff --git a/src/daemon/channels.test.ts b/src/daemon/channels.test.ts index af377e6..35771fb 100644 --- a/src/daemon/channels.test.ts +++ b/src/daemon/channels.test.ts @@ -24,6 +24,7 @@ describe('registerChannels', () => { setGoogleChatHandler: vi.fn(), setBlueBubblesHandler: vi.fn(), setLineHandler: vi.fn(), + setFeishuHandler: vi.fn(), }; registerChannels({ @@ -57,6 +58,7 @@ describe('registerChannels', () => { setGoogleChatHandler: vi.fn(), setBlueBubblesHandler: vi.fn(), setLineHandler: vi.fn(), + setFeishuHandler: vi.fn(), }; registerChannels({ @@ -70,4 +72,38 @@ describe('registerChannels', () => { expect(names).toContain('line'); expect(gateway.setLineHandler).toHaveBeenCalledTimes(1); }); + + it('registers Feishu adapter when configured', () => { + const config = configSchema.parse({ + telegram: { bot_token: 'test-token', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + feishu: { + app_id: 'cli_a1b2c3', + app_secret: 'secret', + allowed_chat_ids: ['oc_123'], + }, + }); + + const channelRegistry = new ChannelRegistry(); + const gateway = { + setWebhookHandler: vi.fn(), + setGmailHandler: vi.fn(), + setTeamsHandler: vi.fn(), + setGoogleChatHandler: vi.fn(), + setBlueBubblesHandler: vi.fn(), + setLineHandler: vi.fn(), + setFeishuHandler: vi.fn(), + }; + + registerChannels({ + config, + channelRegistry, + hookEngine: new HookEngine(config.hooks), + gateway: gateway as unknown as Parameters[0]['gateway'], + }); + + const names = channelRegistry.list().map((adapter) => adapter.name); + expect(names).toContain('feishu'); + expect(gateway.setFeishuHandler).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/daemon/channels.ts b/src/daemon/channels.ts index 29887ed..94a6bbe 100644 --- a/src/daemon/channels.ts +++ b/src/daemon/channels.ts @@ -1,6 +1,6 @@ import type { Config } from '../config/index.js'; import type { HookEngine } from '../hooks/index.js'; -import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, MattermostAdapter, TeamsAdapter, GoogleChatAdapter, BlueBubblesAdapter, LineAdapter, PairingManager } from '../channels/index.js'; +import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, MattermostAdapter, TeamsAdapter, GoogleChatAdapter, BlueBubblesAdapter, LineAdapter, FeishuAdapter, PairingManager } from '../channels/index.js'; import { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js'; import type { GatewayServer } from '../gateway/index.js'; @@ -167,6 +167,21 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult { gateway.setLineHandler(lineAdapter); } + // Register Feishu adapter (if configured) + if (config.feishu) { + const feishuAdapter = new FeishuAdapter({ + appId: config.feishu.app_id, + appSecret: config.feishu.app_secret, + webhookToken: config.feishu.webhook_token, + allowedChatIds: config.feishu.allowed_chat_ids.length > 0 ? config.feishu.allowed_chat_ids : undefined, + requireMention: config.feishu.require_mention, + mentionName: config.feishu.mention_name, + endpoint: config.feishu.endpoint, + }); + channelRegistry.register(feishuAdapter); + gateway.setFeishuHandler(feishuAdapter); + } + // Register WebChat adapter (wraps the gateway) const webChatAdapter = new WebChatAdapter({ gateway }); channelRegistry.register(webChatAdapter); diff --git a/src/gateway/handlers/services.test.ts b/src/gateway/handlers/services.test.ts index f66ff66..258b62e 100644 --- a/src/gateway/handlers/services.test.ts +++ b/src/gateway/handlers/services.test.ts @@ -36,6 +36,7 @@ function makeBaseConfig(): Config { google_chat: undefined, bluebubbles: undefined, line: undefined, + feishu: undefined, } as unknown as Config; } @@ -55,6 +56,7 @@ describe('discoverServices', () => { expect.objectContaining({ name: 'google_chat', status: 'not_configured' }), expect.objectContaining({ name: 'bluebubbles', status: 'not_configured' }), expect.objectContaining({ name: 'line', status: 'not_configured' }), + expect.objectContaining({ name: 'feishu', status: 'not_configured' }), expect.objectContaining({ name: 'cron', status: 'not_configured' }), expect.objectContaining({ name: 'mcp', status: 'not_configured' }), expect.objectContaining({ name: 'web_search', status: 'configured' }), diff --git a/src/gateway/handlers/services.ts b/src/gateway/handlers/services.ts index e9d2df4..3399e5e 100644 --- a/src/gateway/handlers/services.ts +++ b/src/gateway/handlers/services.ts @@ -59,6 +59,7 @@ export function discoverServices( { key: 'google_chat', name: 'google_chat', description: 'Google Chat bot' }, { key: 'bluebubbles', name: 'bluebubbles', description: 'iMessage via BlueBubbles' }, { key: 'line', name: 'line', description: 'LINE Messaging API bot' }, + { key: 'feishu', name: 'feishu', description: 'Feishu/Lark bot' }, ]; for (const { key, name, description } of channelConfigs) { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 84fc7ae..f40060c 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -52,6 +52,7 @@ import type { TeamsAdapter } from '../channels/teams/adapter.js'; import type { GoogleChatAdapter } from '../channels/googleChat/adapter.js'; import type { BlueBubblesAdapter } from '../channels/bluebubbles/adapter.js'; import type { LineAdapter } from '../channels/line/adapter.js'; +import type { FeishuAdapter } from '../channels/feishu/adapter.js'; export interface GatewayServerConfig { port: number; @@ -123,6 +124,8 @@ export interface GatewayServerConfig { blueBubblesHandler?: Pick; /** Optional LINE adapter for inbound webhook events. */ lineHandler?: Pick; + /** Optional Feishu adapter for inbound webhook events. */ + feishuHandler?: Pick; } export class GatewayServer { @@ -733,6 +736,12 @@ export class GatewayServer { return; } + // Feishu events route — bypass gateway auth (Feishu webhook posts directly) + if (this.config.feishuHandler && req.method === 'POST' && req.url?.startsWith('/feishu/events')) { + await this.config.feishuHandler.handleRequest(req, res); + return; + } + // Apply auth to HTTP requests when configured const authConfig = this.config.auth ?? {}; if (this.config.authHttp !== false && authConfig.token) { @@ -856,6 +865,11 @@ export class GatewayServer { this.config.lineHandler = handler; } + /** Set the Feishu handler for inbound webhook HTTP routes (late binding). */ + setFeishuHandler(handler: Pick): void { + this.config.feishuHandler = handler; + } + private async startDiscovery(host: string, port: number): Promise { const discovery = this.config.discovery; if (!discovery?.enabled) {