From cedeca17089aa6139401f0d08752e0b4ce93bbfc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Jul 2026 02:53:07 -0700 Subject: [PATCH] fix(zalo): normalize prefixed Bot API delivery targets (#115814) Preserve the original Zalo provider and target-kind normalization fix and regression test from #106171. Add real configured-plugin loopback HTTP proof for both text and photo delivery through group and user aliases. Source: https://github.com/openclaw/openclaw/pull/106171 Co-authored-by: Peter Steinberger Co-authored-by: lzw112 --- .../channel.target-prefix.integration.test.ts | 149 ++++++++++++++++++ extensions/zalo/src/send.test.ts | 37 +++++ extensions/zalo/src/send.ts | 7 +- 3 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 extensions/zalo/src/channel.target-prefix.integration.test.ts diff --git a/extensions/zalo/src/channel.target-prefix.integration.test.ts b/extensions/zalo/src/channel.target-prefix.integration.test.ts new file mode 100644 index 000000000000..96b8e17c76b0 --- /dev/null +++ b/extensions/zalo/src/channel.target-prefix.integration.test.ts @@ -0,0 +1,149 @@ +// Prove configured Zalo delivery against the actual Bot API HTTP boundary. +import { createServer, type IncomingMessage, type Server } from "node:http"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { zaloPlugin } from "./channel.js"; +import type { OpenClawConfig } from "./runtime-api.js"; + +type RecordedZaloRequest = { + body: Record; + method: string; +}; + +const originalZaloApiUrl = process.env.ZALO_API_URL; +const cfg = { + channels: { + zalo: { + accounts: { + default: { + botToken: "test-bot-token", + }, + }, + }, + }, +} as OpenClawConfig; + +async function readJsonBody(request: IncomingMessage): Promise> { + let body = ""; + for await (const chunk of request) { + body += String(chunk); + } + return JSON.parse(body) as Record; +} + +async function listen(server: Server): Promise { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected loopback Zalo Bot API address"); + } + return `http://127.0.0.1:${String(address.port)}`; +} + +async function close(server: Server): Promise { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +describe("configured Zalo outbound target delivery", () => { + let server: Server; + let requests: RecordedZaloRequest[]; + + beforeEach(async () => { + requests = []; + server = createServer((request, response) => { + void (async () => { + const method = request.url?.match(/\/bot[^/]+\/([^/?]+)/u)?.[1] ?? "unknown"; + const body = await readJsonBody(request); + requests.push({ body, method }); + + const chatId = typeof body.chat_id === "string" ? body.chat_id : ""; + const isBareChatId = Boolean(chatId) && !/^(?:zalo|zl|group|user|dm):/iu.test(chatId); + response.writeHead(200, { "content-type": "application/json" }); + response.end( + JSON.stringify( + isBareChatId + ? { + ok: true, + result: { + message_id: `${method}-delivered`, + }, + } + : { + ok: false, + error_code: 400, + description: "chat_id must be a bare Zalo conversation ID", + }, + ), + ); + })().catch((error: unknown) => { + response.writeHead(500, { "content-type": "application/json" }); + response.end(JSON.stringify({ ok: false, description: String(error) })); + }); + }); + process.env.ZALO_API_URL = await listen(server); + }); + + afterEach(async () => { + if (originalZaloApiUrl === undefined) { + delete process.env.ZALO_API_URL; + } else { + process.env.ZALO_API_URL = originalZaloApiUrl; + } + await close(server); + }); + + it.each([ + { target: "zalo:group:group-123", peer: "group-123", kind: "text" }, + { target: "zl:user:direct-456", peer: "direct-456", kind: "text" }, + { target: "group:group-789", peer: "group-789", kind: "media" }, + { target: "zalo:dm:direct-987", peer: "direct-987", kind: "media" }, + ] as const)( + "sends $kind to the same bare peer selected by the session route ($target)", + async ({ target, peer, kind }) => { + const route = await zaloPlugin.messaging?.resolveOutboundSessionRoute?.({ + cfg, + agentId: "main", + accountId: "default", + target, + }); + expect(route?.peer.id).toBe(peer); + + if (kind === "text") { + const sendText = zaloPlugin.outbound?.sendText; + if (!sendText) { + throw new Error("expected configured Zalo outbound text adapter"); + } + const result = await sendText({ cfg, to: target, text: "proof" }); + expect(result.messageId).toBe("sendMessage-delivered"); + expect(requests).toEqual([ + { + method: "sendMessage", + body: { chat_id: peer, text: "proof" }, + }, + ]); + return; + } + + const sendMedia = zaloPlugin.outbound?.sendMedia; + if (!sendMedia) { + throw new Error("expected configured Zalo outbound media adapter"); + } + const mediaUrl = "https://93.184.216.34/proof.png"; + const result = await sendMedia({ cfg, to: target, text: "proof", mediaUrl }); + expect(result.messageId).toBe("sendPhoto-delivered"); + expect(requests).toEqual([ + { + method: "sendPhoto", + body: { chat_id: peer, photo: mediaUrl, caption: "proof" }, + }, + ]); + }, + ); +}); diff --git a/extensions/zalo/src/send.test.ts b/extensions/zalo/src/send.test.ts index 2a3c70f34c2b..f1a04f49d0e8 100644 --- a/extensions/zalo/src/send.test.ts +++ b/extensions/zalo/src/send.test.ts @@ -131,6 +131,43 @@ describe("zalo send", () => { ); }); + it("normalizes provider and target-kind prefixes before calling the Bot API", async () => { + sendMessageMock.mockResolvedValueOnce({ + ok: true, + result: { message_id: "z-msg-prefixed" }, + }); + sendPhotoMock.mockResolvedValueOnce({ + ok: true, + result: { message_id: "z-photo-prefixed" }, + }); + + await sendMessageZalo("zalo:group:dm-chat-prefixed-text", "hello", { + token: "zalo-token", + }); + await sendMessageZalo("zl:user:dm-chat-prefixed-photo", "", { + token: "zalo-token", + mediaUrl: "https://example.com/photo.jpg", + }); + + expect(sendMessageMock).toHaveBeenCalledWith( + "zalo-token", + { + chat_id: "dm-chat-prefixed-text", + text: "hello", + }, + undefined, + ); + expect(sendPhotoMock).toHaveBeenCalledWith( + "zalo-token", + { + chat_id: "dm-chat-prefixed-photo", + photo: "https://example.com/photo.jpg", + caption: undefined, + }, + undefined, + ); + }); + it("fails fast for missing token or blank photo URLs", async () => { const missingToken = await sendMessageZalo("dm-chat-3", "hello", {}); expectFailedSend(missingToken, "No Zalo bot token configured"); diff --git a/extensions/zalo/src/send.ts b/extensions/zalo/src/send.ts index cc2693c72ad4..295b86fe8d1b 100644 --- a/extensions/zalo/src/send.ts +++ b/extensions/zalo/src/send.ts @@ -5,6 +5,7 @@ import { type MessageReceiptPartKind, } from "openclaw/plugin-sdk/channel-outbound"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { stripChannelTargetPrefix, stripTargetKindPrefix } from "openclaw/plugin-sdk/core"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { resolveZaloAccount } from "./accounts.js"; @@ -119,13 +120,17 @@ function resolveValidatedSendContext( if (!token) { return { ok: false, error: "No Zalo bot token configured" }; } - const trimmedChatId = chatId?.trim(); + const trimmedChatId = normalizeZaloSendChatId(chatId); if (!trimmedChatId) { return { ok: false, error: "No chat_id provided" }; } return { ok: true, chatId: trimmedChatId, token, fetcher }; } +function normalizeZaloSendChatId(chatId: string): string { + return stripTargetKindPrefix(stripChannelTargetPrefix(chatId, "zalo", "zl")); +} + function resolveSendContextOrFailure( chatId: string, options: ZaloSendOptions,