Merge pull request #991 from longmaba/fix/zalo-pairing-and-webhook

fix(zalo): fix pairing channel detection and webhook payload format
This commit is contained in:
Peter Steinberger
2026-01-16 04:54:08 +00:00
committed by GitHub
7 changed files with 46 additions and 23 deletions

View File

@@ -53,8 +53,11 @@ export async function notifyPairingApproved(params: {
id: string;
cfg: ClawdbotConfig;
runtime?: RuntimeEnv;
/** Extension channels can pass their adapter directly to bypass registry lookup. */
pairingAdapter?: ChannelPairingAdapter;
}): Promise<void> {
const adapter = requirePairingAdapter(params.channelId);
// Extensions may provide adapter directly to bypass ESM module isolation
const adapter = params.pairingAdapter ?? requirePairingAdapter(params.channelId);
if (!adapter.notifyApproval) return;
await adapter.notifyApproval({
cfg: params.cfg,

View File

@@ -8,11 +8,10 @@ const pairingIdLabels: Record<string, string> = {
telegram: "telegramUserId",
discord: "discordUserId",
};
const requirePairingAdapter = vi.fn((channel: string) => ({
const getPairingAdapter = vi.fn((channel: string) => ({
idLabel: pairingIdLabels[channel] ?? "userId",
}));
const listPairingChannels = vi.fn(() => ["telegram", "discord"]);
const resolvePairingChannel = vi.fn((raw: string) => raw);
vi.mock("../pairing/pairing-store.js", () => ({
listChannelPairingRequests,
@@ -21,9 +20,8 @@ vi.mock("../pairing/pairing-store.js", () => ({
vi.mock("../channels/plugins/pairing.js", () => ({
listPairingChannels,
resolvePairingChannel,
notifyPairingApproved,
requirePairingAdapter,
getPairingAdapter,
}));
vi.mock("../config/config.js", () => ({

View File

@@ -2,7 +2,6 @@ import type { Command } from "commander";
import {
listPairingChannels,
notifyPairingApproved,
resolvePairingChannel,
} from "../channels/plugins/pairing.js";
import { loadConfig } from "../config/config.js";
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
@@ -16,8 +15,14 @@ import { theme } from "../terminal/theme.js";
const CHANNELS: PairingChannel[] = listPairingChannels();
/** Parse channel, allowing extension channels not in core registry. */
function parseChannel(raw: unknown): PairingChannel {
return resolvePairingChannel(raw);
const value = String(raw ?? "").trim().toLowerCase();
if (!value) throw new Error("Channel required");
if (CHANNELS.includes(value as PairingChannel)) return value 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}`);
}
async function notifyApproved(channel: PairingChannel, id: string) {

View File

@@ -1,6 +1,6 @@
import { requirePairingAdapter } from "../channels/plugins/pairing.js";
import { getPairingAdapter } from "../channels/plugins/pairing.js";
import type { PairingChannel } from "./pairing-store.js";
export function resolvePairingIdLabel(channel: PairingChannel): string {
return requirePairingAdapter(channel).idLabel;
return getPairingAdapter(channel)?.idLabel ?? "userId";
}

View File

@@ -4,8 +4,8 @@ import os from "node:os";
import path from "node:path";
import lockfile from "proper-lockfile";
import { requirePairingAdapter } from "../channels/plugins/pairing.js";
import type { ChannelId } from "../channels/plugins/types.js";
import { getPairingAdapter } from "../channels/plugins/pairing.js";
import type { ChannelId, ChannelPairingAdapter } from "../channels/plugins/types.js";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
const PAIRING_CODE_LENGTH = 8;
@@ -48,15 +48,24 @@ function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string {
return resolveOAuthDir(env, stateDir);
}
/** Sanitize channel ID for use in filenames (prevent path traversal). */
function safeChannelKey(channel: PairingChannel): string {
const raw = String(channel).trim().toLowerCase();
if (!raw) throw new Error("invalid pairing channel");
const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_");
if (!safe || safe === "_") throw new Error("invalid pairing channel");
return safe;
}
function resolvePairingPath(channel: PairingChannel, env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveCredentialsDir(env), `${channel}-pairing.json`);
return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-pairing.json`);
}
function resolveAllowFromPath(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
): string {
return path.join(resolveCredentialsDir(env), `${channel}-allowFrom.json`);
return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-allowFrom.json`);
}
function safeParseJson<T>(raw: string): T | null {
@@ -184,11 +193,11 @@ function normalizeId(value: string | number): string {
}
function normalizeAllowEntry(channel: PairingChannel, entry: string): string {
const adapter = requirePairingAdapter(channel);
const trimmed = entry.trim();
if (!trimmed) return "";
if (trimmed === "*") return "";
const normalized = adapter.normalizeAllowEntry ? adapter.normalizeAllowEntry(trimmed) : trimmed;
const adapter = getPairingAdapter(channel);
const normalized = adapter?.normalizeAllowEntry ? adapter.normalizeAllowEntry(trimmed) : trimmed;
return String(normalized).trim();
}
@@ -196,7 +205,6 @@ export async function readChannelAllowFromStore(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
): Promise<string[]> {
requirePairingAdapter(channel);
const filePath = resolveAllowFromPath(channel, env);
const { value } = await readJsonFile<AllowFromStore>(filePath, {
version: 1,
@@ -211,7 +219,6 @@ export async function addChannelAllowFromStoreEntry(params: {
entry: string | number;
env?: NodeJS.ProcessEnv;
}): Promise<{ changed: boolean; allowFrom: string[] }> {
requirePairingAdapter(params.channel);
const env = params.env ?? process.env;
const filePath = resolveAllowFromPath(params.channel, env);
return await withFileLock(
@@ -242,7 +249,6 @@ export async function listChannelPairingRequests(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
): Promise<PairingRequest[]> {
requirePairingAdapter(channel);
const filePath = resolvePairingPath(channel, env);
return await withFileLock(
filePath,
@@ -287,8 +293,9 @@ export async function upsertChannelPairingRequest(params: {
id: string | number;
meta?: Record<string, string | undefined | null>;
env?: NodeJS.ProcessEnv;
/** Extension channels can pass their adapter directly to bypass registry lookup. */
pairingAdapter?: ChannelPairingAdapter;
}): Promise<{ code: string; created: boolean }> {
requirePairingAdapter(params.channel);
const env = params.env ?? process.env;
const filePath = resolvePairingPath(params.channel, env);
return await withFileLock(
@@ -383,7 +390,6 @@ export async function approveChannelPairingCode(params: {
code: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ id: string; entry?: PairingRequest } | null> {
requirePairingAdapter(params.channel);
const env = params.env ?? process.env;
const code = params.code.trim().toUpperCase();
if (!code) return null;