Add Zalo channel adapter with webhook and send path

This commit is contained in:
William Valentin
2026-02-16 13:11:51 -08:00
parent 891ccb696e
commit 8bed99c770
16 changed files with 491 additions and 6 deletions
+13 -1
View File
@@ -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://<your-flynn-host>/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://<your-flynn-host>/zalo/events
models:
default:
provider: anthropic
+9
View File
@@ -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`.
+1 -1
View File
@@ -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
@@ -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
```
+30 -3
View File
@@ -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",
+1
View File
@@ -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';
+106
View File
@@ -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();
});
});
+177
View File
@@ -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<void> {
this._status = 'connected';
}
async disconnect(): Promise<void> {
this._status = 'disconnected';
}
async send(peerId: string, message: OutboundMessage): Promise<void> {
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<void> {
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<void> {
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<void> {
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, '\\$&');
}
+1
View File
@@ -0,0 +1 @@
export { ZaloAdapter, type ZaloAdapterConfig } from './adapter.js';
+25
View File
@@ -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] },
+10
View File
@@ -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({}),
+36
View File
@@ -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<typeof registerChannels>[0]['gateway'],
});
const names = channelRegistry.list().map((adapter) => adapter.name);
expect(names).toContain('zalo');
expect(gateway.setZaloHandler).toHaveBeenCalledTimes(1);
});
});
+15 -1
View File
@@ -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);
+2
View File
@@ -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' }),
+1
View File
@@ -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) {
+14
View File
@@ -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<LineAdapter, 'handleRequest'>;
/** Optional Feishu adapter for inbound webhook events. */
feishuHandler?: Pick<FeishuAdapter, 'handleRequest'>;
/** Optional Zalo adapter for inbound webhook events. */
zaloHandler?: Pick<ZaloAdapter, 'handleRequest'>;
}
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<ZaloAdapter, 'handleRequest'>): void {
this.config.zaloHandler = handler;
}
private async startDiscovery(host: string, port: number): Promise<void> {
const discovery = this.config.discovery;
if (!discovery?.enabled) {