diff --git a/CHANGELOG.md b/CHANGELOG.md index f43e8e76d..dff875b75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ - Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c. - Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow. - Fix: sanitize user-facing error text + strip `` tags across reply pipelines. (#975) — thanks @ThomsenDrake. +- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba. ## 2026.1.14-1 diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index f7cd5d042..d81025a8c 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -178,9 +178,13 @@ export async function handleZaloWebhookRequest( } // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result } - const raw = body.value as Record; + const raw = body.value; + const record = + raw && typeof raw === "object" ? (raw as Record) : null; const update: ZaloUpdate | undefined = - raw.ok === true && raw.result ? (raw.result as ZaloUpdate) : (raw as ZaloUpdate); + record && record.ok === true && record.result + ? (record.result as ZaloUpdate) + : (record as ZaloUpdate | null) ?? undefined; if (!update?.event_name) { res.statusCode = 400; diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts new file mode 100644 index 000000000..fed1c7b7a --- /dev/null +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -0,0 +1,70 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { describe, expect, it } from "vitest"; + +import type { CoreConfig, ResolvedZaloAccount } from "./types.js"; +import type { loadCoreChannelDeps } from "./core-bridge.js"; +import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js"; + +async function withServer( + handler: Parameters[0], + fn: (baseUrl: string) => Promise, +) { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address() as AddressInfo | null; + if (!address) throw new Error("missing server address"); + try { + await fn(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +describe("handleZaloWebhookRequest", () => { + it("returns 400 for non-object payloads", async () => { + const deps = {} as Awaited>; + const account: ResolvedZaloAccount = { + accountId: "default", + enabled: true, + token: "tok", + tokenSource: "config", + config: {}, + }; + const unregister = registerZaloWebhookTarget({ + token: "tok", + account, + config: {} as CoreConfig, + runtime: {}, + deps, + secret: "secret", + path: "/hook", + mediaMaxMb: 5, + }); + + try { + await withServer(async (req, res) => { + const handled = await handleZaloWebhookRequest(req, res); + if (!handled) { + res.statusCode = 404; + res.end("not found"); + } + }, async (baseUrl) => { + const response = await fetch(`${baseUrl}/hook`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + }, + body: "null", + }); + + expect(response.status).toBe(400); + }); + } finally { + unregister(); + } + }); +}); diff --git a/src/cli/pairing-cli.test.ts b/src/cli/pairing-cli.test.ts index 456171452..00fae9d30 100644 --- a/src/cli/pairing-cli.test.ts +++ b/src/cli/pairing-cli.test.ts @@ -8,10 +8,16 @@ const pairingIdLabels: Record = { telegram: "telegramUserId", discord: "discordUserId", }; +const normalizeChannelId = vi.fn((raw: string) => { + if (!raw) return null; + if (raw === "imsg") return "imessage"; + if (["telegram", "discord", "imessage"].includes(raw)) return raw; + return null; +}); const getPairingAdapter = vi.fn((channel: string) => ({ idLabel: pairingIdLabels[channel] ?? "userId", })); -const listPairingChannels = vi.fn(() => ["telegram", "discord"]); +const listPairingChannels = vi.fn(() => ["telegram", "discord", "imessage"]); vi.mock("../pairing/pairing-store.js", () => ({ listChannelPairingRequests, @@ -24,6 +30,10 @@ vi.mock("../channels/plugins/pairing.js", () => ({ getPairingAdapter, })); +vi.mock("../channels/plugins/index.js", () => ({ + normalizeChannelId, +})); + vi.mock("../config/config.js", () => ({ loadConfig: vi.fn().mockReturnValue({}), })); @@ -63,6 +73,32 @@ describe("pairing cli", () => { expect(listChannelPairingRequests).toHaveBeenCalledWith("telegram"); }); + it("normalizes channel aliases", async () => { + const { registerPairingCli } = await import("./pairing-cli.js"); + listChannelPairingRequests.mockResolvedValueOnce([]); + + const program = new Command(); + program.name("test"); + registerPairingCli(program); + await program.parseAsync(["pairing", "list", "imsg"], { from: "user" }); + + expect(normalizeChannelId).toHaveBeenCalledWith("imsg"); + expect(listChannelPairingRequests).toHaveBeenCalledWith("imessage"); + }); + + it("accepts extension channels outside the registry", async () => { + const { registerPairingCli } = await import("./pairing-cli.js"); + listChannelPairingRequests.mockResolvedValueOnce([]); + + const program = new Command(); + program.name("test"); + registerPairingCli(program); + await program.parseAsync(["pairing", "list", "zalo"], { from: "user" }); + + expect(normalizeChannelId).toHaveBeenCalledWith("zalo"); + expect(listChannelPairingRequests).toHaveBeenCalledWith("zalo"); + }); + it("labels Discord ids as discordUserId", async () => { const { registerPairingCli } = await import("./pairing-cli.js"); listChannelPairingRequests.mockResolvedValueOnce([ diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index a272b135a..7e36c7e66 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -3,6 +3,7 @@ import { listPairingChannels, notifyPairingApproved, } from "../channels/plugins/pairing.js"; +import { normalizeChannelId } from "../channels/plugins/index.js"; import { loadConfig } from "../config/config.js"; import { resolvePairingIdLabel } from "../pairing/pairing-labels.js"; import { @@ -17,9 +18,25 @@ const CHANNELS: PairingChannel[] = listPairingChannels(); /** Parse channel, allowing extension channels not in core registry. */ function parseChannel(raw: unknown): PairingChannel { - const value = String(raw ?? "").trim().toLowerCase(); + const value = ( + typeof raw === "string" + ? raw + : typeof raw === "number" || typeof raw === "boolean" + ? String(raw) + : "" + ) + .trim() + .toLowerCase(); if (!value) throw new Error("Channel required"); - if (CHANNELS.includes(value as PairingChannel)) return value as PairingChannel; + + const normalized = normalizeChannelId(value); + if (normalized) { + if (!CHANNELS.includes(normalized as PairingChannel)) { + throw new Error(`Channel ${normalized} does not support pairing`); + } + return normalized as PairingChannel; + } + // Allow extension channels: validate format but don't require registry if (/^[a-z][a-z0-9_-]{0,63}$/.test(value)) return value as PairingChannel; throw new Error(`Invalid channel: ${value}`);