import type { ChannelAccountSnapshot, ChannelDock, ChannelPlugin, BotConfig, } from "@hanzo/bot/plugin-sdk/zalo"; import { applyAccountNameToChannelSection, buildChannelConfigSchema, buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, chunkTextForOutbound, formatAllowFromLowercase, formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, PAIRING_APPROVED_MESSAGE, resolveDefaultGroupPolicy, resolveOpenProviderRuntimeGroupPolicy, resolveChannelAccountConfigBasePath, setAccountEnabledInConfigSection, } from "@hanzo/bot/plugin-sdk/zalo"; import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount, } from "./accounts.js"; import { zaloMessageActions } from "./actions.js"; import { ZaloConfigSchema } from "./config-schema.js"; import { zaloOnboardingAdapter } from "./onboarding.js"; import { probeZalo } from "./probe.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import { normalizeSecretInputString } from "./secret-input.js"; import { sendMessageZalo } from "./send.js"; import { collectZaloStatusIssues } from "./status-issues.js"; const meta = { id: "zalo", label: "Zalo", selectionLabel: "Zalo (Bot API)", docsPath: "/channels/zalo", docsLabel: "zalo", blurb: "Vietnam-focused messaging platform with Bot API.", aliases: ["zl"], order: 80, quickstartAllowFrom: true, }; function normalizeZaloMessagingTarget(raw: string): string | undefined { const trimmed = raw?.trim(); if (!trimmed) { return undefined; } return trimmed.replace(/^(zalo|zl):/i, ""); } export const zaloDock: ChannelDock = { id: "zalo", capabilities: { chatTypes: ["direct", "group"], media: true, blockStreaming: true, }, outbound: { textChunkLimit: 2000 }, config: { resolveAllowFrom: ({ cfg, accountId }) => (resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry), ), formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }), }, groups: { resolveRequireMention: () => true, }, threading: { resolveReplyToMode: () => "off", }, }; export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, onboarding: zaloOnboardingAdapter, capabilities: { chatTypes: ["direct", "group"], media: true, reactions: false, threads: false, polls: false, nativeCommands: false, blockStreaming: true, }, reload: { configPrefixes: ["channels.zalo"] }, configSchema: buildChannelConfigSchema(ZaloConfigSchema), config: { listAccountIds: (cfg) => listZaloAccountIds(cfg), resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg, accountId }), defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg), setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({ cfg: cfg, sectionKey: "zalo", accountId, enabled, allowTopLevel: true, }), deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({ cfg: cfg, sectionKey: "zalo", accountId, clearBaseFields: ["botToken", "tokenFile", "name"], }), isConfigured: (account) => Boolean(account.token?.trim()), describeAccount: (account): ChannelAccountSnapshot => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: Boolean(account.token?.trim()), tokenSource: account.tokenSource, }), resolveAllowFrom: ({ cfg, accountId }) => (resolveZaloAccount({ cfg: cfg, accountId }).config.allowFrom ?? []).map((entry) => String(entry), ), formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }), }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; const basePath = resolveChannelAccountConfigBasePath({ cfg, channelKey: "zalo", accountId: resolvedAccountId, }); return { policy: account.config.dmPolicy ?? "pairing", allowFrom: account.config.allowFrom ?? [], policyPath: `${basePath}dmPolicy`, allowFromPath: basePath, approveHint: formatPairingApproveHint("zalo"), normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""), }; }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.zalo !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, }); if (groupPolicy !== "open") { return []; } const explicitGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry), ); const dmAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry)); const effectiveAllowFrom = explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom; if (effectiveAllowFrom.length > 0) { return [ `- Zalo groups: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom to restrict senders.`, ]; } return [ `- Zalo groups: groupPolicy="open" with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated). Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom.`, ]; }, }, groups: { resolveRequireMention: () => true, }, threading: { resolveReplyToMode: () => "off", }, actions: zaloMessageActions, messaging: { normalizeTarget: normalizeZaloMessagingTarget, targetResolver: { looksLikeId: (raw) => { const trimmed = raw.trim(); if (!trimmed) { return false; } return /^\d{3,}$/.test(trimmed); }, hint: "", }, }, directory: { self: async () => null, listPeers: async ({ cfg, accountId, query, limit }) => { const account = resolveZaloAccount({ cfg: cfg, accountId }); const q = query?.trim().toLowerCase() || ""; const peers = Array.from( new Set( (account.config.allowFrom ?? []) .map((entry) => String(entry).trim()) .filter((entry) => Boolean(entry) && entry !== "*") .map((entry) => entry.replace(/^(zalo|zl):/i, "")), ), ) .filter((id) => (q ? id.toLowerCase().includes(q) : true)) .slice(0, limit && limit > 0 ? limit : undefined) .map((id) => ({ kind: "user", id }) as const); return peers; }, listGroups: async () => [], }, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({ cfg: cfg, channelKey: "zalo", accountId, name, }), validateInput: ({ accountId, input }) => { if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { return "ZALO_BOT_TOKEN can only be used for the default account."; } if (!input.useEnv && !input.token && !input.tokenFile) { return "Zalo requires token or --token-file (or --use-env)."; } return null; }, applyAccountConfig: ({ cfg, accountId, input }) => { const namedConfig = applyAccountNameToChannelSection({ cfg: cfg, channelKey: "zalo", accountId, name: input.name, }); const next = accountId !== DEFAULT_ACCOUNT_ID ? migrateBaseNameToDefaultAccount({ cfg: namedConfig, channelKey: "zalo", }) : namedConfig; if (accountId === DEFAULT_ACCOUNT_ID) { return { ...next, channels: { ...next.channels, zalo: { ...next.channels?.zalo, enabled: true, ...(input.useEnv ? {} : input.tokenFile ? { tokenFile: input.tokenFile } : input.token ? { botToken: input.token } : {}), }, }, } as BotConfig; } return { ...next, channels: { ...next.channels, zalo: { ...next.channels?.zalo, enabled: true, accounts: { ...next.channels?.zalo?.accounts, [accountId]: { ...next.channels?.zalo?.accounts?.[accountId], enabled: true, ...(input.tokenFile ? { tokenFile: input.tokenFile } : input.token ? { botToken: input.token } : {}), }, }, }, }, } as BotConfig; }, }, pairing: { idLabel: "zaloUserId", normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""), notifyApproval: async ({ cfg, id }) => { const account = resolveZaloAccount({ cfg: cfg }); if (!account.token) { throw new Error("Zalo token not configured"); } await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token }); }, }, outbound: { deliveryMode: "direct", chunker: chunkTextForOutbound, chunkerMode: "text", textChunkLimit: 2000, sendPayload: async (ctx) => { const text = ctx.payload.text ?? ""; const urls = ctx.payload.mediaUrls?.length ? ctx.payload.mediaUrls : ctx.payload.mediaUrl ? [ctx.payload.mediaUrl] : []; if (!text && urls.length === 0) { return { channel: "zalo", messageId: "" }; } if (urls.length > 0) { let lastResult = await zaloPlugin.outbound!.sendMedia!({ ...ctx, text, mediaUrl: urls[0], }); for (let i = 1; i < urls.length; i++) { lastResult = await zaloPlugin.outbound!.sendMedia!({ ...ctx, text: "", mediaUrl: urls[i], }); } return lastResult; } const outbound = zaloPlugin.outbound!; const limit = outbound.textChunkLimit; const chunks = limit && outbound.chunker ? outbound.chunker(text, limit) : [text]; let lastResult: Awaited>>; for (const chunk of chunks) { lastResult = await outbound.sendText!({ ...ctx, text: chunk }); } return lastResult!; }, sendText: async ({ to, text, accountId, cfg }) => { const result = await sendMessageZalo(to, text, { accountId: accountId ?? undefined, cfg: cfg, }); return { channel: "zalo", ok: result.ok, messageId: result.messageId ?? "", error: result.error ? new Error(result.error) : undefined, }; }, sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => { const result = await sendMessageZalo(to, text, { accountId: accountId ?? undefined, mediaUrl, cfg: cfg, }); return { channel: "zalo", ok: result.ok, messageId: result.messageId ?? "", error: result.error ? new Error(result.error) : undefined, }; }, }, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, running: false, lastStartAt: null, lastStopAt: null, lastError: null, }, collectStatusIssues: collectZaloStatusIssues, buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)), buildAccountSnapshot: ({ account, runtime }) => { const configured = Boolean(account.token?.trim()); return { accountId: account.accountId, name: account.name, enabled: account.enabled, configured, tokenSource: account.tokenSource, running: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, mode: account.config.webhookUrl ? "webhook" : "polling", lastInboundAt: runtime?.lastInboundAt ?? null, lastOutboundAt: runtime?.lastOutboundAt ?? null, dmPolicy: account.config.dmPolicy ?? "pairing", }; }, }, gateway: { startAccount: async (ctx) => { const account = ctx.account; const token = account.token.trim(); let zaloBotLabel = ""; const fetcher = resolveZaloProxyFetch(account.config.proxy); try { const probe = await probeZalo(token, 2500, fetcher); const name = probe.ok ? probe.bot?.name?.trim() : null; if (name) { zaloBotLabel = ` (${name})`; } ctx.setStatus({ accountId: account.accountId, bot: probe.bot, }); } catch { // ignore probe errors } ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`); const { monitorZaloProvider } = await import("./monitor.js"); return monitorZaloProvider({ token, account, config: ctx.cfg, runtime: ctx.runtime, abortSignal: ctx.abortSignal, useWebhook: Boolean(account.config.webhookUrl), webhookUrl: account.config.webhookUrl, webhookSecret: normalizeSecretInputString(account.config.webhookSecret), webhookPath: account.config.webhookPath, fetcher, statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), }); }, }, };