diff --git a/README.md b/README.md index b6c39c6..d15b807 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, Feishu/Lark, and iMessage (BlueBubbles) with unified adapter interface +- **Multi-Channel**: Telegram, Discord, Slack, WhatsApp, Matrix, Signal, Mattermost, Microsoft Teams, Google Chat, LINE, Feishu/Lark, Zalo, 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 @@ -219,6 +219,18 @@ feishu: # Feishu webhook endpoint should point to: # POST https:///feishu/events +# Optional: Zalo +zalo: + oa_access_token: "${ZALO_OA_ACCESS_TOKEN}" + webhook_token: "${ZALO_WEBHOOK_TOKEN}" + allowed_user_ids: [] + require_mention: true + mention_name: "flynn" + endpoint: "https://openapi.zalo.me" + +# Zalo webhook endpoint should point to: +# POST https:///zalo/events + models: default: provider: anthropic diff --git a/config/default.yaml b/config/default.yaml index 4ee0a74..b9dda27 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -72,6 +72,15 @@ telegram: # mention_name: flynn # endpoint: https://open.feishu.cn +# Optional: Zalo +# zalo: +# oa_access_token: ${ZALO_OA_ACCESS_TOKEN} +# webhook_token: ${ZALO_WEBHOOK_TOKEN} +# allowed_user_ids: [] # Empty = allow all users +# require_mention: true +# mention_name: flynn +# endpoint: https://openapi.zalo.me + 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 a7c304e..ca30257 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/LINE/Feishu/etc.) + channels/ Chat adapters + pairing gate (Telegram/Discord/Slack/WhatsApp/Matrix/Signal/Mattermost/LINE/Feishu/Zalo/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-zalo-channel-adapter-checklist.md b/docs/plans/2026-02-16-zalo-channel-adapter-checklist.md new file mode 100644 index 0000000..1209f2e --- /dev/null +++ b/docs/plans/2026-02-16-zalo-channel-adapter-checklist.md @@ -0,0 +1,50 @@ +# Zalo Channel Adapter Checklist + +**Date:** 2026-02-16 +**Scope:** Implement Zalo adapter to close the remaining channel gap in the LINE/Feishu/Zalo set. + +## Goal + +Add a Zalo OA channel adapter with webhook ingress, outbound send path, mention/allowlist gating, and runtime wiring through daemon + gateway. + +## Implemented + +- Added Zalo adapter: + - `src/channels/zalo/adapter.ts` + - `src/channels/zalo/index.ts` +- Inbound webhook handling: + - endpoint: `POST /zalo/events` + - optional webhook token validation (`x-zalo-token` header or payload token) +- Message normalization: + - sender/message extraction and normalized `InboundMessage` emission + - mention gating (`require_mention`, `mention_name`) + - optional user allowlist (`allowed_user_ids`) +- Outbound send: + - OA CS text message API call with `oa_access_token` +- Runtime integration: + - config schema for `zalo` block + - daemon registration + gateway handler binding + - service discovery listing + +## Tests + +- `src/channels/zalo/adapter.test.ts` + - name/status + - outbound send path + - inbound normalization + - webhook token enforcement + - mention gating behavior +- `src/daemon/channels.test.ts` + - adapter registration + gateway `setZaloHandler` wiring +- `src/config/schema.test.ts` + - zalo config parse/defaults +- `src/gateway/handlers/services.test.ts` + - `zalo` service visibility + +## Validation Run + +```bash +pnpm test:run src/channels/zalo/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 a3db3d7..a72fc94 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -647,6 +647,33 @@ ], "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" }, + "zalo-channel-adapter": { + "file": "2026-02-16-zalo-channel-adapter-checklist.md", + "status": "completed", + "date": "2026-02-16", + "updated": "2026-02-16", + "summary": "Implemented Zalo OA channel adapter with webhook ingress (`/zalo/events`), optional webhook token validation, mention/allowlist gating, outbound OA message send path, daemon/gateway wiring, and service discovery visibility.", + "files_created": [ + "docs/plans/2026-02-16-zalo-channel-adapter-checklist.md", + "src/channels/zalo/adapter.ts", + "src/channels/zalo/adapter.test.ts", + "src/channels/zalo/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/zalo/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", @@ -3208,7 +3235,7 @@ } }, "overall_progress": { - "total_test_count": 1808, + "total_test_count": 1814, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -3223,12 +3250,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": "125/128 match (98%), 0 partial (0%), 3 missing (2%)", + "feature_gap_scorecard": "126/128 match (98%), 0 partial (0%), 2 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: Zalo channel adapter (open next scoped implementation checklist)" + "next_up": "OpenClaw gap: companion app runtime clients (macOS/iOS/Android implementation) — 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 51c9c9e..c242d0a 100644 --- a/src/channels/index.ts +++ b/src/channels/index.ts @@ -23,4 +23,5 @@ export { GoogleChatAdapter, type GoogleChatAdapterConfig } from './googleChat/in 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 { ZaloAdapter, type ZaloAdapterConfig } from './zalo/index.js'; export { PairingManager, type PairingConfig, type PairingStore, type ApprovedSender } from './pairing.js'; diff --git a/src/channels/zalo/adapter.test.ts b/src/channels/zalo/adapter.test.ts new file mode 100644 index 0000000..201b1b3 --- /dev/null +++ b/src/channels/zalo/adapter.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { IncomingMessage, ServerResponse } from 'http'; + +import { ZaloAdapter } from './adapter.js'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function mockReq(body: string, token?: string): IncomingMessage { + const req = { + headers: token ? { 'x-zalo-token': token } : {}, + 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('ZaloAdapter', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockReset(); + }); + + it('has name zalo and starts disconnected', () => { + const adapter = new ZaloAdapter({ oaAccessToken: 'token' }); + expect(adapter.name).toBe('zalo'); + expect(adapter.status).toBe('disconnected'); + }); + + it('send posts to zalo message API', async () => { + const adapter = new ZaloAdapter({ oaAccessToken: 'token' }); + await adapter.connect(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + text: async () => '', + } as Response); + + await adapter.send('uid-1', { text: 'hello zalo' }); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(String(mockFetch.mock.calls[0]?.[0])).toContain('/v3.0/oa/message/cs'); + }); + + it('handleEvent emits inbound message', async () => { + const adapter = new ZaloAdapter({ oaAccessToken: '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({ + sender: { id: 'uid-1' }, + recipient: { id: 'oa-1' }, + message: { msg_id: 'm1', text: 'ping' }, + timestamp: 123, + }); + + expect(inbound).toEqual([{ channel: 'zalo', senderId: 'uid-1', text: 'ping' }]); + }); + + it('enforces webhook token when configured', async () => { + const adapter = new ZaloAdapter({ oaAccessToken: 'token', webhookToken: 'secret' }); + const body = JSON.stringify({ + sender: { id: 'uid-1' }, + message: { msg_id: 'm1', text: 'ping' }, + }); + const req = mockReq(body, 'wrong'); + const { res, state } = mockRes(); + + await adapter.handleRequest(req, res); + expect(state.statusCode).toBe(401); + }); + + it('drops messages missing required mention', async () => { + const adapter = new ZaloAdapter({ oaAccessToken: 'token', requireMention: true, mentionName: 'flynn' }); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleEvent({ + sender: { id: 'uid-1' }, + message: { msg_id: 'm1', text: 'hello there' }, + }); + + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/zalo/adapter.ts b/src/channels/zalo/adapter.ts new file mode 100644 index 0000000..01bc693 --- /dev/null +++ b/src/channels/zalo/adapter.ts @@ -0,0 +1,177 @@ +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 ZaloAdapterConfig { + oaAccessToken: string; + endpoint?: string; + webhookToken?: string; + allowedUserIds?: string[]; + requireMention?: boolean; + mentionName?: string; +} + +interface ZaloWebhookEvent { + token?: string; + sender?: { id?: string }; + recipient?: { id?: string }; + message?: { + msg_id?: string; + text?: string; + }; + timestamp?: number; +} + +const MAX_MESSAGE_LENGTH = 3500; + +export class ZaloAdapter implements ChannelAdapter { + readonly name = 'zalo'; + private _status: ChannelStatus = 'disconnected'; + private messageHandler?: (msg: InboundMessage) => void; + + constructor(private readonly config: ZaloAdapterConfig) {} + + 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('Zalo 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.sendText(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: ZaloWebhookEvent; + try { + payload = JSON.parse(body) as ZaloWebhookEvent; + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + + if (this.config.webhookToken) { + const headerToken = req.headers['x-zalo-token']; + const token = typeof headerToken === 'string' ? headerToken : payload.token; + if (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: ZaloWebhookEvent): Promise { + if (!this.messageHandler) { + return; + } + const senderId = payload.sender?.id?.trim(); + if (!senderId) { + return; + } + + if (this.config.allowedUserIds && this.config.allowedUserIds.length > 0) { + if (!this.config.allowedUserIds.includes(senderId)) { + return; + } + } + + const text = (payload.message?.text ?? '').trim(); + if (!text) { + return; + } + + const mentionName = this.config.mentionName ?? 'flynn'; + const mentionRegex = new RegExp(`(?:^|\\s)@?${escapeRegex(mentionName)}(?:\\b|:)`, 'i'); + if (shouldIgnoreForMissingMention({ + requireMention: this.config.requireMention, + defaultRequireMention: true, + mentionsBot: mentionRegex.test(text), + })) { + return; + } + + const cleaned = text.replace(new RegExp(`^\\s*@?${escapeRegex(mentionName)}(?:\\b|:)\\s*`, 'i'), '').trim(); + if (!cleaned) { + return; + } + + this.messageHandler({ + id: payload.message?.msg_id ?? `zalo-${Date.now()}`, + channel: 'zalo', + senderId, + text: cleaned, + timestamp: typeof payload.timestamp === 'number' ? payload.timestamp : Date.now(), + metadata: { + replyPeerId: senderId, + recipientId: payload.recipient?.id, + }, + }); + } + + private async sendText(userId: string, text: string): Promise { + const endpoint = `${(this.config.endpoint ?? 'https://openapi.zalo.me').replace(/\/+$/, '')}/v3.0/oa/message/cs`; + const response = await fetch(endpoint, { + method: 'POST', + headers: { + access_token: this.config.oaAccessToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recipient: { user_id: userId }, + message: { text }, + }), + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error(`Zalo send failed (${response.status}): ${body}`); + } + } +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/channels/zalo/index.ts b/src/channels/zalo/index.ts new file mode 100644 index 0000000..23aeda8 --- /dev/null +++ b/src/channels/zalo/index.ts @@ -0,0 +1 @@ +export { ZaloAdapter, type ZaloAdapterConfig } from './adapter.js'; diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index cf2f670..2783446 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -635,6 +635,31 @@ describe('configSchema — feishu', () => { }); }); +describe('configSchema — zalo', () => { + const minimalConfig = { + telegram: { bot_token: 'test', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + }; + + it('accepts zalo config and defaults optional fields', () => { + const result = configSchema.parse({ + ...minimalConfig, + zalo: { + oa_access_token: 'oa-token', + }, + }); + + expect(result.zalo).toBeDefined(); + if (!result.zalo) { + throw new Error('Expected zalo config'); + } + expect(result.zalo.oa_access_token).toBe('oa-token'); + expect(result.zalo.allowed_user_ids).toEqual([]); + expect(result.zalo.require_mention).toBe(true); + expect(result.zalo.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 7b5939c..d222166 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -521,6 +521,15 @@ const feishuSchema = z.object({ endpoint: z.string().url('Feishu endpoint must be a valid URL').optional(), }).optional(); +const zaloSchema = z.object({ + oa_access_token: z.string().min(1, 'Zalo oa_access_token is required'), + endpoint: z.string().url('Zalo endpoint must be a valid URL').optional(), + webhook_token: z.string().optional(), + allowed_user_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(), @@ -702,6 +711,7 @@ export const configSchema = z.object({ bluebubbles: bluebubblesSchema, line: lineSchema, feishu: feishuSchema, + zalo: zaloSchema, server: serverSchema.default({}), models: modelsSchema, backends: backendsSchema.default({}), diff --git a/src/daemon/channels.test.ts b/src/daemon/channels.test.ts index 35771fb..6abdd22 100644 --- a/src/daemon/channels.test.ts +++ b/src/daemon/channels.test.ts @@ -25,6 +25,7 @@ describe('registerChannels', () => { setBlueBubblesHandler: vi.fn(), setLineHandler: vi.fn(), setFeishuHandler: vi.fn(), + setZaloHandler: vi.fn(), }; registerChannels({ @@ -59,6 +60,7 @@ describe('registerChannels', () => { setBlueBubblesHandler: vi.fn(), setLineHandler: vi.fn(), setFeishuHandler: vi.fn(), + setZaloHandler: vi.fn(), }; registerChannels({ @@ -106,4 +108,38 @@ describe('registerChannels', () => { expect(names).toContain('feishu'); expect(gateway.setFeishuHandler).toHaveBeenCalledTimes(1); }); + + it('registers Zalo adapter when configured', () => { + const config = configSchema.parse({ + telegram: { bot_token: 'test-token', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + zalo: { + oa_access_token: 'oa-token', + allowed_user_ids: ['uid-1'], + }, + }); + + 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(), + setZaloHandler: 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('zalo'); + expect(gateway.setZaloHandler).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/daemon/channels.ts b/src/daemon/channels.ts index 94a6bbe..3567b55 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, FeishuAdapter, PairingManager } from '../channels/index.js'; +import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, MattermostAdapter, TeamsAdapter, GoogleChatAdapter, BlueBubblesAdapter, LineAdapter, FeishuAdapter, ZaloAdapter, PairingManager } from '../channels/index.js'; import { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js'; import type { GatewayServer } from '../gateway/index.js'; @@ -182,6 +182,20 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult { gateway.setFeishuHandler(feishuAdapter); } + // Register Zalo adapter (if configured) + if (config.zalo) { + const zaloAdapter = new ZaloAdapter({ + oaAccessToken: config.zalo.oa_access_token, + endpoint: config.zalo.endpoint, + webhookToken: config.zalo.webhook_token, + allowedUserIds: config.zalo.allowed_user_ids.length > 0 ? config.zalo.allowed_user_ids : undefined, + requireMention: config.zalo.require_mention, + mentionName: config.zalo.mention_name, + }); + channelRegistry.register(zaloAdapter); + gateway.setZaloHandler(zaloAdapter); + } + // 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 258b62e..a8067c2 100644 --- a/src/gateway/handlers/services.test.ts +++ b/src/gateway/handlers/services.test.ts @@ -37,6 +37,7 @@ function makeBaseConfig(): Config { bluebubbles: undefined, line: undefined, feishu: undefined, + zalo: undefined, } as unknown as Config; } @@ -57,6 +58,7 @@ describe('discoverServices', () => { expect.objectContaining({ name: 'bluebubbles', status: 'not_configured' }), expect.objectContaining({ name: 'line', status: 'not_configured' }), expect.objectContaining({ name: 'feishu', status: 'not_configured' }), + expect.objectContaining({ name: 'zalo', 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 3399e5e..5ae37bc 100644 --- a/src/gateway/handlers/services.ts +++ b/src/gateway/handlers/services.ts @@ -60,6 +60,7 @@ export function discoverServices( { 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' }, + { key: 'zalo', name: 'zalo', description: 'Zalo OA bot' }, ]; for (const { key, name, description } of channelConfigs) { diff --git a/src/gateway/server.ts b/src/gateway/server.ts index f40060c..8afb8e4 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -53,6 +53,7 @@ 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'; +import type { ZaloAdapter } from '../channels/zalo/adapter.js'; export interface GatewayServerConfig { port: number; @@ -126,6 +127,8 @@ export interface GatewayServerConfig { lineHandler?: Pick; /** Optional Feishu adapter for inbound webhook events. */ feishuHandler?: Pick; + /** Optional Zalo adapter for inbound webhook events. */ + zaloHandler?: Pick; } export class GatewayServer { @@ -742,6 +745,12 @@ export class GatewayServer { return; } + // Zalo events route — bypass gateway auth (Zalo webhook posts directly) + if (this.config.zaloHandler && req.method === 'POST' && req.url?.startsWith('/zalo/events')) { + await this.config.zaloHandler.handleRequest(req, res); + return; + } + // Apply auth to HTTP requests when configured const authConfig = this.config.auth ?? {}; if (this.config.authHttp !== false && authConfig.token) { @@ -870,6 +879,11 @@ export class GatewayServer { this.config.feishuHandler = handler; } + /** Set the Zalo handler for inbound webhook HTTP routes (late binding). */ + setZaloHandler(handler: Pick): void { + this.config.zaloHandler = handler; + } + private async startDiscovery(host: string, port: number): Promise { const discovery = this.config.discovery; if (!discovery?.enabled) {