diff --git a/README.md b/README.md index dafddff..753f69c 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, and iMessage (BlueBubbles) with unified adapter interface +- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, Mattermost, Microsoft Teams, Google Chat, LINE, 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 @@ -195,6 +195,17 @@ bluebubbles: # BlueBubbles webhook endpoint should point to: # POST https:///bluebubbles/events +# Optional: LINE +line: + channel_access_token: "${LINE_CHANNEL_ACCESS_TOKEN}" + channel_secret: "${LINE_CHANNEL_SECRET}" + allowed_source_ids: [] # Empty = allow all users/groups/rooms + require_mention: true + mention_name: "flynn" + +# LINE webhook endpoint should point to: +# POST https:///line/events + models: default: provider: anthropic diff --git a/config/default.yaml b/config/default.yaml index 6131baa..3b2b890 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -54,6 +54,14 @@ telegram: # require_mention: true # mention_name: flynn +# Optional: LINE +# line: +# channel_access_token: ${LINE_CHANNEL_ACCESS_TOKEN} +# channel_secret: ${LINE_CHANNEL_SECRET} +# allowed_source_ids: [] # Empty = allow all users/groups/rooms +# require_mention: true +# mention_name: flynn + server: # Tailscale Serve config (optional). Enable `serve: true` to expose the # gateway to your tailnet via `tailscale serve`. diff --git a/docs/plans/2026-02-16-line-channel-adapter-checklist.md b/docs/plans/2026-02-16-line-channel-adapter-checklist.md new file mode 100644 index 0000000..c365ed6 --- /dev/null +++ b/docs/plans/2026-02-16-line-channel-adapter-checklist.md @@ -0,0 +1,58 @@ +# LINE Channel Adapter Checklist + +**Date:** 2026-02-16 +**Scope:** Close the first item in the LINE/Feishu/Zalo channel gap set by implementing LINE. + +## Goal + +Add a production-safe LINE channel adapter with webhook ingress, signature verification, outbound send path, mention/allowlist gating, and daemon/gateway wiring. + +## Implemented + +- Added new LINE adapter: + - `src/channels/line/adapter.ts` + - `src/channels/line/index.ts` +- Added webhook ingress on gateway: + - `POST /line/events` routed to adapter handler. +- Added daemon registration: + - config-driven adapter creation in `registerChannels()` + - gateway webhook handler binding via `setLineHandler()` +- Added config schema: + - `line.channel_access_token` + - `line.channel_secret` + - `line.allowed_source_ids` + - `line.require_mention` + - `line.mention_name` +- Added service discovery listing: + - `line` channel now appears in `system.services`. +- Added docs/config examples: + - `README.md` + - `config/default.yaml` + +## Security/Safety + +- Verifies `x-line-signature` using HMAC-SHA256 + `channel_secret`. +- Applies source allowlist (`allowed_source_ids`) when configured. +- Applies mention gating (`require_mention` + `mention_name`) for group/room contexts. + +## Tests + +- `src/channels/line/adapter.test.ts` + - inbound signature validation + - mention gating behavior + - outbound push API call + - invalid signature rejection +- `src/daemon/channels.test.ts` + - LINE adapter registration + gateway handler binding. +- `src/config/schema.test.ts` + - LINE config parsing/defaults. +- `src/gateway/handlers/services.test.ts` + - LINE service presence in discovery output. + +## Validation Run + +```bash +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 +``` diff --git a/docs/plans/state.json b/docs/plans/state.json index 8082da8..ccfea79 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -594,6 +594,32 @@ ], "test_status": "pnpm test:run src/gateway/protocol.test.ts src/gateway/handlers/node.test.ts src/gateway/server.test.ts src/gateway/auth.test.ts src/gateway/handlers/handlers.test.ts src/config/schema.test.ts + pnpm typecheck + pnpm build passing" }, + "line-channel-adapter": { + "file": "2026-02-16-line-channel-adapter-checklist.md", + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Implemented LINE channel adapter with webhook ingress (`/line/events`), HMAC signature validation, mention/allowlist gating, outbound push messaging, daemon registration/wiring, and services discovery visibility.", + "files_created": [ + "docs/plans/2026-02-16-line-channel-adapter-checklist.md", + "src/channels/line/adapter.ts", + "src/channels/line/adapter.test.ts", + "src/channels/line/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" + ], + "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" + }, "qmd-backend": { "file": "2026-02-16-qmd-backend-checklist.md", "status": "completed", @@ -3155,7 +3181,7 @@ } }, "overall_progress": { - "total_test_count": 1795, + "total_test_count": 1802, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -3170,12 +3196,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": "123/128 match (96%), 0 partial (0%), 5 missing (4%)", + "feature_gap_scorecard": "124/128 match (97%), 0 partial (0%), 4 missing (3%)", "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: LINE/Feishu/Zalo channel adapter set (open next scoped implementation checklist)" + "next_up": "OpenClaw gap: Feishu channel adapter (open next scoped implementation checklist)" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/channels/index.ts b/src/channels/index.ts index 71240d9..f670e28 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -21,4 +21,5 @@ export { MattermostAdapter, type MattermostAdapterConfig } from './mattermost/in 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 { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js'; diff --git a/src/channels/line/adapter.test.ts b/src/channels/line/adapter.test.ts new file mode 100644 index 0000000..db649cc --- /dev/null +++ b/src/channels/line/adapter.test.ts @@ -0,0 +1,146 @@ +import { createHmac } from 'crypto'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { IncomingMessage, ServerResponse } from 'http'; + +import { LineAdapter } from './adapter.js'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function mockReq(body: string, secret: string): IncomingMessage { + const signature = createHmac('sha256', secret).update(body).digest('base64'); + const req = { + headers: { 'x-line-signature': signature }, + 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('LineAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockReset(); + }); + + it('has name line and starts disconnected', () => { + const adapter = new LineAdapter({ + channelAccessToken: 'token', + channelSecret: 'secret', + }); + expect(adapter.name).toBe('line'); + expect(adapter.status).toBe('disconnected'); + }); + + it('send posts LINE push API', async () => { + const adapter = new LineAdapter({ + channelAccessToken: 'token', + channelSecret: 'secret', + }); + await adapter.connect(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => '', + } as Response); + + await adapter.send('U123', { text: 'hello line' }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe('https://api.line.me/v2/bot/message/push'); + }); + + it('handleRequest validates signature and dispatches text event', async () => { + const adapter = new LineAdapter({ + channelAccessToken: 'token', + channelSecret: 'secret', + requireMention: false, + }); + const inbound: Array<{ channel: string; text: string; senderId: string }> = []; + adapter.onMessage((msg) => inbound.push({ channel: msg.channel, text: msg.text, senderId: msg.senderId })); + + const body = JSON.stringify({ + events: [{ + type: 'message', + timestamp: 1, + source: { type: 'user', userId: 'U123' }, + message: { id: 'm1', type: 'text', text: 'ping' }, + }], + }); + const req = mockReq(body, 'secret'); + const { res, state } = mockRes(); + + await adapter.handleRequest(req, res); + expect(state.statusCode).toBe(200); + expect(inbound).toEqual([{ channel: 'line', text: 'ping', senderId: 'U123' }]); + }); + + it('drops group messages without mention when require_mention=true', async () => { + const adapter = new LineAdapter({ + channelAccessToken: 'token', + channelSecret: 'secret', + requireMention: true, + mentionName: 'flynn', + }); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleEvent({ + type: 'message', + source: { type: 'group', groupId: 'G123', userId: 'U123' }, + message: { id: 'm1', type: 'text', text: 'hello there' }, + }); + + expect(handler).not.toHaveBeenCalled(); + }); + + it('rejects invalid signature', async () => { + const adapter = new LineAdapter({ + channelAccessToken: 'token', + channelSecret: 'secret', + }); + const body = JSON.stringify({ events: [] }); + const req = { + headers: { 'x-line-signature': 'invalid' }, + 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; + const { res, state } = mockRes(); + + await adapter.handleRequest(req, res); + expect(state.statusCode).toBe(401); + }); +}); diff --git a/src/channels/line/adapter.ts b/src/channels/line/adapter.ts new file mode 100644 index 0000000..b3ae055 --- /dev/null +++ b/src/channels/line/adapter.ts @@ -0,0 +1,228 @@ +import { createHmac, timingSafeEqual } from 'crypto'; +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 LineAdapterConfig { + channelAccessToken: string; + channelSecret: string; + allowedSourceIds?: string[]; + requireMention?: boolean; + mentionName?: string; +} + +interface LineWebhookBody { + events?: LineEvent[]; +} + +interface LineEvent { + type?: string; + replyToken?: string; + timestamp?: number; + source?: { + type?: 'user' | 'group' | 'room'; + userId?: string; + groupId?: string; + roomId?: string; + }; + message?: { + id?: string; + type?: string; + text?: string; + }; +} + +const MAX_MESSAGE_LENGTH = 4500; + +export class LineAdapter implements ChannelAdapter { + readonly name = 'line'; + private _status: ChannelStatus = 'disconnected'; + private messageHandler?: (msg: InboundMessage) => void; + + constructor(private readonly config: LineAdapterConfig) {} + + 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('LINE 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.sendPush(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; + } + + if (!this.verifySignature(body, req.headers['x-line-signature'])) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid signature' })); + return; + } + + let payload: LineWebhookBody; + try { + payload = JSON.parse(body) as LineWebhookBody; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + + const events = Array.isArray(payload.events) ? payload.events : []; + for (const event of events) { + await this.handleEvent(event); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + } + + async handleEvent(event: LineEvent): Promise { + if (!this.messageHandler) { + return; + } + if (event.type !== 'message' || event.message?.type !== 'text') { + return; + } + + const source = event.source; + const sourceType = source?.type ?? 'user'; + const sourceId = this.resolveSourceId(source); + if (!sourceId) { + return; + } + + if (this.config.allowedSourceIds && this.config.allowedSourceIds.length > 0) { + if (!this.config.allowedSourceIds.includes(sourceId)) { + return; + } + } + + const text = (event.message?.text ?? '').trim(); + if (!text) { + return; + } + + const mentionName = this.config.mentionName ?? 'flynn'; + const mentionRegex = new RegExp(`(?:^|\\s)@?${escapeRegex(mentionName)}(?:\\b|:)`, 'i'); + const mentionsBot = mentionRegex.test(text); + const isDm = sourceType === 'user'; + 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: event.message?.id ?? `line-${Date.now()}`, + channel: 'line', + senderId: source?.userId?.trim() || sourceId, + text: cleaned, + timestamp: typeof event.timestamp === 'number' ? event.timestamp : Date.now(), + metadata: { + sourceType, + sourceId, + replyPeerId: sourceId, + replyToken: event.replyToken, + }, + }); + } + + private resolveSourceId(source?: LineEvent['source']): string | undefined { + if (!source) { + return undefined; + } + if (source.type === 'group') { + return source.groupId?.trim() || undefined; + } + if (source.type === 'room') { + return source.roomId?.trim() || undefined; + } + return source.userId?.trim() || undefined; + } + + private verifySignature(body: string, signatureHeader: string | string[] | undefined): boolean { + const signature = typeof signatureHeader === 'string' ? signatureHeader : signatureHeader?.[0]; + if (!signature) { + return false; + } + const expected = createHmac('sha256', this.config.channelSecret).update(body).digest('base64'); + const sigBuf = Buffer.from(signature); + const expectedBuf = Buffer.from(expected); + if (sigBuf.length !== expectedBuf.length) { + return false; + } + return timingSafeEqual(sigBuf, expectedBuf); + } + + private async sendPush(to: string, text: string): Promise { + const response = await fetch('https://api.line.me/v2/bot/message/push', { + method: 'POST', + headers: { + Authorization: `Bearer ${this.config.channelAccessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + to, + messages: [ + { + type: 'text', + text, + }, + ], + }), + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`LINE send failed (${response.status}): ${body}`); + } + } +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/channels/line/index.ts b/src/channels/line/index.ts new file mode 100644 index 0000000..dc72335 --- /dev/null +++ b/src/channels/line/index.ts @@ -0,0 +1 @@ +export { LineAdapter, type LineAdapterConfig } from './adapter.js'; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 70f351f..88111ef 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -581,6 +581,33 @@ describe('configSchema — bluebubbles', () => { }); }); +describe('configSchema — line', () => { + const minimalConfig = { + telegram: { bot_token: 'test', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + }; + + it('accepts line config and defaults optional fields', () => { + const result = configSchema.parse({ + ...minimalConfig, + line: { + channel_access_token: 'line-token', + channel_secret: 'line-secret', + }, + }); + + expect(result.line).toBeDefined(); + if (!result.line) { + throw new Error('Expected line config'); + } + expect(result.line.channel_access_token).toBe('line-token'); + expect(result.line.channel_secret).toBe('line-secret'); + expect(result.line.allowed_source_ids).toEqual([]); + expect(result.line.require_mention).toBe(true); + expect(result.line.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 a9a47f4..ee6711e 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -503,6 +503,14 @@ const bluebubblesSchema = z.object({ mention_name: z.string().default('flynn'), }).optional(); +const lineSchema = z.object({ + channel_access_token: z.string().min(1, 'LINE channel_access_token is required'), + channel_secret: z.string().min(1, 'LINE channel_secret is required'), + allowed_source_ids: z.array(z.string()).default([]), + require_mention: z.boolean().default(true), + mention_name: z.string().default('flynn'), +}).optional(); + const browserSchema = z.object({ enabled: z.boolean().default(false), executable_path: z.string().optional(), @@ -682,6 +690,7 @@ export const configSchema = z.object({ teams: teamsSchema, google_chat: googleChatSchema, bluebubbles: bluebubblesSchema, + line: lineSchema, server: serverSchema.default({}), models: modelsSchema, backends: backendsSchema.default({}), diff --git a/src/daemon/channels.test.ts b/src/daemon/channels.test.ts index 0c3e475..af377e6 100644 --- a/src/daemon/channels.test.ts +++ b/src/daemon/channels.test.ts @@ -23,6 +23,7 @@ describe('registerChannels', () => { setTeamsHandler: vi.fn(), setGoogleChatHandler: vi.fn(), setBlueBubblesHandler: vi.fn(), + setLineHandler: vi.fn(), }; registerChannels({ @@ -36,4 +37,37 @@ describe('registerChannels', () => { expect(names).toContain('mattermost'); expect(names).toContain('webchat'); }); + + it('registers LINE adapter when configured', () => { + const config = configSchema.parse({ + telegram: { bot_token: 'test-token', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + line: { + channel_access_token: 'line-token', + channel_secret: 'line-secret', + allowed_source_ids: ['U123'], + }, + }); + + const channelRegistry = new ChannelRegistry(); + const gateway = { + setWebhookHandler: vi.fn(), + setGmailHandler: vi.fn(), + setTeamsHandler: vi.fn(), + setGoogleChatHandler: vi.fn(), + setBlueBubblesHandler: vi.fn(), + setLineHandler: 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('line'); + expect(gateway.setLineHandler).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/daemon/channels.ts b/src/daemon/channels.ts index f7fdd99..29887ed 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, PairingManager } from '../channels/index.js'; +import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, MattermostAdapter, TeamsAdapter, GoogleChatAdapter, BlueBubblesAdapter, LineAdapter, PairingManager } from '../channels/index.js'; import { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js'; import type { GatewayServer } from '../gateway/index.js'; @@ -154,6 +154,19 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult { gateway.setBlueBubblesHandler(blueBubblesAdapter); } + // Register LINE adapter (if configured) + if (config.line) { + const lineAdapter = new LineAdapter({ + channelAccessToken: config.line.channel_access_token, + channelSecret: config.line.channel_secret, + allowedSourceIds: config.line.allowed_source_ids.length > 0 ? config.line.allowed_source_ids : undefined, + requireMention: config.line.require_mention, + mentionName: config.line.mention_name, + }); + channelRegistry.register(lineAdapter); + gateway.setLineHandler(lineAdapter); + } + // 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 ef92020..f66ff66 100644 --- a/src/gateway/handlers/services.test.ts +++ b/src/gateway/handlers/services.test.ts @@ -35,6 +35,7 @@ function makeBaseConfig(): Config { teams: undefined, google_chat: undefined, bluebubbles: undefined, + line: undefined, } as unknown as Config; } @@ -53,6 +54,7 @@ describe('discoverServices', () => { expect.objectContaining({ name: 'teams', status: 'not_configured' }), 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: '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 8d6e20d..e9d2df4 100644 --- a/src/gateway/handlers/services.ts +++ b/src/gateway/handlers/services.ts @@ -58,6 +58,7 @@ export function discoverServices( { key: 'teams', name: 'teams', description: 'Microsoft Teams bot' }, { 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' }, ]; for (const { key, name, description } of channelConfigs) { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 262f5c3..84fc7ae 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -51,6 +51,7 @@ import { RequestBodyTooLargeError, readRequestBody } from '../utils/httpBody.js' 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'; export interface GatewayServerConfig { port: number; @@ -120,6 +121,8 @@ export interface GatewayServerConfig { googleChatHandler?: Pick; /** Optional BlueBubbles adapter for inbound iMessage event webhooks. */ blueBubblesHandler?: Pick; + /** Optional LINE adapter for inbound webhook events. */ + lineHandler?: Pick; } export class GatewayServer { @@ -724,6 +727,12 @@ export class GatewayServer { return; } + // LINE events route — bypass gateway auth (LINE webhook posts directly) + if (this.config.lineHandler && req.method === 'POST' && req.url?.startsWith('/line/events')) { + await this.config.lineHandler.handleRequest(req, res); + return; + } + // Apply auth to HTTP requests when configured const authConfig = this.config.auth ?? {}; if (this.config.authHttp !== false && authConfig.token) { @@ -842,6 +851,11 @@ export class GatewayServer { this.config.blueBubblesHandler = handler; } + /** Set the LINE handler for inbound webhook HTTP routes (late binding). */ + setLineHandler(handler: Pick): void { + this.config.lineHandler = handler; + } + private async startDiscovery(host: string, port: number): Promise { const discovery = this.config.discovery; if (!discovery?.enabled) {