From 174f2de4479efcd200a8665a2a3be7ab2d073037 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Mar 2026 15:40:26 +0000 Subject: [PATCH] feat(zalouser): migrate runtime to native zca-js --- extensions/zalouser/index.ts | 4 +- extensions/zalouser/package.json | 5 +- extensions/zalouser/src/accounts.ts | 31 +- extensions/zalouser/src/channel.ts | 285 ++--- extensions/zalouser/src/monitor.ts | 424 +++---- extensions/zalouser/src/onboarding.ts | 233 ++-- extensions/zalouser/src/probe.ts | 31 +- extensions/zalouser/src/send.ts | 129 +-- extensions/zalouser/src/status-issues.ts | 33 +- extensions/zalouser/src/tool.ts | 90 +- extensions/zalouser/src/types.ts | 68 +- extensions/zalouser/src/zalo-js.ts | 1113 +++++++++++++++++++ extensions/zalouser/src/zca-js-exports.d.ts | 167 +++ extensions/zalouser/src/zca.ts | 198 ---- pnpm-lock.yaml | 36 + 15 files changed, 1822 insertions(+), 1025 deletions(-) create mode 100644 extensions/zalouser/src/zalo-js.ts create mode 100644 extensions/zalouser/src/zca-js-exports.d.ts delete mode 100644 extensions/zalouser/src/zca.ts diff --git a/extensions/zalouser/index.ts b/extensions/zalouser/index.ts index fa80152db33..0867197b995 100644 --- a/extensions/zalouser/index.ts +++ b/extensions/zalouser/index.ts @@ -7,14 +7,12 @@ import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js"; const plugin = { id: "zalouser", name: "Zalo Personal", - description: "Zalo personal account messaging via zca-cli", + description: "Zalo personal account messaging via native zca-js integration", configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { setZalouserRuntime(api.runtime); - // Register channel plugin (for onboarding & gateway) api.registerChannel({ plugin: zalouserPlugin, dock: zalouserDock }); - // Register agent tool api.registerTool({ name: "zalouser", label: "Zalo Personal", diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index 0d9770641c3..de9b90dc738 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -1,10 +1,11 @@ { "name": "@openclaw/zalouser", "version": "2026.3.2", - "description": "OpenClaw Zalo Personal Account plugin via zca-cli", + "description": "OpenClaw Zalo Personal Account plugin via native zca-js integration", "type": "module", "dependencies": { - "@sinclair/typebox": "0.34.48" + "@sinclair/typebox": "0.34.48", + "zca-js": "2.1.1" }, "openclaw": { "extensions": [ diff --git a/extensions/zalouser/src/accounts.ts b/extensions/zalouser/src/accounts.ts index 39bb6bfecc5..4797ec0416a 100644 --- a/extensions/zalouser/src/accounts.ts +++ b/extensions/zalouser/src/accounts.ts @@ -5,7 +5,7 @@ import { normalizeOptionalAccountId, } from "openclaw/plugin-sdk/account-id"; import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js"; -import { runZca, parseJsonOutput } from "./zca.js"; +import { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js"; function listConfiguredAccountIds(cfg: OpenClawConfig): string[] { const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts; @@ -57,10 +57,13 @@ function mergeZalouserAccountConfig(cfg: OpenClawConfig, accountId: string): Zal return { ...base, ...account }; } -function resolveZcaProfile(config: ZalouserAccountConfig, accountId: string): string { +function resolveProfile(config: ZalouserAccountConfig, accountId: string): string { if (config.profile?.trim()) { return config.profile.trim(); } + if (process.env.ZALOUSER_PROFILE?.trim()) { + return process.env.ZALOUSER_PROFILE.trim(); + } if (process.env.ZCA_PROFILE?.trim()) { return process.env.ZCA_PROFILE.trim(); } @@ -70,11 +73,6 @@ function resolveZcaProfile(config: ZalouserAccountConfig, accountId: string): st return "default"; } -export async function checkZcaAuthenticated(profile: string): Promise { - const result = await runZca(["auth", "status"], { profile, timeout: 5000 }); - return result.ok; -} - export async function resolveZalouserAccount(params: { cfg: OpenClawConfig; accountId?: string | null; @@ -85,8 +83,8 @@ export async function resolveZalouserAccount(params: { const merged = mergeZalouserAccountConfig(params.cfg, accountId); const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; - const profile = resolveZcaProfile(merged, accountId); - const authenticated = await checkZcaAuthenticated(profile); + const profile = resolveProfile(merged, accountId); + const authenticated = await checkZaloAuthenticated(profile); return { accountId, @@ -108,14 +106,14 @@ export function resolveZalouserAccountSync(params: { const merged = mergeZalouserAccountConfig(params.cfg, accountId); const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; - const profile = resolveZcaProfile(merged, accountId); + const profile = resolveProfile(merged, accountId); return { accountId, name: merged.name?.trim() || undefined, enabled, profile, - authenticated: false, // unknown without async check + authenticated: false, config: merged, }; } @@ -133,11 +131,16 @@ export async function listEnabledZalouserAccounts( export async function getZcaUserInfo( profile: string, ): Promise<{ userId?: string; displayName?: string } | null> { - const result = await runZca(["me", "info", "-j"], { profile, timeout: 10000 }); - if (!result.ok) { + const info = await getZaloUserInfo(profile); + if (!info) { return null; } - return parseJsonOutput<{ userId?: string; displayName?: string }>(result.stdout); + return { + userId: info.userId, + displayName: info.displayName, + }; } +export { checkZaloAuthenticated as checkZcaAuthenticated }; + export type { ResolvedZalouserAccount } from "./types.js"; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 1b711074d65..ef0c5dc97b8 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -1,3 +1,5 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; import type { ChannelAccountSnapshot, ChannelDirectoryEntry, @@ -17,6 +19,7 @@ import { formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, + resolvePreferredOpenClawTmpDir, resolveChannelAccountConfigBasePath, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk"; @@ -33,8 +36,15 @@ import { zalouserOnboardingAdapter } from "./onboarding.js"; import { probeZalouser } from "./probe.js"; import { sendMessageZalouser } from "./send.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; -import type { ZcaFriend, ZcaGroup, ZcaUserInfo } from "./types.js"; -import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js"; +import { + listZaloFriendsMatching, + listZaloGroupMembers, + listZaloGroupsMatching, + logoutZaloProfile, + startZaloQrLogin, + waitForZaloQrLogin, + getZaloUserInfo, +} from "./zalo-js.js"; const meta = { id: "zalouser", @@ -51,11 +61,30 @@ const meta = { function resolveZalouserQrProfile(accountId?: string | null): string { const normalized = normalizeAccountId(accountId); if (!normalized || normalized === DEFAULT_ACCOUNT_ID) { - return process.env.ZCA_PROFILE?.trim() || "default"; + return process.env.ZALOUSER_PROFILE?.trim() || process.env.ZCA_PROFILE?.trim() || "default"; } return normalized; } +async function writeQrDataUrlToTempFile( + qrDataUrl: string, + profile: string, +): Promise { + const trimmed = qrDataUrl.trim(); + const match = trimmed.match(/^data:image\/png;base64,(.+)$/i); + const base64 = (match?.[1] ?? "").trim(); + if (!base64) { + return null; + } + const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default"; + const filePath = path.join( + resolvePreferredOpenClawTmpDir(), + `openclaw-zalouser-qr-${safeProfile}.png`, + ); + await fsp.writeFile(filePath, Buffer.from(base64, "base64")); + return filePath; +} + function mapUser(params: { id: string; name?: string | null; @@ -173,14 +202,7 @@ export const zalouserPlugin: ChannelPlugin = { "messagePrefix", ], }), - isConfigured: async (account) => { - // Check if zca auth status is OK for this profile - const result = await runZca(["auth", "status"], { - profile: account.profile, - timeout: 5000, - }); - return result.ok; - }, + isConfigured: async (account) => await checkZcaAuthenticated(account.profile), describeAccount: (account): ChannelAccountSnapshot => ({ accountId: account.accountId, name: account.name, @@ -294,21 +316,9 @@ export const zalouserPlugin: ChannelPlugin = { }, }, directory: { - self: async ({ cfg, accountId, runtime }) => { - const ok = await checkZcaInstalled(); - if (!ok) { - throw new Error("Missing dependency: `zca` not found in PATH"); - } + self: async ({ cfg, accountId }) => { const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const result = await runZca(["me", "info", "-j"], { - profile: account.profile, - timeout: 10000, - }); - if (!result.ok) { - runtime.error(result.stderr || "Failed to fetch profile"); - return null; - } - const parsed = parseJsonOutput(result.stdout); + const parsed = await getZaloUserInfo(account.profile); if (!parsed?.userId) { return null; } @@ -320,92 +330,42 @@ export const zalouserPlugin: ChannelPlugin = { }); }, listPeers: async ({ cfg, accountId, query, limit }) => { - const ok = await checkZcaInstalled(); - if (!ok) { - throw new Error("Missing dependency: `zca` not found in PATH"); - } const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const args = query?.trim() ? ["friend", "find", query.trim()] : ["friend", "list", "-j"]; - const result = await runZca(args, { profile: account.profile, timeout: 15000 }); - if (!result.ok) { - throw new Error(result.stderr || "Failed to list peers"); - } - const parsed = parseJsonOutput(result.stdout); - const rows = Array.isArray(parsed) - ? parsed.map((f) => - mapUser({ - id: String(f.userId), - name: f.displayName ?? null, - avatarUrl: f.avatar ?? null, - raw: f, - }), - ) - : []; + const friends = await listZaloFriendsMatching(account.profile, query); + const rows = friends.map((friend) => + mapUser({ + id: String(friend.userId), + name: friend.displayName ?? null, + avatarUrl: friend.avatar ?? null, + raw: friend, + }), + ); return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows; }, listGroups: async ({ cfg, accountId, query, limit }) => { - const ok = await checkZcaInstalled(); - if (!ok) { - throw new Error("Missing dependency: `zca` not found in PATH"); - } const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const result = await runZca(["group", "list", "-j"], { - profile: account.profile, - timeout: 15000, - }); - if (!result.ok) { - throw new Error(result.stderr || "Failed to list groups"); - } - const parsed = parseJsonOutput(result.stdout); - let rows = Array.isArray(parsed) - ? parsed.map((g) => - mapGroup({ - id: String(g.groupId), - name: g.name ?? null, - raw: g, - }), - ) - : []; - const q = query?.trim().toLowerCase(); - if (q) { - rows = rows.filter((g) => (g.name ?? "").toLowerCase().includes(q) || g.id.includes(q)); - } + const groups = await listZaloGroupsMatching(account.profile, query); + const rows = groups.map((group) => + mapGroup({ + id: String(group.groupId), + name: group.name ?? null, + raw: group, + }), + ); return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows; }, listGroupMembers: async ({ cfg, accountId, groupId, limit }) => { - const ok = await checkZcaInstalled(); - if (!ok) { - throw new Error("Missing dependency: `zca` not found in PATH"); - } const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const result = await runZca(["group", "members", groupId, "-j"], { - profile: account.profile, - timeout: 20000, - }); - if (!result.ok) { - throw new Error(result.stderr || "Failed to list group members"); - } - const parsed = parseJsonOutput & { userId?: string | number }>>( - result.stdout, + const members = await listZaloGroupMembers(account.profile, groupId); + const rows = members.map((member) => + mapUser({ + id: member.userId, + name: member.displayName, + avatarUrl: member.avatar ?? null, + raw: member, + }), ); - const rows = Array.isArray(parsed) - ? parsed - .map((m) => { - const id = m.userId ?? (m as { id?: string | number }).id; - if (!id) { - return null; - } - return mapUser({ - id: String(id), - name: (m as { displayName?: string }).displayName ?? null, - avatarUrl: (m as { avatar?: string }).avatar ?? null, - raw: m, - }); - }) - .filter(Boolean) - : []; - const sliced = typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows; - return sliced as ChannelDirectoryEntry[]; + return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows; }, }, resolver: { @@ -426,48 +386,27 @@ export const zalouserPlugin: ChannelPlugin = { cfg: cfg, accountId: accountId ?? DEFAULT_ACCOUNT_ID, }); - const args = - kind === "user" - ? trimmed - ? ["friend", "find", trimmed] - : ["friend", "list", "-j"] - : ["group", "list", "-j"]; - const result = await runZca(args, { profile: account.profile, timeout: 15000 }); - if (!result.ok) { - throw new Error(result.stderr || "zca lookup failed"); - } if (kind === "user") { - const parsed = parseJsonOutput(result.stdout) ?? []; - const matches = Array.isArray(parsed) - ? parsed.map((f) => ({ - id: String(f.userId), - name: f.displayName ?? undefined, - })) - : []; - const best = matches[0]; + const friends = await listZaloFriendsMatching(account.profile, trimmed); + const best = friends[0]; results.push({ input, - resolved: Boolean(best?.id), - id: best?.id, - name: best?.name, - note: matches.length > 1 ? "multiple matches; chose first" : undefined, + resolved: Boolean(best?.userId), + id: best?.userId, + name: best?.displayName, + note: friends.length > 1 ? "multiple matches; chose first" : undefined, }); } else { - const parsed = parseJsonOutput(result.stdout) ?? []; - const matches = Array.isArray(parsed) - ? parsed.map((g) => ({ - id: String(g.groupId), - name: g.name ?? undefined, - })) - : []; + const groups = await listZaloGroupsMatching(account.profile, trimmed); const best = - matches.find((g) => g.name?.toLowerCase() === trimmed.toLowerCase()) ?? matches[0]; + groups.find((group) => group.name.toLowerCase() === trimmed.toLowerCase()) ?? + groups[0]; results.push({ input, - resolved: Boolean(best?.id), - id: best?.id, + resolved: Boolean(best?.groupId), + id: best?.groupId, name: best?.name, - note: matches.length > 1 ? "multiple matches; chose first" : undefined, + note: groups.length > 1 ? "multiple matches; chose first" : undefined, }); } } catch (err) { @@ -498,19 +437,32 @@ export const zalouserPlugin: ChannelPlugin = { cfg: cfg, accountId: accountId ?? DEFAULT_ACCOUNT_ID, }); - const ok = await checkZcaInstalled(); - if (!ok) { - throw new Error( - "Missing dependency: `zca` not found in PATH. See docs.openclaw.ai/channels/zalouser", - ); - } + runtime.log( - `Scan the QR code in this terminal to link Zalo Personal (account: ${account.accountId}, profile: ${account.profile}).`, + `Generating QR login for Zalo Personal (account: ${account.accountId}, profile: ${account.profile})...`, ); - const result = await runZcaInteractive(["auth", "login"], { profile: account.profile }); - if (!result.ok) { - throw new Error(result.stderr || "Zalouser login failed"); + + const started = await startZaloQrLogin({ + profile: account.profile, + timeoutMs: 35_000, + }); + if (!started.qrDataUrl) { + throw new Error(started.message || "Failed to start QR login"); } + + const qrPath = await writeQrDataUrlToTempFile(started.qrDataUrl, account.profile); + if (qrPath) { + runtime.log(`Scan QR image: ${qrPath}`); + } else { + runtime.log("QR generated but could not be written to a temp file."); + } + + const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 180_000 }); + if (!waited.connected) { + throw new Error(waited.message || "Zalouser login failed"); + } + + runtime.log(waited.message); }, }, outbound: { @@ -562,11 +514,12 @@ export const zalouserPlugin: ChannelPlugin = { error: result.error ? new Error(result.error) : undefined, }; }, - sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => { + sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => { const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); const result = await sendMessageZalouser(to, text, { profile: account.profile, mediaUrl, + mediaLocalRoots, }); return { channel: "zalouser", @@ -596,9 +549,8 @@ export const zalouserPlugin: ChannelPlugin = { }), probeAccount: async ({ account, timeoutMs }) => probeZalouser(account.profile, timeoutMs), buildAccountSnapshot: async ({ account, runtime }) => { - const zcaInstalled = await checkZcaInstalled(); - const configured = zcaInstalled ? await checkZcaAuthenticated(account.profile) : false; - const configError = zcaInstalled ? "not authenticated" : "zca CLI not found in PATH"; + const configured = await checkZcaAuthenticated(account.profile); + const configError = "not authenticated"; return { accountId: account.accountId, name: account.name, @@ -642,44 +594,21 @@ export const zalouserPlugin: ChannelPlugin = { }, loginWithQrStart: async (params) => { const profile = resolveZalouserQrProfile(params.accountId); - // Start login and get QR code - const result = await runZca(["auth", "login", "--qr-base64"], { + return await startZaloQrLogin({ profile, - timeout: params.timeoutMs ?? 30000, + force: params.force, + timeoutMs: params.timeoutMs, }); - if (!result.ok) { - return { message: result.stderr || "Failed to start QR login" }; - } - // The stdout should contain the base64 QR data URL - const qrMatch = result.stdout.match(/data:image\/png;base64,[A-Za-z0-9+/=]+/); - if (qrMatch) { - return { qrDataUrl: qrMatch[0], message: "Scan QR code with Zalo app" }; - } - return { message: result.stdout || "QR login started" }; }, loginWithQrWait: async (params) => { const profile = resolveZalouserQrProfile(params.accountId); - // Check if already authenticated - const statusResult = await runZca(["auth", "status"], { + return await waitForZaloQrLogin({ profile, - timeout: params.timeoutMs ?? 60000, + timeoutMs: params.timeoutMs, }); - return { - connected: statusResult.ok, - message: statusResult.ok ? "Login successful" : statusResult.stderr || "Login pending", - }; - }, - logoutAccount: async (ctx) => { - const result = await runZca(["auth", "logout"], { - profile: ctx.account.profile, - timeout: 10000, - }); - return { - cleared: result.ok, - loggedOut: result.ok, - message: result.ok ? "Logged out" : result.stderr, - }; }, + logoutAccount: async (ctx) => + await logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)), }, }; diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 78d6ecb35d2..40f48d1f07a 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,4 +1,3 @@ -import type { ChildProcess } from "node:child_process"; import type { MarkdownTableMode, OpenClawConfig, @@ -10,19 +9,17 @@ import { createReplyPrefixOptions, resolveOutboundMediaUrls, mergeAllowlist, - resolveDirectDmAuthorizationOutcome, resolveOpenProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, - resolveInboundRouteEnvelopeBuilderWithRuntime, - resolveSenderCommandAuthorizationWithRuntime, + resolveSenderCommandAuthorization, sendMediaWithLeadingCaption, summarizeMapping, warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser } from "./send.js"; -import type { ResolvedZalouserAccount, ZcaFriend, ZcaGroup, ZcaMessage } from "./types.js"; -import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js"; +import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js"; +import { listZaloFriends, listZaloGroups, startZaloListener } from "./zalo-js.js"; export type ZalouserMonitorOptions = { account: ResolvedZalouserAccount; @@ -116,84 +113,30 @@ function isGroupAllowed(params: { return false; } -function startZcaListener( - runtime: RuntimeEnv, - profile: string, - onMessage: (msg: ZcaMessage) => void, - onError: (err: Error) => void, - abortSignal: AbortSignal, -): ChildProcess { - let buffer = ""; - - const { proc, promise } = runZcaStreaming(["listen", "-r", "-k"], { - profile, - onData: (chunk) => { - buffer += chunk; - const lines = buffer.split("\n"); - buffer = lines.pop() ?? ""; - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - try { - const parsed = JSON.parse(trimmed) as ZcaMessage; - onMessage(parsed); - } catch { - // ignore non-JSON lines - } - } - }, - onError, - }); - - proc.stderr?.on("data", (data: Buffer) => { - const text = data.toString().trim(); - if (text) { - runtime.error(`[zalouser] zca stderr: ${text}`); - } - }); - - void promise.then((result) => { - if (!result.ok && !abortSignal.aborted) { - onError(new Error(result.stderr || `zca listen exited with code ${result.exitCode}`)); - } - }); - - abortSignal.addEventListener( - "abort", - () => { - proc.kill("SIGTERM"); - }, - { once: true }, - ); - - return proc; -} - async function processMessage( - message: ZcaMessage, + message: ZaloInboundMessage, account: ResolvedZalouserAccount, config: OpenClawConfig, core: ZalouserCoreRuntime, runtime: RuntimeEnv, statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void, ): Promise { - const { threadId, content, timestamp, metadata } = message; const pairing = createScopedPairingAccess({ core, channel: "zalouser", accountId: account.accountId, }); - if (!content?.trim()) { + + const rawBody = message.content?.trim(); + if (!rawBody) { return; } - const isGroup = metadata?.isGroup ?? false; - const senderId = metadata?.fromId ?? threadId; - const senderName = metadata?.senderName ?? ""; - const groupName = metadata?.threadName ?? ""; - const chatId = threadId; + const isGroup = message.isGroup; + const senderId = message.senderId; + const senderName = message.senderName ?? ""; + const groupName = message.groupName ?? ""; + const chatId = message.threadId; const defaultGroupPolicy = resolveDefaultGroupPolicy(config); const { groupPolicy, providerMissingFallbackApplied } = resolveOpenProviderRuntimeGroupPolicy({ @@ -205,8 +148,9 @@ async function processMessage( providerMissingFallbackApplied, providerKey: "zalouser", accountId: account.accountId, - log: (message) => logVerbose(core, runtime, message), + log: (entry) => logVerbose(core, runtime, entry), }); + const groups = account.config.groups ?? {}; if (isGroup) { if (groupPolicy === "disabled") { @@ -224,65 +168,67 @@ async function processMessage( const dmPolicy = account.config.dmPolicy ?? "pairing"; const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); - const rawBody = content.trim(); - const { senderAllowedForCommands, commandAuthorized } = - await resolveSenderCommandAuthorizationWithRuntime({ - cfg: config, - rawBody, - isGroup, - dmPolicy, - configuredAllowFrom: configAllowFrom, - senderId, - isSenderAllowed, - readAllowFromStore: pairing.readAllowFromStore, - runtime: core.channel.commands, - }); - - const directDmOutcome = resolveDirectDmAuthorizationOutcome({ + const { senderAllowedForCommands, commandAuthorized } = await resolveSenderCommandAuthorization({ + cfg: config, + rawBody, isGroup, dmPolicy, - senderAllowedForCommands, + configuredAllowFrom: configAllowFrom, + senderId, + isSenderAllowed, + readAllowFromStore: pairing.readAllowFromStore, + shouldComputeCommandAuthorized: (body, cfg) => + core.channel.commands.shouldComputeCommandAuthorized(body, cfg), + resolveCommandAuthorizedFromAuthorizers: (params) => + core.channel.commands.resolveCommandAuthorizedFromAuthorizers(params), }); - if (directDmOutcome === "disabled") { - logVerbose(core, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`); - return; - } - if (directDmOutcome === "unauthorized") { - if (dmPolicy === "pairing") { - const { code, created } = await pairing.upsertPairingRequest({ - id: senderId, - meta: { name: senderName || undefined }, - }); - if (created) { - logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`); - try { - await sendMessageZalouser( - chatId, - core.channel.pairing.buildPairingReply({ - channel: "zalouser", - idLine: `Your Zalo user id: ${senderId}`, - code, - }), - { profile: account.profile }, - ); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { + if (!isGroup) { + if (dmPolicy === "disabled") { + logVerbose(core, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`); + return; + } + + if (dmPolicy !== "open") { + const allowed = senderAllowedForCommands; + if (!allowed) { + if (dmPolicy === "pairing") { + const { code, created } = await pairing.upsertPairingRequest({ + id: senderId, + meta: { name: senderName || undefined }, + }); + + if (created) { + logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`); + try { + await sendMessageZalouser( + chatId, + core.channel.pairing.buildPairingReply({ + channel: "zalouser", + idLine: `Your Zalo user id: ${senderId}`, + code, + }), + { profile: account.profile }, + ); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerbose( + core, + runtime, + `zalouser pairing reply failed for ${senderId}: ${String(err)}`, + ); + } + } + } else { logVerbose( core, runtime, - `zalouser pairing reply failed for ${senderId}: ${String(err)}`, + `Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`, ); } + return; } - } else { - logVerbose( - core, - runtime, - `Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`, - ); } - return; } if ( @@ -302,7 +248,7 @@ async function processMessage( ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId }; - const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({ + const route = core.channel.routing.resolveAgentRoute({ cfg: config, channel: "zalouser", accountId: account.accountId, @@ -311,15 +257,23 @@ async function processMessage( kind: peer.kind, id: peer.id, }, - runtime: core.channel, - sessionStore: config.session?.store, }); - const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`; - const { storePath, body } = buildEnvelope({ + const fromLabel = isGroup ? groupName || `group:${chatId}` : senderName || `user:${senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ channel: "Zalo Personal", from: fromLabel, - timestamp: timestamp ? timestamp * 1000 : undefined, + timestamp: message.timestampMs, + previousTimestamp, + envelope: envelopeOptions, body: rawBody, }); @@ -339,7 +293,7 @@ async function processMessage( CommandAuthorized: commandAuthorized, Provider: "zalouser", Surface: "zalouser", - MessageSid: message.msgId ?? `${timestamp}`, + MessageSid: message.msgId ?? message.cliMsgId ?? `${message.timestampMs}`, OriginatingChannel: "zalouser", OriginatingTo: `zalouser:${chatId}`, }); @@ -456,10 +410,6 @@ export async function monitorZalouserProvider( const { abortSignal, statusSink, runtime } = options; const core = getZalouserRuntime(); - let stopped = false; - let proc: ChildProcess | null = null; - let restartTimer: ReturnType | null = null; - let resolveRunning: (() => void) | null = null; try { const profile = account.profile; @@ -468,154 +418,132 @@ export async function monitorZalouserProvider( .filter((entry) => entry && entry !== "*"); if (allowFromEntries.length > 0) { - const result = await runZca(["friend", "list", "-j"], { profile, timeout: 15000 }); - if (result.ok) { - const friends = parseJsonOutput(result.stdout) ?? []; - const byName = buildNameIndex(friends, (friend) => friend.displayName); - const additions: string[] = []; - const mapping: string[] = []; - const unresolved: string[] = []; - for (const entry of allowFromEntries) { - if (/^\d+$/.test(entry)) { - additions.push(entry); - continue; - } - const matches = byName.get(entry.toLowerCase()) ?? []; - const match = matches[0]; - const id = match?.userId ? String(match.userId) : undefined; - if (id) { - additions.push(id); - mapping.push(`${entry}→${id}`); - } else { - unresolved.push(entry); - } + const friends = await listZaloFriends(profile); + const byName = buildNameIndex(friends, (friend) => friend.displayName); + const additions: string[] = []; + const mapping: string[] = []; + const unresolved: string[] = []; + for (const entry of allowFromEntries) { + if (/^\d+$/.test(entry)) { + additions.push(entry); + continue; + } + const matches = byName.get(entry.toLowerCase()) ?? []; + const match = matches[0]; + const id = match?.userId ? String(match.userId) : undefined; + if (id) { + additions.push(id); + mapping.push(`${entry}→${id}`); + } else { + unresolved.push(entry); } - const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions }); - account = { - ...account, - config: { - ...account.config, - allowFrom, - }, - }; - summarizeMapping("zalouser users", mapping, unresolved, runtime); - } else { - runtime.log?.(`zalouser user resolve failed; using config entries. ${result.stderr}`); } + const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions }); + account = { + ...account, + config: { + ...account.config, + allowFrom, + }, + }; + summarizeMapping("zalouser users", mapping, unresolved, runtime); } const groupsConfig = account.config.groups ?? {}; const groupKeys = Object.keys(groupsConfig).filter((key) => key !== "*"); if (groupKeys.length > 0) { - const result = await runZca(["group", "list", "-j"], { profile, timeout: 15000 }); - if (result.ok) { - const groups = parseJsonOutput(result.stdout) ?? []; - const byName = buildNameIndex(groups, (group) => group.name); - const mapping: string[] = []; - const unresolved: string[] = []; - const nextGroups = { ...groupsConfig }; - for (const entry of groupKeys) { - const cleaned = normalizeZalouserEntry(entry); - if (/^\d+$/.test(cleaned)) { - if (!nextGroups[cleaned]) { - nextGroups[cleaned] = groupsConfig[entry]; - } - mapping.push(`${entry}→${cleaned}`); - continue; - } - const matches = byName.get(cleaned.toLowerCase()) ?? []; - const match = matches[0]; - const id = match?.groupId ? String(match.groupId) : undefined; - if (id) { - if (!nextGroups[id]) { - nextGroups[id] = groupsConfig[entry]; - } - mapping.push(`${entry}→${id}`); - } else { - unresolved.push(entry); + const groups = await listZaloGroups(profile); + const byName = buildNameIndex(groups, (group) => group.name); + const mapping: string[] = []; + const unresolved: string[] = []; + const nextGroups = { ...groupsConfig }; + for (const entry of groupKeys) { + const cleaned = normalizeZalouserEntry(entry); + if (/^\d+$/.test(cleaned)) { + if (!nextGroups[cleaned]) { + nextGroups[cleaned] = groupsConfig[entry]; } + mapping.push(`${entry}→${cleaned}`); + continue; + } + const matches = byName.get(cleaned.toLowerCase()) ?? []; + const match = matches[0]; + const id = match?.groupId ? String(match.groupId) : undefined; + if (id) { + if (!nextGroups[id]) { + nextGroups[id] = groupsConfig[entry]; + } + mapping.push(`${entry}→${id}`); + } else { + unresolved.push(entry); } - account = { - ...account, - config: { - ...account.config, - groups: nextGroups, - }, - }; - summarizeMapping("zalouser groups", mapping, unresolved, runtime); - } else { - runtime.log?.(`zalouser group resolve failed; using config entries. ${result.stderr}`); } + account = { + ...account, + config: { + ...account.config, + groups: nextGroups, + }, + }; + summarizeMapping("zalouser groups", mapping, unresolved, runtime); } } catch (err) { runtime.log?.(`zalouser resolve failed; using config entries. ${String(err)}`); } - const stop = () => { - stopped = true; - if (restartTimer) { - clearTimeout(restartTimer); - restartTimer = null; - } - if (proc) { - proc.kill("SIGTERM"); - proc = null; - } - resolveRunning?.(); - }; + let listenerStop: (() => void) | null = null; + let stopped = false; - const startListener = () => { - if (stopped || abortSignal.aborted) { - resolveRunning?.(); + const stop = () => { + if (stopped) { return; } - - logVerbose( - core, - runtime, - `[${account.accountId}] starting zca listener (profile=${account.profile})`, - ); - - proc = startZcaListener( - runtime, - account.profile, - (msg) => { - logVerbose(core, runtime, `[${account.accountId}] inbound message`); - statusSink?.({ lastInboundAt: Date.now() }); - processMessage(msg, account, config, core, runtime, statusSink).catch((err) => { - runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`); - }); - }, - (err) => { - runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`); - if (!stopped && !abortSignal.aborted) { - logVerbose(core, runtime, `[${account.accountId}] restarting listener in 5s...`); - restartTimer = setTimeout(startListener, 5000); - } else { - resolveRunning?.(); - } - }, - abortSignal, - ); + stopped = true; + listenerStop?.(); + listenerStop = null; }; - // Create a promise that stays pending until abort or stop - const runningPromise = new Promise((resolve) => { - resolveRunning = resolve; - abortSignal.addEventListener("abort", () => resolve(), { once: true }); + const listener = await startZaloListener({ + accountId: account.accountId, + profile: account.profile, + abortSignal, + onMessage: (msg) => { + if (stopped) { + return; + } + logVerbose(core, runtime, `[${account.accountId}] inbound message`); + statusSink?.({ lastInboundAt: Date.now() }); + processMessage(msg, account, config, core, runtime, statusSink).catch((err) => { + runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`); + }); + }, + onError: (err) => { + if (stopped || abortSignal.aborted) { + return; + } + runtime.error(`[${account.accountId}] Zalo listener error: ${String(err)}`); + }, }); - startListener(); + listenerStop = listener.stop; - // Wait for the running promise to resolve (on abort/stop) - await runningPromise; + await new Promise((resolve) => { + abortSignal.addEventListener( + "abort", + () => { + stop(); + resolve(); + }, + { once: true }, + ); + }); return { stop }; } export const __testing = { processMessage: async (params: { - message: ZcaMessage; + message: ZaloInboundMessage; account: ResolvedZalouserAccount; config: OpenClawConfig; runtime: RuntimeEnv; diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index fa694a64748..8c702efeb7d 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -1,3 +1,5 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy, @@ -12,6 +14,7 @@ import { normalizeAccountId, promptAccountId, promptChannelAccessConfig, + resolvePreferredOpenClawTmpDir, } from "openclaw/plugin-sdk"; import { listZalouserAccountIds, @@ -19,8 +22,13 @@ import { resolveZalouserAccountSync, checkZcaAuthenticated, } from "./accounts.js"; -import type { ZcaFriend, ZcaGroup } from "./types.js"; -import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js"; +import { + logoutZaloProfile, + resolveZaloAllowFromEntries, + resolveZaloGroupsByEntries, + startZaloQrLogin, + waitForZaloQrLogin, +} from "./zalo-js.js"; const channel = "zalouser" as const; @@ -87,9 +95,7 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise { [ "Zalo Personal Account login via QR code.", "", - "Prerequisites:", - "1) Install zca-cli", - "2) You'll scan a QR code with your Zalo app", + "This plugin uses zca-js directly (no external CLI dependency).", "", "Docs: https://docs.openclaw.ai/channels/zalouser", ].join("\n"), @@ -97,6 +103,25 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise { ); } +async function writeQrDataUrlToTempFile( + qrDataUrl: string, + profile: string, +): Promise { + const trimmed = qrDataUrl.trim(); + const match = trimmed.match(/^data:image\/png;base64,(.+)$/i); + const base64 = (match?.[1] ?? "").trim(); + if (!base64) { + return null; + } + const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default"; + const filePath = path.join( + resolvePreferredOpenClawTmpDir(), + `openclaw-zalouser-qr-${safeProfile}.png`, + ); + await fsp.writeFile(filePath, Buffer.from(base64, "base64")); + return filePath; +} + async function promptZalouserAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -111,58 +136,40 @@ async function promptZalouserAllowFrom(params: { .map((entry) => entry.trim()) .filter(Boolean); - const resolveUserId = async (input: string): Promise => { - const trimmed = input.trim(); - if (!trimmed) { - return null; - } - if (/^\d+$/.test(trimmed)) { - return trimmed; - } - const ok = await checkZcaInstalled(); - if (!ok) { - return null; - } - const result = await runZca(["friend", "find", trimmed], { - profile: resolved.profile, - timeout: 15000, - }); - if (!result.ok) { - return null; - } - const parsed = parseJsonOutput(result.stdout); - const rows = Array.isArray(parsed) ? parsed : []; - const match = rows[0]; - if (!match?.userId) { - return null; - } - if (rows.length > 1) { - await prompter.note( - `Multiple matches for "${trimmed}", using ${match.displayName ?? match.userId}.`, - "Zalo Personal allowlist", - ); - } - return String(match.userId); - }; - while (true) { const entry = await prompter.text({ - message: "Zalouser allowFrom (username or user id)", + message: "Zalouser allowFrom (name or user id)", placeholder: "Alice, 123456789", initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), }); const parts = parseInput(String(entry)); - const results = await Promise.all(parts.map((part) => resolveUserId(part))); - const unresolved = parts.filter((_, idx) => !results[idx]); + const resolvedEntries = await resolveZaloAllowFromEntries({ + profile: resolved.profile, + entries: parts, + }); + + const unresolved = resolvedEntries.filter((item) => !item.resolved).map((item) => item.input); if (unresolved.length > 0) { await prompter.note( - `Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or ensure zca is available.`, + `Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or exact friend names.`, "Zalo Personal allowlist", ); continue; } - const unique = mergeAllowFromEntries(existingAllowFrom, results.filter(Boolean) as string[]); + + const resolvedIds = resolvedEntries + .filter((item) => item.resolved && item.id) + .map((item) => item.id as string); + const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds); + + const notes = resolvedEntries + .filter((item) => item.note) + .map((item) => `${item.input} -> ${item.id} (${item.note})`); + if (notes.length > 0) { + await prompter.note(notes.join("\n"), "Zalo Personal allowlist"); + } + return setZalouserAccountScopedConfig(cfg, accountId, { dmPolicy: "allowlist", allowFrom: unique, @@ -191,49 +198,6 @@ function setZalouserGroupAllowlist( }); } -async function resolveZalouserGroups(params: { - cfg: OpenClawConfig; - accountId: string; - entries: string[]; -}): Promise> { - const account = resolveZalouserAccountSync({ cfg: params.cfg, accountId: params.accountId }); - const result = await runZca(["group", "list", "-j"], { - profile: account.profile, - timeout: 15000, - }); - if (!result.ok) { - throw new Error(result.stderr || "Failed to list groups"); - } - const groups = (parseJsonOutput(result.stdout) ?? []).filter((group) => - Boolean(group.groupId), - ); - const byName = new Map(); - for (const group of groups) { - const name = group.name?.trim().toLowerCase(); - if (!name) { - continue; - } - const list = byName.get(name) ?? []; - list.push(group); - byName.set(name, list); - } - - return params.entries.map((input) => { - const trimmed = input.trim(); - if (!trimmed) { - return { input, resolved: false }; - } - if (/^\d+$/.test(trimmed)) { - return { input, resolved: true, id: trimmed }; - } - const matches = byName.get(trimmed.toLowerCase()) ?? []; - const match = matches[0]; - return match?.groupId - ? { input, resolved: true, id: String(match.groupId) } - : { input, resolved: false }; - }); -} - const dmPolicy: ChannelOnboardingDmPolicy = { label: "Zalo Personal", channel, @@ -247,7 +211,7 @@ const dmPolicy: ChannelOnboardingDmPolicy = { ? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID) : resolveDefaultZalouserAccountId(cfg); return promptZalouserAllowFrom({ - cfg: cfg, + cfg, prompter, accountId: id, }); @@ -261,7 +225,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { const ids = listZalouserAccountIds(cfg); let configured = false; for (const accountId of ids) { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const account = resolveZalouserAccountSync({ cfg, accountId }); const isAuth = await checkZcaAuthenticated(account.profile); if (isAuth) { configured = true; @@ -283,28 +247,13 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { shouldPromptAccountIds, forceAllowFrom, }) => { - // Check zca is installed - const zcaInstalled = await checkZcaInstalled(); - if (!zcaInstalled) { - await prompter.note( - [ - "The `zca` binary was not found in PATH.", - "", - "Install zca-cli, then re-run onboarding:", - "Docs: https://docs.openclaw.ai/channels/zalouser", - ].join("\n"), - "Missing Dependency", - ); - return { cfg, accountId: DEFAULT_ACCOUNT_ID }; - } - const zalouserOverride = accountOverrides.zalouser?.trim(); const defaultAccountId = resolveDefaultZalouserAccountId(cfg); let accountId = zalouserOverride ? normalizeAccountId(zalouserOverride) : defaultAccountId; if (shouldPromptAccountIds && !zalouserOverride) { accountId = await promptAccountId({ - cfg: cfg, + cfg, prompter, label: "Zalo Personal", currentId: accountId, @@ -326,23 +275,32 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { }); if (wantsLogin) { - await prompter.note( - "A QR code will appear in your terminal.\nScan it with your Zalo app to login.", - "QR Login", - ); - - // Run interactive login - const result = await runZcaInteractive(["auth", "login"], { - profile: account.profile, - }); - - if (!result.ok) { - await prompter.note(`Login failed: ${result.stderr || "Unknown error"}`, "Error"); - } else { - const isNowAuth = await checkZcaAuthenticated(account.profile); - if (isNowAuth) { - await prompter.note("Login successful!", "Success"); + const start = await startZaloQrLogin({ profile: account.profile, timeoutMs: 35_000 }); + if (start.qrDataUrl) { + const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile); + await prompter.note( + [ + start.message, + qrPath + ? `QR image saved to: ${qrPath}` + : "Could not write QR image file; use gateway web login UI instead.", + "Scan + approve on phone, then continue.", + ].join("\n"), + "QR Login", + ); + const scanned = await prompter.confirm({ + message: "Did you scan and approve the QR on your phone?", + initialValue: true, + }); + if (scanned) { + const waited = await waitForZaloQrLogin({ + profile: account.profile, + timeoutMs: 120_000, + }); + await prompter.note(waited.message, waited.connected ? "Success" : "Login pending"); } + } else { + await prompter.note(start.message, "Login pending"); } } } else { @@ -351,12 +309,26 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (!keepSession) { - await runZcaInteractive(["auth", "logout"], { profile: account.profile }); - await runZcaInteractive(["auth", "login"], { profile: account.profile }); + await logoutZaloProfile(account.profile); + const start = await startZaloQrLogin({ + profile: account.profile, + force: true, + timeoutMs: 35_000, + }); + if (start.qrDataUrl) { + const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile); + await prompter.note( + [start.message, qrPath ? `QR image saved to: ${qrPath}` : undefined] + .filter(Boolean) + .join("\n"), + "QR Login", + ); + const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 120_000 }); + await prompter.note(waited.message, waited.connected ? "Success" : "Login pending"); + } } } - // Enable the channel next = setZalouserAccountScopedConfig( next, accountId, @@ -372,14 +344,16 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { }); } + const updatedAccount = resolveZalouserAccountSync({ cfg: next, accountId }); const accessConfig = await promptChannelAccessConfig({ prompter, label: "Zalo groups", - currentPolicy: account.config.groupPolicy ?? "allowlist", - currentEntries: Object.keys(account.config.groups ?? {}), + currentPolicy: updatedAccount.config.groupPolicy ?? "allowlist", + currentEntries: Object.keys(updatedAccount.config.groups ?? {}), placeholder: "Family, Work, 123456789", - updatePrompt: Boolean(account.config.groups), + updatePrompt: Boolean(updatedAccount.config.groups), }); + if (accessConfig) { if (accessConfig.policy !== "allowlist") { next = setZalouserGroupPolicy(next, accountId, accessConfig.policy); @@ -387,9 +361,8 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = { let keys = accessConfig.entries; if (accessConfig.entries.length > 0) { try { - const resolved = await resolveZalouserGroups({ - cfg: next, - accountId, + const resolved = await resolveZaloGroupsByEntries({ + profile: updatedAccount.profile, entries: accessConfig.entries, }); const resolvedIds = resolved diff --git a/extensions/zalouser/src/probe.ts b/extensions/zalouser/src/probe.ts index 6bdc962052f..2285c46feaf 100644 --- a/extensions/zalouser/src/probe.ts +++ b/extensions/zalouser/src/probe.ts @@ -1,6 +1,6 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk"; import type { ZcaUserInfo } from "./types.js"; -import { runZca, parseJsonOutput } from "./zca.js"; +import { getZaloUserInfo } from "./zalo-js.js"; export type ZalouserProbeResult = BaseProbeResult & { user?: ZcaUserInfo; @@ -10,18 +10,25 @@ export async function probeZalouser( profile: string, timeoutMs?: number, ): Promise { - const result = await runZca(["me", "info", "-j"], { - profile, - timeout: timeoutMs, - }); + try { + const user = timeoutMs + ? await Promise.race([ + getZaloUserInfo(profile), + new Promise((resolve) => + setTimeout(() => resolve(null), Math.max(timeoutMs, 1000)), + ), + ]) + : await getZaloUserInfo(profile); - if (!result.ok) { - return { ok: false, error: result.stderr || "Failed to probe" }; - } + if (!user) { + return { ok: false, error: "Not authenticated" }; + } - const user = parseJsonOutput(result.stdout); - if (!user) { - return { ok: false, error: "Failed to parse user info" }; + return { ok: true, user }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; } - return { ok: true, user }; } diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts index 1a3c3d3ea66..1608c707e3f 100644 --- a/extensions/zalouser/src/send.ts +++ b/extensions/zalouser/src/send.ts @@ -1,104 +1,15 @@ -import { runZca } from "./zca.js"; +import type { ZaloSendOptions, ZaloSendResult } from "./types.js"; +import { sendZaloLink, sendZaloTextMessage } from "./zalo-js.js"; -export type ZalouserSendOptions = { - profile?: string; - mediaUrl?: string; - caption?: string; - isGroup?: boolean; -}; - -export type ZalouserSendResult = { - ok: boolean; - messageId?: string; - error?: string; -}; - -function resolveProfile(options: ZalouserSendOptions): string { - return options.profile || process.env.ZCA_PROFILE || "default"; -} - -function appendCaptionAndGroupFlags(args: string[], options: ZalouserSendOptions): void { - if (options.caption) { - args.push("-m", options.caption.slice(0, 2000)); - } - if (options.isGroup) { - args.push("-g"); - } -} - -async function runSendCommand( - args: string[], - profile: string, - fallbackError: string, -): Promise { - try { - const result = await runZca(args, { profile }); - if (result.ok) { - return { ok: true, messageId: extractMessageId(result.stdout) }; - } - return { ok: false, error: result.stderr || fallbackError }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } -} +export type ZalouserSendOptions = ZaloSendOptions; +export type ZalouserSendResult = ZaloSendResult; export async function sendMessageZalouser( threadId: string, text: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = resolveProfile(options); - - if (!threadId?.trim()) { - return { ok: false, error: "No threadId provided" }; - } - - // Handle media sending - if (options.mediaUrl) { - return sendMediaZalouser(threadId, options.mediaUrl, { - ...options, - caption: text || options.caption, - }); - } - - // Send text message - const args = ["msg", "send", threadId.trim(), text.slice(0, 2000)]; - if (options.isGroup) { - args.push("-g"); - } - - return runSendCommand(args, profile, "Failed to send message"); -} - -async function sendMediaZalouser( - threadId: string, - mediaUrl: string, - options: ZalouserSendOptions = {}, -): Promise { - const profile = resolveProfile(options); - - if (!threadId?.trim()) { - return { ok: false, error: "No threadId provided" }; - } - - if (!mediaUrl?.trim()) { - return { ok: false, error: "No media URL provided" }; - } - - // Determine media type from URL - const lowerUrl = mediaUrl.toLowerCase(); - let command: string; - if (lowerUrl.match(/\.(mp4|mov|avi|webm)$/)) { - command = "video"; - } else if (lowerUrl.match(/\.(mp3|wav|ogg|m4a)$/)) { - command = "voice"; - } else { - command = "image"; - } - - const args = ["msg", command, threadId.trim(), "-u", mediaUrl.trim()]; - appendCaptionAndGroupFlags(args, options); - return runSendCommand(args, profile, `Failed to send ${command}`); + return await sendZaloTextMessage(threadId, text, options); } export async function sendImageZalouser( @@ -106,10 +17,10 @@ export async function sendImageZalouser( imageUrl: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = resolveProfile(options); - const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()]; - appendCaptionAndGroupFlags(args, options); - return runSendCommand(args, profile, "Failed to send image"); + return await sendZaloTextMessage(threadId, options.caption ?? "", { + ...options, + mediaUrl: imageUrl, + }); } export async function sendLinkZalouser( @@ -117,25 +28,5 @@ export async function sendLinkZalouser( url: string, options: ZalouserSendOptions = {}, ): Promise { - const profile = resolveProfile(options); - const args = ["msg", "link", threadId.trim(), url.trim()]; - if (options.isGroup) { - args.push("-g"); - } - - return runSendCommand(args, profile, "Failed to send link"); -} - -function extractMessageId(stdout: string): string | undefined { - // Try to extract message ID from output - const match = stdout.match(/message[_\s]?id[:\s]+(\S+)/i); - if (match) { - return match[1]; - } - // Return first word if it looks like an ID - const firstWord = stdout.trim().split(/\s+/)[0]; - if (firstWord && /^[a-zA-Z0-9_-]+$/.test(firstWord)) { - return firstWord; - } - return undefined; + return await sendZaloLink(threadId, url, options); } diff --git a/extensions/zalouser/src/status-issues.ts b/extensions/zalouser/src/status-issues.ts index 08fc0f64266..34ebdc2e330 100644 --- a/extensions/zalouser/src/status-issues.ts +++ b/extensions/zalouser/src/status-issues.ts @@ -27,14 +27,6 @@ function readZalouserAccountStatus(value: ChannelAccountSnapshot): ZalouserAccou }; } -function isMissingZca(lastError?: string): boolean { - if (!lastError) { - return false; - } - const lower = lastError.toLowerCase(); - return lower.includes("zca") && (lower.includes("not found") || lower.includes("enoent")); -} - export function collectZalouserStatusIssues( accounts: ChannelAccountSnapshot[], ): ChannelStatusIssue[] { @@ -51,26 +43,15 @@ export function collectZalouserStatusIssues( } const configured = account.configured === true; - const lastError = asString(account.lastError)?.trim(); if (!configured) { - if (isMissingZca(lastError)) { - issues.push({ - channel: "zalouser", - accountId, - kind: "runtime", - message: "zca CLI not found in PATH.", - fix: "Install zca-cli and ensure it is on PATH for the Gateway process.", - }); - } else { - issues.push({ - channel: "zalouser", - accountId, - kind: "auth", - message: "Not authenticated (no zca session).", - fix: "Run: openclaw channels login --channel zalouser", - }); - } + issues.push({ + channel: "zalouser", + accountId, + kind: "auth", + message: "Not authenticated (no saved Zalo session).", + fix: "Run: openclaw channels login --channel zalouser", + }); continue; } diff --git a/extensions/zalouser/src/tool.ts b/extensions/zalouser/src/tool.ts index 20d7d1bd6ed..e6a2f3bbe6a 100644 --- a/extensions/zalouser/src/tool.ts +++ b/extensions/zalouser/src/tool.ts @@ -1,5 +1,11 @@ import { Type } from "@sinclair/typebox"; -import { runZca, parseJsonOutput } from "./zca.js"; +import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js"; +import { + checkZaloAuthenticated, + getZaloUserInfo, + listZaloFriendsMatching, + listZaloGroupsMatching, +} from "./zalo-js.js"; const ACTIONS = ["send", "image", "link", "friends", "groups", "me", "status"] as const; @@ -19,7 +25,6 @@ function stringEnum( }); } -// Tool schema - avoiding Type.Union per tool schema guardrails export const ZalouserToolSchema = Type.Object( { action: stringEnum(ACTIONS, { description: `Action to perform: ${ACTIONS.join(", ")}` }), @@ -62,15 +67,14 @@ export async function executeZalouserTool( if (!params.threadId || !params.message) { throw new Error("threadId and message required for send action"); } - const args = ["msg", "send", params.threadId, params.message]; - if (params.isGroup) { - args.push("-g"); - } - const result = await runZca(args, { profile: params.profile }); + const result = await sendMessageZalouser(params.threadId, params.message, { + profile: params.profile, + isGroup: params.isGroup, + }); if (!result.ok) { - throw new Error(result.stderr || "Failed to send message"); + throw new Error(result.error || "Failed to send message"); } - return json({ success: true, output: result.stdout }); + return json({ success: true, messageId: result.messageId }); } case "image": { @@ -80,74 +84,52 @@ export async function executeZalouserTool( if (!params.url) { throw new Error("url required for image action"); } - const args = ["msg", "image", params.threadId, "-u", params.url]; - if (params.message) { - args.push("-m", params.message); - } - if (params.isGroup) { - args.push("-g"); - } - const result = await runZca(args, { profile: params.profile }); + const result = await sendImageZalouser(params.threadId, params.url, { + profile: params.profile, + caption: params.message, + isGroup: params.isGroup, + }); if (!result.ok) { - throw new Error(result.stderr || "Failed to send image"); + throw new Error(result.error || "Failed to send image"); } - return json({ success: true, output: result.stdout }); + return json({ success: true, messageId: result.messageId }); } case "link": { if (!params.threadId || !params.url) { throw new Error("threadId and url required for link action"); } - const args = ["msg", "link", params.threadId, params.url]; - if (params.isGroup) { - args.push("-g"); - } - const result = await runZca(args, { profile: params.profile }); + const result = await sendLinkZalouser(params.threadId, params.url, { + profile: params.profile, + caption: params.message, + isGroup: params.isGroup, + }); if (!result.ok) { - throw new Error(result.stderr || "Failed to send link"); + throw new Error(result.error || "Failed to send link"); } - return json({ success: true, output: result.stdout }); + return json({ success: true, messageId: result.messageId }); } case "friends": { - const args = params.query ? ["friend", "find", params.query] : ["friend", "list", "-j"]; - const result = await runZca(args, { profile: params.profile }); - if (!result.ok) { - throw new Error(result.stderr || "Failed to get friends"); - } - const parsed = parseJsonOutput(result.stdout); - return json(parsed ?? { raw: result.stdout }); + const rows = await listZaloFriendsMatching(params.profile, params.query); + return json(rows); } case "groups": { - const result = await runZca(["group", "list", "-j"], { - profile: params.profile, - }); - if (!result.ok) { - throw new Error(result.stderr || "Failed to get groups"); - } - const parsed = parseJsonOutput(result.stdout); - return json(parsed ?? { raw: result.stdout }); + const rows = await listZaloGroupsMatching(params.profile, params.query); + return json(rows); } case "me": { - const result = await runZca(["me", "info", "-j"], { - profile: params.profile, - }); - if (!result.ok) { - throw new Error(result.stderr || "Failed to get profile"); - } - const parsed = parseJsonOutput(result.stdout); - return json(parsed ?? { raw: result.stdout }); + const info = await getZaloUserInfo(params.profile); + return json(info ?? { error: "Not authenticated" }); } case "status": { - const result = await runZca(["auth", "status"], { - profile: params.profile, - }); + const authenticated = await checkZaloAuthenticated(params.profile); return json({ - authenticated: result.ok, - output: result.stdout || result.stderr, + authenticated, + output: authenticated ? "authenticated" : "not authenticated", }); } diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index 8be1649bae5..e9f7ae71a23 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -1,48 +1,32 @@ -// zca-cli wrapper types -export type ZcaRunOptions = { - profile?: string; - cwd?: string; - timeout?: number; -}; - -export type ZcaResult = { - ok: boolean; - stdout: string; - stderr: string; - exitCode: number; -}; - -export type ZcaProfile = { - name: string; - label?: string; - isDefault?: boolean; -}; - export type ZcaFriend = { userId: string; displayName: string; avatar?: string; }; -export type ZcaGroup = { +export type ZaloGroup = { groupId: string; name: string; memberCount?: number; }; -export type ZcaMessage = { +export type ZaloGroupMember = { + userId: string; + displayName: string; + avatar?: string; +}; + +export type ZaloInboundMessage = { threadId: string; + isGroup: boolean; + senderId: string; + senderName?: string; + groupName?: string; + content: string; + timestampMs: number; msgId?: string; cliMsgId?: string; - type: number; - content: string; - timestamp: number; - metadata?: { - isGroup: boolean; - threadName?: string; - senderName?: string; - fromId?: string; - }; + raw: unknown; }; export type ZcaUserInfo = { @@ -51,21 +35,23 @@ export type ZcaUserInfo = { avatar?: string; }; -export type CommonOptions = { +export type ZaloSendOptions = { profile?: string; - json?: boolean; + mediaUrl?: string; + caption?: string; + isGroup?: boolean; + mediaLocalRoots?: readonly string[]; }; -export type SendOptions = CommonOptions & { - group?: boolean; +export type ZaloSendResult = { + ok: boolean; + messageId?: string; + error?: string; }; -export type ListenOptions = CommonOptions & { - raw?: boolean; - keepAlive?: boolean; - webhook?: string; - echo?: boolean; - prefix?: string; +export type ZaloAuthStatus = { + connected: boolean; + message: string; }; type ZalouserToolConfig = { allow?: string[]; deny?: string[] }; diff --git a/extensions/zalouser/src/zalo-js.ts b/extensions/zalouser/src/zalo-js.ts new file mode 100644 index 00000000000..ec8d3b6e2df --- /dev/null +++ b/extensions/zalouser/src/zalo-js.ts @@ -0,0 +1,1113 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk"; +import { + LoginQRCallbackEventType, + ThreadType, + Zalo, + type API, + type Credentials, + type GroupInfo, + type LoginQRCallbackEvent, + type Message, + type User, +} from "zca-js"; +import { getZalouserRuntime } from "./runtime.js"; +import type { + ZaloAuthStatus, + ZaloGroup, + ZaloGroupMember, + ZaloInboundMessage, + ZaloSendOptions, + ZaloSendResult, + ZcaFriend, + ZcaUserInfo, +} from "./types.js"; + +const API_LOGIN_TIMEOUT_MS = 20_000; +const QR_LOGIN_TTL_MS = 3 * 60_000; +const DEFAULT_QR_START_TIMEOUT_MS = 30_000; +const DEFAULT_QR_WAIT_TIMEOUT_MS = 120_000; +const GROUP_INFO_CHUNK_SIZE = 80; + +const apiByProfile = new Map(); +const apiInitByProfile = new Map>(); + +type ActiveZaloQrLogin = { + id: string; + profile: string; + startedAt: number; + qrDataUrl?: string; + connected: boolean; + error?: string; + abort?: () => void; + waitPromise: Promise; +}; + +const activeQrLogins = new Map(); + +type ActiveZaloListener = { + profile: string; + accountId: string; + stop: () => void; +}; + +const activeListeners = new Map(); + +type StoredZaloCredentials = { + imei: string; + cookie: Credentials["cookie"]; + userAgent: string; + language?: string; + createdAt: string; + lastUsedAt?: string; +}; + +function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string { + return getZalouserRuntime().state.resolveStateDir(env, os.homedir); +} + +function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string { + return path.join(resolveStateDir(env), "credentials", "zalouser"); +} + +function credentialsFilename(profile: string): string { + const trimmed = profile.trim().toLowerCase(); + if (!trimmed || trimmed === "default") { + return "credentials.json"; + } + return `credentials-${encodeURIComponent(trimmed)}.json`; +} + +function resolveCredentialsPath(profile: string, env: NodeJS.ProcessEnv = process.env): string { + return path.join(resolveCredentialsDir(env), credentialsFilename(profile)); +} + +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(label)); + }, timeoutMs); + void promise + .then((result) => { + clearTimeout(timer); + resolve(result); + }) + .catch((err) => { + clearTimeout(timer); + reject(err); + }); + }); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function normalizeProfile(profile?: string | null): string { + const trimmed = profile?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : "default"; +} + +function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + return String(error); +} + +function toNumberId(value: unknown): string { + if (typeof value === "number" && Number.isFinite(value)) { + return String(Math.trunc(value)); + } + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length > 0) { + return trimmed.replace(/_\d+$/, ""); + } + } + return ""; +} + +function normalizeMessageContent(content: unknown): string { + if (typeof content === "string") { + return content; + } + if (!content || typeof content !== "object") { + return ""; + } + const record = content as Record; + const title = typeof record.title === "string" ? record.title.trim() : ""; + const description = typeof record.description === "string" ? record.description.trim() : ""; + const href = typeof record.href === "string" ? record.href.trim() : ""; + const combined = [title, description, href].filter(Boolean).join("\n").trim(); + if (combined) { + return combined; + } + try { + return JSON.stringify(content); + } catch { + return ""; + } +} + +function resolveInboundTimestamp(rawTs: unknown): number { + if (typeof rawTs === "number" && Number.isFinite(rawTs)) { + return rawTs > 1_000_000_000_000 ? rawTs : rawTs * 1000; + } + const parsed = Number.parseInt(String(rawTs ?? ""), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return Date.now(); + } + return parsed > 1_000_000_000_000 ? parsed : parsed * 1000; +} + +function extractSendMessageId(result: unknown): string | undefined { + if (!result || typeof result !== "object") { + return undefined; + } + const payload = result as { + message?: { msgId?: string | number } | null; + attachment?: Array<{ msgId?: string | number }>; + }; + const primary = payload.message?.msgId; + if (primary !== undefined && primary !== null) { + return String(primary); + } + const attachmentId = payload.attachment?.[0]?.msgId; + if (attachmentId !== undefined && attachmentId !== null) { + return String(attachmentId); + } + return undefined; +} + +function resolveMediaFileName(params: { + mediaUrl: string; + fileName?: string; + contentType?: string; + kind?: string; +}): string { + const explicit = params.fileName?.trim(); + if (explicit) { + return explicit; + } + + try { + const parsed = new URL(params.mediaUrl); + const fromPath = path.basename(parsed.pathname).trim(); + if (fromPath) { + return fromPath; + } + } catch { + // ignore URL parse failures + } + + const ext = + params.contentType === "image/png" + ? "png" + : params.contentType === "image/webp" + ? "webp" + : params.contentType === "image/jpeg" + ? "jpg" + : params.contentType === "video/mp4" + ? "mp4" + : params.contentType === "audio/mpeg" + ? "mp3" + : params.contentType === "audio/ogg" + ? "ogg" + : params.contentType === "audio/wav" + ? "wav" + : params.kind === "video" + ? "mp4" + : params.kind === "audio" + ? "mp3" + : params.kind === "image" + ? "jpg" + : "bin"; + + return `upload.${ext}`; +} + +function mapFriend(friend: User): ZcaFriend { + return { + userId: String(friend.userId), + displayName: friend.displayName || friend.zaloName || friend.username || String(friend.userId), + avatar: friend.avatar || undefined, + }; +} + +function mapGroup(groupId: string, group: GroupInfo & Record): ZaloGroup { + const totalMember = + typeof group.totalMember === "number" && Number.isFinite(group.totalMember) + ? group.totalMember + : undefined; + return { + groupId: String(groupId), + name: group.name?.trim() || String(groupId), + memberCount: totalMember, + }; +} + +function readCredentials(profile: string): StoredZaloCredentials | null { + const filePath = resolveCredentialsPath(profile); + try { + if (!fs.existsSync(filePath)) { + return null; + } + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.imei !== "string" || + !parsed.imei || + !parsed.cookie || + typeof parsed.userAgent !== "string" || + !parsed.userAgent + ) { + return null; + } + return { + imei: parsed.imei, + cookie: parsed.cookie as Credentials["cookie"], + userAgent: parsed.userAgent, + language: typeof parsed.language === "string" ? parsed.language : undefined, + createdAt: typeof parsed.createdAt === "string" ? parsed.createdAt : new Date().toISOString(), + lastUsedAt: typeof parsed.lastUsedAt === "string" ? parsed.lastUsedAt : undefined, + }; + } catch { + return null; + } +} + +function touchCredentials(profile: string): void { + const existing = readCredentials(profile); + if (!existing) { + return; + } + const next: StoredZaloCredentials = { + ...existing, + lastUsedAt: new Date().toISOString(), + }; + const dir = resolveCredentialsDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(resolveCredentialsPath(profile), JSON.stringify(next, null, 2), "utf-8"); +} + +function writeCredentials( + profile: string, + credentials: Omit, +): void { + const dir = resolveCredentialsDir(); + fs.mkdirSync(dir, { recursive: true }); + const existing = readCredentials(profile); + const now = new Date().toISOString(); + const next: StoredZaloCredentials = { + ...credentials, + createdAt: existing?.createdAt ?? now, + lastUsedAt: now, + }; + fs.writeFileSync(resolveCredentialsPath(profile), JSON.stringify(next, null, 2), "utf-8"); +} + +function clearCredentials(profile: string): boolean { + const filePath = resolveCredentialsPath(profile); + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + return true; + } + } catch { + // ignore + } + return false; +} + +async function ensureApi( + profileInput?: string | null, + timeoutMs = API_LOGIN_TIMEOUT_MS, +): Promise { + const profile = normalizeProfile(profileInput); + const cached = apiByProfile.get(profile); + if (cached) { + return cached; + } + + const pending = apiInitByProfile.get(profile); + if (pending) { + return await pending; + } + + const initPromise = (async () => { + const stored = readCredentials(profile); + if (!stored) { + throw new Error(`No saved Zalo session for profile \"${profile}\"`); + } + const zalo = new Zalo({ + logging: false, + selfListen: false, + }); + const api = await withTimeout( + zalo.login({ + imei: stored.imei, + cookie: stored.cookie, + userAgent: stored.userAgent, + language: stored.language, + }), + timeoutMs, + `Timed out restoring Zalo session for profile \"${profile}\"`, + ); + apiByProfile.set(profile, api); + touchCredentials(profile); + return api; + })(); + + apiInitByProfile.set(profile, initPromise); + try { + return await initPromise; + } catch (error) { + apiByProfile.delete(profile); + throw error; + } finally { + apiInitByProfile.delete(profile); + } +} + +function invalidateApi(profileInput?: string | null): void { + const profile = normalizeProfile(profileInput); + const api = apiByProfile.get(profile); + if (api) { + try { + api.listener.stop(); + } catch { + // ignore + } + } + apiByProfile.delete(profile); + apiInitByProfile.delete(profile); +} + +function isQrLoginFresh(login: ActiveZaloQrLogin): boolean { + return Date.now() - login.startedAt < QR_LOGIN_TTL_MS; +} + +function resetQrLogin(profileInput?: string | null): void { + const profile = normalizeProfile(profileInput); + const active = activeQrLogins.get(profile); + if (!active) { + return; + } + try { + active.abort?.(); + } catch { + // ignore + } + activeQrLogins.delete(profile); +} + +async function fetchGroupsByIds(api: API, ids: string[]): Promise> { + const result = new Map(); + for (let index = 0; index < ids.length; index += GROUP_INFO_CHUNK_SIZE) { + const chunk = ids.slice(index, index + GROUP_INFO_CHUNK_SIZE); + if (chunk.length === 0) { + continue; + } + const response = await api.getGroupInfo(chunk); + const map = response.gridInfoMap ?? {}; + for (const [groupId, info] of Object.entries(map)) { + result.set(groupId, info); + } + } + return result; +} + +function toInboundMessage(message: Message): ZaloInboundMessage | null { + const data = message.data as Record; + const isGroup = message.type === ThreadType.Group; + const senderId = toNumberId(data.uidFrom); + const threadId = isGroup + ? toNumberId(data.idTo) + : toNumberId(data.uidFrom) || toNumberId(data.idTo); + if (!threadId || !senderId) { + return null; + } + const content = normalizeMessageContent(data.content); + return { + threadId, + isGroup, + senderId, + senderName: typeof data.dName === "string" ? data.dName.trim() || undefined : undefined, + content, + timestampMs: resolveInboundTimestamp(data.ts), + msgId: typeof data.msgId === "string" ? data.msgId : undefined, + cliMsgId: typeof data.cliMsgId === "string" ? data.cliMsgId : undefined, + raw: message, + }; +} + +export function zalouserSessionExists(profileInput?: string | null): boolean { + const profile = normalizeProfile(profileInput); + return readCredentials(profile) !== null; +} + +export async function checkZaloAuthenticated(profileInput?: string | null): Promise { + const profile = normalizeProfile(profileInput); + if (!zalouserSessionExists(profile)) { + return false; + } + try { + const api = await ensureApi(profile, 12_000); + await withTimeout(api.fetchAccountInfo(), 12_000, "Timed out checking Zalo session"); + return true; + } catch { + invalidateApi(profile); + return false; + } +} + +export async function getZaloUserInfo(profileInput?: string | null): Promise { + const profile = normalizeProfile(profileInput); + const api = await ensureApi(profile); + const info = await api.fetchAccountInfo(); + const user = + info && typeof info === "object" && "profile" in info ? (info.profile as User) : (info as User); + if (!user?.userId) { + return null; + } + return { + userId: String(user.userId), + displayName: user.displayName || user.zaloName || String(user.userId), + avatar: user.avatar || undefined, + }; +} + +export async function listZaloFriends(profileInput?: string | null): Promise { + const profile = normalizeProfile(profileInput); + const api = await ensureApi(profile); + const friends = await api.getAllFriends(); + return friends.map(mapFriend); +} + +export async function listZaloFriendsMatching( + profileInput: string | null | undefined, + query?: string | null, +): Promise { + const friends = await listZaloFriends(profileInput); + const q = query?.trim().toLowerCase(); + if (!q) { + return friends; + } + const scored = friends + .map((friend) => { + const id = friend.userId.toLowerCase(); + const name = friend.displayName.toLowerCase(); + const exact = id === q || name === q; + const includes = id.includes(q) || name.includes(q); + return { friend, exact, includes }; + }) + .filter((entry) => entry.includes) + .sort((a, b) => Number(b.exact) - Number(a.exact)); + return scored.map((entry) => entry.friend); +} + +export async function listZaloGroups(profileInput?: string | null): Promise { + const profile = normalizeProfile(profileInput); + const api = await ensureApi(profile); + const allGroups = await api.getAllGroups(); + const ids = Object.keys(allGroups.gridVerMap ?? {}); + if (ids.length === 0) { + return []; + } + const details = await fetchGroupsByIds(api, ids); + const rows: ZaloGroup[] = []; + for (const id of ids) { + const info = details.get(id); + if (!info) { + rows.push({ groupId: id, name: id }); + continue; + } + rows.push(mapGroup(id, info as GroupInfo & Record)); + } + return rows; +} + +export async function listZaloGroupsMatching( + profileInput: string | null | undefined, + query?: string | null, +): Promise { + const groups = await listZaloGroups(profileInput); + const q = query?.trim().toLowerCase(); + if (!q) { + return groups; + } + return groups.filter((group) => { + const id = group.groupId.toLowerCase(); + const name = group.name.toLowerCase(); + return id.includes(q) || name.includes(q); + }); +} + +export async function listZaloGroupMembers( + profileInput: string | null | undefined, + groupId: string, +): Promise { + const profile = normalizeProfile(profileInput); + const api = await ensureApi(profile); + + const infoResponse = await api.getGroupInfo(groupId); + const groupInfo = infoResponse.gridInfoMap?.[groupId] as + | (GroupInfo & { memVerList?: unknown }) + | undefined; + if (!groupInfo) { + return []; + } + + const memberIds = Array.isArray(groupInfo.memberIds) + ? groupInfo.memberIds.map((id: unknown) => toNumberId(id)).filter(Boolean) + : []; + const memVerIds = Array.isArray(groupInfo.memVerList) + ? groupInfo.memVerList.map((id: unknown) => toNumberId(id)).filter(Boolean) + : []; + const currentMembers = Array.isArray(groupInfo.currentMems) ? groupInfo.currentMems : []; + + const currentById = new Map(); + for (const member of currentMembers) { + const id = toNumberId(member?.id); + if (!id) { + continue; + } + currentById.set(id, { + displayName: member.dName?.trim() || member.zaloName?.trim() || undefined, + avatar: member.avatar || undefined, + }); + } + + const uniqueIds = Array.from( + new Set([...memberIds, ...memVerIds, ...currentById.keys()]), + ); + + const profileMap = new Map(); + if (uniqueIds.length > 0) { + const profiles = await api.getGroupMembersInfo(uniqueIds); + const profileEntries = profiles.profiles as Record< + string, + { + id?: string; + displayName?: string; + zaloName?: string; + avatar?: string; + } + >; + for (const [rawId, profileValue] of Object.entries(profileEntries)) { + const id = toNumberId(rawId) || toNumberId((profileValue as { id?: unknown })?.id); + if (!id || !profileValue) { + continue; + } + profileMap.set(id, { + displayName: profileValue.displayName?.trim() || profileValue.zaloName?.trim() || undefined, + avatar: profileValue.avatar || undefined, + }); + } + } + + return uniqueIds.map((id) => ({ + userId: id, + displayName: profileMap.get(id)?.displayName || currentById.get(id)?.displayName || id, + avatar: profileMap.get(id)?.avatar || currentById.get(id)?.avatar, + })); +} + +export async function sendZaloTextMessage( + threadId: string, + text: string, + options: ZaloSendOptions = {}, +): Promise { + const profile = normalizeProfile(options.profile); + const trimmedThreadId = threadId.trim(); + if (!trimmedThreadId) { + return { ok: false, error: "No threadId provided" }; + } + + const api = await ensureApi(profile); + const type = options.isGroup ? ThreadType.Group : ThreadType.User; + + try { + if (options.mediaUrl?.trim()) { + const media = await loadOutboundMediaFromUrl(options.mediaUrl.trim(), { + mediaLocalRoots: options.mediaLocalRoots, + }); + const fileName = resolveMediaFileName({ + mediaUrl: options.mediaUrl, + fileName: media.fileName, + contentType: media.contentType, + kind: media.kind, + }); + const payloadText = (text || options.caption || "").slice(0, 2000); + const response = await api.sendMessage( + { + msg: payloadText, + attachments: [ + { + data: media.buffer, + filename: fileName.includes(".") ? fileName : `${fileName}.bin`, + metadata: { + totalSize: media.buffer.length, + }, + }, + ], + }, + trimmedThreadId, + type, + ); + return { ok: true, messageId: extractSendMessageId(response) }; + } + + const response = await api.sendMessage(text.slice(0, 2000), trimmedThreadId, type); + return { ok: true, messageId: extractSendMessageId(response) }; + } catch (error) { + return { ok: false, error: toErrorMessage(error) }; + } +} + +export async function sendZaloLink( + threadId: string, + url: string, + options: ZaloSendOptions = {}, +): Promise { + const profile = normalizeProfile(options.profile); + const trimmedThreadId = threadId.trim(); + const trimmedUrl = url.trim(); + if (!trimmedThreadId) { + return { ok: false, error: "No threadId provided" }; + } + if (!trimmedUrl) { + return { ok: false, error: "No URL provided" }; + } + + try { + const api = await ensureApi(profile); + const type = options.isGroup ? ThreadType.Group : ThreadType.User; + const response = await api.sendLink( + { link: trimmedUrl, msg: options.caption }, + trimmedThreadId, + type, + ); + return { ok: true, messageId: String(response.msgId) }; + } catch (error) { + return { ok: false, error: toErrorMessage(error) }; + } +} + +export async function startZaloQrLogin(params: { + profile?: string | null; + force?: boolean; + timeoutMs?: number; +}): Promise<{ qrDataUrl?: string; message: string }> { + const profile = normalizeProfile(params.profile); + + if (!params.force && (await checkZaloAuthenticated(profile))) { + const info = await getZaloUserInfo(profile).catch(() => null); + const name = info?.displayName ? ` (${info.displayName})` : ""; + return { + message: `Zalo is already linked${name}.`, + }; + } + + if (params.force) { + await logoutZaloProfile(profile); + } + + const existing = activeQrLogins.get(profile); + if (existing && isQrLoginFresh(existing)) { + if (existing.qrDataUrl) { + return { + qrDataUrl: existing.qrDataUrl, + message: "QR already active. Scan it with the Zalo app.", + }; + } + } else if (existing) { + resetQrLogin(profile); + } + + if (!activeQrLogins.has(profile)) { + const login: ActiveZaloQrLogin = { + id: randomUUID(), + profile, + startedAt: Date.now(), + connected: false, + waitPromise: Promise.resolve(), + }; + + login.waitPromise = (async () => { + let capturedCredentials: Omit | null = + null; + try { + const zalo = new Zalo({ logging: false, selfListen: false }); + const api = await zalo.loginQR(undefined, (event: LoginQRCallbackEvent) => { + const current = activeQrLogins.get(profile); + if (!current || current.id !== login.id) { + return; + } + + if (event.actions?.abort) { + current.abort = () => { + try { + event.actions?.abort?.(); + } catch { + // ignore + } + }; + } + + switch (event.type) { + case LoginQRCallbackEventType.QRCodeGenerated: { + const image = event.data.image.replace(/^data:image\/png;base64,/, ""); + current.qrDataUrl = image.startsWith("data:image") + ? image + : `data:image/png;base64,${image}`; + break; + } + case LoginQRCallbackEventType.QRCodeExpired: { + try { + event.actions.retry(); + } catch { + current.error = "QR expired before confirmation. Start login again."; + } + break; + } + case LoginQRCallbackEventType.QRCodeDeclined: { + current.error = "QR login was declined on the phone."; + break; + } + case LoginQRCallbackEventType.GotLoginInfo: { + capturedCredentials = { + imei: event.data.imei, + cookie: event.data.cookie, + userAgent: event.data.userAgent, + }; + break; + } + default: + break; + } + }); + + const current = activeQrLogins.get(profile); + if (!current || current.id !== login.id) { + return; + } + + if (!capturedCredentials) { + const ctx = api.getContext(); + const cookieJar = api.getCookie(); + const cookieJson = cookieJar.toJSON(); + capturedCredentials = { + imei: ctx.imei, + cookie: cookieJson?.cookies ?? [], + userAgent: ctx.userAgent, + language: ctx.language, + }; + } + + writeCredentials(profile, capturedCredentials); + invalidateApi(profile); + apiByProfile.set(profile, api); + current.connected = true; + } catch (error) { + const current = activeQrLogins.get(profile); + if (current && current.id === login.id) { + current.error = toErrorMessage(error); + } + } + })(); + + activeQrLogins.set(profile, login); + } + + const active = activeQrLogins.get(profile); + if (!active) { + return { message: "Failed to initialize Zalo QR login." }; + } + + const timeoutMs = Math.max(params.timeoutMs ?? DEFAULT_QR_START_TIMEOUT_MS, 3000); + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (active.error) { + resetQrLogin(profile); + return { + message: `Failed to start QR login: ${active.error}`, + }; + } + if (active.connected) { + resetQrLogin(profile); + return { + message: "Zalo already connected.", + }; + } + if (active.qrDataUrl) { + return { + qrDataUrl: active.qrDataUrl, + message: "Scan this QR with the Zalo app.", + }; + } + await delay(150); + } + + return { + message: "Still preparing QR. Call wait to continue checking login status.", + }; +} + +export async function waitForZaloQrLogin(params: { + profile?: string | null; + timeoutMs?: number; +}): Promise { + const profile = normalizeProfile(params.profile); + const active = activeQrLogins.get(profile); + + if (!active) { + const connected = await checkZaloAuthenticated(profile); + return { + connected, + message: connected ? "Zalo session is ready." : "No active Zalo QR login in progress.", + }; + } + + if (!isQrLoginFresh(active)) { + resetQrLogin(profile); + return { + connected: false, + message: "QR login expired. Start again to generate a fresh QR code.", + }; + } + + const timeoutMs = Math.max(params.timeoutMs ?? DEFAULT_QR_WAIT_TIMEOUT_MS, 1000); + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (active.error) { + const message = `Zalo login failed: ${active.error}`; + resetQrLogin(profile); + return { + connected: false, + message, + }; + } + if (active.connected) { + resetQrLogin(profile); + return { + connected: true, + message: "Login successful.", + }; + } + await Promise.race([active.waitPromise, delay(400)]); + } + + return { + connected: false, + message: "Still waiting for QR scan confirmation.", + }; +} + +export async function logoutZaloProfile(profileInput?: string | null): Promise<{ + cleared: boolean; + loggedOut: boolean; + message: string; +}> { + const profile = normalizeProfile(profileInput); + resetQrLogin(profile); + + const listener = activeListeners.get(profile); + if (listener) { + try { + listener.stop(); + } catch { + // ignore + } + activeListeners.delete(profile); + } + + invalidateApi(profile); + const cleared = clearCredentials(profile); + + return { + cleared, + loggedOut: true, + message: cleared ? "Logged out and cleared local session." : "No local session to clear.", + }; +} + +export async function startZaloListener(params: { + accountId: string; + profile?: string | null; + abortSignal: AbortSignal; + onMessage: (message: ZaloInboundMessage) => void; + onError: (error: Error) => void; +}): Promise<{ stop: () => void }> { + const profile = normalizeProfile(params.profile); + + const existing = activeListeners.get(profile); + if (existing) { + throw new Error( + `Zalo listener already running for profile \"${profile}\" (account \"${existing.accountId}\")`, + ); + } + + const api = await ensureApi(profile); + let stopped = false; + + const cleanup = () => { + if (stopped) { + return; + } + stopped = true; + try { + api.listener.off("message", onMessage); + api.listener.off("error", onError); + api.listener.off("closed", onClosed); + } catch { + // ignore listener detachment errors + } + try { + api.listener.stop(); + } catch { + // ignore + } + activeListeners.delete(profile); + }; + + const onMessage = (incoming: Message) => { + if (incoming.isSelf) { + return; + } + const normalized = toInboundMessage(incoming); + if (!normalized) { + return; + } + params.onMessage(normalized); + }; + + const onError = (error: unknown) => { + if (stopped || params.abortSignal.aborted) { + return; + } + const wrapped = error instanceof Error ? error : new Error(String(error)); + params.onError(wrapped); + }; + + const onClosed = (code: number, reason: string) => { + if (stopped || params.abortSignal.aborted) { + return; + } + params.onError(new Error(`Zalo listener closed (${code}): ${reason || "no reason"}`)); + }; + + api.listener.on("message", onMessage); + api.listener.on("error", onError); + api.listener.on("closed", onClosed); + + try { + api.listener.start({ retryOnClose: true }); + } catch (error) { + cleanup(); + throw error; + } + + params.abortSignal.addEventListener( + "abort", + () => { + cleanup(); + }, + { once: true }, + ); + + activeListeners.set(profile, { + profile, + accountId: params.accountId, + stop: cleanup, + }); + + return { stop: cleanup }; +} + +export async function resolveZaloGroupsByEntries(params: { + profile?: string | null; + entries: string[]; +}): Promise> { + const groups = await listZaloGroups(params.profile); + const byName = new Map(); + for (const group of groups) { + const key = group.name.trim().toLowerCase(); + if (!key) { + continue; + } + const list = byName.get(key) ?? []; + list.push(group); + byName.set(key, list); + } + + return params.entries.map((input) => { + const trimmed = input.trim(); + if (!trimmed) { + return { input, resolved: false }; + } + if (/^\d+$/.test(trimmed)) { + return { input, resolved: true, id: trimmed }; + } + const candidates = byName.get(trimmed.toLowerCase()) ?? []; + const match = candidates[0]; + return match ? { input, resolved: true, id: match.groupId } : { input, resolved: false }; + }); +} + +export async function resolveZaloAllowFromEntries(params: { + profile?: string | null; + entries: string[]; +}): Promise> { + const friends = await listZaloFriends(params.profile); + const byName = new Map(); + for (const friend of friends) { + const key = friend.displayName.trim().toLowerCase(); + if (!key) { + continue; + } + const list = byName.get(key) ?? []; + list.push(friend); + byName.set(key, list); + } + + return params.entries.map((input) => { + const trimmed = input.trim(); + if (!trimmed) { + return { input, resolved: false }; + } + if (/^\d+$/.test(trimmed)) { + return { input, resolved: true, id: trimmed }; + } + const matches = byName.get(trimmed.toLowerCase()) ?? []; + const match = matches[0]; + if (!match) { + return { input, resolved: false }; + } + return { + input, + resolved: true, + id: match.userId, + note: matches.length > 1 ? "multiple matches; chose first" : undefined, + }; + }); +} + +export async function clearProfileRuntimeArtifacts(profileInput?: string | null): Promise { + const profile = normalizeProfile(profileInput); + resetQrLogin(profile); + const listener = activeListeners.get(profile); + if (listener) { + listener.stop(); + activeListeners.delete(profile); + } + invalidateApi(profile); + await fsp.mkdir(resolveCredentialsDir(), { recursive: true }).catch(() => undefined); +} diff --git a/extensions/zalouser/src/zca-js-exports.d.ts b/extensions/zalouser/src/zca-js-exports.d.ts new file mode 100644 index 00000000000..0721cee05ee --- /dev/null +++ b/extensions/zalouser/src/zca-js-exports.d.ts @@ -0,0 +1,167 @@ +declare module "zca-js" { + export enum ThreadType { + User = 0, + Group = 1, + } + + export enum LoginQRCallbackEventType { + QRCodeGenerated = 0, + QRCodeExpired = 1, + QRCodeScanned = 2, + QRCodeDeclined = 3, + GotLoginInfo = 4, + } + + export type Credentials = { + imei: string; + cookie: unknown; + userAgent: string; + language?: string; + }; + + export type User = { + userId: string; + username: string; + displayName: string; + zaloName: string; + avatar: string; + }; + + export type GroupInfo = { + groupId: string; + name: string; + totalMember?: number; + memberIds?: unknown[]; + currentMems?: Array<{ + id?: unknown; + dName?: string; + zaloName?: string; + avatar?: string; + }>; + }; + + export type Message = { + type: ThreadType; + threadId: string; + isSelf: boolean; + data: Record; + }; + + export type LoginQRCallbackEvent = + | { + type: LoginQRCallbackEventType.QRCodeGenerated; + data: { + code: string; + image: string; + }; + actions: { + saveToFile: (qrPath?: string) => Promise; + retry: () => unknown; + abort: () => unknown; + }; + } + | { + type: LoginQRCallbackEventType.QRCodeExpired; + data: null; + actions: { + retry: () => unknown; + abort: () => unknown; + }; + } + | { + type: LoginQRCallbackEventType.QRCodeScanned; + data: { + avatar: string; + display_name: string; + }; + actions: { + retry: () => unknown; + abort: () => unknown; + }; + } + | { + type: LoginQRCallbackEventType.QRCodeDeclined; + data: { + code: string; + }; + actions: { + retry: () => unknown; + abort: () => unknown; + }; + } + | { + type: LoginQRCallbackEventType.GotLoginInfo; + data: { + cookie: unknown; + imei: string; + userAgent: string; + }; + actions: null; + }; + + export type Listener = { + on(event: "message", callback: (message: Message) => void): void; + on(event: "error", callback: (error: unknown) => void): void; + on(event: "closed", callback: (code: number, reason: string) => void): void; + off(event: "message", callback: (message: Message) => void): void; + off(event: "error", callback: (error: unknown) => void): void; + off(event: "closed", callback: (code: number, reason: string) => void): void; + start(opts?: { retryOnClose?: boolean }): void; + stop(): void; + }; + + export class API { + listener: Listener; + getContext(): { + imei: string; + userAgent: string; + language?: string; + }; + getCookie(): { + toJSON(): { + cookies: unknown[]; + }; + }; + fetchAccountInfo(): Promise<{ profile: User } | User>; + getAllFriends(): Promise; + getAllGroups(): Promise<{ + gridVerMap: Record; + }>; + getGroupInfo(groupId: string | string[]): Promise<{ + gridInfoMap: Record; + }>; + getGroupMembersInfo(memberId: string | string[]): Promise<{ + profiles: Record< + string, + { + id?: string; + displayName?: string; + zaloName?: string; + avatar?: string; + } + >; + }>; + sendMessage( + message: string | Record, + threadId: string, + type?: ThreadType, + ): Promise<{ + message?: { msgId?: string | number } | null; + attachment?: Array<{ msgId?: string | number }>; + }>; + sendLink( + payload: { link: string; msg?: string }, + threadId: string, + type?: ThreadType, + ): Promise<{ msgId?: string | number }>; + } + + export class Zalo { + constructor(options?: { logging?: boolean; selfListen?: boolean }); + login(credentials: Credentials): Promise; + loginQR( + options?: { userAgent?: string; language?: string; qrPath?: string }, + callback?: (event: LoginQRCallbackEvent) => unknown, + ): Promise; + } +} diff --git a/extensions/zalouser/src/zca.ts b/extensions/zalouser/src/zca.ts deleted file mode 100644 index 841f448a4c1..00000000000 --- a/extensions/zalouser/src/zca.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { spawn, type SpawnOptions } from "node:child_process"; -import { stripAnsi } from "openclaw/plugin-sdk"; -import type { ZcaResult, ZcaRunOptions } from "./types.js"; - -const ZCA_BINARY = "zca"; -const DEFAULT_TIMEOUT = 30000; - -function buildArgs(args: string[], options?: ZcaRunOptions): string[] { - const result: string[] = []; - // Profile flag comes first (before subcommand) - const profile = options?.profile || process.env.ZCA_PROFILE; - if (profile) { - result.push("--profile", profile); - } - result.push(...args); - return result; -} - -export async function runZca(args: string[], options?: ZcaRunOptions): Promise { - const fullArgs = buildArgs(args, options); - const timeout = options?.timeout ?? DEFAULT_TIMEOUT; - - return new Promise((resolve) => { - const spawnOpts: SpawnOptions = { - cwd: options?.cwd, - env: { ...process.env }, - stdio: ["pipe", "pipe", "pipe"], - }; - - const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts); - let stdout = ""; - let stderr = ""; - let timedOut = false; - - const timer = setTimeout(() => { - timedOut = true; - proc.kill("SIGTERM"); - }, timeout); - - proc.stdout?.on("data", (data: Buffer) => { - stdout += data.toString(); - }); - - proc.stderr?.on("data", (data: Buffer) => { - stderr += data.toString(); - }); - - proc.on("close", (code) => { - clearTimeout(timer); - if (timedOut) { - resolve({ - ok: false, - stdout, - stderr: stderr || "Command timed out", - exitCode: code ?? 124, - }); - return; - } - resolve({ - ok: code === 0, - stdout: stdout.trim(), - stderr: stderr.trim(), - exitCode: code ?? 1, - }); - }); - - proc.on("error", (err) => { - clearTimeout(timer); - resolve({ - ok: false, - stdout: "", - stderr: err.message, - exitCode: 1, - }); - }); - }); -} - -export function runZcaInteractive(args: string[], options?: ZcaRunOptions): Promise { - const fullArgs = buildArgs(args, options); - - return new Promise((resolve) => { - const spawnOpts: SpawnOptions = { - cwd: options?.cwd, - env: { ...process.env }, - stdio: "inherit", - }; - - const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts); - - proc.on("close", (code) => { - resolve({ - ok: code === 0, - stdout: "", - stderr: "", - exitCode: code ?? 1, - }); - }); - - proc.on("error", (err) => { - resolve({ - ok: false, - stdout: "", - stderr: err.message, - exitCode: 1, - }); - }); - }); -} - -export function parseJsonOutput(stdout: string): T | null { - try { - return JSON.parse(stdout) as T; - } catch { - const cleaned = stripAnsi(stdout); - - try { - return JSON.parse(cleaned) as T; - } catch { - // zca may prefix output with INFO/log lines, try to find JSON - const lines = cleaned.split("\n"); - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - if (line.startsWith("{") || line.startsWith("[")) { - // Try parsing from this line to the end - const jsonCandidate = lines.slice(i).join("\n").trim(); - try { - return JSON.parse(jsonCandidate) as T; - } catch { - continue; - } - } - } - return null; - } - } -} - -export async function checkZcaInstalled(): Promise { - const result = await runZca(["--version"], { timeout: 5000 }); - return result.ok; -} - -export type ZcaStreamingOptions = ZcaRunOptions & { - onData?: (data: string) => void; - onError?: (err: Error) => void; -}; - -export function runZcaStreaming( - args: string[], - options?: ZcaStreamingOptions, -): { proc: ReturnType; promise: Promise } { - const fullArgs = buildArgs(args, options); - - const spawnOpts: SpawnOptions = { - cwd: options?.cwd, - env: { ...process.env }, - stdio: ["pipe", "pipe", "pipe"], - }; - - const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts); - let stdout = ""; - let stderr = ""; - - proc.stdout?.on("data", (data: Buffer) => { - const text = data.toString(); - stdout += text; - options?.onData?.(text); - }); - - proc.stderr?.on("data", (data: Buffer) => { - stderr += data.toString(); - }); - - const promise = new Promise((resolve) => { - proc.on("close", (code) => { - resolve({ - ok: code === 0, - stdout: stdout.trim(), - stderr: stderr.trim(), - exitCode: code ?? 1, - }); - }); - - proc.on("error", (err) => { - options?.onError?.(err); - resolve({ - ok: false, - stdout: "", - stderr: err.message, - exitCode: 1, - }); - }); - }); - - return { proc, promise }; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d43f3334acb..d670165d879 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -477,6 +477,9 @@ importers: '@sinclair/typebox': specifier: 0.34.48 version: 0.34.48 + zca-js: + specifier: 2.1.1 + version: 2.1.1 packages/clawdbot: dependencies: @@ -3670,6 +3673,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -5008,6 +5014,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parse-ms@3.0.0: resolution: {integrity: sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==} engines: {node: '>=12'} @@ -5522,6 +5531,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spark-md5@3.0.2: + resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -6063,6 +6075,10 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} + zca-js@2.1.1: + resolution: {integrity: sha512-6zCmaIIWg/1eYlvCvO4rVsFt6SQ8MRodro3dCzMkk+LNgB3MyaEMBywBJfsw44WhODmOh8iMlPv4xDTNTMWDWA==} + engines: {node: '>=18.0.0'} + zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -9958,6 +9974,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-js@4.2.0: {} + css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -11543,6 +11561,8 @@ snapshots: pako@1.0.11: {} + pako@2.1.0: {} + parse-ms@3.0.0: {} parse-ms@4.0.0: {} @@ -12201,6 +12221,8 @@ snapshots: space-separated-tokens@2.0.2: {} + spark-md5@3.0.2: {} + split2@4.2.0: {} sqlite-vec-darwin-arm64@0.1.7-alpha.2: @@ -12702,6 +12724,20 @@ snapshots: yoctocolors@2.1.2: {} + zca-js@2.1.1: + dependencies: + crypto-js: 4.2.0 + form-data: 2.5.4 + json-bigint: 1.0.0 + pako: 2.1.0 + semver: 7.7.4 + spark-md5: 3.0.2 + tough-cookie: 4.1.3 + ws: 8.19.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: zod: 3.25.76