Spaces:
Paused
Paused
| import type { | |
| ChannelAccountSnapshot, | |
| ChannelDock, | |
| ChannelPlugin, | |
| OpenClawConfig, | |
| } from "openclaw/plugin-sdk"; | |
| import { | |
| applyAccountNameToChannelSection, | |
| buildChannelConfigSchema, | |
| DEFAULT_ACCOUNT_ID, | |
| deleteAccountFromConfigSection, | |
| formatPairingApproveHint, | |
| migrateBaseNameToDefaultAccount, | |
| normalizeAccountId, | |
| PAIRING_APPROVED_MESSAGE, | |
| setAccountEnabledInConfigSection, | |
| } from "openclaw/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 { probeZalo } from "./probe.js"; | |
| import { resolveZaloProxyFetch } from "./proxy.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, 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<ResolvedZaloAccount> = { | |
| 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), | |
| 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 }) => | |
| 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.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: "<chatId>", | |
| }, | |
| }, | |
| 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 OpenClawConfig; | |
| } | |
| 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 OpenClawConfig; | |
| }, | |
| }, | |
| 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: (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; | |
| }, | |
| chunkerMode: "text", | |
| textChunkLimit: 2000, | |
| 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 }) => ({ | |
| 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, | |
| 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 }), | |
| }); | |
| }, | |
| }, | |
| }; | |