feat(zalouser): migrate runtime to native zca-js

This commit is contained in:
Peter Steinberger
2026-03-02 15:40:26 +00:00
parent db3d8d82c1
commit 174f2de447
15 changed files with 1822 additions and 1025 deletions

View File

@@ -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",

View File

@@ -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": [

View File

@@ -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<boolean> {
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";

View File

@@ -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<string | null> {
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<ResolvedZalouserAccount> = {
"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<ResolvedZalouserAccount> = {
},
},
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<ZcaUserInfo>(result.stdout);
const parsed = await getZaloUserInfo(account.profile);
if (!parsed?.userId) {
return null;
}
@@ -320,92 +330,42 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
});
},
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<ZcaFriend[]>(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<ZcaGroup[]>(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<Array<Partial<ZcaFriend> & { 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<ResolvedZalouserAccount> = {
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<ZcaFriend[]>(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<ZcaGroup[]>(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<ResolvedZalouserAccount> = {
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<ResolvedZalouserAccount> = {
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<ResolvedZalouserAccount> = {
}),
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<ResolvedZalouserAccount> = {
},
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)),
},
};

View File

@@ -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<void> {
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<typeof setTimeout> | 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<ZcaFriend[]>(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<ZcaGroup[]>(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<void>((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<void>((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;

View File

@@ -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<void> {
[
"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<void> {
);
}
async function writeQrDataUrlToTempFile(
qrDataUrl: string,
profile: string,
): Promise<string | null> {
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<string | null> => {
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<ZcaFriend[]>(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<Array<{ input: string; resolved: boolean; id?: string }>> {
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<ZcaGroup[]>(result.stdout) ?? []).filter((group) =>
Boolean(group.groupId),
);
const byName = new Map<string, ZcaGroup[]>();
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

View File

@@ -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<string> & {
user?: ZcaUserInfo;
@@ -10,18 +10,25 @@ export async function probeZalouser(
profile: string,
timeoutMs?: number,
): Promise<ZalouserProbeResult> {
const result = await runZca(["me", "info", "-j"], {
profile,
timeout: timeoutMs,
});
try {
const user = timeoutMs
? await Promise.race([
getZaloUserInfo(profile),
new Promise<null>((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<ZcaUserInfo>(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 };
}

View File

@@ -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<ZalouserSendResult> {
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<ZalouserSendResult> {
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<ZalouserSendResult> {
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<ZalouserSendResult> {
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<ZalouserSendResult> {
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);
}

View File

@@ -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;
}

View File

@@ -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<T extends readonly string[]>(
});
}
// 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",
});
}

View File

@@ -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[] };

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, unknown>;
};
export type LoginQRCallbackEvent =
| {
type: LoginQRCallbackEventType.QRCodeGenerated;
data: {
code: string;
image: string;
};
actions: {
saveToFile: (qrPath?: string) => Promise<unknown>;
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<User[]>;
getAllGroups(): Promise<{
gridVerMap: Record<string, string>;
}>;
getGroupInfo(groupId: string | string[]): Promise<{
gridInfoMap: Record<string, GroupInfo & { memVerList?: unknown }>;
}>;
getGroupMembersInfo(memberId: string | string[]): Promise<{
profiles: Record<
string,
{
id?: string;
displayName?: string;
zaloName?: string;
avatar?: string;
}
>;
}>;
sendMessage(
message: string | Record<string, unknown>,
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<API>;
loginQR(
options?: { userAgent?: string; language?: string; qrPath?: string },
callback?: (event: LoginQRCallbackEvent) => unknown,
): Promise<API>;
}
}

View File

@@ -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<ZcaResult> {
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<ZcaResult> {
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<T>(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<boolean> {
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<typeof spawn>; promise: Promise<ZcaResult> } {
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<ZcaResult>((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 };
}

36
pnpm-lock.yaml generated
View File

@@ -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