Add Zalo channel adapter with webhook and send path
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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, '\\$&');
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ZaloAdapter, type ZaloAdapterConfig } from './adapter.js';
|
||||
@@ -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] },
|
||||
|
||||
@@ -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({}),
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user