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:
@@ -30,6 +30,11 @@ export type CoreChannelDeps = {
|
|||||||
channel: string;
|
channel: string;
|
||||||
id: string;
|
id: string;
|
||||||
meta?: { name?: string };
|
meta?: { name?: string };
|
||||||
|
pairingAdapter?: {
|
||||||
|
idLabel: string;
|
||||||
|
normalizeAllowEntry?: (entry: string) => string;
|
||||||
|
notifyApproval?: (params: { cfg: unknown; id: string; runtime?: unknown }) => Promise<void>;
|
||||||
|
};
|
||||||
}) => Promise<{ code: string; created: boolean }>;
|
}) => Promise<{ code: string; created: boolean }>;
|
||||||
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
|
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
|
||||||
saveMediaBuffer: (
|
saveMediaBuffer: (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type ZaloMessage,
|
type ZaloMessage,
|
||||||
type ZaloUpdate,
|
type ZaloUpdate,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
import { zaloPlugin } from "./channel.js";
|
||||||
import { loadCoreChannelDeps } from "./core-bridge.js";
|
import { loadCoreChannelDeps } from "./core-bridge.js";
|
||||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||||
import type { CoreConfig } from "./types.js";
|
import type { CoreConfig } from "./types.js";
|
||||||
@@ -176,8 +177,12 @@ export async function handleZaloWebhookRequest(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = body.value as { ok?: boolean; result?: ZaloUpdate };
|
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }
|
||||||
if (!payload?.ok || !payload.result) {
|
const raw = body.value as Record<string, unknown>;
|
||||||
|
const update: ZaloUpdate | undefined =
|
||||||
|
raw.ok === true && raw.result ? (raw.result as ZaloUpdate) : (raw as ZaloUpdate);
|
||||||
|
|
||||||
|
if (!update?.event_name) {
|
||||||
res.statusCode = 400;
|
res.statusCode = 400;
|
||||||
res.end("invalid payload");
|
res.end("invalid payload");
|
||||||
return true;
|
return true;
|
||||||
@@ -185,7 +190,7 @@ export async function handleZaloWebhookRequest(
|
|||||||
|
|
||||||
target.statusSink?.({ lastInboundAt: Date.now() });
|
target.statusSink?.({ lastInboundAt: Date.now() });
|
||||||
processUpdate(
|
processUpdate(
|
||||||
payload.result,
|
update,
|
||||||
target.token,
|
target.token,
|
||||||
target.account,
|
target.account,
|
||||||
target.config,
|
target.config,
|
||||||
@@ -445,6 +450,7 @@ async function processMessageWithPipeline(params: {
|
|||||||
channel: "zalo",
|
channel: "zalo",
|
||||||
id: senderId,
|
id: senderId,
|
||||||
meta: { name: senderName ?? undefined },
|
meta: { name: senderName ?? undefined },
|
||||||
|
pairingAdapter: zaloPlugin.pairing,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (created) {
|
if (created) {
|
||||||
|
|||||||
@@ -53,8 +53,11 @@ export async function notifyPairingApproved(params: {
|
|||||||
id: string;
|
id: string;
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
runtime?: RuntimeEnv;
|
runtime?: RuntimeEnv;
|
||||||
|
/** Extension channels can pass their adapter directly to bypass registry lookup. */
|
||||||
|
pairingAdapter?: ChannelPairingAdapter;
|
||||||
}): Promise<void> {
|
}): 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;
|
if (!adapter.notifyApproval) return;
|
||||||
await adapter.notifyApproval({
|
await adapter.notifyApproval({
|
||||||
cfg: params.cfg,
|
cfg: params.cfg,
|
||||||
|
|||||||
@@ -8,11 +8,10 @@ const pairingIdLabels: Record<string, string> = {
|
|||||||
telegram: "telegramUserId",
|
telegram: "telegramUserId",
|
||||||
discord: "discordUserId",
|
discord: "discordUserId",
|
||||||
};
|
};
|
||||||
const requirePairingAdapter = vi.fn((channel: string) => ({
|
const getPairingAdapter = vi.fn((channel: string) => ({
|
||||||
idLabel: pairingIdLabels[channel] ?? "userId",
|
idLabel: pairingIdLabels[channel] ?? "userId",
|
||||||
}));
|
}));
|
||||||
const listPairingChannels = vi.fn(() => ["telegram", "discord"]);
|
const listPairingChannels = vi.fn(() => ["telegram", "discord"]);
|
||||||
const resolvePairingChannel = vi.fn((raw: string) => raw);
|
|
||||||
|
|
||||||
vi.mock("../pairing/pairing-store.js", () => ({
|
vi.mock("../pairing/pairing-store.js", () => ({
|
||||||
listChannelPairingRequests,
|
listChannelPairingRequests,
|
||||||
@@ -21,9 +20,8 @@ vi.mock("../pairing/pairing-store.js", () => ({
|
|||||||
|
|
||||||
vi.mock("../channels/plugins/pairing.js", () => ({
|
vi.mock("../channels/plugins/pairing.js", () => ({
|
||||||
listPairingChannels,
|
listPairingChannels,
|
||||||
resolvePairingChannel,
|
|
||||||
notifyPairingApproved,
|
notifyPairingApproved,
|
||||||
requirePairingAdapter,
|
getPairingAdapter,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../config/config.js", () => ({
|
vi.mock("../config/config.js", () => ({
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Command } from "commander";
|
|||||||
import {
|
import {
|
||||||
listPairingChannels,
|
listPairingChannels,
|
||||||
notifyPairingApproved,
|
notifyPairingApproved,
|
||||||
resolvePairingChannel,
|
|
||||||
} from "../channels/plugins/pairing.js";
|
} from "../channels/plugins/pairing.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
|
import { resolvePairingIdLabel } from "../pairing/pairing-labels.js";
|
||||||
@@ -16,8 +15,14 @@ import { theme } from "../terminal/theme.js";
|
|||||||
|
|
||||||
const CHANNELS: PairingChannel[] = listPairingChannels();
|
const CHANNELS: PairingChannel[] = listPairingChannels();
|
||||||
|
|
||||||
|
/** Parse channel, allowing extension channels not in core registry. */
|
||||||
function parseChannel(raw: unknown): PairingChannel {
|
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) {
|
async function notifyApproved(channel: PairingChannel, id: string) {
|
||||||
|
|||||||
@@ -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";
|
import type { PairingChannel } from "./pairing-store.js";
|
||||||
|
|
||||||
export function resolvePairingIdLabel(channel: PairingChannel): string {
|
export function resolvePairingIdLabel(channel: PairingChannel): string {
|
||||||
return requirePairingAdapter(channel).idLabel;
|
return getPairingAdapter(channel)?.idLabel ?? "userId";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import os from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import lockfile from "proper-lockfile";
|
import lockfile from "proper-lockfile";
|
||||||
import { requirePairingAdapter } from "../channels/plugins/pairing.js";
|
import { getPairingAdapter } from "../channels/plugins/pairing.js";
|
||||||
import type { ChannelId } from "../channels/plugins/types.js";
|
import type { ChannelId, ChannelPairingAdapter } from "../channels/plugins/types.js";
|
||||||
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
|
||||||
|
|
||||||
const PAIRING_CODE_LENGTH = 8;
|
const PAIRING_CODE_LENGTH = 8;
|
||||||
@@ -48,15 +48,24 @@ function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string {
|
|||||||
return resolveOAuthDir(env, stateDir);
|
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 {
|
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(
|
function resolveAllowFromPath(
|
||||||
channel: PairingChannel,
|
channel: PairingChannel,
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
): string {
|
): 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 {
|
function safeParseJson<T>(raw: string): T | null {
|
||||||
@@ -184,11 +193,11 @@ function normalizeId(value: string | number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAllowEntry(channel: PairingChannel, entry: string): string {
|
function normalizeAllowEntry(channel: PairingChannel, entry: string): string {
|
||||||
const adapter = requirePairingAdapter(channel);
|
|
||||||
const trimmed = entry.trim();
|
const trimmed = entry.trim();
|
||||||
if (!trimmed) return "";
|
if (!trimmed) return "";
|
||||||
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();
|
return String(normalized).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +205,6 @@ export async function readChannelAllowFromStore(
|
|||||||
channel: PairingChannel,
|
channel: PairingChannel,
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
requirePairingAdapter(channel);
|
|
||||||
const filePath = resolveAllowFromPath(channel, env);
|
const filePath = resolveAllowFromPath(channel, env);
|
||||||
const { value } = await readJsonFile<AllowFromStore>(filePath, {
|
const { value } = await readJsonFile<AllowFromStore>(filePath, {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -211,7 +219,6 @@ export async function addChannelAllowFromStoreEntry(params: {
|
|||||||
entry: string | number;
|
entry: string | number;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
}): Promise<{ changed: boolean; allowFrom: string[] }> {
|
}): Promise<{ changed: boolean; allowFrom: string[] }> {
|
||||||
requirePairingAdapter(params.channel);
|
|
||||||
const env = params.env ?? process.env;
|
const env = params.env ?? process.env;
|
||||||
const filePath = resolveAllowFromPath(params.channel, env);
|
const filePath = resolveAllowFromPath(params.channel, env);
|
||||||
return await withFileLock(
|
return await withFileLock(
|
||||||
@@ -242,7 +249,6 @@ export async function listChannelPairingRequests(
|
|||||||
channel: PairingChannel,
|
channel: PairingChannel,
|
||||||
env: NodeJS.ProcessEnv = process.env,
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
): Promise<PairingRequest[]> {
|
): Promise<PairingRequest[]> {
|
||||||
requirePairingAdapter(channel);
|
|
||||||
const filePath = resolvePairingPath(channel, env);
|
const filePath = resolvePairingPath(channel, env);
|
||||||
return await withFileLock(
|
return await withFileLock(
|
||||||
filePath,
|
filePath,
|
||||||
@@ -287,8 +293,9 @@ export async function upsertChannelPairingRequest(params: {
|
|||||||
id: string | number;
|
id: string | number;
|
||||||
meta?: Record<string, string | undefined | null>;
|
meta?: Record<string, string | undefined | null>;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
|
/** Extension channels can pass their adapter directly to bypass registry lookup. */
|
||||||
|
pairingAdapter?: ChannelPairingAdapter;
|
||||||
}): Promise<{ code: string; created: boolean }> {
|
}): Promise<{ code: string; created: boolean }> {
|
||||||
requirePairingAdapter(params.channel);
|
|
||||||
const env = params.env ?? process.env;
|
const env = params.env ?? process.env;
|
||||||
const filePath = resolvePairingPath(params.channel, env);
|
const filePath = resolvePairingPath(params.channel, env);
|
||||||
return await withFileLock(
|
return await withFileLock(
|
||||||
@@ -383,7 +390,6 @@ export async function approveChannelPairingCode(params: {
|
|||||||
code: string;
|
code: string;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
}): Promise<{ id: string; entry?: PairingRequest } | null> {
|
}): Promise<{ id: string; entry?: PairingRequest } | null> {
|
||||||
requirePairingAdapter(params.channel);
|
|
||||||
const env = params.env ?? process.env;
|
const env = params.env ?? process.env;
|
||||||
const code = params.code.trim().toUpperCase();
|
const code = params.code.trim().toUpperCase();
|
||||||
if (!code) return null;
|
if (!code) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user