mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-16 08:20:48 +00:00
1040 lines
30 KiB
TypeScript
1040 lines
30 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
|
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
|
import {
|
|
resolveDirectDmAuthorizationOutcome,
|
|
resolveSenderCommandAuthorizationWithRuntime,
|
|
} from "openclaw/plugin-sdk/command-auth";
|
|
import type { MarkdownTableMode, OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
|
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
|
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
|
import {
|
|
deliverTextOrMediaReply,
|
|
type OutboundReplyPayload,
|
|
} from "openclaw/plugin-sdk/reply-payload";
|
|
import { waitForAbortSignal } from "openclaw/plugin-sdk/runtime-env";
|
|
import {
|
|
resolveDefaultGroupPolicy,
|
|
warnMissingProviderGroupPolicyFallbackOnce,
|
|
} from "openclaw/plugin-sdk/runtime-group-policy";
|
|
import { registerPluginHttpRoute, resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
|
import type { ResolvedZaloAccount } from "./accounts.js";
|
|
import {
|
|
ZaloApiError,
|
|
deleteWebhook,
|
|
getWebhookInfo,
|
|
getUpdates,
|
|
sendChatAction,
|
|
sendMessage,
|
|
sendPhoto,
|
|
setWebhook,
|
|
type ZaloFetch,
|
|
type ZaloMessage,
|
|
type ZaloUpdate,
|
|
} from "./api.js";
|
|
import {
|
|
evaluateZaloGroupAccess,
|
|
isZaloSenderAllowed,
|
|
resolveZaloRuntimeGroupPolicy,
|
|
} from "./group-access.js";
|
|
import { resolveZaloProxyFetch } from "./proxy.js";
|
|
import { getZaloRuntime } from "./runtime.js";
|
|
export type { ZaloRuntimeEnv } from "./monitor.types.js";
|
|
import {
|
|
prepareZaloDurableReplyPayload,
|
|
resolveZaloDurableReplyOptions,
|
|
} from "./monitor-durable.js";
|
|
import type { ZaloRuntimeEnv } from "./monitor.types.js";
|
|
import {
|
|
prepareHostedZaloMediaUrl,
|
|
resolveHostedZaloMediaRoutePrefix,
|
|
tryHandleHostedZaloMediaRequest,
|
|
} from "./outbound-media.js";
|
|
|
|
export type ZaloMonitorOptions = {
|
|
token: string;
|
|
account: ResolvedZaloAccount;
|
|
config: OpenClawConfig;
|
|
runtime: ZaloRuntimeEnv;
|
|
abortSignal: AbortSignal;
|
|
useWebhook?: boolean;
|
|
webhookUrl?: string;
|
|
webhookSecret?: string;
|
|
webhookPath?: string;
|
|
fetcher?: ZaloFetch;
|
|
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
};
|
|
|
|
const ZALO_TEXT_LIMIT = 2000;
|
|
const DEFAULT_MEDIA_MAX_MB = 5;
|
|
const WEBHOOK_CLEANUP_TIMEOUT_MS = 5_000;
|
|
const ZALO_TYPING_TIMEOUT_MS = 5_000;
|
|
|
|
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
|
|
type ZaloStatusSink = (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
|
type ZaloWebhookModule = typeof import("./monitor.webhook.js");
|
|
type ZaloProcessingContext = {
|
|
token: string;
|
|
account: ResolvedZaloAccount;
|
|
config: OpenClawConfig;
|
|
runtime: ZaloRuntimeEnv;
|
|
core: ZaloCoreRuntime;
|
|
mediaMaxMb: number;
|
|
canHostMedia: boolean;
|
|
webhookUrl?: string;
|
|
webhookPath?: string;
|
|
statusSink?: ZaloStatusSink;
|
|
fetcher?: ZaloFetch;
|
|
};
|
|
type ZaloPollingLoopParams = ZaloProcessingContext & {
|
|
abortSignal: AbortSignal;
|
|
isStopped: () => boolean;
|
|
};
|
|
type ZaloUpdateProcessingParams = ZaloProcessingContext & {
|
|
update: ZaloUpdate;
|
|
};
|
|
|
|
let zaloWebhookModulePromise: Promise<ZaloWebhookModule> | undefined;
|
|
const hostedMediaRouteRefs = new Map<string, { count: number; unregisters: Array<() => void> }>();
|
|
|
|
function loadZaloWebhookModule(): Promise<ZaloWebhookModule> {
|
|
zaloWebhookModulePromise ??= import("./monitor.webhook.js");
|
|
return zaloWebhookModulePromise;
|
|
}
|
|
|
|
function registerSharedHostedMediaRoute(params: {
|
|
path: string;
|
|
accountId: string;
|
|
log?: (message: string) => void;
|
|
}): () => void {
|
|
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) => {
|
|
const handled = await tryHandleHostedZaloMediaRequest(req, res);
|
|
if (!handled && !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();
|
|
}
|
|
};
|
|
}
|
|
|
|
type ZaloMessagePipelineParams = ZaloProcessingContext & {
|
|
message: ZaloMessage;
|
|
text?: string;
|
|
mediaPath?: string;
|
|
mediaType?: string;
|
|
authorization?: ZaloMessageAuthorizationResult;
|
|
};
|
|
type ZaloImageMessageParams = ZaloProcessingContext & {
|
|
message: ZaloMessage;
|
|
};
|
|
type ZaloMessageAuthorizationResult = {
|
|
chatId: string;
|
|
commandAuthorized: boolean | undefined;
|
|
isGroup: boolean;
|
|
rawBody: string;
|
|
senderId: string;
|
|
senderName: string | undefined;
|
|
};
|
|
|
|
function formatZaloError(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
return error.stack ?? `${error.name}: ${error.message}`;
|
|
}
|
|
return String(error);
|
|
}
|
|
|
|
function describeWebhookTarget(rawUrl: string): string {
|
|
try {
|
|
const parsed = new URL(rawUrl);
|
|
return `${parsed.origin}${parsed.pathname}`;
|
|
} catch {
|
|
return rawUrl;
|
|
}
|
|
}
|
|
|
|
function normalizeWebhookUrl(url: string | undefined): string | undefined {
|
|
const trimmed = url?.trim();
|
|
return trimmed ? trimmed : undefined;
|
|
}
|
|
|
|
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
|
|
if (core.logging.shouldLogVerbose()) {
|
|
runtime.log?.(`[zalo] ${message}`);
|
|
}
|
|
}
|
|
|
|
export async function handleZaloWebhookRequest(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
): Promise<boolean> {
|
|
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 as ZaloCoreRuntime,
|
|
mediaMaxMb: target.mediaMaxMb,
|
|
canHostMedia: target.canHostMedia,
|
|
webhookUrl: target.webhookUrl,
|
|
webhookPath: target.webhookPath,
|
|
statusSink: target.statusSink,
|
|
fetcher: target.fetcher,
|
|
});
|
|
});
|
|
}
|
|
|
|
function startPollingLoop(params: ZaloPollingLoopParams) {
|
|
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 (): Promise<void> => {
|
|
if (isStopped() || abortSignal.aborted) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
|
|
if (isStopped() || abortSignal.aborted) {
|
|
return undefined;
|
|
}
|
|
if (response.ok && response.result) {
|
|
statusSink?.({ lastInboundAt: Date.now() });
|
|
await processUpdate({
|
|
update: response.result,
|
|
...processingContext,
|
|
});
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof ZaloApiError && err.isPollingTimeout) {
|
|
// no updates
|
|
} else if (!isStopped() && !abortSignal.aborted) {
|
|
runtime.error?.(`[${account.accountId}] Zalo polling error: ${formatZaloError(err)}`);
|
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
}
|
|
}
|
|
|
|
if (!isStopped() && !abortSignal.aborted) {
|
|
setImmediate(poll);
|
|
}
|
|
};
|
|
|
|
void poll();
|
|
}
|
|
|
|
async function processUpdate(params: ZaloUpdateProcessingParams): Promise<void> {
|
|
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 undefined;
|
|
}
|
|
|
|
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: ZaloProcessingContext & { message: ZaloMessage },
|
|
): Promise<void> {
|
|
const { message } = params;
|
|
const { text } = message;
|
|
if (!text?.trim()) {
|
|
return undefined;
|
|
}
|
|
|
|
await processMessageWithPipeline({
|
|
...params,
|
|
text,
|
|
mediaPath: undefined,
|
|
mediaType: undefined,
|
|
});
|
|
}
|
|
|
|
async function handleImageMessage(params: ZaloImageMessageParams): Promise<void> {
|
|
const { message, mediaMaxMb, account, core, runtime } = params;
|
|
const { photo_url, caption } = message;
|
|
const authorization = await authorizeZaloMessage({
|
|
...params,
|
|
text: caption,
|
|
// Use a sentinel so auth sees this as an inbound image before the download happens.
|
|
mediaPath: photo_url ? "__pending_media__" : undefined,
|
|
mediaType: undefined,
|
|
});
|
|
if (!authorization) {
|
|
return;
|
|
}
|
|
|
|
let mediaPath: string | undefined;
|
|
let mediaType: string | undefined;
|
|
|
|
if (photo_url) {
|
|
try {
|
|
const maxBytes = mediaMaxMb * 1024 * 1024;
|
|
const fetched = await core.channel.media.fetchRemoteMedia({ url: photo_url, maxBytes });
|
|
const saved = await core.channel.media.saveMediaBuffer(
|
|
fetched.buffer,
|
|
fetched.contentType,
|
|
"inbound",
|
|
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: ZaloMessagePipelineParams,
|
|
): Promise<ZaloMessageAuthorizationResult | undefined> {
|
|
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 configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
|
const configuredGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v));
|
|
const groupAllowFrom =
|
|
configuredGroupAllowFrom.length > 0 ? configuredGroupAllowFrom : configAllowFrom;
|
|
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
|
const groupAccess = isGroup
|
|
? evaluateZaloGroupAccess({
|
|
providerConfigPresent: config.channels?.zalo !== undefined,
|
|
configuredGroupPolicy: account.config.groupPolicy,
|
|
defaultGroupPolicy,
|
|
groupAllowFrom,
|
|
senderId,
|
|
})
|
|
: undefined;
|
|
if (groupAccess) {
|
|
warnMissingProviderGroupPolicyFallbackOnce({
|
|
providerMissingFallbackApplied: groupAccess.providerMissingFallbackApplied,
|
|
providerKey: "zalo",
|
|
accountId: account.accountId,
|
|
log: (message) => logVerbose(core, runtime, message),
|
|
});
|
|
if (!groupAccess.allowed) {
|
|
if (groupAccess.reason === "disabled") {
|
|
logVerbose(core, runtime, `zalo: drop group ${chatId} (groupPolicy=disabled)`);
|
|
} else if (groupAccess.reason === "empty_allowlist") {
|
|
logVerbose(
|
|
core,
|
|
runtime,
|
|
`zalo: drop group ${chatId} (groupPolicy=allowlist, no groupAllowFrom)`,
|
|
);
|
|
} else if (groupAccess.reason === "sender_not_allowlisted") {
|
|
logVerbose(core, runtime, `zalo: drop group sender ${senderId} (groupPolicy=allowlist)`);
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
|
const { senderAllowedForCommands, commandAuthorized } =
|
|
await resolveSenderCommandAuthorizationWithRuntime({
|
|
cfg: config,
|
|
rawBody,
|
|
isGroup,
|
|
dmPolicy,
|
|
configuredAllowFrom: configAllowFrom,
|
|
configuredGroupAllowFrom: groupAllowFrom,
|
|
senderId,
|
|
isSenderAllowed: isZaloSenderAllowed,
|
|
channel: "zalo",
|
|
accountId: account.accountId,
|
|
readAllowFromStore: pairing.readAllowFromStore,
|
|
runtime: core.channel.commands,
|
|
});
|
|
|
|
const directDmOutcome = resolveDirectDmAuthorizationOutcome({
|
|
isGroup,
|
|
dmPolicy,
|
|
senderAllowedForCommands,
|
|
});
|
|
if (directDmOutcome === "disabled") {
|
|
logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
|
|
return undefined;
|
|
}
|
|
if (directDmOutcome === "unauthorized") {
|
|
if (dmPolicy === "pairing") {
|
|
await pairing.issueChallenge({
|
|
senderId,
|
|
senderIdLine: `Your Zalo user id: ${senderId}`,
|
|
meta: { name: senderName ?? undefined },
|
|
onCreated: () => {
|
|
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
|
|
},
|
|
sendPairingReply: async (text) => {
|
|
await sendMessage(
|
|
token,
|
|
{
|
|
chat_id: chatId,
|
|
text,
|
|
},
|
|
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 undefined;
|
|
}
|
|
|
|
return {
|
|
chatId,
|
|
commandAuthorized,
|
|
isGroup,
|
|
rawBody,
|
|
senderId,
|
|
senderName,
|
|
};
|
|
}
|
|
|
|
async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Promise<void> {
|
|
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" as const) : ("direct" as const),
|
|
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 * 1000 : undefined,
|
|
body: rawBody,
|
|
});
|
|
|
|
const ctxPayload = core.channel.turn.buildContext({
|
|
channel: "zalo",
|
|
accountId: route.accountId,
|
|
messageId: message_id,
|
|
timestamp: date ? date * 1000 : undefined,
|
|
from: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
|
|
sender: {
|
|
id: senderId,
|
|
name: senderName || undefined,
|
|
},
|
|
conversation: {
|
|
kind: isGroup ? "group" : "direct",
|
|
id: chatId,
|
|
label: fromLabel,
|
|
routePeer: {
|
|
kind: isGroup ? "group" : "direct",
|
|
id: chatId,
|
|
},
|
|
},
|
|
route: {
|
|
agentId: route.agentId,
|
|
accountId: route.accountId,
|
|
routeSessionKey: route.sessionKey,
|
|
},
|
|
reply: {
|
|
to: `zalo:${chatId}`,
|
|
originatingTo: `zalo:${chatId}`,
|
|
},
|
|
message: {
|
|
body,
|
|
bodyForAgent: rawBody,
|
|
rawBody,
|
|
commandBody: rawBody,
|
|
envelopeFrom: fromLabel,
|
|
},
|
|
media:
|
|
mediaPath || mediaType
|
|
? [
|
|
{
|
|
path: mediaPath,
|
|
url: mediaPath,
|
|
contentType: mediaType,
|
|
},
|
|
]
|
|
: undefined,
|
|
extra: {
|
|
CommandAuthorized: commandAuthorized,
|
|
GroupSubject: undefined,
|
|
},
|
|
});
|
|
|
|
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
|
cfg: config,
|
|
channel: "zalo",
|
|
accountId: account.accountId,
|
|
});
|
|
const replyPipeline = {
|
|
typing: {
|
|
start: async () => {
|
|
await sendChatAction(
|
|
token,
|
|
{
|
|
chat_id: chatId,
|
|
action: "typing",
|
|
},
|
|
fetcher,
|
|
ZALO_TYPING_TIMEOUT_MS,
|
|
);
|
|
},
|
|
onStartError: (err: unknown) => {
|
|
logTypingFailure({
|
|
log: (message) => logVerbose(core, runtime, message),
|
|
channel: "zalo",
|
|
action: "start",
|
|
target: chatId,
|
|
error: err,
|
|
});
|
|
},
|
|
},
|
|
};
|
|
|
|
await core.channel.turn.run({
|
|
channel: "zalo",
|
|
accountId: account.accountId,
|
|
raw: message,
|
|
adapter: {
|
|
ingest: () => ({
|
|
id: message_id,
|
|
timestamp: date ? date * 1000 : undefined,
|
|
rawText: rawBody,
|
|
textForAgent: rawBody,
|
|
textForCommands: rawBody,
|
|
raw: message,
|
|
}),
|
|
resolveTurn: () => ({
|
|
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,
|
|
record: {
|
|
onRecordError: (err) => {
|
|
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
|
|
},
|
|
},
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
|
|
async function deliverZaloReply(params: {
|
|
payload: OutboundReplyPayload;
|
|
token: string;
|
|
chatId: string;
|
|
runtime: ZaloRuntimeEnv;
|
|
core: ZaloCoreRuntime;
|
|
config: OpenClawConfig;
|
|
webhookUrl?: string;
|
|
webhookPath?: string;
|
|
proxyUrl?: string;
|
|
mediaMaxBytes: number;
|
|
canHostMedia: boolean;
|
|
accountId?: string;
|
|
statusSink?: ZaloStatusSink;
|
|
fetcher?: ZaloFetch;
|
|
tableMode?: MarkdownTableMode;
|
|
}): Promise<void> {
|
|
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 }) => {
|
|
const sendableMediaUrl =
|
|
canHostMedia && webhookUrl && webhookPath
|
|
? await prepareHostedZaloMediaUrl({
|
|
mediaUrl,
|
|
webhookUrl,
|
|
webhookPath,
|
|
maxBytes: mediaMaxBytes,
|
|
proxyUrl,
|
|
})
|
|
: mediaUrl;
|
|
await sendPhoto(token, { chat_id: chatId, photo: sendableMediaUrl, caption }, fetcher);
|
|
statusSink?.({ lastOutboundAt: Date.now() });
|
|
},
|
|
onMediaError: (error) => {
|
|
runtime.error?.(
|
|
`Zalo photo send failed: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
|
|
);
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function monitorZaloProvider(options: ZaloMonitorOptions): Promise<void> {
|
|
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,
|
|
}) ?? undefined)
|
|
: undefined;
|
|
const canHostMedia = Boolean(effectiveWebhookUrl && effectiveWebhookPath);
|
|
const hostedMediaRoutePath =
|
|
canHostMedia && effectiveWebhookUrl
|
|
? resolveHostedZaloMediaRoutePrefix({
|
|
webhookUrl: effectiveWebhookUrl,
|
|
webhookPath: effectiveWebhookPath,
|
|
})
|
|
: undefined;
|
|
|
|
let stopped = false;
|
|
const stopHandlers: Array<() => void> = [];
|
|
let cleanupWebhook: (() => Promise<void>) | undefined;
|
|
|
|
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: Promise<void> | undefined;
|
|
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) => {
|
|
const handled = await handleZaloWebhookRequest(req, res);
|
|
if (!handled && !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) {
|
|
// Some Zalo environments do not expose webhook inspection for polling bots.
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
export const __testing = {
|
|
evaluateZaloGroupAccess,
|
|
resolveZaloRuntimeGroupPolicy,
|
|
clearHostedMediaRouteRefsForTest: () => hostedMediaRouteRefs.clear(),
|
|
};
|