openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
798 lines (797 loc) • 27.8 kB
JavaScript
import { C as resolveExpiresAtMsFromDurationMs, o as asDateTimestampMs } from "./number-coercion-CJQ8TR--.js";
import { l as normalizeStringEntries } from "./string-normalization-WNUDCpXX.js";
import { i as deliverTextOrMediaReply, m as resolveSendableOutboundReplyParts } from "./reply-payload-D-VMfpYI.js";
import { a as warnMissingProviderGroupPolicyFallbackOnce, r as resolveDefaultGroupPolicy } from "./runtime-group-policy-CSD1Is8G.js";
import "./number-runtime-DBLVDypr.js";
import { t as waitForAbortSignal } from "./abort-signal-BAyXz5Zx.js";
import "./runtime-env-D_2q8-VK.js";
import "./string-coerce-runtime-CEGJWkQ_.js";
import { r as resolveInboundRouteEnvelopeBuilderWithRuntime } from "./inbound-envelope-DF4N8x5q.js";
import { r as logTypingFailure } from "./logging-gUWPKC5g.js";
import { t as createHostedOutboundMediaStore } from "./outbound-media-MHBXtJsJ.js";
import { t as registerPluginHttpRoute } from "./http-registry-C6KnnU9A.js";
import "./channel-feedback-Bi1IYmE_.js";
import { i as resolveStableChannelMessageIngress } from "./message-access-mwxdTUGC.js";
import "./channel-ingress-runtime-rGltVkKC.js";
import { n as createChannelPairingController } from "./channel-pairing-Bk6_JhTm.js";
import "./webhook-ingress-Dgd3Jtks.js";
import { n as resolveWebhookPath } from "./webhook-path-CaYfbDPb.js";
import { n as resolveZaloRuntimeGroupPolicy, t as normalizeZaloAllowEntry } from "./group-access-D9aRMnO8.js";
import { t as getZaloRuntime } from "./runtime-BtVraGqZ.js";
import { a as getUpdates, c as sendMessage, l as sendPhoto, n as ZaloApiError, o as getWebhookInfo, r as deleteWebhook, s as sendChatAction, t as resolveZaloProxyFetch, u as setWebhook } from "./proxy-DseE4oKJ.js";
//#region extensions/zalo/src/monitor-durable.ts
function prepareZaloDurableReplyPayload(params) {
if (!params.payload.text) return params.payload;
return {
...params.payload,
text: params.convertMarkdownTables(params.payload.text, params.tableMode)
};
}
function resolveZaloDurableReplyOptions(params) {
if (params.infoKind !== "final") return false;
const reply = resolveSendableOutboundReplyParts(params.payload);
if (reply.hasMedia || !reply.hasText) return false;
return { to: params.chatId };
}
//#endregion
//#region extensions/zalo/src/outbound-media.ts
const ZALO_OUTBOUND_MEDIA_TTL_MS = 2 * 6e4;
const ZALO_OUTBOUND_MEDIA_SEGMENT = "media";
const ZALO_OUTBOUND_MEDIA_PREFIX = `/${ZALO_OUTBOUND_MEDIA_SEGMENT}/`;
const ZALO_OUTBOUND_MEDIA_ID_RE = /^[a-f0-9]{24}$/;
const ZALO_OUTBOUND_MEDIA_NAMESPACE = "hosted-outbound-media";
const ZALO_OUTBOUND_MEDIA_CHUNKS_NAMESPACE = "hosted-outbound-media-chunks";
const ZALO_OUTBOUND_MEDIA_MAX_ENTRIES = 64;
const ZALO_OUTBOUND_MEDIA_MAX_CHUNK_ROWS = ZALO_OUTBOUND_MEDIA_MAX_ENTRIES * 256;
let hostedZaloMediaStore;
function createHostedZaloMediaStore() {
const runtime = getZaloRuntime();
return createHostedOutboundMediaStore({
metadataStore: runtime.state.openKeyedStore({
namespace: ZALO_OUTBOUND_MEDIA_NAMESPACE,
maxEntries: 80
}),
chunkStore: runtime.state.openKeyedStore({
namespace: ZALO_OUTBOUND_MEDIA_CHUNKS_NAMESPACE,
maxEntries: ZALO_OUTBOUND_MEDIA_MAX_CHUNK_ROWS
}),
ttlMs: ZALO_OUTBOUND_MEDIA_TTL_MS,
maxEntries: ZALO_OUTBOUND_MEDIA_MAX_ENTRIES,
maxChunkRows: ZALO_OUTBOUND_MEDIA_MAX_CHUNK_ROWS,
resolveExpiresAtMs: (ttlMs) => resolveExpiresAtMsFromDurationMs(ttlMs)
});
}
function getHostedZaloMediaStore() {
hostedZaloMediaStore ??= createHostedZaloMediaStore();
return hostedZaloMediaStore;
}
function resolveHostedZaloMediaRoutePrefix(params) {
const webhookRoutePath = resolveWebhookPath({
webhookPath: params.webhookPath,
webhookUrl: params.webhookUrl,
defaultPath: null
});
if (!webhookRoutePath) throw new Error("Zalo webhookPath could not be derived for outbound media hosting");
return webhookRoutePath === "/" ? `/${ZALO_OUTBOUND_MEDIA_SEGMENT}` : `${webhookRoutePath}/${ZALO_OUTBOUND_MEDIA_SEGMENT}`;
}
function resolveHostedZaloMediaRoutePath(params) {
return `${resolveHostedZaloMediaRoutePrefix(params)}/`;
}
async function prepareHostedZaloMediaUrl(params) {
const now = asDateTimestampMs(Date.now());
if ((now === void 0 ? void 0 : resolveExpiresAtMsFromDurationMs(ZALO_OUTBOUND_MEDIA_TTL_MS, { nowMs: now })) === void 0) throw new Error("Zalo outbound media expiry could not be resolved");
const routePath = resolveHostedZaloMediaRoutePath({
webhookUrl: params.webhookUrl,
webhookPath: params.webhookPath
});
const publicBaseUrl = new URL(params.webhookUrl).origin;
return await getHostedZaloMediaStore().prepareUrl({
mediaUrl: params.mediaUrl,
routePath,
publicBaseUrl,
maxBytes: params.maxBytes,
...params.proxyUrl ? { proxyUrl: params.proxyUrl } : {}
});
}
async function tryHandleHostedZaloMediaRequest(req, res) {
const store = getHostedZaloMediaStore();
await store.cleanupExpired();
const method = req.method ?? "GET";
if (method !== "GET" && method !== "HEAD") return false;
let url;
try {
url = new URL(req.url ?? "/", "http://localhost");
} catch {
return false;
}
const mediaPath = url.pathname;
const prefixIndex = mediaPath.lastIndexOf(ZALO_OUTBOUND_MEDIA_PREFIX);
if (prefixIndex < 0) return false;
const routePath = mediaPath.slice(0, prefixIndex + ZALO_OUTBOUND_MEDIA_PREFIX.length);
const id = mediaPath.slice(prefixIndex + ZALO_OUTBOUND_MEDIA_PREFIX.length);
if (!id || !ZALO_OUTBOUND_MEDIA_ID_RE.test(id)) {
res.statusCode = 404;
res.end("Not Found");
return true;
}
const now = asDateTimestampMs(Date.now());
if (now === void 0) {
await store.delete(id);
res.statusCode = 410;
res.end("Expired");
return true;
}
const entry = await store.read(id, now);
if (!entry || entry.metadata.routePath !== routePath) {
res.statusCode = 404;
res.end("Not Found");
return true;
}
const expiresAt = asDateTimestampMs(entry.metadata.expiresAt);
if (expiresAt === void 0 || expiresAt <= now) {
await store.delete(id);
res.statusCode = 410;
res.end("Expired");
return true;
}
if (url.searchParams.get("token") !== entry.metadata.token) {
res.statusCode = 401;
res.end("Unauthorized");
return true;
}
if (entry.metadata.contentType) res.setHeader("Content-Type", entry.metadata.contentType);
res.setHeader("Cache-Control", "no-store");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Length", String(entry.metadata.byteLength));
if (method === "HEAD") {
res.statusCode = 200;
res.end();
return true;
}
res.statusCode = 200;
res.end(entry.buffer);
await store.delete(id);
return true;
}
//#endregion
//#region extensions/zalo/src/monitor.ts
const ZALO_TEXT_LIMIT = 2e3;
const DEFAULT_MEDIA_MAX_MB = 5;
const WEBHOOK_CLEANUP_TIMEOUT_MS = 5e3;
const ZALO_TYPING_TIMEOUT_MS = 5e3;
let zaloWebhookModulePromise;
const hostedMediaRouteRefs = /* @__PURE__ */ new Map();
function loadZaloWebhookModule() {
zaloWebhookModulePromise ??= import("./monitor.webhook-DlIKHGp1.js");
return zaloWebhookModulePromise;
}
function registerSharedHostedMediaRoute(params) {
const unregister = registerPluginHttpRoute({
auth: "plugin",
match: "prefix",
path: params.path,
pluginId: "zalo",
source: "zalo-hosted-media",
accountId: params.accountId,
log: params.log,
handler: async (req, res) => {
if (!await tryHandleHostedZaloMediaRequest(req, res) && !res.headersSent) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
}
});
const existing = hostedMediaRouteRefs.get(params.path);
if (existing) {
existing.count += 1;
existing.unregisters.push(unregister);
return () => {
const current = hostedMediaRouteRefs.get(params.path);
if (!current) return;
if (current.count > 1) {
current.count -= 1;
return;
}
hostedMediaRouteRefs.delete(params.path);
for (const unregisterHandle of current.unregisters) unregisterHandle();
};
}
hostedMediaRouteRefs.set(params.path, {
count: 1,
unregisters: [unregister]
});
return () => {
const current = hostedMediaRouteRefs.get(params.path);
if (!current) return;
if (current.count > 1) {
current.count -= 1;
return;
}
hostedMediaRouteRefs.delete(params.path);
for (const unregisterHandle of current.unregisters) unregisterHandle();
};
}
function formatZaloError(error) {
if (error instanceof Error) return error.stack ?? `${error.name}: ${error.message}`;
return String(error);
}
function describeWebhookTarget(rawUrl) {
try {
const parsed = new URL(rawUrl);
return `${parsed.origin}${parsed.pathname}`;
} catch {
return rawUrl;
}
}
function normalizeWebhookUrl(url) {
const trimmed = url?.trim();
return trimmed ? trimmed : void 0;
}
function logVerbose(core, runtime, message) {
if (core.logging.shouldLogVerbose()) runtime.log?.(`[zalo] ${message}`);
}
async function handleZaloWebhookRequest(req, res) {
const { handleZaloWebhookRequest: handleZaloWebhookRequestInternal } = await loadZaloWebhookModule();
return await handleZaloWebhookRequestInternal(req, res, async ({ update, target }) => {
await processUpdate({
update,
token: target.token,
account: target.account,
config: target.config,
runtime: target.runtime,
core: target.core,
mediaMaxMb: target.mediaMaxMb,
canHostMedia: target.canHostMedia,
webhookUrl: target.webhookUrl,
webhookPath: target.webhookPath,
statusSink: target.statusSink,
fetcher: target.fetcher
});
});
}
function startPollingLoop(params) {
const { token, account, config, runtime, core, mediaMaxMb, canHostMedia, webhookUrl, webhookPath, abortSignal, isStopped, statusSink, fetcher } = params;
const pollTimeout = 30;
const processingContext = {
token,
account,
config,
runtime,
core,
mediaMaxMb,
canHostMedia,
webhookUrl,
webhookPath,
statusSink,
fetcher
};
runtime.log?.(`[${account.accountId}] Zalo polling loop started timeout=${String(pollTimeout)}s`);
const poll = async () => {
if (isStopped() || abortSignal.aborted) return;
try {
const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
if (isStopped() || abortSignal.aborted) return;
if (response.ok && response.result) {
statusSink?.({ lastInboundAt: Date.now() });
await processUpdate({
update: response.result,
...processingContext
});
}
} catch (err) {
if (err instanceof ZaloApiError && err.isPollingTimeout) {} else if (!isStopped() && !abortSignal.aborted) {
runtime.error?.(`[${account.accountId}] Zalo polling error: ${formatZaloError(err)}`);
await new Promise((resolve) => {
setTimeout(resolve, 5e3);
});
}
}
if (!isStopped() && !abortSignal.aborted) setImmediate(() => {
poll();
});
};
poll();
}
async function processUpdate(params) {
const { update, token, account, config, runtime, core, mediaMaxMb, statusSink, fetcher } = params;
const { event_name, message } = update;
const sharedContext = {
token,
account,
config,
runtime,
core,
mediaMaxMb,
canHostMedia: params.canHostMedia,
webhookUrl: params.webhookUrl,
webhookPath: params.webhookPath,
statusSink,
fetcher
};
if (!message) return;
switch (event_name) {
case "message.text.received":
await handleTextMessage({
message,
...sharedContext
});
break;
case "message.image.received":
await handleImageMessage({
message,
...sharedContext,
mediaMaxMb
});
break;
case "message.sticker.received":
logVerbose(core, runtime, `[${account.accountId}] Received sticker from ${message.from.id}`);
break;
case "message.unsupported.received":
logVerbose(core, runtime, `[${account.accountId}] Received unsupported message type from ${message.from.id}`);
break;
}
}
async function handleTextMessage(params) {
const { message } = params;
const { text } = message;
if (!text?.trim()) return;
await processMessageWithPipeline({
...params,
text,
mediaPath: void 0,
mediaType: void 0
});
}
async function handleImageMessage(params) {
const { message, mediaMaxMb, account, core, runtime } = params;
const { photo_url, caption } = message;
const authorization = await authorizeZaloMessage({
...params,
text: caption,
mediaPath: photo_url ? "__pending_media__" : void 0,
mediaType: void 0
});
if (!authorization) return;
let mediaPath;
let mediaType;
if (photo_url) try {
const maxBytes = mediaMaxMb * 1024 * 1024;
const saved = await core.channel.media.saveRemoteMedia({
url: photo_url,
maxBytes
});
mediaPath = saved.path;
mediaType = saved.contentType;
} catch (err) {
runtime.error?.(`[${account.accountId}] Failed to download Zalo image: ${String(err)}`);
}
await processMessageWithPipeline({
...params,
authorization,
text: caption,
mediaPath,
mediaType
});
}
async function authorizeZaloMessage(params) {
const { message, account, config, runtime, core, text, mediaPath, token, statusSink, fetcher } = params;
const pairing = createChannelPairingController({
core,
channel: "zalo",
accountId: account.accountId
});
const { from, chat } = message;
const isGroup = chat.chat_type === "GROUP";
const chatId = chat.id;
const senderId = from.id;
const senderName = from.display_name ?? from.name;
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
const { groupPolicy, providerMissingFallbackApplied } = resolveZaloRuntimeGroupPolicy({
providerConfigPresent: config.channels?.zalo !== void 0,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy
});
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const access = await resolveStableChannelMessageIngress({
channelId: "zalo",
accountId: account.accountId,
identity: {
key: "zalo-user-id",
normalize: normalizeZaloAllowEntry,
sensitivity: "pii",
entryIdPrefix: "zalo-entry"
},
cfg: config,
readStoreAllowFrom: async () => await pairing.readAllowFromStore(),
subject: { stableId: senderId },
conversation: {
kind: isGroup ? "group" : "direct",
id: chatId
},
providerMissingFallbackApplied,
dmPolicy,
groupPolicy,
policy: { groupAllowFromFallbackToAllowFrom: true },
allowFrom: normalizeStringEntries(account.config.allowFrom),
groupAllowFrom: normalizeStringEntries(account.config.groupAllowFrom),
command: shouldComputeAuth ? {} : void 0
});
const senderAccess = access.senderAccess;
if (isGroup) {
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied: senderAccess.providerMissingFallbackApplied,
providerKey: "zalo",
accountId: account.accountId,
log: (messageValue) => logVerbose(core, runtime, messageValue)
});
if (!senderAccess.allowed) {
if (senderAccess.reasonCode === "group_policy_disabled") logVerbose(core, runtime, `zalo: drop group ${chatId} (groupPolicy=disabled)`);
else if (senderAccess.reasonCode === "group_policy_empty_allowlist") logVerbose(core, runtime, `zalo: drop group ${chatId} (groupPolicy=allowlist, no groupAllowFrom)`);
else if (senderAccess.reasonCode === "group_policy_not_allowlisted") logVerbose(core, runtime, `zalo: drop group sender ${senderId} (groupPolicy=allowlist)`);
return;
}
}
if (!isGroup && senderAccess.decision === "block" && senderAccess.reasonCode === "dm_policy_disabled") {
logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
return;
}
if (!isGroup && senderAccess.decision !== "allow") {
if (dmPolicy === "pairing") await pairing.issueChallenge({
senderId,
senderIdLine: `Your Zalo user id: ${senderId}`,
meta: { name: senderName ?? void 0 },
onCreated: () => {
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
},
sendPairingReply: async (textLocal) => {
await sendMessage(token, {
chat_id: chatId,
text: textLocal
}, fetcher);
statusSink?.({ lastOutboundAt: Date.now() });
},
onReplyError: (err) => {
logVerbose(core, runtime, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
}
});
else logVerbose(core, runtime, `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`);
return;
}
return {
chatId,
commandAuthorized: access.commandAccess.requested ? access.commandAccess.authorized : void 0,
isGroup,
rawBody,
senderId,
senderName
};
}
async function processMessageWithPipeline(params) {
const { message, token, account, config, runtime, core, mediaPath, mediaType, statusSink, fetcher, authorization: authorizationOverride } = params;
const { message_id, date } = message;
const authorization = authorizationOverride ?? await authorizeZaloMessage({
...params,
mediaPath,
mediaType
});
if (!authorization) return;
const { isGroup, chatId, senderId, senderName, rawBody, commandAuthorized } = authorization;
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
cfg: config,
channel: "zalo",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: chatId
},
runtime: core.channel,
sessionStore: config.session?.store
});
if (isGroup && core.channel.commands.isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
logVerbose(core, runtime, `zalo: drop control command from unauthorized sender ${senderId}`);
return;
}
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const { storePath, body } = buildEnvelope({
channel: "Zalo",
from: fromLabel,
timestamp: date ? date * 1e3 : void 0,
body: rawBody
});
const ctxPayload = core.channel.inbound.buildContext({
channel: "zalo",
accountId: route.accountId,
messageId: message_id,
timestamp: date ? date * 1e3 : void 0,
from: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
sender: {
id: senderId,
name: senderName || void 0
},
conversation: {
kind: isGroup ? "group" : "direct",
id: chatId,
label: fromLabel
},
route: {
agentId: route.agentId,
accountId: route.accountId,
routeSessionKey: route.sessionKey
},
reply: { to: `zalo:${chatId}` },
message: {
body,
bodyForAgent: rawBody,
rawBody,
commandBody: rawBody
},
media: mediaPath || mediaType ? [{
path: mediaPath,
url: mediaPath,
contentType: mediaType
}] : void 0,
extra: {
CommandAuthorized: commandAuthorized,
GroupSubject: void 0
}
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "zalo",
accountId: account.accountId
});
await core.channel.inbound.dispatchReply({
cfg: config,
channel: "zalo",
accountId: account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
preparePayload: (payload) => prepareZaloDurableReplyPayload({
payload,
tableMode,
convertMarkdownTables: core.channel.text.convertMarkdownTables
}),
durable: (payload, info) => resolveZaloDurableReplyOptions({
payload,
infoKind: info.kind,
chatId
}),
deliver: async (payload) => {
await deliverZaloReply({
payload,
token,
chatId,
runtime,
core,
config,
webhookUrl: params.webhookUrl,
webhookPath: params.webhookPath,
proxyUrl: account.config.proxy,
mediaMaxBytes: params.mediaMaxMb * 1024 * 1024,
canHostMedia: params.canHostMedia,
accountId: account.accountId,
statusSink,
fetcher,
tableMode: "off"
});
},
onDelivered: () => {
statusSink?.({ lastOutboundAt: Date.now() });
},
onError: (err, info) => {
runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`);
}
},
replyPipeline: { typing: {
start: async () => {
await sendChatAction(token, {
chat_id: chatId,
action: "typing"
}, fetcher, ZALO_TYPING_TIMEOUT_MS);
},
onStartError: (err) => {
logTypingFailure({
log: (messageLocal) => logVerbose(core, runtime, messageLocal),
channel: "zalo",
action: "start",
target: chatId,
error: err
});
}
} },
record: { onRecordError: (err) => {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
} }
});
}
async function deliverZaloReply(params) {
const { payload, token, chatId, runtime, core, config, webhookUrl, webhookPath, proxyUrl, mediaMaxBytes, canHostMedia, accountId, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code";
const reply = resolveSendableOutboundReplyParts(payload, { text: core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode) });
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
await deliverTextOrMediaReply({
payload,
text: reply.text,
chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, ZALO_TEXT_LIMIT, chunkMode),
sendText: async (chunk) => {
try {
await sendMessage(token, {
chat_id: chatId,
text: chunk
}, fetcher);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Zalo message send failed: ${String(err)}`);
}
},
sendMedia: async ({ mediaUrl, caption }) => {
await sendPhoto(token, {
chat_id: chatId,
photo: canHostMedia && webhookUrl && webhookPath ? await prepareHostedZaloMediaUrl({
mediaUrl,
webhookUrl,
webhookPath,
maxBytes: mediaMaxBytes,
proxyUrl
}) : mediaUrl,
caption
}, fetcher);
statusSink?.({ lastOutboundAt: Date.now() });
},
onMediaError: (error) => {
runtime.error?.(`Zalo photo send failed: ${error instanceof Error ? error.message : JSON.stringify(error)}`);
}
});
}
async function monitorZaloProvider(options) {
const { token, account, config, runtime, abortSignal, useWebhook, webhookUrl, webhookSecret, webhookPath, statusSink, fetcher: fetcherOverride } = options;
const core = getZaloRuntime();
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
const mode = useWebhook ? "webhook" : "polling";
const effectiveWebhookUrl = normalizeWebhookUrl(webhookUrl ?? account.config.webhookUrl);
const effectiveWebhookPath = effectiveWebhookUrl || webhookPath?.trim() || account.config.webhookPath?.trim() ? resolveWebhookPath({
webhookPath: webhookPath ?? account.config.webhookPath,
webhookUrl: effectiveWebhookUrl,
defaultPath: null
}) ?? void 0 : void 0;
const canHostMedia = Boolean(effectiveWebhookUrl && effectiveWebhookPath);
const hostedMediaRoutePath = canHostMedia && effectiveWebhookUrl ? resolveHostedZaloMediaRoutePrefix({
webhookUrl: effectiveWebhookUrl,
webhookPath: effectiveWebhookPath
}) : void 0;
let stopped = false;
const stopHandlers = [];
let cleanupWebhook;
const stop = () => {
if (stopped) return;
stopped = true;
for (const handler of stopHandlers) handler();
};
const stopOnAbort = () => {
if (!useWebhook) stop();
};
abortSignal.addEventListener("abort", stopOnAbort, { once: true });
runtime.log?.(`[${account.accountId}] Zalo provider init mode=${mode} mediaMaxMb=${String(effectiveMediaMaxMb)}`);
try {
if (hostedMediaRoutePath) {
const unregisterHostedMediaRoute = registerSharedHostedMediaRoute({
path: hostedMediaRoutePath,
accountId: account.accountId,
log: runtime.log
});
stopHandlers.push(unregisterHostedMediaRoute);
}
if (useWebhook) {
const { registerZaloWebhookTarget } = await loadZaloWebhookModule();
if (!effectiveWebhookUrl || !webhookSecret) throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
if (!effectiveWebhookUrl.startsWith("https://")) throw new Error("Zalo webhook URL must use HTTPS");
if (webhookSecret.length < 8 || webhookSecret.length > 256) throw new Error("Zalo webhook secret must be 8-256 characters");
const path = effectiveWebhookPath;
if (!path) throw new Error("Zalo webhookPath could not be derived");
runtime.log?.(`[${account.accountId}] Zalo configuring webhook path=${path} target=${describeWebhookTarget(effectiveWebhookUrl)}`);
await setWebhook(token, {
url: effectiveWebhookUrl,
secret_token: webhookSecret
}, fetcher);
let webhookCleanupPromise;
cleanupWebhook = async () => {
if (!webhookCleanupPromise) webhookCleanupPromise = (async () => {
runtime.log?.(`[${account.accountId}] Zalo stopping; deleting webhook`);
try {
await deleteWebhook(token, fetcher, WEBHOOK_CLEANUP_TIMEOUT_MS);
runtime.log?.(`[${account.accountId}] Zalo webhook deleted`);
} catch (err) {
const detail = err instanceof Error && err.name === "AbortError" ? `timed out after ${String(WEBHOOK_CLEANUP_TIMEOUT_MS)}ms` : formatZaloError(err);
runtime.error?.(`[${account.accountId}] Zalo webhook delete failed: ${detail}`);
}
})();
await webhookCleanupPromise;
};
runtime.log?.(`[${account.accountId}] Zalo webhook registered path=${path}`);
const unregister = registerZaloWebhookTarget({
token,
account,
config,
runtime,
core,
path,
webhookUrl: effectiveWebhookUrl,
webhookPath: path,
secret: webhookSecret,
statusSink: (patch) => statusSink?.(patch),
mediaMaxMb: effectiveMediaMaxMb,
canHostMedia,
fetcher
}, { route: {
auth: "plugin",
match: "exact",
pluginId: "zalo",
source: "zalo-webhook",
accountId: account.accountId,
log: runtime.log,
handler: async (req, res) => {
if (!await handleZaloWebhookRequest(req, res) && !res.headersSent) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end("Not Found");
}
}
} });
stopHandlers.push(unregister);
await waitForAbortSignal(abortSignal);
return;
}
runtime.log?.(`[${account.accountId}] Zalo polling mode: clearing webhook before startup`);
try {
try {
const currentWebhookUrl = normalizeWebhookUrl((await getWebhookInfo(token, fetcher)).result?.url);
if (!currentWebhookUrl) runtime.log?.(`[${account.accountId}] Zalo polling mode ready (no webhook configured)`);
else {
runtime.log?.(`[${account.accountId}] Zalo polling mode disabling existing webhook ${describeWebhookTarget(currentWebhookUrl)}`);
await deleteWebhook(token, fetcher);
runtime.log?.(`[${account.accountId}] Zalo polling mode ready (webhook disabled)`);
}
} catch (err) {
if (err instanceof ZaloApiError && err.errorCode === 404) runtime.log?.(`[${account.accountId}] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup`);
else throw err;
}
} catch (err) {
runtime.error?.(`[${account.accountId}] Zalo polling startup could not clear webhook: ${formatZaloError(err)}`);
}
startPollingLoop({
token,
account,
config,
runtime,
core,
canHostMedia,
webhookUrl: effectiveWebhookUrl,
webhookPath: effectiveWebhookPath,
abortSignal,
isStopped: () => stopped,
mediaMaxMb: effectiveMediaMaxMb,
statusSink,
fetcher
});
await waitForAbortSignal(abortSignal);
} catch (err) {
runtime.error?.(`[${account.accountId}] Zalo provider startup failed mode=${mode}: ${formatZaloError(err)}`);
throw err;
} finally {
abortSignal.removeEventListener("abort", stopOnAbort);
await cleanupWebhook?.();
stop();
runtime.log?.(`[${account.accountId}] Zalo provider stopped mode=${mode}`);
}
}
//#endregion
export { monitorZaloProvider };