feat: add zalouser channel + directory CLI (#1032) (thanks @suminhthanh)

- Unified UX: channels login + message send; no plugin-specific top-level command\n- Added generic directory CLI for channel identity/groups\n- Docs: channel + plugin pages
This commit is contained in:
tsu
2026-01-16 13:28:18 -08:00
committed by GitHub
parent 16768a9998
commit 390bd11f33
28 changed files with 2820 additions and 2 deletions

View File

@@ -0,0 +1,221 @@
# @clawdbot/zalouser
Clawdbot extension for Zalo Personal Account messaging via [zca-cli](https://zca-cli.dev).
> **Warning:** Using Zalo automation may result in account suspension or ban. Use at your own risk. This is an unofficial integration.
## Features
- **Channel Plugin Integration**: Appears in onboarding wizard with QR login
- **Gateway Integration**: Real-time message listening via the gateway
- **Multi-Account Support**: Manage multiple Zalo personal accounts
- **CLI Commands**: Full command-line interface for messaging
- **Agent Tool**: AI agent integration for automated messaging
## Prerequisites
Install `zca` CLI and ensure it's in your PATH:
**macOS / Linux:**
```bash
curl -fsSL https://get.zca-cli.dev/install.sh | bash
# Or with custom install directory
ZCA_INSTALL_DIR=~/.local/bin curl -fsSL https://get.zca-cli.dev/install.sh | bash
# Install specific version
curl -fsSL https://get.zca-cli.dev/install.sh | bash -s v1.0.0
# Uninstall
curl -fsSL https://get.zca-cli.dev/install.sh | bash -s uninstall
```
**Windows (PowerShell):**
```powershell
irm https://get.zca-cli.dev/install.ps1 | iex
# Or with custom install directory
$env:ZCA_INSTALL_DIR = "C:\Tools\zca"; irm https://get.zca-cli.dev/install.ps1 | iex
# Install specific version
iex "& { $(irm https://get.zca-cli.dev/install.ps1) } -Version v1.0.0"
# Uninstall
iex "& { $(irm https://get.zca-cli.dev/install.ps1) } -Uninstall"
```
### Manual Download
Download binary directly:
**macOS / Linux:**
```bash
curl -fsSL https://get.zca-cli.dev/latest/zca-darwin-arm64 -o zca && chmod +x zca
```
**Windows (PowerShell):**
```powershell
Invoke-WebRequest -Uri https://get.zca-cli.dev/latest/zca-windows-x64.exe -OutFile zca.exe
```
Available binaries:
- `zca-darwin-arm64` - macOS Apple Silicon
- `zca-darwin-x64` - macOS Intel
- `zca-linux-arm64` - Linux ARM64
- `zca-linux-x64` - Linux x86_64
- `zca-windows-x64.exe` - Windows
See [zca-cli](https://zca-cli.dev) for manual download (binaries for macOS/Linux/Windows) or building from source.
## Quick Start
### Option 1: Onboarding Wizard (Recommended)
```bash
clawdbot onboard
# Select "Zalo Personal" from channel list
# Follow QR code login flow
```
### Option 2: Login (QR, on the Gateway machine)
```bash
clawdbot channels login --channel zalouser
# Scan QR code with Zalo app
```
### Send a Message
```bash
clawdbot message send --channel zalouser --to <threadId> --message "Hello from Clawdbot!"
```
## Configuration
After onboarding, your config will include:
```yaml
channels:
zalouser:
enabled: true
dmPolicy: pairing # pairing | allowlist | open | disabled
```
For multi-account:
```yaml
channels:
zalouser:
enabled: true
defaultAccount: default
accounts:
default:
enabled: true
profile: default
work:
enabled: true
profile: work
```
## Commands
### Authentication
```bash
clawdbot channels login --channel zalouser # Login via QR
clawdbot channels login --channel zalouser --account work
clawdbot channels status --probe
clawdbot channels logout --channel zalouser
```
### Directory (IDs, contacts, groups)
```bash
clawdbot directory self --channel zalouser
clawdbot directory peers list --channel zalouser --query "name"
clawdbot directory groups list --channel zalouser --query "work"
clawdbot directory groups members --channel zalouser --group-id <id>
```
### Account Management
```bash
zca account list # List all profiles
zca account current # Show active profile
zca account switch <profile>
zca account remove <profile>
zca account label <profile> "Work Account"
```
### Messaging
```bash
# Text
clawdbot message send --channel zalouser --to <threadId> --message "message"
# Media (URL)
clawdbot message send --channel zalouser --to <threadId> --message "caption" --media-url "https://example.com/img.jpg"
```
### Listener
The listener runs inside the Gateway when the channel is enabled. For debugging,
use `clawdbot channels logs --channel zalouser` or run `zca listen` directly.
### Data Access
```bash
# Friends
zca friend list
zca friend list -j # JSON output
zca friend find "name"
zca friend online
# Groups
zca group list
zca group info <groupId>
zca group members <groupId>
# Profile
zca me info
zca me id
```
## Multi-Account Support
Use `--profile` or `-p` to work with multiple accounts:
```bash
clawdbot channels login --channel zalouser --account work
clawdbot message send --channel zalouser --account work --to <id> --message "Hello"
ZCA_PROFILE=work zca listen
```
Profile resolution order: `--profile` flag > `ZCA_PROFILE` env > default
## Agent Tool
The extension registers a `zalouser` tool for AI agents:
```json
{
"action": "send",
"threadId": "123456",
"message": "Hello from AI!",
"isGroup": false,
"profile": "default"
}
```
Available actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
## Troubleshooting
- **Login Issues:** Run `zca auth logout` then `zca auth login`
- **API Errors:** Try `zca auth cache-refresh` or re-login
- **File Uploads:** Check size (max 100MB) and path accessibility
## Credits
Built on [zca-cli](https://zca-cli.dev) which uses [zca-js](https://github.com/RFS-ADRENO/zca-js).

View File

@@ -0,0 +1,28 @@
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
import { zalouserPlugin } from "./src/channel.js";
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
const plugin = {
id: "zalouser",
name: "Zalo Personal",
description: "Zalo personal account messaging via zca-cli",
register(api: ClawdbotPluginApi) {
// Register channel plugin (for onboarding & gateway)
api.registerChannel(zalouserPlugin);
// Register agent tool
api.registerTool({
name: "zalouser",
label: "Zalo Personal",
description:
"Send messages and access data via Zalo personal account. " +
"Actions: send (text message), image (send image URL), link (send link), " +
"friends (list/search friends), groups (list groups), me (profile info), status (auth check).",
parameters: ZalouserToolSchema,
execute: executeZalouserTool,
});
},
};
export default plugin;

View File

@@ -0,0 +1,12 @@
{
"name": "@clawdbot/zalouser",
"version": "2026.1.15",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
"@sinclair/typebox": "0.34.47"
},
"clawdbot": {
"extensions": ["./index.ts"]
}
}

View File

@@ -0,0 +1,121 @@
import { runZca, parseJsonOutput } from "./zca.js";
import {
DEFAULT_ACCOUNT_ID,
type CoreConfig,
type ResolvedZalouserAccount,
type ZalouserAccountConfig,
type ZalouserConfig,
} from "./types.js";
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listZalouserAccountIds(cfg: CoreConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultZalouserAccountId(cfg: CoreConfig): string {
const zalouserConfig = cfg.channels?.zalouser as ZalouserConfig | undefined;
if (zalouserConfig?.defaultAccount?.trim()) return zalouserConfig.defaultAccount.trim();
const ids = listZalouserAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
export function normalizeAccountId(accountId?: string | null): string {
const trimmed = accountId?.trim();
if (!trimmed) return DEFAULT_ACCOUNT_ID;
return trimmed.toLowerCase();
}
function resolveAccountConfig(
cfg: CoreConfig,
accountId: string,
): ZalouserAccountConfig | undefined {
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
return accounts[accountId] as ZalouserAccountConfig | undefined;
}
function mergeZalouserAccountConfig(cfg: CoreConfig, accountId: string): ZalouserAccountConfig {
const raw = (cfg.channels?.zalouser ?? {}) as ZalouserConfig;
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
function resolveZcaProfile(config: ZalouserAccountConfig, accountId: string): string {
if (config.profile?.trim()) return config.profile.trim();
if (process.env.ZCA_PROFILE?.trim()) return process.env.ZCA_PROFILE.trim();
if (accountId !== DEFAULT_ACCOUNT_ID) return accountId;
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: CoreConfig;
accountId?: string | null;
}): Promise<ResolvedZalouserAccount> {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = (params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
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);
return {
accountId,
name: merged.name?.trim() || undefined,
enabled,
profile,
authenticated,
config: merged,
};
}
export function resolveZalouserAccountSync(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedZalouserAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = (params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const profile = resolveZcaProfile(merged, accountId);
return {
accountId,
name: merged.name?.trim() || undefined,
enabled,
profile,
authenticated: false, // unknown without async check
config: merged,
};
}
export async function listEnabledZalouserAccounts(cfg: CoreConfig): Promise<ResolvedZalouserAccount[]> {
const ids = listZalouserAccountIds(cfg);
const accounts = await Promise.all(
ids.map((accountId) => resolveZalouserAccount({ cfg, accountId }))
);
return accounts.filter((account) => account.enabled);
}
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) return null;
return parseJsonOutput<{ userId?: string; displayName?: string }>(result.stdout);
}
export type { ResolvedZalouserAccount } from "./types.js";

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { zalouserPlugin } from "./channel.js";
describe("zalouser outbound chunker", () => {
it("chunks without empty strings and respects limit", () => {
const chunker = zalouserPlugin.outbound?.chunker;
expect(chunker).toBeTypeOf("function");
if (!chunker) return;
const limit = 10;
const chunks = chunker("hello world\nthis is a test", limit);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((c) => c.length > 0)).toBe(true);
expect(chunks.every((c) => c.length <= limit)).toBe(true);
});
});

View File

@@ -0,0 +1,525 @@
import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js";
import type {
ChannelAccountSnapshot,
ChannelDirectoryEntry,
} from "../../../src/channels/plugins/types.core.js";
import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
getZcaUserInfo,
checkZcaAuthenticated,
type ResolvedZalouserAccount,
} from "./accounts.js";
import { zalouserOnboardingAdapter } from "./onboarding.js";
import { sendMessageZalouser } from "./send.js";
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
import {
DEFAULT_ACCOUNT_ID,
type CoreConfig,
type ZalouserConfig,
type ZcaFriend,
type ZcaGroup,
type ZcaUserInfo,
} from "./types.js";
const meta = {
id: "zalouser",
label: "Zalo Personal",
selectionLabel: "Zalo (Personal Account)",
docsPath: "/channels/zalouser",
docsLabel: "zalouser",
blurb: "Zalo personal account via QR code login.",
aliases: ["zlu"],
order: 85,
quickstartAllowFrom: true,
};
function resolveZalouserQrProfile(accountId?: string | null): string {
const normalized = String(accountId ?? "").trim();
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
return process.env.ZCA_PROFILE?.trim() || "default";
}
return normalized;
}
function mapUser(params: {
id: string;
name?: string | null;
avatarUrl?: string | null;
raw?: unknown;
}): ChannelDirectoryEntry {
return {
kind: "user",
id: params.id,
name: params.name ?? undefined,
avatarUrl: params.avatarUrl ?? undefined,
raw: params.raw,
};
}
function mapGroup(params: {
id: string;
name?: string | null;
raw?: unknown;
}): ChannelDirectoryEntry {
return {
kind: "group",
id: params.id,
name: params.name ?? undefined,
raw: params.raw,
};
}
function deleteAccountFromConfigSection(params: {
cfg: CoreConfig;
accountId: string;
}): CoreConfig {
const { cfg, accountId } = params;
if (accountId === DEFAULT_ACCOUNT_ID) {
const { zalouser: _removed, ...restChannels } = cfg.channels ?? {};
return { ...cfg, channels: restChannels };
}
const accounts = { ...(cfg.channels?.zalouser?.accounts ?? {}) };
delete accounts[accountId];
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
accounts,
},
},
};
}
function setAccountEnabledInConfigSection(params: {
cfg: CoreConfig;
accountId: string;
enabled: boolean;
}): CoreConfig {
const { cfg, accountId, enabled } = params;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
accounts: {
...(cfg.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled,
},
},
},
},
};
}
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
id: "zalouser",
meta,
onboarding: zalouserOnboardingAdapter,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
threads: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.zalouser"] },
config: {
listAccountIds: (cfg) => listZalouserAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) =>
resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as CoreConfig,
accountId,
enabled,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
accountId,
}),
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;
},
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: undefined,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(zalouser|zlu):/i, ""))
.map((entry) => entry.toLowerCase()),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as CoreConfig).channels?.zalouser?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.zalouser.accounts.${resolvedAccountId}.`
: "channels.zalouser.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("zalouser"),
normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""),
};
},
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
messaging: {
normalizeTarget: (raw) => {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
return trimmed.replace(/^(zalouser|zlu):/i, "");
},
},
directory: {
self: async ({ cfg, accountId, runtime }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, 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);
if (!parsed?.userId) return null;
return mapUser({
id: String(parsed.userId),
name: parsed.displayName ?? null,
avatarUrl: parsed.avatar ?? null,
raw: parsed,
});
},
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 as CoreConfig, 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,
}),
)
: [];
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 as CoreConfig, 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));
}
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 as CoreConfig, 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 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[];
},
},
pairing: {
idLabel: "zalouserUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
notifyApproval: async ({ cfg, id }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig });
const authenticated = await checkZcaAuthenticated(account.profile);
if (!authenticated) throw new Error("Zalouser not authenticated");
await sendMessageZalouser(id, "Your pairing request has been approved.", {
profile: account.profile,
});
},
},
auth: {
login: async ({ cfg, accountId, runtime }) => {
const account = resolveZalouserAccountSync({
cfg: cfg as CoreConfig,
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
});
const ok = await checkZcaInstalled();
if (!ok) {
throw new Error(
"Missing dependency: `zca` not found in PATH. See docs.clawd.bot/channels/zalouser",
);
}
runtime.log(
`Scan the QR code in this terminal to link 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");
}
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => {
if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > limit) {
const window = remaining.slice(0, limit);
const lastNewline = window.lastIndexOf("\n");
const lastSpace = window.lastIndexOf(" ");
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
if (breakIdx <= 0) breakIdx = limit;
const rawChunk = remaining.slice(0, breakIdx);
const chunk = rawChunk.trimEnd();
if (chunk.length > 0) chunks.push(chunk);
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
remaining = remaining.slice(nextStart).trimStart();
}
if (remaining.length) chunks.push(remaining);
return chunks;
},
textChunkLimit: 2000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to Zalouser requires --to <threadId>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await sendMessageZalouser(to, text, { profile: account.profile });
return {
channel: "zalouser",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await sendMessageZalouser(to, text, {
profile: account.profile,
mediaUrl,
});
return {
channel: "zalouser",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) => {
const result = await runZca(["me", "info", "-j"], {
profile: account.profile,
timeout: timeoutMs,
});
if (!result.ok) {
return { ok: false, error: result.stderr };
}
try {
return { ok: true, user: JSON.parse(result.stdout) };
} catch {
return { ok: false, error: "Failed to parse user info" };
}
},
buildAccountSnapshot: async ({ account, runtime }) => {
const configured = await checkZcaAuthenticated(account.profile);
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: configured ? (runtime?.lastError ?? null) : "not configured",
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
dmPolicy: account.config.dmPolicy ?? "pairing",
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
let userLabel = "";
try {
const userInfo = await getZcaUserInfo(account.profile);
if (userInfo?.displayName) userLabel = ` (${userInfo.displayName})`;
ctx.setStatus({
accountId: account.accountId,
user: userInfo,
});
} catch {
// ignore probe errors
}
ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
const { monitorZalouserProvider } = await import("./monitor.js");
return monitorZalouserProvider({
account,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
});
},
loginWithQrStart: async (params) => {
const profile = resolveZalouserQrProfile(params.accountId);
// Start login and get QR code
const result = await runZca(["auth", "login", "--qr-base64"], {
profile,
timeout: params.timeoutMs ?? 30000,
});
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"], {
profile,
timeout: params.timeoutMs ?? 60000,
});
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,
};
},
},
};
export type { ResolvedZalouserAccount };

View File

@@ -0,0 +1,171 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
export type CoreChannelDeps = {
chunkMarkdownText: (text: string, limit: number) => string[];
formatAgentEnvelope: (params: {
channel: string;
from: string;
timestamp?: number;
body: string;
}) => string;
dispatchReplyWithBufferedBlockDispatcher: (params: {
ctx: unknown;
cfg: unknown;
dispatcherOptions: {
deliver: (payload: unknown) => Promise<void>;
onError?: (err: unknown, info: { kind: string }) => void;
};
}) => Promise<void>;
resolveAgentRoute: (params: {
cfg: unknown;
channel: string;
accountId: string;
peer: { kind: "dm" | "group" | "channel"; id: string };
}) => { sessionKey: string; accountId: string };
buildPairingReply: (params: { channel: string; idLine: string; code: string }) => string;
readChannelAllowFromStore: (channel: string) => Promise<string[]>;
upsertChannelPairingRequest: (params: {
channel: string;
id: string;
meta?: { name?: string };
}) => Promise<{ code: string; created: boolean }>;
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
saveMediaBuffer: (
buffer: Buffer,
contentType: string | undefined,
type: "inbound" | "outbound",
maxBytes: number,
) => Promise<{ path: string; contentType: string }>;
shouldLogVerbose: () => boolean;
};
let coreRootCache: string | null = null;
let coreDepsPromise: Promise<CoreChannelDeps> | null = null;
function findPackageRoot(startDir: string, name: string): string | null {
let dir = startDir;
for (;;) {
const pkgPath = path.join(dir, "package.json");
try {
if (fs.existsSync(pkgPath)) {
const raw = fs.readFileSync(pkgPath, "utf8");
const pkg = JSON.parse(raw) as { name?: string };
if (pkg.name === name) return dir;
}
} catch {
// ignore parse errors
}
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function resolveClawdbotRoot(): string {
if (coreRootCache) return coreRootCache;
const override = process.env.CLAWDBOT_ROOT?.trim();
if (override) {
coreRootCache = override;
return override;
}
const candidates = new Set<string>();
if (process.argv[1]) {
candidates.add(path.dirname(process.argv[1]));
}
candidates.add(process.cwd());
try {
const urlPath = fileURLToPath(import.meta.url);
candidates.add(path.dirname(urlPath));
} catch {
// ignore
}
for (const start of candidates) {
const found = findPackageRoot(start, "clawdbot");
if (found) {
coreRootCache = found;
return found;
}
}
throw new Error(
"Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.",
);
}
async function importCoreModule<T>(relativePath: string): Promise<T> {
const root = resolveClawdbotRoot();
const distPath = path.join(root, "dist", relativePath);
if (!fs.existsSync(distPath)) {
throw new Error(
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
);
}
return (await import(pathToFileURL(distPath).href)) as T;
}
export async function loadCoreChannelDeps(): Promise<CoreChannelDeps> {
if (coreDepsPromise) return coreDepsPromise;
coreDepsPromise = (async () => {
const [
chunk,
envelope,
dispatcher,
routing,
pairingMessages,
pairingStore,
mediaFetch,
mediaStore,
globals,
] = await Promise.all([
importCoreModule<{ chunkMarkdownText: CoreChannelDeps["chunkMarkdownText"] }>(
"auto-reply/chunk.js",
),
importCoreModule<{ formatAgentEnvelope: CoreChannelDeps["formatAgentEnvelope"] }>(
"auto-reply/envelope.js",
),
importCoreModule<{
dispatchReplyWithBufferedBlockDispatcher: CoreChannelDeps["dispatchReplyWithBufferedBlockDispatcher"];
}>("auto-reply/reply/provider-dispatcher.js"),
importCoreModule<{ resolveAgentRoute: CoreChannelDeps["resolveAgentRoute"] }>(
"routing/resolve-route.js",
),
importCoreModule<{ buildPairingReply: CoreChannelDeps["buildPairingReply"] }>(
"pairing/pairing-messages.js",
),
importCoreModule<{
readChannelAllowFromStore: CoreChannelDeps["readChannelAllowFromStore"];
upsertChannelPairingRequest: CoreChannelDeps["upsertChannelPairingRequest"];
}>("pairing/pairing-store.js"),
importCoreModule<{ fetchRemoteMedia: CoreChannelDeps["fetchRemoteMedia"] }>(
"media/fetch.js",
),
importCoreModule<{ saveMediaBuffer: CoreChannelDeps["saveMediaBuffer"] }>(
"media/store.js",
),
importCoreModule<{ shouldLogVerbose: CoreChannelDeps["shouldLogVerbose"] }>(
"globals.js",
),
]);
return {
chunkMarkdownText: chunk.chunkMarkdownText,
formatAgentEnvelope: envelope.formatAgentEnvelope,
dispatchReplyWithBufferedBlockDispatcher:
dispatcher.dispatchReplyWithBufferedBlockDispatcher,
resolveAgentRoute: routing.resolveAgentRoute,
buildPairingReply: pairingMessages.buildPairingReply,
readChannelAllowFromStore: pairingStore.readChannelAllowFromStore,
upsertChannelPairingRequest: pairingStore.upsertChannelPairingRequest,
fetchRemoteMedia: mediaFetch.fetchRemoteMedia,
saveMediaBuffer: mediaStore.saveMediaBuffer,
shouldLogVerbose: globals.shouldLogVerbose,
};
})();
return coreDepsPromise;
}

View File

@@ -0,0 +1,348 @@
import type { ChildProcess } from "node:child_process";
import type { RuntimeEnv } from "../../../src/runtime.js";
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
import { sendMessageZalouser } from "./send.js";
import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js";
import { runZcaStreaming } from "./zca.js";
export type ZalouserMonitorOptions = {
account: ResolvedZalouserAccount;
config: CoreConfig;
runtime: RuntimeEnv;
abortSignal: AbortSignal;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
export type ZalouserMonitorResult = {
stop: () => void;
};
const ZALOUSER_TEXT_LIMIT = 2000;
function logVerbose(deps: CoreChannelDeps, runtime: RuntimeEnv, message: string): void {
if (deps.shouldLogVerbose()) {
runtime.log(`[zalouser] ${message}`);
}
}
function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
if (allowFrom.includes("*")) return true;
const normalizedSenderId = senderId.toLowerCase();
return allowFrom.some((entry) => {
const normalized = entry.toLowerCase().replace(/^(zalouser|zlu):/i, "");
return normalized === normalizedSenderId;
});
}
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,
account: ResolvedZalouserAccount,
config: CoreConfig,
deps: CoreChannelDeps,
runtime: RuntimeEnv,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
): Promise<void> {
const { threadId, content, timestamp, metadata } = message;
if (!content?.trim()) return;
const isGroup = metadata?.isGroup ?? false;
const senderId = metadata?.fromId ?? threadId;
const senderName = metadata?.senderName ?? "";
const chatId = threadId;
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
if (!isGroup) {
if (dmPolicy === "disabled") {
logVerbose(deps, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open") {
const storeAllowFrom = await deps.readChannelAllowFromStore("zalouser").catch(() => []);
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await deps.upsertChannelPairingRequest({
channel: "zalouser",
id: senderId,
meta: { name: senderName || undefined },
});
if (created) {
logVerbose(deps, runtime, `zalouser pairing request sender=${senderId}`);
try {
await sendMessageZalouser(
chatId,
deps.buildPairingReply({
channel: "zalouser",
idLine: `Your Zalo user id: ${senderId}`,
code,
}),
{ profile: account.profile },
);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(
deps,
runtime,
`zalouser pairing reply failed for ${senderId}: ${String(err)}`,
);
}
}
} else {
logVerbose(
deps,
runtime,
`Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
}
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
const route = deps.resolveAgentRoute({
cfg: config,
channel: "zalouser",
accountId: account.accountId,
peer: {
// Use "group" kind to avoid dmScope=main collapsing all DMs into the main session.
kind: peer.kind,
id: peer.id,
},
});
const rawBody = content.trim();
const fromLabel = isGroup
? `group:${chatId} from ${senderName || senderId}`
: senderName || `user:${senderId}`;
const body = deps.formatAgentEnvelope({
channel: "Zalo Personal",
from: fromLabel,
timestamp: timestamp ? timestamp * 1000 : undefined,
body: rawBody,
});
const ctxPayload = {
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: isGroup ? `group:${chatId}` : `zalouser:${senderId}`,
To: `zalouser:${chatId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
SenderName: senderName || undefined,
SenderId: senderId,
Provider: "zalouser",
Surface: "zalouser",
MessageSid: message.msgId ?? `${timestamp}`,
OriginatingChannel: "zalouser",
OriginatingTo: `zalouser:${chatId}`,
};
await deps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload) => {
await deliverZalouserReply({
payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
profile: account.profile,
chatId,
isGroup,
runtime,
deps,
statusSink,
});
},
onError: (err, info) => {
runtime.error(
`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`,
);
},
},
});
}
async function deliverZalouserReply(params: {
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
profile: string;
chatId: string;
isGroup: boolean;
runtime: RuntimeEnv;
deps: CoreChannelDeps;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): Promise<void> {
const { payload, profile, chatId, isGroup, runtime, deps, statusSink } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
if (mediaList.length > 0) {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined;
first = false;
try {
logVerbose(deps, runtime, `Sending media to ${chatId}`);
await sendMessageZalouser(chatId, caption ?? "", {
profile,
mediaUrl,
isGroup,
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error(`Zalouser media send failed: ${String(err)}`);
}
}
return;
}
if (payload.text) {
const chunks = deps.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
logVerbose(deps, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) {
try {
await sendMessageZalouser(chatId, chunk, { profile, isGroup });
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error(`Zalouser message send failed: ${String(err)}`);
}
}
}
}
export async function monitorZalouserProvider(
options: ZalouserMonitorOptions,
): Promise<ZalouserMonitorResult> {
const { account, config, abortSignal, statusSink, runtime } = options;
const deps = await loadCoreChannelDeps();
let stopped = false;
let proc: ChildProcess | null = null;
let restartTimer: ReturnType<typeof setTimeout> | null = null;
let resolveRunning: (() => void) | null = null;
const stop = () => {
stopped = true;
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
}
if (proc) {
proc.kill("SIGTERM");
proc = null;
}
resolveRunning?.();
};
const startListener = () => {
if (stopped || abortSignal.aborted) {
resolveRunning?.();
return;
}
logVerbose(
deps,
runtime,
`[${account.accountId}] starting zca listener (profile=${account.profile})`,
);
proc = startZcaListener(
runtime,
account.profile,
(msg) => {
logVerbose(deps, runtime, `[${account.accountId}] inbound message`);
statusSink?.({ lastInboundAt: Date.now() });
processMessage(msg, account, config, deps, 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(deps, runtime, `[${account.accountId}] restarting listener in 5s...`);
restartTimer = setTimeout(startListener, 5000);
} else {
resolveRunning?.();
}
},
abortSignal,
);
};
// Create a promise that stays pending until abort or stop
const runningPromise = new Promise<void>((resolve) => {
resolveRunning = resolve;
abortSignal.addEventListener("abort", () => resolve(), { once: true });
});
startListener();
// Wait for the running promise to resolve (on abort/stop)
await runningPromise;
return { stop };
}

View File

@@ -0,0 +1,312 @@
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
normalizeAccountId,
checkZcaAuthenticated,
} from "./accounts.js";
import { runZcaInteractive, checkZcaInstalled } from "./zca.js";
import { DEFAULT_ACCOUNT_ID, type CoreConfig } from "./types.js";
const channel = "zalouser" as const;
function setZalouserDmPolicy(
cfg: CoreConfig,
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
): CoreConfig {
const allowFrom =
dmPolicy === "open"
? [...(cfg.channels?.zalouser?.allowFrom ?? []), "*"].filter(
(v, i, a) => a.indexOf(v) === i,
)
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
} as CoreConfig;
}
async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"Zalo Personal Account login via QR code.",
"",
"Prerequisites:",
"1) Install zca-cli",
"2) You'll scan a QR code with your Zalo app",
"",
"Docs: https://docs.clawd.bot/channels/zalouser",
].join("\n"),
"Zalo Personal Setup",
);
}
async function promptZalouserAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveZalouserAccountSync({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
const entry = await prompter.text({
message: "Zalouser allowFrom (user id)",
placeholder: "123456789",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id";
return undefined;
},
});
const normalized = String(entry).trim();
const merged = [
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
normalized,
];
const unique = [...new Set(merged)];
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
} as CoreConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...(cfg.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
} as CoreConfig;
}
async function promptAccountId(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
label: string;
currentId: string;
listAccountIds: (cfg: CoreConfig) => string[];
defaultAccountId: string;
}): Promise<string> {
const { cfg, prompter, label, currentId, listAccountIds, defaultAccountId } = params;
const existingIds = listAccountIds(cfg);
const options = [
...existingIds.map((id) => ({
value: id,
label: id === defaultAccountId ? `${id} (default)` : id,
})),
{ value: "__new__", label: "Create new account" },
];
const selected = await prompter.select({
message: `${label} account`,
options,
initialValue: currentId,
});
if (selected === "__new__") {
const newId = await prompter.text({
message: "New account ID",
placeholder: "work",
validate: (value) => {
const raw = String(value ?? "").trim().toLowerCase();
if (!raw) return "Required";
if (!/^[a-z0-9_-]+$/.test(raw)) return "Use lowercase alphanumeric, dash, or underscore";
if (existingIds.includes(raw)) return "Account already exists";
return undefined;
},
});
return String(newId).trim().toLowerCase();
}
return selected as string;
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Zalo Personal",
channel,
policyKey: "channels.zalouser.dmPolicy",
allowFromKey: "channels.zalouser.allowFrom",
getCurrent: (cfg) => ((cfg as CoreConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as CoreConfig, policy),
};
export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const ids = listZalouserAccountIds(cfg as CoreConfig);
let configured = false;
for (const accountId of ids) {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const isAuth = await checkZcaAuthenticated(account.profile);
if (isAuth) {
configured = true;
break;
}
}
return {
channel,
configured,
statusLines: [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`],
selectionHint: configured ? "recommended · logged in" : "recommended · QR login",
quickstartScore: configured ? 1 : 15,
};
},
configure: async ({ cfg, prompter, accountOverrides, 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.clawd.bot/channels/zalouser",
].join("\n"),
"Missing Dependency",
);
return { cfg, accountId: DEFAULT_ACCOUNT_ID };
}
const zalouserOverride = accountOverrides.zalouser?.trim();
const defaultAccountId = resolveDefaultZalouserAccountId(cfg as CoreConfig);
let accountId = zalouserOverride
? normalizeAccountId(zalouserOverride)
: defaultAccountId;
if (shouldPromptAccountIds && !zalouserOverride) {
accountId = await promptAccountId({
cfg: cfg as CoreConfig,
prompter,
label: "Zalo Personal",
currentId: accountId,
listAccountIds: listZalouserAccountIds,
defaultAccountId,
});
}
let next = cfg as CoreConfig;
const account = resolveZalouserAccountSync({ cfg: next, accountId });
const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
if (!alreadyAuthenticated) {
await noteZalouserHelp(prompter);
const wantsLogin = await prompter.confirm({
message: "Login via QR code now?",
initialValue: true,
});
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");
}
}
}
} else {
const keepSession = await prompter.confirm({
message: "Zalo Personal already logged in. Keep session?",
initialValue: true,
});
if (!keepSession) {
await runZcaInteractive(["auth", "logout"], { profile: account.profile });
await runZcaInteractive(["auth", "login"], { profile: account.profile });
}
}
// Enable the channel
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
zalouser: {
...next.channels?.zalouser,
enabled: true,
profile: account.profile !== "default" ? account.profile : undefined,
},
},
} as CoreConfig;
} else {
next = {
...next,
channels: {
...next.channels,
zalouser: {
...next.channels?.zalouser,
enabled: true,
accounts: {
...(next.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(next.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled: true,
profile: account.profile,
},
},
},
},
} as CoreConfig;
}
if (forceAllowFrom) {
next = await promptZalouserAllowFrom({
cfg: next,
prompter,
accountId,
});
}
return { cfg: next, accountId };
},
};

View File

@@ -0,0 +1,150 @@
import { runZca } from "./zca.js";
export type ZalouserSendOptions = {
profile?: string;
mediaUrl?: string;
caption?: string;
isGroup?: boolean;
};
export type ZalouserSendResult = {
ok: boolean;
messageId?: string;
error?: string;
};
export async function sendMessageZalouser(
threadId: string,
text: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default";
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");
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || "Failed to send message" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
async function sendMediaZalouser(
threadId: string,
mediaUrl: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default";
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()];
if (options.caption) {
args.push("-m", options.caption.slice(0, 2000));
}
if (options.isGroup) args.push("-g");
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || `Failed to send ${command}` };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function sendImageZalouser(
threadId: string,
imageUrl: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default";
const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()];
if (options.caption) {
args.push("-m", options.caption.slice(0, 2000));
}
if (options.isGroup) args.push("-g");
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || "Failed to send image" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function sendLinkZalouser(
threadId: string,
url: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default";
const args = ["msg", "link", threadId.trim(), url.trim()];
if (options.isGroup) args.push("-g");
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || "Failed to send link" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
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;
}

View File

@@ -0,0 +1,156 @@
import { Type } from "@sinclair/typebox";
import { runZca, parseJsonOutput } from "./zca.js";
const ACTIONS = ["send", "image", "link", "friends", "groups", "me", "status"] as const;
function stringEnum<T extends readonly string[]>(
values: T,
options: { description?: string } = {},
) {
return Type.Unsafe<T[number]>({
type: "string",
enum: [...values],
...options,
});
}
// Tool schema - avoiding Type.Union per tool schema guardrails
export const ZalouserToolSchema = Type.Object({
action: stringEnum(ACTIONS, { description: `Action to perform: ${ACTIONS.join(", ")}` }),
threadId: Type.Optional(
Type.String({ description: "Thread ID for messaging" }),
),
message: Type.Optional(Type.String({ description: "Message text" })),
isGroup: Type.Optional(Type.Boolean({ description: "Is group chat" })),
profile: Type.Optional(Type.String({ description: "Profile name" })),
query: Type.Optional(Type.String({ description: "Search query" })),
url: Type.Optional(Type.String({ description: "URL for media/link" })),
}, { additionalProperties: false });
type ToolParams = {
action: (typeof ACTIONS)[number];
threadId?: string;
message?: string;
isGroup?: boolean;
profile?: string;
query?: string;
url?: string;
};
type ToolResult = {
content: Array<{ type: string; text: string }>;
details: unknown;
};
function json(payload: unknown): ToolResult {
return {
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
details: payload,
};
}
export async function executeZalouserTool(
_toolCallId: string,
params: ToolParams,
): Promise<ToolResult> {
try {
switch (params.action) {
case "send": {
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 });
if (!result.ok) {
throw new Error(result.stderr || "Failed to send message");
}
return json({ success: true, output: result.stdout });
}
case "image": {
if (!params.threadId) {
throw new Error("threadId required for image action");
}
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 });
if (!result.ok) {
throw new Error(result.stderr || "Failed to send image");
}
return json({ success: true, output: result.stdout });
}
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 });
if (!result.ok) {
throw new Error(result.stderr || "Failed to send link");
}
return json({ success: true, output: result.stdout });
}
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 });
}
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 });
}
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 });
}
case "status": {
const result = await runZca(["auth", "status"], {
profile: params.profile,
});
return json({
authenticated: result.ok,
output: result.stdout || result.stderr,
});
}
default:
throw new Error(
`Unknown action: ${params.action}. Valid actions: send, image, link, friends, groups, me, status`,
);
}
} catch (err) {
return json({
error: err instanceof Error ? err.message : String(err),
});
}
}

View File

@@ -0,0 +1,109 @@
// 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 = {
groupId: string;
name: string;
memberCount?: number;
};
export type ZcaMessage = {
threadId: string;
msgId?: string;
cliMsgId?: string;
type: number;
content: string;
timestamp: number;
metadata?: {
isGroup: boolean;
threadName?: string;
senderName?: string;
fromId?: string;
};
};
export type ZcaUserInfo = {
userId: string;
displayName: string;
avatar?: string;
};
export type CommonOptions = {
profile?: string;
json?: boolean;
};
export type SendOptions = CommonOptions & {
group?: boolean;
};
export type ListenOptions = CommonOptions & {
raw?: boolean;
keepAlive?: boolean;
webhook?: string;
echo?: boolean;
prefix?: string;
};
// Channel plugin config types
export const DEFAULT_ACCOUNT_ID = "default";
export type ZalouserAccountConfig = {
enabled?: boolean;
name?: string;
profile?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
messagePrefix?: string;
};
export type ZalouserConfig = {
enabled?: boolean;
name?: string;
profile?: string;
defaultAccount?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
messagePrefix?: string;
accounts?: Record<string, ZalouserAccountConfig>;
};
export type CoreConfig = {
channels?: {
zalouser?: ZalouserConfig;
[key: string]: unknown;
};
[key: string]: unknown;
};
export type ResolvedZalouserAccount = {
accountId: string;
name?: string;
enabled: boolean;
profile: string;
authenticated: boolean;
config: ZalouserAccountConfig;
};

View File

@@ -0,0 +1,183 @@
import { spawn, type SpawnOptions } from "node:child_process";
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 {
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 };
}