import type { ChannelAccountSnapshot, ChannelDock, ChannelPlugin, ClawdbotConfig, } from "clawdbot/plugin-sdk"; import { applyAccountNameToChannelSection, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, PAIRING_APPROVED_MESSAGE, setAccountEnabledInConfigSection, } from "clawdbot/plugin-sdk"; 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 { resolveZaloProxyFetch } from "./proxy.js"; import { probeZalo } from "./probe.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"], media: true, blockStreaming: true, }, outbound: { textChunkLimit: 2000 }, config: { resolveAllowFrom: ({ cfg, accountId }) => (resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map( (entry) => String(entry), ), formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => entry.replace(/^(zalo|zl):/i, "")) .map((entry) => entry.toLowerCase()), }, groups: { resolveRequireMention: () => true, }, threading: { resolveReplyToMode: () => "off", }, }; export const zaloPlugin: ChannelPlugin = { id: "zalo", meta, onboarding: zaloOnboardingAdapter, capabilities: { chatTypes: ["direct"], media: true, reactions: false, threads: false, polls: false, nativeCommands: false, blockStreaming: true, }, reload: { configPrefixes: ["channels.zalo"] }, configSchema: buildChannelConfigSchema(ZaloConfigSchema), config: { listAccountIds: (cfg) => listZaloAccountIds(cfg as ClawdbotConfig), resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }), defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg as ClawdbotConfig), setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({ cfg: cfg as ClawdbotConfig, sectionKey: "zalo", accountId, enabled, allowTopLevel: true, }), deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({ cfg: cfg as ClawdbotConfig, 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 as ClawdbotConfig, accountId }).config.allowFrom ?? []).map( (entry) => String(entry), ), formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => entry.replace(/^(zalo|zl):/i, "")) .map((entry) => entry.toLowerCase()), }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; const useAccountPath = Boolean( (cfg as ClawdbotConfig).channels?.zalo?.accounts?.[resolvedAccountId], ); const basePath = useAccountPath ? `channels.zalo.accounts.${resolvedAccountId}.` : "channels.zalo."; 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, ""), }; }, }, 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 as ClawdbotConfig, 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 as ClawdbotConfig, 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 as ClawdbotConfig, 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 ClawdbotConfig; } 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 ClawdbotConfig; }, }, pairing: { idLabel: "zaloUserId", normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""), notifyApproval: async ({ cfg, id }) => { const account = resolveZaloAccount({ cfg: cfg as ClawdbotConfig }); if (!account.token) throw new Error("Zalo token not configured"); await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token }); }, }, outbound: { deliveryMode: "direct", chunker: (text, limit) => { if (!text) return []; if (limit <= 0 || text.length <= limit) return [text]; const chunks: string[] = []; let remaining = text; while (remaining.length > limit) { const window = remaining.slice(0, limit); const lastNewline = window.lastIndexOf("\n"); const lastSpace = window.lastIndexOf(" "); let breakIdx = lastNewline > 0 ? lastNewline : lastSpace; if (breakIdx <= 0) breakIdx = limit; const rawChunk = remaining.slice(0, breakIdx); const chunk = rawChunk.trimEnd(); if (chunk.length > 0) chunks.push(chunk); const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]); const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0)); remaining = remaining.slice(nextStart).trimStart(); } if (remaining.length) chunks.push(remaining); return chunks; }, textChunkLimit: 2000, sendText: async ({ to, text, accountId, cfg }) => { const result = await sendMessageZalo(to, text, { accountId: accountId ?? undefined, cfg: cfg as ClawdbotConfig, }); 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 as ClawdbotConfig, }); 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 }) => ({ configured: snapshot.configured ?? false, tokenSource: snapshot.tokenSource ?? "none", running: snapshot.running ?? false, mode: snapshot.mode ?? null, lastStartAt: snapshot.lastStartAt ?? null, lastStopAt: snapshot.lastStopAt ?? null, lastError: snapshot.lastError ?? null, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), 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 as ClawdbotConfig, runtime: ctx.runtime, abortSignal: ctx.abortSignal, useWebhook: Boolean(account.config.webhookUrl), webhookUrl: account.config.webhookUrl, webhookSecret: account.config.webhookSecret, webhookPath: account.config.webhookPath, fetcher, statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), }); }, }, };