From fad88bd319263ef6a4d5eae97a4d065366c3c881 Mon Sep 17 00:00:00 2001 From: buiducnhat Date: Sun, 5 Apr 2026 23:59:16 +0700 Subject: [PATCH 1/3] feat(adapter-zalo): implement Zalo adapter for chat SDK - Add ZaloAdapter class to handle messaging via Zalo Bot Platform API. - Implement message handling, including text, image, and sticker messages. - Create ZaloFormatConverter for markdown to plain text conversion. - Add types for Zalo API responses and webhook payloads. - Implement message splitting to comply with Zalo's 2000-character limit. - Add tests for ZaloFormatConverter to ensure proper formatting and parsing. - Configure TypeScript and build settings for the adapter package. - Update pnpm lockfile and turbo.json for environment variables. --- packages/adapter-zalo/README.md | 187 ++++ packages/adapter-zalo/package.json | 56 ++ packages/adapter-zalo/src/index.test.ts | 970 +++++++++++++++++++++ packages/adapter-zalo/src/index.ts | 678 ++++++++++++++ packages/adapter-zalo/src/markdown.test.ts | 257 ++++++ packages/adapter-zalo/src/markdown.ts | 111 +++ packages/adapter-zalo/src/types.ts | 128 +++ packages/adapter-zalo/tsconfig.json | 10 + packages/adapter-zalo/tsup.config.ts | 9 + packages/adapter-zalo/vitest.config.ts | 14 + pnpm-lock.yaml | 52 ++ turbo.json | 3 + vitest.config.ts | 1 + 13 files changed, 2476 insertions(+) create mode 100644 packages/adapter-zalo/README.md create mode 100644 packages/adapter-zalo/package.json create mode 100644 packages/adapter-zalo/src/index.test.ts create mode 100644 packages/adapter-zalo/src/index.ts create mode 100644 packages/adapter-zalo/src/markdown.test.ts create mode 100644 packages/adapter-zalo/src/markdown.ts create mode 100644 packages/adapter-zalo/src/types.ts create mode 100644 packages/adapter-zalo/tsconfig.json create mode 100644 packages/adapter-zalo/tsup.config.ts create mode 100644 packages/adapter-zalo/vitest.config.ts diff --git a/packages/adapter-zalo/README.md b/packages/adapter-zalo/README.md new file mode 100644 index 00000000..d335e3ec --- /dev/null +++ b/packages/adapter-zalo/README.md @@ -0,0 +1,187 @@ +# @chat-adapter/zalo + +[![npm version](https://img.shields.io/npm/v/@chat-adapter/zalo)](https://www.npmjs.com/package/@chat-adapter/zalo) +[![npm downloads](https://img.shields.io/npm/dm/@chat-adapter/zalo)](https://www.npmjs.com/package/@chat-adapter/zalo) + +Zalo Bot adapter for [Chat SDK](https://chat-sdk.dev), using the [Zalo Bot Platform API](https://bot.zapps.me/docs). + +## Installation + +```bash +pnpm add @chat-adapter/zalo +``` + +## Usage + +```typescript +import { Chat } from "chat"; +import { createZaloAdapter } from "@chat-adapter/zalo"; + +const bot = new Chat({ + userName: "mybot", + adapters: { + zalo: createZaloAdapter(), + }, +}); + +bot.onNewMention(async (thread, message) => { + await thread.post(`You said: ${message.text}`); +}); +``` + +When using `createZaloAdapter()` without arguments, credentials are auto-detected from environment variables. + +## Zalo Bot setup + +### 1. Create a Zalo Bot + +1. Go to [bot.zapps.me](https://bot.zapps.me) and sign in with your Zalo account +2. Create a new bot and note your **Bot Token** (format: `12345689:abc-xyz`) +3. Go to **Webhooks** settings and set your webhook URL + +### 2. Configure webhooks + +1. In the Zalo Bot dashboard, navigate to **Webhooks** +2. Set **Webhook URL** to `https://your-domain.com/api/webhooks/zalo` +3. Set a **Secret Token** of your choice (8–256 characters) — this becomes `ZALO_WEBHOOK_SECRET` +4. Subscribe to the message events you need (`message.text.received`, `message.image.received`, etc.) + +### 3. Get credentials + +From your Zalo Bot dashboard, copy: + +- **Bot Token** as `ZALO_BOT_TOKEN` +- The **Secret Token** you set in the webhook config as `ZALO_WEBHOOK_SECRET` + +## Configuration + +All options are auto-detected from environment variables when not provided. + +| Option | Required | Description | +| --------------- | -------- | --------------------------------------------------------------------------------- | +| `botToken` | No\* | Zalo bot token. Auto-detected from `ZALO_BOT_TOKEN` | +| `webhookSecret` | No\* | Secret token for webhook verification. Auto-detected from `ZALO_WEBHOOK_SECRET` | +| `userName` | No | Bot display name. Auto-detected from `ZALO_BOT_USERNAME` (defaults to `zalo-bot`) | +| `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | + +\*Required at runtime — either via config or environment variable. + +## Environment variables + +```bash +ZALO_BOT_TOKEN=12345689:abc-xyz # Bot token from Zalo Bot dashboard +ZALO_WEBHOOK_SECRET=your-secret # Secret token for X-Bot-Api-Secret-Token verification +ZALO_BOT_USERNAME=mybot # Optional, defaults to "zalo-bot" +``` + +## Webhook setup + +```typescript +// Next.js App Router example +import { bot } from "@/lib/bot"; + +export async function POST(request: Request) { + return bot.webhooks.zalo(request); +} +``` + +Zalo delivers all events via POST requests with an `X-Bot-Api-Secret-Token` header. The adapter verifies this header using timing-safe comparison before processing any payload. + +## Features + +### Messaging + +| Feature | Supported | +| -------------- | --------------------------------- | +| Post message | Yes | +| Edit message | No (Zalo limitation) | +| Delete message | No (Zalo limitation) | +| Streaming | Buffered (accumulates then sends) | +| Auto-chunking | Yes (splits at 2000 chars) | + +### Rich content + +| Feature | Supported | +| ------------------- | ------------------ | +| Interactive buttons | No (text fallback) | +| Cards | Text fallback | +| Tables | ASCII | + +### Conversations + +| Feature | Supported | +| ---------------- | ---------------------- | +| Reactions | No (Zalo limitation) | +| Typing indicator | Yes (`sendChatAction`) | +| DMs | Yes | +| Group chats | Yes | +| Open DM | Yes | + +### Incoming message types + +| Type | Supported | +| ----------------- | ----------------------------- | +| Text | Yes | +| Images | Yes (with optional caption) | +| Stickers | Yes (rendered as `[Sticker]`) | +| Unsupported types | Ignored gracefully | + +### Message history + +| Feature | Supported | +| ----------------- | ------------------------ | +| Fetch messages | No (Zalo API limitation) | +| Fetch thread info | Yes | + +## Cards + +Zalo has no interactive message API. All card elements are rendered as plain text: + +``` +CARD TITLE + +Body text here... + +• Button 1 label +• Button 2 label: https://example.com + +--- +``` + +## Thread ID format + +``` +zalo:{chatId} +``` + +Example: `zalo:1234567890` + +The `chatId` is the conversation ID from the Zalo webhook payload. For group chats it is the group ID; for private chats it is the user ID. + +## Notes + +- Zalo does not expose message history APIs to bots. `fetchMessages` returns an empty array. +- All formatting (bold, italic, code blocks) is stripped to plain text — Zalo renders no markdown. +- The bot token is embedded in the API URL path and is never logged. +- `isDM()` always returns `true` — Zalo thread IDs do not encode chat type. + +## Troubleshooting + +### Webhook verification failing + +- Confirm `ZALO_WEBHOOK_SECRET` matches the value you entered in the Zalo Bot dashboard +- The adapter compares the `X-Bot-Api-Secret-Token` header using a timing-safe byte comparison — ensure the secret contains only ASCII characters and has no trailing whitespace + +### Messages not arriving + +- Verify your webhook URL is reachable and returns `200 OK` +- Check that the event types you need are subscribed in the Zalo Bot dashboard + +### "Zalo API error" on send + +- Confirm `ZALO_BOT_TOKEN` is correct — it should be in `12345689:abc-xyz` format +- The adapter calls `getMe` during `initialize()` to validate the token; check logs for initialization errors + +## License + +MIT diff --git a/packages/adapter-zalo/package.json b/packages/adapter-zalo/package.json new file mode 100644 index 00000000..0f12862e --- /dev/null +++ b/packages/adapter-zalo/package.json @@ -0,0 +1,56 @@ +{ + "name": "@chat-adapter/zalo", + "version": "0.0.0", + "description": "Zalo adapter for chat - Zalo Bot Platform", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest run --coverage", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@chat-adapter/shared": "workspace:*", + "chat": "workspace:*" + }, + "devDependencies": { + "@types/node": "^25.3.2", + "tsup": "^8.3.5", + "typescript": "^5.7.2", + "vitest": "^4.0.18" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/chat.git", + "directory": "packages/adapter-zalo" + }, + "homepage": "https://github.com/vercel/chat#readme", + "bugs": { + "url": "https://github.com/vercel/chat/issues" + }, + "publishConfig": { + "access": "public" + }, + "keywords": [ + "chat", + "zalo", + "bot", + "adapter", + "messaging" + ], + "license": "MIT" +} diff --git a/packages/adapter-zalo/src/index.test.ts b/packages/adapter-zalo/src/index.test.ts new file mode 100644 index 00000000..76dd77b1 --- /dev/null +++ b/packages/adapter-zalo/src/index.test.ts @@ -0,0 +1,970 @@ +import { + AdapterError, + AdapterRateLimitError, + ValidationError, +} from "@chat-adapter/shared"; +import type { ChatInstance, Logger } from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createZaloAdapter, splitMessage, ZaloAdapter } from "./index"; +import type { ZaloInboundMessage } from "./types"; + +// --------------------------------------------------------------------------- +// Mock helpers +// --------------------------------------------------------------------------- + +const mockLogger: Logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), +}; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal("fetch", mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function zaloOk(result: T): Response { + return new Response(JSON.stringify({ ok: true, result }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function zaloError( + status: number, + errorCode: number, + description: string +): Response { + return new Response( + JSON.stringify({ ok: false, error_code: errorCode, description }), + { status, headers: { "content-type": "application/json" } } + ); +} + +function createMockChat(): ChatInstance { + return { + getLogger: vi.fn().mockReturnValue(mockLogger), + getState: vi.fn(), + getUserName: vi.fn().mockReturnValue("zalo-bot"), + handleIncomingMessage: vi.fn().mockResolvedValue(undefined), + processMessage: vi.fn(), + processReaction: vi.fn(), + processAction: vi.fn(), + processModalClose: vi.fn(), + processModalSubmit: vi.fn().mockResolvedValue(undefined), + processSlashCommand: vi.fn(), + processAssistantThreadStarted: vi.fn(), + processAssistantContextChanged: vi.fn(), + processAppHomeOpened: vi.fn(), + } as unknown as ChatInstance; +} + +function sampleInboundMessage( + overrides?: Partial +): ZaloInboundMessage { + return { + message_id: "msg-001", + date: 1735689600000, + chat: { id: "chat-123", chat_type: "PRIVATE" }, + from: { id: "user-456", display_name: "Alice", is_bot: false }, + text: "Hello", + ...overrides, + }; +} + +const getMeResponse = { + id: "bot-999", + account_name: "my-zalo-bot", + account_type: "OA", + can_join_groups: false, +}; + +function createAdapter(overrides?: { + botToken?: string; + webhookSecret?: string; +}) { + return new ZaloAdapter({ + botToken: overrides?.botToken ?? "test-token", + webhookSecret: overrides?.webhookSecret ?? "super-secret", + userName: "zalo-bot", + logger: mockLogger, + }); +} + +async function createInitializedAdapter() { + const adapter = createAdapter(); + mockFetch.mockResolvedValueOnce(zaloOk(getMeResponse)); + await adapter.initialize(createMockChat()); + // Reset so subsequent tests start with a clean call history + mockFetch.mockReset(); + return adapter; +} + +// --------------------------------------------------------------------------- +// createZaloAdapter +// --------------------------------------------------------------------------- + +describe("createZaloAdapter", () => { + let savedToken: string | undefined; + let savedSecret: string | undefined; + let savedUsername: string | undefined; + + beforeEach(() => { + savedToken = process.env.ZALO_BOT_TOKEN; + savedSecret = process.env.ZALO_WEBHOOK_SECRET; + savedUsername = process.env.ZALO_BOT_USERNAME; + }); + + afterEach(() => { + if (savedToken === undefined) { + Reflect.deleteProperty(process.env, "ZALO_BOT_TOKEN"); + } else { + process.env.ZALO_BOT_TOKEN = savedToken; + } + if (savedSecret === undefined) { + Reflect.deleteProperty(process.env, "ZALO_WEBHOOK_SECRET"); + } else { + process.env.ZALO_WEBHOOK_SECRET = savedSecret; + } + if (savedUsername === undefined) { + Reflect.deleteProperty(process.env, "ZALO_BOT_USERNAME"); + } else { + process.env.ZALO_BOT_USERNAME = savedUsername; + } + }); + + it("throws when botToken is missing", () => { + Reflect.deleteProperty(process.env, "ZALO_BOT_TOKEN"); + Reflect.deleteProperty(process.env, "ZALO_WEBHOOK_SECRET"); + expect(() => createZaloAdapter()).toThrow(ValidationError); + }); + + it("throws when webhookSecret is missing", () => { + process.env.ZALO_BOT_TOKEN = "some-token"; + Reflect.deleteProperty(process.env, "ZALO_WEBHOOK_SECRET"); + expect(() => createZaloAdapter()).toThrow(ValidationError); + }); + + it("uses env vars when config omitted", () => { + process.env.ZALO_BOT_TOKEN = "env-token"; + process.env.ZALO_WEBHOOK_SECRET = "env-secret"; + const adapter = createZaloAdapter(); + expect(adapter).toBeInstanceOf(ZaloAdapter); + }); + + it("uses config values over env vars", () => { + process.env.ZALO_BOT_TOKEN = "env-token"; + process.env.ZALO_WEBHOOK_SECRET = "env-secret"; + const adapter = createZaloAdapter({ + botToken: "cfg-token", + webhookSecret: "cfg-secret", + }); + expect(adapter).toBeInstanceOf(ZaloAdapter); + expect(adapter.userName).toBe("zalo-bot"); + }); + + it("defaults userName to 'zalo-bot' when env var not set", () => { + process.env.ZALO_BOT_TOKEN = "tok"; + process.env.ZALO_WEBHOOK_SECRET = "sec"; + Reflect.deleteProperty(process.env, "ZALO_BOT_USERNAME"); + const adapter = createZaloAdapter(); + expect(adapter.userName).toBe("zalo-bot"); + }); + + it("reads ZALO_BOT_USERNAME from env", () => { + process.env.ZALO_BOT_TOKEN = "tok"; + process.env.ZALO_WEBHOOK_SECRET = "sec"; + process.env.ZALO_BOT_USERNAME = "my-custom-bot"; + const adapter = createZaloAdapter(); + expect(adapter.userName).toBe("my-custom-bot"); + }); +}); + +// --------------------------------------------------------------------------- +// Thread ID +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — thread ID", () => { + const adapter = createAdapter(); + + it("botUserId returns undefined before initialize", () => { + expect(adapter.botUserId).toBeUndefined(); + }); + + it("encodes thread ID", () => { + expect(adapter.encodeThreadId({ chatId: "chat-123" })).toBe( + "zalo:chat-123" + ); + }); + + it("decodes thread ID", () => { + expect(adapter.decodeThreadId("zalo:chat-123")).toEqual({ + chatId: "chat-123", + }); + }); + + it("throws on invalid thread ID prefix", () => { + expect(() => adapter.decodeThreadId("slack:foo")).toThrow(ValidationError); + }); + + it("throws on empty chatId", () => { + expect(() => adapter.decodeThreadId("zalo:")).toThrow(ValidationError); + }); + + it("channelIdFromThreadId returns threadId unchanged", () => { + expect(adapter.channelIdFromThreadId("zalo:abc")).toBe("zalo:abc"); + }); + + it("isDM always returns true", () => { + expect(adapter.isDM("zalo:chat-123")).toBe(true); + expect(adapter.isDM("zalo:anything")).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// initialize +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — initialize", () => { + it("calls getMe and stores botUserId", async () => { + const adapter = createAdapter(); + mockFetch.mockResolvedValueOnce(zaloOk(getMeResponse)); + await adapter.initialize(createMockChat()); + expect(adapter.botUserId).toBe("bot-999"); + }); + + it("throws AdapterError when getMe fails with HTTP error", async () => { + const adapter = createAdapter(); + mockFetch.mockResolvedValueOnce( + new Response("Internal Server Error", { status: 500 }) + ); + await expect(adapter.initialize(createMockChat())).rejects.toThrow( + AdapterError + ); + }); +}); + +// --------------------------------------------------------------------------- +// handleWebhook +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — handleWebhook", () => { + function makeRequest( + body: string, + secretHeader?: string, + method = "POST" + ): Request { + const headers: Record = { + "content-type": "application/json", + }; + if (secretHeader !== undefined) { + headers["x-bot-api-secret-token"] = secretHeader; + } + return new Request("https://example.com/webhook", { + method, + headers, + body, + }); + } + + function textEvent( + eventName: string, + overrides?: Partial + ): string { + return JSON.stringify({ + event_name: eventName, + message: sampleInboundMessage(overrides), + }); + } + + it("returns 401 for missing secret token", async () => { + const adapter = await createInitializedAdapter(); + const req = makeRequest(textEvent("message.text.received")); + const res = await adapter.handleWebhook(req); + expect(res.status).toBe(401); + }); + + it("returns 401 for wrong secret token", async () => { + const adapter = await createInitializedAdapter(); + const req = makeRequest(textEvent("message.text.received"), "wrong-secret"); + const res = await adapter.handleWebhook(req); + expect(res.status).toBe(401); + }); + + it("returns 400 for invalid JSON", async () => { + const adapter = await createInitializedAdapter(); + const req = makeRequest("not-json", "super-secret"); + const res = await adapter.handleWebhook(req); + expect(res.status).toBe(400); + }); + + it("dispatches text message and returns 200", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce(zaloOk(getMeResponse)); + await adapter.initialize(chat); + mockFetch.mockReset(); + + const req = makeRequest(textEvent("message.text.received"), "super-secret"); + const res = await adapter.handleWebhook(req); + expect(res.status).toBe(200); + expect(chat.processMessage).toHaveBeenCalledOnce(); + const [, threadId] = (chat.processMessage as ReturnType).mock + .calls[0]; + expect(threadId).toBe("zalo:chat-123"); + }); + + it("dispatches image message", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce(zaloOk(getMeResponse)); + await adapter.initialize(chat); + + const body = JSON.stringify({ + event_name: "message.image.received", + message: sampleInboundMessage({ + photo: "https://img.example.com/1.jpg", + text: undefined, + }), + }); + const req = makeRequest(body, "super-secret"); + const res = await adapter.handleWebhook(req); + expect(res.status).toBe(200); + expect(chat.processMessage).toHaveBeenCalledOnce(); + }); + + it("dispatches sticker message", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce(zaloOk(getMeResponse)); + await adapter.initialize(chat); + + const body = JSON.stringify({ + event_name: "message.sticker.received", + message: sampleInboundMessage({ sticker: "sticker-id", text: undefined }), + }); + const req = makeRequest(body, "super-secret"); + const res = await adapter.handleWebhook(req); + expect(res.status).toBe(200); + expect(chat.processMessage).toHaveBeenCalledOnce(); + }); + + it("ignores unsupported messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce(zaloOk(getMeResponse)); + await adapter.initialize(chat); + + const body = JSON.stringify({ + event_name: "message.unsupported.received", + message: sampleInboundMessage(), + }); + const req = makeRequest(body, "super-secret"); + const res = await adapter.handleWebhook(req); + expect(res.status).toBe(200); + expect(chat.processMessage).not.toHaveBeenCalled(); + }); + + it("ignores unknown events", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + mockFetch.mockResolvedValueOnce(zaloOk(getMeResponse)); + await adapter.initialize(chat); + + const body = JSON.stringify({ + event_name: "some.other.event", + message: sampleInboundMessage(), + }); + const req = makeRequest(body, "super-secret"); + const res = await adapter.handleWebhook(req); + expect(res.status).toBe(200); + expect(chat.processMessage).not.toHaveBeenCalled(); + }); + + it("returns 200 when JSON.parse returns null/falsy", async () => { + const adapter = await createInitializedAdapter(); + // JSON.stringify(null) === "null" which parses to null (falsy) + const req = makeRequest("null", "super-secret"); + const res = await adapter.handleWebhook(req); + expect(res.status).toBe(200); + }); + + it("verifySecretToken catches error for mismatched-length secrets", async () => { + // timingSafeEqual throws when buffer lengths differ — adapter should return 401, not throw + const adapter = await createInitializedAdapter(); + const req = makeRequest( + JSON.stringify({ + event_name: "message.text.received", + message: sampleInboundMessage(), + }), + "short" // length differs from "super-secret" (12) — timingSafeEqual throws + ); + const res = await adapter.handleWebhook(req); + expect(res.status).toBe(401); + }); + + it("ignores message dispatch when chat not initialized", async () => { + // handleInboundMessage guard: chat === null + const adapter = createAdapter(); + // Do NOT call initialize — chat remains null + const body = JSON.stringify({ + event_name: "message.text.received", + message: sampleInboundMessage(), + }); + const req = makeRequest(body, "super-secret"); + // Should still return 200 (webhook handling is separate from dispatch) + const res = await adapter.handleWebhook(req); + expect(res.status).toBe(200); + }); +}); + +// --------------------------------------------------------------------------- +// parseMessage +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — parseMessage", () => { + it("parses text message", async () => { + const adapter = await createInitializedAdapter(); + const raw = { message: sampleInboundMessage({ text: "Hello world" }) }; + const msg = adapter.parseMessage(raw); + expect(msg.text).toBe("Hello world"); + expect(msg.attachments).toHaveLength(0); + }); + + it("parses image message with caption", async () => { + const adapter = await createInitializedAdapter(); + const raw = { + message: sampleInboundMessage({ + text: undefined, + photo: "https://img.example.com/1.jpg", + caption: "Nice photo", + }), + }; + const msg = adapter.parseMessage(raw); + expect(msg.text).toBe("Nice photo"); + expect(msg.attachments).toHaveLength(1); + expect(msg.attachments[0].type).toBe("image"); + expect(msg.attachments[0].url).toBe("https://img.example.com/1.jpg"); + }); + + it("parses image without caption uses [Image]", async () => { + const adapter = await createInitializedAdapter(); + const raw = { + message: sampleInboundMessage({ + text: undefined, + photo: "https://img.example.com/1.jpg", + }), + }; + const msg = adapter.parseMessage(raw); + expect(msg.text).toBe("[Image]"); + }); + + it("parses sticker message", async () => { + const adapter = await createInitializedAdapter(); + const raw = { + message: sampleInboundMessage({ + text: undefined, + sticker: "sticker-123", + }), + }; + const msg = adapter.parseMessage(raw); + expect(msg.text).toBe("[Sticker]"); + expect(msg.attachments).toHaveLength(0); + }); + + it("parses unsupported message", async () => { + const adapter = await createInitializedAdapter(); + const raw = { + message: sampleInboundMessage({ text: undefined }), + }; + const msg = adapter.parseMessage(raw); + expect(msg.text).toBe("[Unsupported message]"); + }); + + it("sets correct author fields", async () => { + const adapter = await createInitializedAdapter(); + const raw = { message: sampleInboundMessage() }; + const msg = adapter.parseMessage(raw); + expect(msg.author.userId).toBe("user-456"); + expect(msg.author.userName).toBe("Alice"); + expect(msg.author.fullName).toBe("Alice"); + expect(msg.author.isBot).toBe(false); + }); + + it("sets isMe = false for user messages", async () => { + const adapter = await createInitializedAdapter(); + const raw = { message: sampleInboundMessage() }; + const msg = adapter.parseMessage(raw); + expect(msg.author.isMe).toBe(false); + }); + + it("sets isMe = true for bot's own messages", async () => { + const adapter = await createInitializedAdapter(); + const raw = { + message: sampleInboundMessage({ + from: { id: "bot-999", display_name: "my-zalo-bot", is_bot: true }, + }), + }; + const msg = adapter.parseMessage(raw); + expect(msg.author.isMe).toBe(true); + }); + + it("sets dateSent from message.date", async () => { + const adapter = await createInitializedAdapter(); + const raw = { message: sampleInboundMessage({ date: 1735689600000 }) }; + const msg = adapter.parseMessage(raw); + expect(msg.metadata.dateSent).toEqual(new Date(1735689600000)); + }); + + it("sets correct threadId", async () => { + const adapter = await createInitializedAdapter(); + const raw = { + message: sampleInboundMessage({ + chat: { id: "chat-xyz", chat_type: "PRIVATE" }, + }), + }; + const msg = adapter.parseMessage(raw); + expect(msg.threadId).toBe("zalo:chat-xyz"); + }); + + it("sets message id", async () => { + const adapter = await createInitializedAdapter(); + const raw = { message: sampleInboundMessage({ message_id: "msg-abc" }) }; + const msg = adapter.parseMessage(raw); + expect(msg.id).toBe("msg-abc"); + }); +}); + +// --------------------------------------------------------------------------- +// postMessage +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — postMessage", () => { + const sendResponse = { + message_id: "sent-001", + date: 1735689601000, + message_type: "TEXT" as const, + }; + + it("sends text message to correct URL", async () => { + const adapter = await createInitializedAdapter(); + mockFetch.mockResolvedValueOnce(zaloOk(sendResponse)); + await adapter.postMessage("zalo:chat-123", "Hello"); + const [url, init] = mockFetch.mock.calls[0]; + expect(String(url)).toContain("/sendMessage"); + const body = JSON.parse(init?.body as string); + expect(body.chat_id).toBe("chat-123"); + expect(body.text).toBe("Hello"); + }); + + it("returns correct RawMessage", async () => { + const adapter = await createInitializedAdapter(); + mockFetch.mockResolvedValueOnce(zaloOk(sendResponse)); + const result = await adapter.postMessage("zalo:chat-123", "Hello"); + expect(result.id).toBe("sent-001"); + expect(result.threadId).toBe("zalo:chat-123"); + }); + + it("sends photo via raw message without caption", async () => { + const adapter = await createInitializedAdapter(); + const photoResp = { + message_id: "photo-002", + date: 1735689602000, + message_type: "CHAT_PHOTO" as const, + }; + mockFetch.mockResolvedValueOnce(zaloOk(photoResp)); + await adapter.postMessage("zalo:chat-123", { + raw: JSON.stringify({ photo: "https://img.example.com/pic.jpg" }), + }); + const [url] = mockFetch.mock.calls[0]; + expect(String(url)).toContain("/sendPhoto"); + }); + + it("sends photo via raw message", async () => { + const adapter = await createInitializedAdapter(); + const photoResp = { + message_id: "photo-001", + date: 1735689602000, + message_type: "CHAT_PHOTO" as const, + }; + mockFetch.mockResolvedValueOnce(zaloOk(photoResp)); + const result = await adapter.postMessage("zalo:chat-123", { + raw: JSON.stringify({ + photo: "https://img.example.com/pic.jpg", + caption: "Nice", + }), + }); + const [url] = mockFetch.mock.calls[0]; + expect(String(url)).toContain("/sendPhoto"); + expect(result.id).toBe("photo-001"); + }); + + it("throws AdapterError for card messages", async () => { + const adapter = await createInitializedAdapter(); + await expect( + adapter.postMessage("zalo:chat-123", { + card: { title: "Card", sections: [] }, + } as unknown as string) + ).rejects.toThrow(AdapterError); + }); + + it("splits long messages into multiple calls", async () => { + const adapter = await createInitializedAdapter(); + const longText = "A".repeat(2001); + mockFetch + .mockResolvedValueOnce(zaloOk(sendResponse)) + .mockResolvedValueOnce(zaloOk(sendResponse)); + await adapter.postMessage("zalo:chat-123", longText); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("renders markdown to plain text", async () => { + const adapter = await createInitializedAdapter(); + mockFetch.mockResolvedValueOnce(zaloOk(sendResponse)); + await adapter.postMessage("zalo:chat-123", { markdown: "**bold**" }); + const [, init] = mockFetch.mock.calls[0]; + const body = JSON.parse(init?.body as string); + expect(body.text).not.toContain("**"); + expect(body.text).toContain("bold"); + }); + + it("renders ast message to text", async () => { + const adapter = await createInitializedAdapter(); + const { ZaloFormatConverter } = await import("./markdown"); + const converter = new ZaloFormatConverter(); + mockFetch.mockResolvedValueOnce(zaloOk(sendResponse)); + await adapter.postMessage("zalo:chat-123", { + ast: converter.toAst("hello world"), + }); + const [, init] = mockFetch.mock.calls[0]; + const body = JSON.parse(init?.body as string); + expect(body.text).toContain("hello world"); + }); + + it("raw.message.from.id is empty string when adapter not initialized", async () => { + // Covers the `this._botUserId ?? ""` branch when _botUserId is null (line 369) + const adapter = createAdapter(); + const sendResponse = { + message_id: "sent-uninit", + date: 1735689601000, + message_type: "TEXT" as const, + }; + mockFetch.mockResolvedValueOnce(zaloOk(sendResponse)); + const result = await adapter.postMessage("zalo:chat-123", "Hello"); + expect(result.raw.message.from.id).toBe(""); + }); + + it("raw.message.from.id is empty string for photo when not initialized", async () => { + // Covers `this._botUserId ?? ""` branch in sendPhoto response (line 316) + const adapter = createAdapter(); + const photoResp = { + message_id: "photo-uninit", + date: 1735689602000, + message_type: "CHAT_PHOTO" as const, + }; + mockFetch.mockResolvedValueOnce(zaloOk(photoResp)); + const result = await adapter.postMessage("zalo:chat-123", { + raw: JSON.stringify({ photo: "https://img.example.com/pic.jpg" }), + }); + expect(result.raw.message.from.id).toBe(""); + }); +}); + +// --------------------------------------------------------------------------- +// Unsupported operations +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — unsupported operations", () => { + it("editMessage throws", async () => { + const adapter = await createInitializedAdapter(); + await expect( + adapter.editMessage("zalo:chat-123", "msg-1", "new text") + ).rejects.toThrow(Error); + }); + + it("deleteMessage throws", async () => { + const adapter = await createInitializedAdapter(); + await expect( + adapter.deleteMessage("zalo:chat-123", "msg-1") + ).rejects.toThrow(Error); + }); + + it("addReaction throws", async () => { + const adapter = await createInitializedAdapter(); + await expect( + adapter.addReaction("zalo:chat-123", "msg-1", "thumbsup") + ).rejects.toThrow(Error); + }); + + it("removeReaction throws", async () => { + const adapter = await createInitializedAdapter(); + await expect( + adapter.removeReaction("zalo:chat-123", "msg-1", "thumbsup") + ).rejects.toThrow(Error); + }); +}); + +// --------------------------------------------------------------------------- +// startTyping +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — startTyping", () => { + it("calls sendChatAction with action=typing", async () => { + const adapter = await createInitializedAdapter(); + mockFetch.mockResolvedValueOnce(zaloOk({})); + await adapter.startTyping("zalo:chat-123"); + const [url, init] = mockFetch.mock.calls[0]; + expect(String(url)).toContain("/sendChatAction"); + const body = JSON.parse(init?.body as string); + expect(body.action).toBe("typing"); + expect(body.chat_id).toBe("chat-123"); + }); + + it("does not throw when sendChatAction fails", async () => { + const adapter = await createInitializedAdapter(); + mockFetch.mockResolvedValueOnce(new Response("Error", { status: 500 })); + await expect(adapter.startTyping("zalo:chat-123")).resolves.toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// stream +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — stream", () => { + const sendResponse = { + message_id: "stream-001", + date: 1735689601000, + message_type: "TEXT" as const, + }; + + async function* stringChunks(...parts: string[]): AsyncIterable { + for (const part of parts) { + yield part; + } + } + + async function* mixedChunks(): AsyncIterable<{ type: string; text: string }> { + yield { type: "markdown_text", text: "Hello " }; + yield { type: "thinking_text", text: "this is internal" }; + yield { type: "markdown_text", text: "world" }; + } + + it("buffers string chunks and sends as single message", async () => { + const adapter = await createInitializedAdapter(); + mockFetch.mockResolvedValueOnce(zaloOk(sendResponse)); + await adapter.stream("zalo:chat-123", stringChunks("Hello", " ", "world")); + expect(mockFetch).toHaveBeenCalledOnce(); + const [, init] = mockFetch.mock.calls[0]; + const body = JSON.parse(init?.body as string); + expect(body.text).toContain("Hello"); + expect(body.text).toContain("world"); + }); + + it("buffers markdown_text StreamChunks and sends", async () => { + const adapter = await createInitializedAdapter(); + mockFetch.mockResolvedValueOnce(zaloOk(sendResponse)); + await adapter.stream("zalo:chat-123", mixedChunks()); + const [, init] = mockFetch.mock.calls[0]; + const body = JSON.parse(init?.body as string); + expect(body.text).toContain("Hello"); + expect(body.text).toContain("world"); + expect(body.text).not.toContain("this is internal"); + }); +}); + +// --------------------------------------------------------------------------- +// fetchMessages +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — fetchMessages", () => { + it("returns empty messages array", async () => { + const adapter = await createInitializedAdapter(); + const result = await adapter.fetchMessages("zalo:chat-123"); + expect(result).toEqual({ messages: [] }); + }); +}); + +// --------------------------------------------------------------------------- +// fetchThread +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — fetchThread", () => { + it("returns ThreadInfo with isDM=true", async () => { + const adapter = await createInitializedAdapter(); + const info = await adapter.fetchThread("zalo:chat-123"); + expect(info.isDM).toBe(true); + expect(info.channelId).toBe("zalo:chat-123"); + }); + + it("channelName contains chatId", async () => { + const adapter = await createInitializedAdapter(); + const info = await adapter.fetchThread("zalo:chat-123"); + expect(info.channelName).toContain("chat-123"); + }); +}); + +// --------------------------------------------------------------------------- +// openDM +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — openDM", () => { + it("returns encoded thread ID for userId", async () => { + const adapter = await createInitializedAdapter(); + const threadId = await adapter.openDM("user-42"); + expect(threadId).toBe("zalo:user-42"); + }); +}); + +// --------------------------------------------------------------------------- +// renderFormatted +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — renderFormatted", () => { + it("renders AST to plain text", async () => { + const adapter = await createInitializedAdapter(); + const { ZaloFormatConverter } = await import("./markdown"); + const converter = new ZaloFormatConverter(); + const ast = converter.toAst("**bold** text"); + const result = adapter.renderFormatted(ast); + expect(result).toContain("bold"); + expect(result).not.toContain("**"); + }); +}); + +// --------------------------------------------------------------------------- +// API error handling +// --------------------------------------------------------------------------- + +describe("ZaloAdapter — API error handling", () => { + it("HTTP 429 → AdapterRateLimitError", async () => { + const adapter = await createInitializedAdapter(); + mockFetch.mockResolvedValueOnce( + new Response("Too Many Requests", { status: 429 }) + ); + await expect(adapter.postMessage("zalo:chat-123", "Hello")).rejects.toThrow( + AdapterRateLimitError + ); + }); + + it("HTTP 5xx → AdapterError", async () => { + const adapter = await createInitializedAdapter(); + mockFetch.mockResolvedValueOnce( + new Response("Internal Server Error", { status: 500 }) + ); + await expect(adapter.postMessage("zalo:chat-123", "Hello")).rejects.toThrow( + AdapterError + ); + }); + + it("ok=false with error_code=429 → AdapterRateLimitError", async () => { + const adapter = await createInitializedAdapter(); + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + ok: false, + error_code: 429, + description: "Rate limited", + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ) + ); + await expect(adapter.postMessage("zalo:chat-123", "Hello")).rejects.toThrow( + AdapterRateLimitError + ); + }); + + it("ok=false with other error code → AdapterError", async () => { + const adapter = await createInitializedAdapter(); + mockFetch.mockResolvedValueOnce(zaloError(200, 400, "Bad Request")); + await expect(adapter.postMessage("zalo:chat-123", "Hello")).rejects.toThrow( + AdapterError + ); + }); + + it("ok=false without error_code or description → AdapterError with 'unknown'", async () => { + const adapter = await createInitializedAdapter(); + mockFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ ok: false }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + await expect(adapter.postMessage("zalo:chat-123", "Hello")).rejects.toThrow( + AdapterError + ); + }); +}); + +// --------------------------------------------------------------------------- +// splitMessage (standalone export) +// --------------------------------------------------------------------------- + +describe("splitMessage", () => { + it("returns single-element array for short text", () => { + expect(splitMessage("hello")).toEqual(["hello"]); + }); + + it("returns single-element array for exactly 2000 chars", () => { + const text = "A".repeat(2000); + expect(splitMessage(text)).toHaveLength(1); + }); + + it("splits on paragraph boundary", () => { + const para1 = "A".repeat(1200); + const para2 = "B".repeat(1200); + const text = `${para1}\n\n${para2}`; + const chunks = splitMessage(text); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks[0]).toContain("A"); + expect(chunks[1]).toContain("B"); + }); + + it("splits on line boundary when no paragraph break", () => { + const line1 = "A".repeat(1200); + const line2 = "B".repeat(1200); + const text = `${line1}\n${line2}`; + const chunks = splitMessage(text); + expect(chunks.length).toBeGreaterThan(1); + }); + + it("hard-breaks at 2000 when no whitespace", () => { + const text = "X".repeat(4000); + const chunks = splitMessage(text); + expect(chunks).toHaveLength(2); + for (const chunk of chunks) { + expect(chunk.length).toBeLessThanOrEqual(2000); + } + }); + + it("empty string returns ['']", () => { + expect(splitMessage("")).toEqual([""]); + }); + + it("loop exits with no remainder when text splits exactly at boundary", () => { + // Hard-break at 2000; trimStart on "\n\n" leaves "" → if(remaining.length>0) false + const text = `${"X".repeat(2000)}\n\n`; + const chunks = splitMessage(text); + expect(chunks).toHaveLength(1); + expect(chunks[0]).toBe("X".repeat(2000)); + }); + + it("returns correct chunk count for 6000-char text", () => { + // Use paragraphs so we get clean splits + const para = "A".repeat(1900); + const text = [para, para, para].join("\n\n"); + const chunks = splitMessage(text); + expect(chunks).toHaveLength(3); + }); +}); diff --git a/packages/adapter-zalo/src/index.ts b/packages/adapter-zalo/src/index.ts new file mode 100644 index 00000000..3c560252 --- /dev/null +++ b/packages/adapter-zalo/src/index.ts @@ -0,0 +1,678 @@ +import { timingSafeEqual } from "node:crypto"; +import { + AdapterError, + AdapterRateLimitError, + extractCard, + ValidationError, +} from "@chat-adapter/shared"; +import type { + Adapter, + AdapterPostableMessage, + Attachment, + Author, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FormattedContent, + Logger, + RawMessage, + StreamChunk, + StreamOptions, + ThreadInfo, + WebhookOptions, +} from "chat"; +import { ConsoleLogger, Message } from "chat"; +import { ZaloFormatConverter } from "./markdown"; +import type { + ZaloAdapterConfig, + ZaloApiResponse, + ZaloGetMeResponse, + ZaloInboundMessage, + ZaloRawMessage, + ZaloSendResponse, + ZaloThreadId, + ZaloWebhookResult, +} from "./types"; + +/** Maximum message length for Zalo Bot API */ +const ZALO_MESSAGE_LIMIT = 2000; + +/** + * Split text into chunks that fit within Zalo's 2000-character message limit, + * breaking on paragraph boundaries when possible, then line boundaries, + * and finally at the character limit as a last resort. + */ +export function splitMessage(text: string): string[] { + if (text.length <= ZALO_MESSAGE_LIMIT) { + return [text]; + } + + const chunks: string[] = []; + let remaining = text; + + while (remaining.length > ZALO_MESSAGE_LIMIT) { + const slice = remaining.slice(0, ZALO_MESSAGE_LIMIT); + + // Try to break at a paragraph boundary + let breakIndex = slice.lastIndexOf("\n\n"); + if (breakIndex === -1 || breakIndex < ZALO_MESSAGE_LIMIT / 2) { + // Try a line boundary + breakIndex = slice.lastIndexOf("\n"); + } + if (breakIndex === -1 || breakIndex < ZALO_MESSAGE_LIMIT / 2) { + // Hard break at the limit + breakIndex = ZALO_MESSAGE_LIMIT; + } + + chunks.push(remaining.slice(0, breakIndex).trimEnd()); + remaining = remaining.slice(breakIndex).trimStart(); + } + + if (remaining.length > 0) { + chunks.push(remaining); + } + + return chunks; +} + +// Re-export types +export type { ZaloAdapterConfig, ZaloRawMessage, ZaloThreadId } from "./types"; + +/** + * Zalo adapter for chat SDK. + * + * Supports messaging via the Zalo Bot Platform API. + * Handles both PRIVATE and GROUP chat types. + * + * @example + * ```typescript + * import { Chat } from "chat"; + * import { createZaloAdapter } from "@chat-adapter/zalo"; + * import { MemoryState } from "@chat-adapter/state-memory"; + * + * const chat = new Chat({ + * userName: "my-bot", + * adapters: { + * zalo: createZaloAdapter(), + * }, + * state: new MemoryState(), + * }); + * ``` + */ +export class ZaloAdapter implements Adapter { + readonly name = "zalo"; + readonly lockScope = "channel" as const; + readonly persistMessageHistory = true; + readonly userName: string; + + private readonly botToken: string; + private readonly webhookSecret: string; + private readonly logger: Logger; + private readonly formatConverter = new ZaloFormatConverter(); + private chat: ChatInstance | null = null; + private _botUserId: string | null = null; + + /** Bot user ID used for self-message detection */ + get botUserId(): string | undefined { + return this._botUserId ?? undefined; + } + + constructor(config: ZaloAdapterConfig) { + this.botToken = config.botToken; + this.webhookSecret = config.webhookSecret; + this.logger = config.logger; + this.userName = config.userName; + } + + /** + * Initialize the adapter and validate the bot token via getMe. + */ + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + + const me = await this.apiRequest("getMe"); + this._botUserId = me.id; + this.logger.info("Zalo adapter initialized", { + botId: me.id, + accountName: me.account_name, + }); + } + + /** + * Handle incoming webhook from Zalo Bot Platform. + * + * Verifies the X-Bot-Api-Secret-Token header, parses the payload, + * and dispatches messages to the Chat instance. + */ + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + // Verify secret token + const token = request.headers.get("x-bot-api-secret-token"); + if (!this.verifySecretToken(token)) { + this.logger.warn("Zalo webhook: invalid secret token"); + return new Response("Unauthorized", { status: 401 }); + } + + const body = await request.text(); + this.logger.debug("Zalo webhook raw body", { + body: body.substring(0, 500), + }); + + let payload: ZaloWebhookResult; + try { + payload = JSON.parse(body) as ZaloWebhookResult; + } catch { + this.logger.error("Zalo webhook invalid JSON", { + bodyPreview: body.substring(0, 200), + }); + return new Response("Invalid JSON", { status: 400 }); + } + + if (!payload) { + this.logger.debug("Zalo webhook: payload ok=false or missing result", { + ok: false, + }); + return new Response("OK", { status: 200 }); + } + + const { event_name, message } = payload; + + switch (event_name) { + case "message.text.received": + case "message.image.received": + case "message.sticker.received": + this.handleInboundMessage(message, options); + break; + case "message.unsupported.received": + this.logger.debug("Zalo webhook: unsupported message type, ignoring", { + messageId: message.message_id, + }); + break; + default: + this.logger.debug("Zalo webhook: unknown event, ignoring", { + event_name, + }); + } + + return new Response("OK", { status: 200 }); + } + + /** + * Handle an inbound message from a user. + */ + private handleInboundMessage( + inbound: ZaloInboundMessage, + options?: WebhookOptions + ): void { + if (!this.chat) { + this.logger.warn("Chat instance not initialized, ignoring message"); + return; + } + + const threadId = this.encodeThreadId({ chatId: inbound.chat.id }); + const raw: ZaloRawMessage = { message: inbound }; + const message = this.parseMessage(raw); + this.chat.processMessage(this, threadId, message, options); + } + + /** + * Verify the webhook secret token using timing-safe comparison. + */ + private verifySecretToken(token: string | null): boolean { + if (!token) { + return false; + } + + try { + return timingSafeEqual( + Buffer.from(token), + Buffer.from(this.webhookSecret) + ); + } catch { + return false; + } + } + + /** + * Parse platform message format to normalized format. + */ + parseMessage(raw: ZaloRawMessage): Message { + const { message } = raw; + + // Extract text content based on message type + let text: string; + const attachments: Attachment[] = []; + + if (message.text) { + text = message.text; + } else if (message.photo) { + text = message.caption ?? "[Image]"; + attachments.push({ type: "image", url: message.photo }); + } else if (message.sticker) { + text = "[Sticker]"; + } else { + text = "[Unsupported message]"; + } + + const threadId = this.encodeThreadId({ chatId: message.chat.id }); + const formatted: FormattedContent = this.formatConverter.toAst(text); + + const author: Author = { + userId: message.from.id, + userName: message.from.display_name, + fullName: message.from.display_name, + isBot: message.from.is_bot, + isMe: message.from.id === this._botUserId, + }; + + return new Message({ + id: message.message_id, + threadId, + text, + formatted, + raw, + author, + metadata: { + dateSent: new Date(message.date), + edited: false, + }, + attachments, + }); + } + + /** + * Send a message to a Zalo chat. + */ + async postMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + const { chatId } = this.decodeThreadId(threadId); + + // Check for photo message + if (typeof message === "object" && "raw" in message) { + const rawMessage = JSON.parse(message.raw) as { + photo: string; + caption?: string; + }; + + const response = await this.sendPhoto( + chatId, + rawMessage.photo, + rawMessage.caption + ); + return { + id: response.message_id, + threadId, + raw: { + message: { + message_id: response.message_id, + date: response.date, + chat: { id: chatId, chat_type: "PRIVATE" }, + from: { + id: this._botUserId ?? "", + display_name: this.userName, + is_bot: true, + }, + photo: rawMessage.photo, + caption: rawMessage.caption, + }, + }, + }; + } + + const card = extractCard(message); + if (card) { + // Zalo doesn't support rich cards, throw an error if the message contains unsupported content + throw new AdapterError( + "Zalo adapter does not support card messages. Please convert your card to text or image before sending.", + "zalo" + ); + } + + const body = this.formatConverter.renderPostable(message); + return this.sendTextMessage(threadId, chatId, body); + } + + /** + * Split text into chunks at the 2000-character limit. + */ + splitMessage(text: string): string[] { + return splitMessage(text); + } + + /** + * Send a single text message (must be within 2000-char limit). + */ + private async sendSingleTextMessage( + threadId: string, + chatId: string, + text: string + ): Promise> { + const response = await this.apiRequest("sendMessage", { + chat_id: chatId, + text, + }); + + return { + id: response.message_id, + threadId, + raw: { + message: { + message_id: response.message_id, + date: response.date, + chat: { id: chatId, chat_type: "PRIVATE" }, + from: { + id: this._botUserId ?? "", + display_name: this.userName, + is_bot: true, + }, + text, + }, + }, + }; + } + + /** + * Send a text message, splitting into multiple messages if it exceeds + * Zalo's 2000-character limit. Returns the last message sent. + */ + private async sendTextMessage( + threadId: string, + chatId: string, + text: string + ): Promise> { + const chunks = this.splitMessage(text); + let result: RawMessage | undefined; + + for (const chunk of chunks) { + result = await this.sendSingleTextMessage(threadId, chatId, chunk); + } + + return result as RawMessage; + } + + /** + * Send a photo to a Zalo chat. + */ + async sendPhoto( + chatId: string, + photoUrl: string, + caption?: string + ): Promise { + return this.apiRequest("sendPhoto", { + chat_id: chatId, + photo: photoUrl, + ...(caption ? { caption } : {}), + }); + } + + /** + * Edit a message. Not supported by Zalo Bot API. + */ + async editMessage( + _threadId: string, + _messageId: string, + _message: AdapterPostableMessage + ): Promise> { + throw new Error("Zalo does not support editing messages."); + } + + /** + * Delete a message. Not supported by Zalo Bot API. + */ + async deleteMessage(_threadId: string, _messageId: string): Promise { + throw new Error("Zalo does not support deleting messages."); + } + + /** + * Add a reaction. Not supported by Zalo Bot API. + */ + async addReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new Error("Zalo does not support reactions."); + } + + /** + * Remove a reaction. Not supported by Zalo Bot API. + */ + async removeReaction( + _threadId: string, + _messageId: string, + _emoji: EmojiValue | string + ): Promise { + throw new Error("Zalo does not support reactions."); + } + + /** + * Start typing indicator via sendChatAction. + */ + async startTyping(threadId: string, _status?: string): Promise { + const { chatId } = this.decodeThreadId(threadId); + try { + await this.apiRequest("sendChatAction", { + chat_id: chatId, + action: "typing", + }); + } catch (error) { + // Typing is best-effort — don't propagate errors + this.logger.debug("Zalo startTyping failed (non-fatal)", { error }); + } + } + + /** + * Stream a message by buffering all chunks and sending as a single message. + * Zalo doesn't support message editing. + */ + async stream( + threadId: string, + textStream: AsyncIterable, + _options?: StreamOptions + ): Promise> { + let accumulated = ""; + for await (const chunk of textStream) { + if (typeof chunk === "string") { + accumulated += chunk; + } else if (chunk.type === "markdown_text") { + accumulated += chunk.text; + } + } + return this.postMessage(threadId, { markdown: accumulated }); + } + + /** + * Fetch messages. Not supported by Zalo Bot API. + */ + async fetchMessages( + _threadId: string, + _options?: FetchOptions + ): Promise> { + this.logger.debug( + "fetchMessages not supported on Zalo - message history is not available via Bot API" + ); + return { messages: [] }; + } + + /** + * Fetch thread info from decoded thread ID. + */ + async fetchThread(threadId: string): Promise { + const { chatId } = this.decodeThreadId(threadId); + + return { + id: threadId, + channelId: threadId, + channelName: `Zalo: ${chatId}`, + isDM: true, + metadata: { chatId }, + }; + } + + /** + * Encode a Zalo thread ID. + * + * Format: zalo:{chatId} + */ + encodeThreadId(platformData: ZaloThreadId): string { + return `zalo:${platformData.chatId}`; + } + + /** + * Decode a Zalo thread ID. + * + * Format: zalo:{chatId} + */ + decodeThreadId(threadId: string): ZaloThreadId { + if (!threadId.startsWith("zalo:")) { + throw new ValidationError("zalo", `Invalid Zalo thread ID: ${threadId}`); + } + + const chatId = threadId.slice(5); + if (!chatId) { + throw new ValidationError( + "zalo", + `Invalid Zalo thread ID format: ${threadId}` + ); + } + + return { chatId }; + } + + /** + * Derive channel ID from a Zalo thread ID. + * Zalo has no threading — channel === thread. + */ + channelIdFromThreadId(threadId: string): string { + return threadId; + } + + /** + * Zalo conversations default to DM (PRIVATE). + * We don't store chat_type in the thread ID. + */ + isDM(_threadId: string): boolean { + return true; + } + + /** + * Open a DM with a user. Returns the thread ID for the conversation. + */ + async openDM(userId: string): Promise { + return this.encodeThreadId({ chatId: userId }); + } + + /** + * Render formatted content to plain text. + */ + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content); + } + + // ============================================================================= + // Private helpers + // ============================================================================= + + /** + * Make an authenticated request to the Zalo Bot API. + * Note: bot token is embedded in the URL path — never log it. + */ + private async apiRequest( + method: string, + body?: unknown + ): Promise { + const url = `https://bot-api.zaloplatforms.com/bot${this.botToken}/${method}`; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + + if (response.status === 429) { + this.logger.error("Zalo API rate limited", { method }); + throw new AdapterRateLimitError("zalo"); + } + + if (!response.ok) { + const errorBody = await response.text(); + this.logger.error("Zalo API HTTP error", { + status: response.status, + body: errorBody, + method, + }); + throw new AdapterError( + `Zalo API error: ${response.status} ${errorBody}`, + "zalo" + ); + } + + const data = (await response.json()) as ZaloApiResponse; + + if (!data.ok) { + this.logger.error("Zalo API returned ok=false", { + error_code: data.error_code, + description: data.description, + method, + }); + + if (data.error_code === 429) { + throw new AdapterRateLimitError("zalo"); + } + + throw new AdapterError( + `Zalo API error (${data.error_code ?? "unknown"}): ${data.description ?? "unknown error"}`, + "zalo" + ); + } + + return data.result as T; + } +} + +/** + * Factory function to create a Zalo adapter. + * + * @example + * ```typescript + * const adapter = createZaloAdapter({ + * botToken: process.env.ZALO_BOT_TOKEN!, + * webhookSecret: process.env.ZALO_WEBHOOK_SECRET!, + * }); + * ``` + */ +export function createZaloAdapter(config?: { + botToken?: string; + logger?: Logger; + userName?: string; + webhookSecret?: string; +}): ZaloAdapter { + const logger = config?.logger ?? new ConsoleLogger("info").child("zalo"); + + const botToken = config?.botToken ?? process.env.ZALO_BOT_TOKEN; + if (!botToken) { + throw new ValidationError( + "zalo", + "botToken is required. Set ZALO_BOT_TOKEN or provide it in config." + ); + } + + const webhookSecret = + config?.webhookSecret ?? process.env.ZALO_WEBHOOK_SECRET; + if (!webhookSecret) { + throw new ValidationError( + "zalo", + "webhookSecret is required. Set ZALO_WEBHOOK_SECRET or provide it in config." + ); + } + + const userName = + config?.userName ?? process.env.ZALO_BOT_USERNAME ?? "zalo-bot"; + + return new ZaloAdapter({ botToken, webhookSecret, userName, logger }); +} diff --git a/packages/adapter-zalo/src/markdown.test.ts b/packages/adapter-zalo/src/markdown.test.ts new file mode 100644 index 00000000..6e2ceed8 --- /dev/null +++ b/packages/adapter-zalo/src/markdown.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from "vitest"; +import { ZaloFormatConverter } from "./markdown"; + +const ASTERISK_ITALIC_PATTERN = /\*italic\*/; +const HEADING_PREFIX_PATTERN = /^#/; +const PIPE_TABLE_NAME_PATTERN = /\|.*Name.*\|/; +const UNDERSCORE_ITALIC_PATTERN = /_italic_/; + +describe("ZaloFormatConverter", () => { + const converter = new ZaloFormatConverter(); + + // ------------------------------------------------------------------------- + // fromAst (AST -> plain text) + // ------------------------------------------------------------------------- + + describe("fromAst (AST -> plain text)", () => { + it("plain text paragraph", () => { + const result = converter.fromAst(converter.toAst("Hello world")); + expect(result).toContain("Hello world"); + }); + + it("strips bold markers", () => { + const result = converter.fromAst(converter.toAst("**bold text**")); + expect(result).toContain("bold text"); + expect(result).not.toContain("**"); + }); + + it("strips italic underscore markers", () => { + const result = converter.fromAst(converter.toAst("_italic text_")); + expect(result).toContain("italic text"); + expect(result).not.toContain("_italic"); + }); + + it("strips asterisk italic markers", () => { + const result = converter.fromAst(converter.toAst("*italic*")); + expect(result).toContain("italic"); + expect(result).not.toMatch(ASTERISK_ITALIC_PATTERN); + }); + + it("strips strikethrough markers", () => { + const result = converter.fromAst(converter.toAst("~~strike~~")); + expect(result).toContain("strike"); + expect(result).not.toContain("~~"); + }); + + it("keeps link text (links preserved as markdown)", () => { + const result = converter.fromAst( + converter.toAst("[link text](https://example.com)") + ); + expect(result).toContain("link text"); + }); + + it("preserves inline code text", () => { + const result = converter.fromAst(converter.toAst("Use `const x = 1`")); + expect(result).toContain("const x = 1"); + }); + + it("preserves code block content", () => { + const result = converter.fromAst( + converter.toAst("```js\nconst x = 1;\n```") + ); + expect(result).toContain("const x = 1;"); + }); + + it("converts heading to plain paragraph", () => { + const result = converter.fromAst(converter.toAst("# Heading text")); + expect(result).toContain("Heading text"); + expect(result).not.toMatch(HEADING_PREFIX_PATTERN); + }); + + it("unwraps bold inside heading", () => { + // "## **Bold heading**" → heading child is `strong` → branch that extracts children + const result = converter.fromAst(converter.toAst("## **Bold heading**")); + expect(result).toContain("Bold heading"); + expect(result).not.toContain("**"); + }); + + it("converts thematic break to ---", () => { + const result = converter.fromAst(converter.toAst("---")); + expect(result).toContain("---"); + }); + + it("converts table to ASCII code block", () => { + const table = "| Name | Age |\n|------|-----|\n| Alice | 30 |"; + const result = converter.fromAst(converter.toAst(table)); + expect(result).toContain("```"); + expect(result).toContain("Name"); + expect(result).toContain("Alice"); + expect(result).not.toMatch(PIPE_TABLE_NAME_PATTERN); + }); + }); + + // ------------------------------------------------------------------------- + // toAst (plain text -> AST) + // ------------------------------------------------------------------------- + + describe("toAst (plain text -> AST)", () => { + it("parses plain text", () => { + const ast = converter.toAst("Hello world"); + expect(ast.type).toBe("root"); + expect(ast.children.length).toBeGreaterThan(0); + }); + + it("parses text with markdown", () => { + const ast = converter.toAst("**bold**"); + expect(ast.type).toBe("root"); + expect(ast.children.length).toBeGreaterThan(0); + }); + + it("parses inline code", () => { + const ast = converter.toAst("`code`"); + expect(ast.type).toBe("root"); + expect(ast.children.length).toBeGreaterThan(0); + }); + }); + + // ------------------------------------------------------------------------- + // renderPostable + // ------------------------------------------------------------------------- + + describe("renderPostable", () => { + it("plain string returned as-is", () => { + expect(converter.renderPostable("Hello world")).toBe("Hello world"); + }); + + it("empty string unchanged", () => { + expect(converter.renderPostable("")).toBe(""); + }); + + it("raw message returned as-is", () => { + expect(converter.renderPostable({ raw: "raw content" })).toBe( + "raw content" + ); + }); + + it("markdown message stripped of formatting", () => { + const result = converter.renderPostable({ markdown: "**bold** text" }); + expect(result).toContain("bold"); + expect(result).not.toContain("**"); + }); + + it("ast message rendered as plain text", () => { + const result = converter.renderPostable({ + ast: converter.toAst("Hello from AST"), + }); + expect(result).toContain("Hello from AST"); + }); + + it("markdown with bold and italic stripped", () => { + const result = converter.renderPostable({ + markdown: "**bold** and _italic_", + }); + expect(result).toContain("bold"); + expect(result).toContain("italic"); + expect(result).not.toContain("**"); + expect(result).not.toMatch(UNDERSCORE_ITALIC_PATTERN); + }); + + it("table rendered as ASCII code block", () => { + const result = converter.renderPostable({ + markdown: "| A | B |\n| --- | --- |\n| 1 | 2 |", + }); + expect(result).toContain("```"); + expect(result).toContain("A"); + }); + + it("card message with fallback text uses base fallback", () => { + // Hits the super.renderPostable() path for unhandled message shapes + const result = converter.renderPostable({ + card: { title: "Hello", sections: [] }, + fallbackText: "fallback plain text", + } as unknown as Parameters[0]); + expect(result).toContain("fallback"); + }); + }); + + // ------------------------------------------------------------------------- + // stripFormatting (exercised via renderPostable) + // ------------------------------------------------------------------------- + + describe("stripFormatting (plain text output)", () => { + it("strips **bold**", () => { + expect(converter.renderPostable({ markdown: "Hello **world**!" })).toBe( + "Hello world!" + ); + }); + + it("strips *italic*", () => { + expect(converter.renderPostable({ markdown: "Hello *world*!" })).toBe( + "Hello world!" + ); + }); + + it("strips _italic_", () => { + expect(converter.renderPostable({ markdown: "Hello _world_!" })).toBe( + "Hello world!" + ); + }); + + it("strips ~~strike~~", () => { + expect(converter.renderPostable({ markdown: "Hello ~~world~~!" })).toBe( + "Hello world!" + ); + }); + + it("strips all formatting in combination", () => { + const result = converter.renderPostable({ + markdown: "**Bold** and _italic_ and ~~strike~~", + }); + expect(result).not.toContain("**"); + expect(result).not.toMatch(UNDERSCORE_ITALIC_PATTERN); + expect(result).not.toContain("~~"); + expect(result).toContain("Bold"); + expect(result).toContain("italic"); + expect(result).toContain("strike"); + }); + }); + + // ------------------------------------------------------------------------- + // roundtrip + // ------------------------------------------------------------------------- + + describe("roundtrip", () => { + it("plain text preserved", () => { + const input = "Hello world"; + const result = converter.fromAst(converter.toAst(input)); + expect(result).toContain("Hello world"); + }); + + it("bold stripped on roundtrip", () => { + const input = "**bold text**"; + const result = converter.fromAst(converter.toAst(input)); + expect(result).not.toContain("**"); + expect(result).toContain("bold text"); + }); + + it("link text preserved (links kept as markdown)", () => { + const input = "[click here](https://example.com)"; + const result = converter.fromAst(converter.toAst(input)); + expect(result).toContain("click here"); + }); + + it("code block content preserved", () => { + const input = "```\nconst x = 1;\n```"; + const result = converter.fromAst(converter.toAst(input)); + expect(result).toContain("const x = 1;"); + }); + + it("table converted to ASCII on roundtrip", () => { + const input = "| Col1 | Col2 |\n|------|------|\n| A | B |"; + const result = converter.fromAst(converter.toAst(input)); + expect(result).toContain("```"); + expect(result).toContain("Col1"); + expect(result).toContain("A"); + }); + }); +}); diff --git a/packages/adapter-zalo/src/markdown.ts b/packages/adapter-zalo/src/markdown.ts new file mode 100644 index 00000000..858bcced --- /dev/null +++ b/packages/adapter-zalo/src/markdown.ts @@ -0,0 +1,111 @@ +/** + * Zalo-specific format conversion using AST-based parsing. + * + * Zalo Bot API has no rich text formatting support. + * All messages are sent and received as plain text. + */ + +import { + type AdapterPostableMessage, + BaseFormatConverter, + type Content, + isTableNode, + parseMarkdown, + type Root, + stringifyMarkdown, + tableToAscii, + walkAst, +} from "chat"; + +export class ZaloFormatConverter extends BaseFormatConverter { + /** + * Convert an AST to plain text for Zalo. + * + * Strips all markdown formatting since Zalo doesn't render it. + * Preserves structure via whitespace. + */ + fromAst(ast: Root): string { + const transformed = walkAst(structuredClone(ast), (node: Content) => { + // Headings -> plain paragraph (strip bold wrapping) + if (node.type === "heading") { + const heading = node as Content & { children: Content[] }; + const children = heading.children.flatMap((child) => + child.type === "strong" + ? (child as Content & { children: Content[] }).children + : [child] + ); + return { + type: "paragraph", + children, + } as Content; + } + // Thematic breaks -> text separator + if (node.type === "thematicBreak") { + return { + type: "paragraph", + children: [{ type: "text", value: "---" }], + } as Content; + } + // Tables -> ASCII table + if (isTableNode(node)) { + return { + type: "code" as const, + value: tableToAscii(node), + lang: undefined, + } as Content; + } + return node; + }); + + // Stringify as plain markdown, then strip formatting markers + const markdown = stringifyMarkdown(transformed, { + emphasis: "_", + bullet: "-", + }).trim(); + + return this.stripFormatting(markdown); + } + + /** + * Parse plain text from Zalo into an AST. + */ + toAst(text: string): Root { + return parseMarkdown(text); + } + + /** + * Render a postable message to a plain text string for Zalo. + */ + override renderPostable(message: AdapterPostableMessage): string { + if (typeof message === "string") { + return message; + } + if ("raw" in message) { + return message.raw; + } + if ("markdown" in message) { + return this.fromAst(parseMarkdown(message.markdown)); + } + if ("ast" in message) { + return this.fromAst(message.ast); + } + return super.renderPostable(message); + } + + /** + * Strip markdown formatting markers so output is plain text. + * Converts **bold** -> text, _italic_ -> text, ~~strike~~ -> text. + */ + private stripFormatting(text: string): string { + let result = text; + // Strip **bold** + result = result.replace(/\*\*(.+?)\*\*/g, "$1"); + // Strip *bold* (single asterisk) + result = result.replace(/\*(.+?)\*/g, "$1"); + // Strip _italic_ + result = result.replace(/_(.+?)_/g, "$1"); + // Strip ~~strikethrough~~ + result = result.replace(/~~(.+?)~~/g, "$1"); + return result; + } +} diff --git a/packages/adapter-zalo/src/types.ts b/packages/adapter-zalo/src/types.ts new file mode 100644 index 00000000..ade8616a --- /dev/null +++ b/packages/adapter-zalo/src/types.ts @@ -0,0 +1,128 @@ +/** + * Type definitions for the Zalo adapter. + * + * Based on the Zalo Bot Platform API. + * @see https://bot.zapps.me/docs + */ + +import type { Logger } from "chat"; + +// ============================================================================= +// Configuration +// ============================================================================= + +/** + * Zalo adapter configuration. + */ +export interface ZaloAdapterConfig { + /** Bot token in format: 12345689:abc-xyz */ + botToken: string; + /** Logger instance for error reporting */ + logger: Logger; + /** Bot display name used for identification */ + userName: string; + /** Secret token for webhook verification (8-256 chars) */ + webhookSecret: string; +} + +// ============================================================================= +// Thread ID +// ============================================================================= + +/** + * Decoded thread ID for Zalo. + * + * Zalo has no threading concept. Each conversation is identified by chat.id. + * + * Format: zalo:{chatId} + */ +export interface ZaloThreadId { + /** Conversation/chat ID */ + chatId: string; +} + +// ============================================================================= +// Webhook Payloads +// ============================================================================= + +/** + * The result object within the webhook notification. + */ +export interface ZaloWebhookResult { + event_name: string; + message: ZaloInboundMessage; +} + +/** + * Inbound message from a user via Zalo webhook. + */ +export interface ZaloInboundMessage { + /** Optional caption for image messages */ + caption?: string; + /** Chat context */ + chat: { + chat_type: "PRIVATE" | "GROUP"; + id: string; + }; + /** Unix timestamp in milliseconds */ + date: number; + /** Message sender */ + from: { + display_name: string; + id: string; + is_bot: boolean; + }; + /** Unique message ID */ + message_id: string; + /** Photo URL (image messages) */ + photo?: string; + /** Sticker ID */ + sticker?: string; + /** Text content (text messages) */ + text?: string; +} + +// ============================================================================= +// Raw Message Type +// ============================================================================= + +/** + * Platform-specific raw message type for Zalo. + */ +export interface ZaloRawMessage { + /** The raw inbound message data */ + message: ZaloInboundMessage; +} + +// ============================================================================= +// API Response Types +// ============================================================================= + +/** + * Generic Zalo Bot API response envelope. + */ +export interface ZaloApiResponse { + description?: string; + error_code?: number; + ok: boolean; + result?: T; +} + +/** + * Response from sendMessage / sendPhoto / sendSticker. + */ +export interface ZaloSendResponse { + date: number; + message_id: string; + message_type: "TEXT" | "CHAT_PHOTO" | "STICKER"; +} + +/** + * Response from getMe. + */ +export interface ZaloGetMeResponse { + account_name: string; + account_type: string; + can_join_groups: boolean; + id: string; +} diff --git a/packages/adapter-zalo/tsconfig.json b/packages/adapter-zalo/tsconfig.json new file mode 100644 index 00000000..8768f5bd --- /dev/null +++ b/packages/adapter-zalo/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "strictNullChecks": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/adapter-zalo/tsup.config.ts b/packages/adapter-zalo/tsup.config.ts new file mode 100644 index 00000000..faf3167a --- /dev/null +++ b/packages/adapter-zalo/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, +}); diff --git a/packages/adapter-zalo/vitest.config.ts b/packages/adapter-zalo/vitest.config.ts new file mode 100644 index 00000000..edc2d946 --- /dev/null +++ b/packages/adapter-zalo/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b80f0e7..b6e1940a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -245,6 +245,16 @@ importers: specifier: ^5.7.2 version: 5.9.3 + examples/zalo: + dependencies: + hono: + specifier: ^4.12.10 + version: 4.12.10 + devDependencies: + '@types/bun': + specifier: latest + version: 1.3.11 + packages/adapter-discord: dependencies: '@chat-adapter/shared': @@ -479,6 +489,28 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-zalo: + dependencies: + '@chat-adapter/shared': + specifier: workspace:* + version: link:../adapter-shared + chat: + specifier: workspace:* + version: link:../chat + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/chat: dependencies: '@workflow/serde': @@ -2729,6 +2761,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/bun@1.3.11': + resolution: {integrity: sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3137,6 +3172,9 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + bun-types@1.3.11: + resolution: {integrity: sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4035,6 +4073,10 @@ packages: hls.js@1.6.15: resolution: {integrity: sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA==} + hono@4.12.10: + resolution: {integrity: sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w==} + engines: {node: '>=16.9.0'} + html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} @@ -8023,6 +8065,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/bun@1.3.11': + dependencies: + bun-types: 1.3.11 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -8423,6 +8469,10 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + bun-types@1.3.11: + dependencies: + '@types/node': 25.3.2 + bundle-require@5.1.0(esbuild@0.27.2): dependencies: esbuild: 0.27.2 @@ -9538,6 +9588,8 @@ snapshots: hls.js@1.6.15: {} + hono@4.12.10: {} + html-entities@2.6.0: {} html-escaper@2.0.2: {} diff --git a/turbo.json b/turbo.json index b9c56a82..e623d23a 100644 --- a/turbo.json +++ b/turbo.json @@ -14,6 +14,9 @@ "WHATSAPP_APP_SECRET", "WHATSAPP_PHONE_NUMBER_ID", "WHATSAPP_VERIFY_TOKEN", + "ZALO_BOT_TOKEN", + "ZALO_WEBHOOK_SECRET", + "ZALO_BOT_USERNAME", "BOT_USERNAME", "REDIS_URL" ], diff --git a/vitest.config.ts b/vitest.config.ts index 9a11f035..aabcbcee 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "packages/adapter-shared", "packages/adapter-slack", "packages/adapter-teams", + "packages/adapter-zalo", "packages/state-ioredis", "packages/state-memory", "packages/state-redis", From 2ed97233b1ab09408e4e8afeeea331d5f3345eb9 Mon Sep 17 00:00:00 2001 From: buiducnhat Date: Mon, 6 Apr 2026 00:52:02 +0700 Subject: [PATCH 2/3] feat(adapter-zalo): add Zalo adapter with support details and update documentation --- apps/docs/adapters.json | 10 +++ .../adapters/components/adapter-card.tsx | 2 + apps/docs/content/docs/adapters.mdx | 72 +++++++++---------- apps/docs/content/docs/index.mdx | 4 +- apps/docs/lib/logos.tsx | 51 +++++++++++++ packages/adapter-zalo/README.md | 25 +------ 6 files changed, 103 insertions(+), 61 deletions(-) diff --git a/apps/docs/adapters.json b/apps/docs/adapters.json index eabaad5e..4411e33a 100644 --- a/apps/docs/adapters.json +++ b/apps/docs/adapters.json @@ -79,6 +79,16 @@ "beta": true, "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-whatsapp" }, + { + "name": "Zalo", + "slug": "zalo", + "type": "platform", + "icon": "zalo", + "description": "Build bots for Zalo — Vietnam's leading messaging platform — with support for official account messaging and webhooks.", + "packageName": "@chat-adapter/zalo", + "beta": true, + "readme": "https://github.com/vercel/chat/tree/main/packages/adapter-zalo" + }, { "name": "Redis", "slug": "redis", diff --git a/apps/docs/app/[lang]/(home)/adapters/components/adapter-card.tsx b/apps/docs/app/[lang]/(home)/adapters/components/adapter-card.tsx index 44038077..1cd06bd0 100644 --- a/apps/docs/app/[lang]/(home)/adapters/components/adapter-card.tsx +++ b/apps/docs/app/[lang]/(home)/adapters/components/adapter-card.tsx @@ -27,6 +27,7 @@ import { teams, telegram, whatsapp, + zalo, } from "@/lib/logos"; const iconMap: Record< @@ -45,6 +46,7 @@ const iconMap: Record< postgres, memory, whatsapp, + zalo, instagram: SiInstagram, signal: SiSignal, x: SiX, diff --git a/apps/docs/content/docs/adapters.mdx b/apps/docs/content/docs/adapters.mdx index e0ca5a39..a7121167 100644 --- a/apps/docs/content/docs/adapters.mdx +++ b/apps/docs/content/docs/adapters.mdx @@ -14,51 +14,51 @@ Ready to build your own? Follow the [building](/docs/contributing/building) guid ### Messaging -| Feature | [Slack](/adapters/slack) | [Teams](/adapters/teams) | [Google Chat](/adapters/google-chat) | [Discord](/adapters/discord) | [Telegram](/adapters/telegram) | [GitHub](/adapters/github) | [Linear](/adapters/linear) | [WhatsApp](/adapters/whatsapp) | -|---------|-------|-------|-------------|---------|---------|--------|--------|-----------| -| Post message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ❌ | ❌ | ✅ Images, audio, docs | -| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ❌ | ❌ | ❌ | -| Scheduled messages | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | [Slack](/adapters/slack) | [Teams](/adapters/teams) | [Google Chat](/adapters/google-chat) | [Discord](/adapters/discord) | [Telegram](/adapters/telegram) | [GitHub](/adapters/github) | [Linear](/adapters/linear) | [WhatsApp](/adapters/whatsapp) | [Zalo](/adapters/zalo) | +|---------|-------|-------|-------------|---------|---------|--------|--------|-----------|------| +| Post message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Edit message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | +| Delete message | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| File uploads | ✅ | ✅ | ❌ | ✅ | ⚠️ Single file | ❌ | ❌ | ✅ Images, audio, docs | ✅ Images | +| Streaming | ✅ Native | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ⚠️ Post+Edit | ❌ | ❌ | ❌ | ⚠️ Post only | +| Scheduled messages | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ### Rich content -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| -| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | WhatsApp templates | -| Buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard callbacks | ❌ | ❌ | ✅ Interactive replies | -| Link buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard URLs | ❌ | ❌ | ❌ | -| Select menus | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | -| Tables | ✅ Block Kit | ✅ GFM | ⚠️ ASCII | ✅ GFM | ⚠️ ASCII | ✅ GFM | ✅ GFM | ❌ | -| Fields | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ Template variables | -| Images in cards | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | -| Modals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Zalo | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|------| +| Card format | Block Kit | Adaptive Cards | Google Chat Cards | Embeds | Markdown + inline keyboard buttons | GFM Markdown | Markdown | WhatsApp templates | ❌ | +| Buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard callbacks | ❌ | ❌ | ✅ Interactive replies | ❌ | +| Link buttons | ✅ | ✅ | ✅ | ✅ | ⚠️ Inline keyboard URLs | ❌ | ❌ | ❌ | ❌ | +| Select menus | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Tables | ✅ Block Kit | ✅ GFM | ⚠️ ASCII | ✅ GFM | ⚠️ ASCII | ✅ GFM | ✅ GFM | ❌ | ❌ | +| Fields | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ Template variables | ❌ | +| Images in cards | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ✅ | ❌ | +| Modals | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ### Conversations -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| -| Slash commands | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | -| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | -| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | -| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | -| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Zalo | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|------| +| Slash commands | ✅ | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Mentions | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Add reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Remove reactions | ✅ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | ❌ | +| Typing indicator | ❌ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | +| DMs | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | +| Ephemeral messages | ✅ Native | ❌ | ✅ Native | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ### Message history -| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | -|---------|-------|-------|-------------|---------|----------|--------|--------|-----------| -| Fetch messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ✅ | ⚠️ Cached sent messages only | -| Fetch single message | ✅ | ❌ | ❌ | ❌ | ⚠️ Cached | ❌ | ❌ | ⚠️ Cached sent messages only | -| Fetch thread info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | -| Fetch channel messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ❌ | ⚠️ Cached sent messages only | -| List threads | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | -| Fetch channel info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | -| Post channel message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | +| Feature | Slack | Teams | Google Chat | Discord | Telegram | GitHub | Linear | WhatsApp | Zalo | +|---------|-------|-------|-------------|---------|----------|--------|--------|-----------|------| +| Fetch messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ✅ | ⚠️ Cached sent messages only | ❌ | +| Fetch single message | ✅ | ❌ | ❌ | ❌ | ⚠️ Cached | ❌ | ❌ | ⚠️ Cached sent messages only | ❌ | +| Fetch thread info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | +| Fetch channel messages | ✅ | ✅ | ✅ | ✅ | ⚠️ Cached | ✅ | ❌ | ⚠️ Cached sent messages only | ❌ | +| List threads | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ | +| Fetch channel info | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | +| Post channel message | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ⚠️ indicates partial support — the feature works with limitations. See individual adapter pages for details. diff --git a/apps/docs/content/docs/index.mdx b/apps/docs/content/docs/index.mdx index 42633146..5efd9417 100644 --- a/apps/docs/content/docs/index.mdx +++ b/apps/docs/content/docs/index.mdx @@ -4,7 +4,7 @@ description: A unified SDK for building chat bots across Slack, Microsoft Teams, type: overview --- -Chat SDK is a TypeScript library for building chat bots that work across multiple platforms with a single codebase. Write your bot logic once and deploy it to Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, and WhatsApp. +Chat SDK is a TypeScript library for building chat bots that work across multiple platforms with a single codebase. Write your bot logic once and deploy it to Slack, Microsoft Teams, Google Chat, Discord, Telegram, GitHub, Linear, WhatsApp, and Zalo. ## Why Chat SDK? @@ -59,6 +59,7 @@ Each adapter factory auto-detects credentials from environment variables (`SLACK | GitHub | `@chat-adapter/github` | Yes | Yes | No | No | No | No | | Linear | `@chat-adapter/linear` | Yes | Yes | No | No | No | No | | WhatsApp | `@chat-adapter/whatsapp` | N/A | Yes | Partial | No | No | Yes | +| Zalo | `@chat-adapter/zalo` | No | No | No | No | Post only | Yes | ## AI coding agent support @@ -85,6 +86,7 @@ The SDK is distributed as a set of packages you install based on your needs: | `@chat-adapter/github` | GitHub Issues adapter | | `@chat-adapter/linear` | Linear Issues adapter | | `@chat-adapter/whatsapp` | WhatsApp Business adapter | +| `@chat-adapter/zalo` | Zalo adapter | | `@chat-adapter/state-redis` | Redis state adapter (production) | | `@chat-adapter/state-ioredis` | ioredis state adapter (alternative) | | `@chat-adapter/state-pg` | PostgreSQL state adapter (production) | diff --git a/apps/docs/lib/logos.tsx b/apps/docs/lib/logos.tsx index 805ac5cc..0487c614 100644 --- a/apps/docs/lib/logos.tsx +++ b/apps/docs/lib/logos.tsx @@ -494,3 +494,54 @@ export const whatsapp = (props: ComponentProps<"svg">) => ( ); + +export const zalo = (props: ComponentProps<"svg">) => ( + + + + + + + + + + +); diff --git a/packages/adapter-zalo/README.md b/packages/adapter-zalo/README.md index d335e3ec..07bf8f4d 100644 --- a/packages/adapter-zalo/README.md +++ b/packages/adapter-zalo/README.md @@ -99,14 +99,6 @@ Zalo delivers all events via POST requests with an `X-Bot-Api-Secret-Token` head | Streaming | Buffered (accumulates then sends) | | Auto-chunking | Yes (splits at 2000 chars) | -### Rich content - -| Feature | Supported | -| ------------------- | ------------------ | -| Interactive buttons | No (text fallback) | -| Cards | Text fallback | -| Tables | ASCII | - ### Conversations | Feature | Supported | @@ -114,7 +106,7 @@ Zalo delivers all events via POST requests with an `X-Bot-Api-Secret-Token` head | Reactions | No (Zalo limitation) | | Typing indicator | Yes (`sendChatAction`) | | DMs | Yes | -| Group chats | Yes | +| Group chats | Pending | | Open DM | Yes | ### Incoming message types @@ -133,21 +125,6 @@ Zalo delivers all events via POST requests with an `X-Bot-Api-Secret-Token` head | Fetch messages | No (Zalo API limitation) | | Fetch thread info | Yes | -## Cards - -Zalo has no interactive message API. All card elements are rendered as plain text: - -``` -CARD TITLE - -Body text here... - -• Button 1 label -• Button 2 label: https://example.com - ---- -``` - ## Thread ID format ``` From 17a86d7d3506622b7143605f5bdec9689c2d298c Mon Sep 17 00:00:00 2001 From: buiducnhat Date: Mon, 6 Apr 2026 09:14:24 +0700 Subject: [PATCH 3/3] fix(adapter-zalo): handle unsupported message formats in photo message processing --- packages/adapter-zalo/src/index.ts | 60 +++++++++++++++++------------- 1 file changed, 34 insertions(+), 26 deletions(-) diff --git a/packages/adapter-zalo/src/index.ts b/packages/adapter-zalo/src/index.ts index 3c560252..d12986d4 100644 --- a/packages/adapter-zalo/src/index.ts +++ b/packages/adapter-zalo/src/index.ts @@ -294,34 +294,42 @@ export class ZaloAdapter implements Adapter { // Check for photo message if (typeof message === "object" && "raw" in message) { - const rawMessage = JSON.parse(message.raw) as { - photo: string; - caption?: string; - }; - - const response = await this.sendPhoto( - chatId, - rawMessage.photo, - rawMessage.caption - ); - return { - id: response.message_id, - threadId, - raw: { - message: { - message_id: response.message_id, - date: response.date, - chat: { id: chatId, chat_type: "PRIVATE" }, - from: { - id: this._botUserId ?? "", - display_name: this.userName, - is_bot: true, + try { + const rawMessage = JSON.parse(message.raw) as { + photo: string; + caption?: string; + }; + + const response = await this.sendPhoto( + chatId, + rawMessage.photo, + rawMessage.caption + ); + return { + id: response.message_id, + threadId, + raw: { + message: { + message_id: response.message_id, + date: response.date, + chat: { id: chatId, chat_type: "PRIVATE" }, + from: { + id: this._botUserId ?? "", + display_name: this.userName, + is_bot: true, + }, + photo: rawMessage.photo, + caption: rawMessage.caption, }, - photo: rawMessage.photo, - caption: rawMessage.caption, }, - }, - }; + }; + } catch (_error) { + // If parsing fails, throw an error about unsupported message format + throw new AdapterError( + "Zalo adapter does not support this message type. Please convert your card to text or image before sending.", + "zalo" + ); + } } const card = extractCard(message);