From 390bd11f3381aa2f8753eeeb0b29d664337bd5b5 Mon Sep 17 00:00:00 2001 From: tsu Date: Fri, 16 Jan 2026 13:28:18 -0800 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + docs/channels/index.md | 1 + docs/channels/zalouser.md | 98 +++++ docs/cli/directory.md | 38 ++ docs/docs.json | 3 + docs/plugin.md | 1 + docs/plugins/zalouser.md | 75 ++++ extensions/zalouser/README.md | 221 ++++++++++ extensions/zalouser/index.ts | 28 ++ extensions/zalouser/package.json | 12 + extensions/zalouser/src/accounts.ts | 121 ++++++ extensions/zalouser/src/channel.test.ts | 18 + extensions/zalouser/src/channel.ts | 525 ++++++++++++++++++++++++ extensions/zalouser/src/core-bridge.ts | 171 ++++++++ extensions/zalouser/src/monitor.ts | 348 ++++++++++++++++ extensions/zalouser/src/onboarding.ts | 312 ++++++++++++++ extensions/zalouser/src/send.ts | 150 +++++++ extensions/zalouser/src/tool.ts | 156 +++++++ extensions/zalouser/src/types.ts | 109 +++++ extensions/zalouser/src/zca.ts | 183 +++++++++ pnpm-lock.yaml | 6 + src/channels/plugins/types.adapters.ts | 30 ++ src/channels/plugins/types.core.ts | 11 + src/channels/plugins/types.plugin.ts | 2 + src/channels/plugins/types.ts | 3 + src/cli/channels-cli.ts | 4 +- src/cli/directory-cli.ts | 193 +++++++++ src/cli/program/register.subclis.ts | 2 + 28 files changed, 2820 insertions(+), 2 deletions(-) create mode 100644 docs/channels/zalouser.md create mode 100644 docs/cli/directory.md create mode 100644 docs/plugins/zalouser.md create mode 100644 extensions/zalouser/README.md create mode 100644 extensions/zalouser/index.ts create mode 100644 extensions/zalouser/package.json create mode 100644 extensions/zalouser/src/accounts.ts create mode 100644 extensions/zalouser/src/channel.test.ts create mode 100644 extensions/zalouser/src/channel.ts create mode 100644 extensions/zalouser/src/core-bridge.ts create mode 100644 extensions/zalouser/src/monitor.ts create mode 100644 extensions/zalouser/src/onboarding.ts create mode 100644 extensions/zalouser/src/send.ts create mode 100644 extensions/zalouser/src/tool.ts create mode 100644 extensions/zalouser/src/types.ts create mode 100644 extensions/zalouser/src/zca.ts create mode 100644 src/cli/directory-cli.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e948628a..3b12624c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ - Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino. - Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow. - Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE. +- Channels/Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) via zca-cli. (#1032) — thanks @suminhthanh. ### Fixes - Messages: make `/stop` clear queued followups and pending session lane work for a hard abort. diff --git a/docs/channels/index.md b/docs/channels/index.md index b6b9559da..3021d5237 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -20,6 +20,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). +- [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). - [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. ## Notes diff --git a/docs/channels/zalouser.md b/docs/channels/zalouser.md new file mode 100644 index 000000000..495c59286 --- /dev/null +++ b/docs/channels/zalouser.md @@ -0,0 +1,98 @@ +--- +summary: "Zalo personal account support via zca-cli (QR login), capabilities, and configuration" +read_when: + - Setting up Zalo Personal for Clawdbot + - Debugging Zalo Personal login or message flow +--- +# Zalo Personal (unofficial) + +Status: experimental. This integration automates a **personal Zalo account** via `zca-cli`. + +> **Warning:** This is an unofficial integration and may result in account suspension/ban. Use at your own risk. + +## Plugin required +Zalo Personal ships as a plugin and is not bundled with the core install. +- Install via CLI: `clawdbot plugins install @clawdbot/zalouser` +- Or from a source checkout: `clawdbot plugins install ./extensions/zalouser` +- Details: [Plugins](/plugin) + +## Prerequisite: zca-cli +The Gateway machine must have the `zca` binary available in `PATH`. + +- Verify: `zca --version` +- If missing, install zca-cli (see `extensions/zalouser/README.md` or the upstream zca-cli docs). + +## Quick setup (beginner) +1) Install the plugin (see above). +2) Login (QR, on the Gateway machine): + - `clawdbot channels login --channel zalouser` + - Scan the QR code in the terminal with the Zalo mobile app. +3) Enable the channel: + +```json5 +{ + channels: { + zalouser: { + enabled: true, + dmPolicy: "pairing" + } + } +} +``` + +4) Restart the Gateway (or finish onboarding). +5) DM access defaults to pairing; approve the pairing code on first contact. + +## What it is +- Uses `zca listen` to receive inbound messages. +- Uses `zca msg ...` to send replies (text/media/link). +- Designed for “personal account” use cases where Zalo Bot API is not available. + +## Naming +Channel id is `zalouser` to make it explicit this automates a **personal Zalo user account** (unofficial). We keep `zalo` reserved for a potential future official Zalo API integration. + +## Finding IDs (directory) +Use the directory CLI to discover peers/groups and their IDs: + +```bash +clawdbot directory self --channel zalouser +clawdbot directory peers list --channel zalouser --query "name" +clawdbot directory groups list --channel zalouser --query "work" +``` + +## Limits +- Outbound text is chunked to ~2000 characters (Zalo client limits). +- Streaming is blocked by default. + +## Access control (DMs) +`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`). + +Approve via: +- `clawdbot pairing list zalouser` +- `clawdbot pairing approve zalouser ` + +## Multi-account +Accounts map to zca profiles. Example: + +```json5 +{ + channels: { + zalouser: { + enabled: true, + defaultAccount: "default", + accounts: { + work: { enabled: true, profile: "work" } + } + } + } +} +``` + +## Troubleshooting + +**`zca` not found:** +- Install zca-cli and ensure it’s on `PATH` for the Gateway process. + +**Login doesn’t stick:** +- `clawdbot channels status --probe` +- Re-login: `clawdbot channels logout --channel zalouser && clawdbot channels login --channel zalouser` diff --git a/docs/cli/directory.md b/docs/cli/directory.md new file mode 100644 index 000000000..7ec247e02 --- /dev/null +++ b/docs/cli/directory.md @@ -0,0 +1,38 @@ +--- +summary: "CLI reference for `clawdbot directory` (self, peers, groups)" +read_when: + - You want to look up contacts/groups/self ids for a channel + - You are developing a channel directory adapter +--- + +# `clawdbot directory` + +Directory lookups for channels that support it (contacts/peers, groups, and “me”). + +## Common flags +- `--channel `: channel id/alias (default: `whatsapp`) +- `--account `: account id (default: channel default) +- `--json`: output JSON + +## Self (“me”) + +```bash +clawdbot directory self --channel zalouser +``` + +## Peers (contacts/users) + +```bash +clawdbot directory peers list --channel zalouser +clawdbot directory peers list --channel zalouser --query "name" +clawdbot directory peers list --channel zalouser --limit 50 +``` + +## Groups + +```bash +clawdbot directory groups list --channel zalouser +clawdbot directory groups list --channel zalouser --query "work" +clawdbot directory groups members --channel zalouser --group-id +``` + diff --git a/docs/docs.json b/docs/docs.json index ecf4f88d8..b2e06dbf2 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -792,6 +792,7 @@ "cli/health", "cli/sessions", "cli/channels", + "cli/directory", "cli/skills", "cli/plugins", "cli/memory", @@ -907,6 +908,7 @@ "channels/msteams", "channels/matrix", "channels/zalo", + "channels/zalouser", "broadcast-groups", "channels/troubleshooting", "channels/location" @@ -945,6 +947,7 @@ "tools", "plugin", "plugins/voice-call", + "plugins/zalouser", "tools/exec", "tools/web", "tools/apply-patch", diff --git a/docs/plugin.md b/docs/plugin.md index 54dc8af11..11d204439 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -37,6 +37,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin. - Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams. - [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call` +- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser` - [Matrix](/channels/matrix) — `@clawdbot/matrix` - [Zalo](/channels/zalo) — `@clawdbot/zalo` - [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams` diff --git a/docs/plugins/zalouser.md b/docs/plugins/zalouser.md new file mode 100644 index 000000000..fbaa0bbd2 --- /dev/null +++ b/docs/plugins/zalouser.md @@ -0,0 +1,75 @@ +--- +summary: "Zalo Personal plugin: QR login + messaging via zca-cli (plugin install + channel config + CLI + tool)" +read_when: + - You want Zalo Personal (unofficial) support in Clawdbot + - You are configuring or developing the zalouser plugin +--- + +# Zalo Personal (plugin) + +Zalo Personal support for Clawdbot via a plugin, using `zca-cli` to automate a normal Zalo user account. + +> **Warning:** Unofficial automation may lead to account suspension/ban. Use at your own risk. + +## Naming +Channel id is `zalouser` to make it explicit this automates a **personal Zalo user account** (unofficial). We keep `zalo` reserved for a potential future official Zalo API integration. + +## Where it runs +This plugin runs **inside the Gateway process**. + +If you use a remote Gateway, install/configure it on the **machine running the Gateway**, then restart the Gateway. + +## Install + +### Option A: install from npm + +```bash +clawdbot plugins install @clawdbot/zalouser +``` + +Restart the Gateway afterwards. + +### Option B: install from a local folder (dev) + +```bash +clawdbot plugins install ./extensions/zalouser +cd ./extensions/zalouser && pnpm install +``` + +Restart the Gateway afterwards. + +## Prerequisite: zca-cli +The Gateway machine must have `zca` on `PATH`: + +```bash +zca --version +``` + +## Config +Channel config lives under `channels.zalouser` (not `plugins.entries.*`): + +```json5 +{ + channels: { + zalouser: { + enabled: true, + dmPolicy: "pairing" + } + } +} +``` + +## CLI + +```bash +clawdbot channels login --channel zalouser +clawdbot channels logout --channel zalouser +clawdbot channels status --probe +clawdbot message send --channel zalouser --to --message "Hello from Clawdbot" +clawdbot directory peers list --channel zalouser --query "name" +``` + +## Agent tool +Tool name: `zalouser` + +Actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status` diff --git a/extensions/zalouser/README.md b/extensions/zalouser/README.md new file mode 100644 index 000000000..b9edc6dc4 --- /dev/null +++ b/extensions/zalouser/README.md @@ -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 --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 +``` + +### Account Management + +```bash +zca account list # List all profiles +zca account current # Show active profile +zca account switch +zca account remove +zca account label "Work Account" +``` + +### Messaging + +```bash +# Text +clawdbot message send --channel zalouser --to --message "message" + +# Media (URL) +clawdbot message send --channel zalouser --to --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 +zca group members + +# 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 --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). diff --git a/extensions/zalouser/index.ts b/extensions/zalouser/index.ts new file mode 100644 index 000000000..5f47692ac --- /dev/null +++ b/extensions/zalouser/index.ts @@ -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; diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json new file mode 100644 index 000000000..18c254cfd --- /dev/null +++ b/extensions/zalouser/package.json @@ -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"] + } +} diff --git a/extensions/zalouser/src/accounts.ts b/extensions/zalouser/src/accounts.ts new file mode 100644 index 000000000..31f487d05 --- /dev/null +++ b/extensions/zalouser/src/accounts.ts @@ -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 { + const result = await runZca(["auth", "status"], { profile, timeout: 5000 }); + return result.ok; +} + +export async function resolveZalouserAccount(params: { + cfg: CoreConfig; + accountId?: string | null; +}): Promise { + 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 { + 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"; diff --git a/extensions/zalouser/src/channel.test.ts b/extensions/zalouser/src/channel.test.ts new file mode 100644 index 000000000..45487edd0 --- /dev/null +++ b/extensions/zalouser/src/channel.test.ts @@ -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); + }); +}); + diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts new file mode 100644 index 000000000..d578f7ed9 --- /dev/null +++ b/extensions/zalouser/src/channel.ts @@ -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 = { + 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(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(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(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 & { 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 "), + }; + } + 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 }; diff --git a/extensions/zalouser/src/core-bridge.ts b/extensions/zalouser/src/core-bridge.ts new file mode 100644 index 000000000..46162412b --- /dev/null +++ b/extensions/zalouser/src/core-bridge.ts @@ -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; + onError?: (err: unknown, info: { kind: string }) => void; + }; + }) => Promise; + 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; + 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 | 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(); + 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(relativePath: string): Promise { + 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 { + 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; +} diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts new file mode 100644 index 000000000..8b6d22361 --- /dev/null +++ b/extensions/zalouser/src/monitor.ts @@ -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 { + 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 { + 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 { + const { account, config, abortSignal, statusSink, runtime } = options; + + const deps = await loadCoreChannelDeps(); + let stopped = false; + let proc: ChildProcess | null = null; + let restartTimer: ReturnType | 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((resolve) => { + resolveRunning = resolve; + abortSignal.addEventListener("abort", () => resolve(), { once: true }); + }); + + startListener(); + + // Wait for the running promise to resolve (on abort/stop) + await runningPromise; + + return { stop }; +} diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts new file mode 100644 index 000000000..61634df19 --- /dev/null +++ b/extensions/zalouser/src/onboarding.ts @@ -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 { + 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 { + 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 { + 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 }; + }, +}; diff --git a/extensions/zalouser/src/send.ts b/extensions/zalouser/src/send.ts new file mode 100644 index 000000000..3b07a6060 --- /dev/null +++ b/extensions/zalouser/src/send.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/extensions/zalouser/src/tool.ts b/extensions/zalouser/src/tool.ts new file mode 100644 index 000000000..7e52484c2 --- /dev/null +++ b/extensions/zalouser/src/tool.ts @@ -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( + values: T, + options: { description?: string } = {}, +) { + return Type.Unsafe({ + 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 { + 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), + }); + } +} diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts new file mode 100644 index 000000000..db787823b --- /dev/null +++ b/extensions/zalouser/src/types.ts @@ -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; + messagePrefix?: string; +}; + +export type ZalouserConfig = { + enabled?: boolean; + name?: string; + profile?: string; + defaultAccount?: string; + dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; + allowFrom?: Array; + messagePrefix?: string; + accounts?: Record; +}; + +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; +}; diff --git a/extensions/zalouser/src/zca.ts b/extensions/zalouser/src/zca.ts new file mode 100644 index 000000000..83849835f --- /dev/null +++ b/extensions/zalouser/src/zca.ts @@ -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 { + const fullArgs = buildArgs(args, options); + const timeout = options?.timeout ?? DEFAULT_TIMEOUT; + + return new Promise((resolve) => { + const spawnOpts: SpawnOptions = { + cwd: options?.cwd, + env: { ...process.env }, + stdio: ["pipe", "pipe", "pipe"], + }; + + const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts); + let stdout = ""; + let stderr = ""; + let timedOut = false; + + const timer = setTimeout(() => { + timedOut = true; + proc.kill("SIGTERM"); + }, timeout); + + proc.stdout?.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + proc.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on("close", (code) => { + clearTimeout(timer); + if (timedOut) { + resolve({ + ok: false, + stdout, + stderr: stderr || "Command timed out", + exitCode: code ?? 124, + }); + return; + } + resolve({ + ok: code === 0, + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: code ?? 1, + }); + }); + + proc.on("error", (err) => { + clearTimeout(timer); + resolve({ + ok: false, + stdout: "", + stderr: err.message, + exitCode: 1, + }); + }); + }); +} + +export function runZcaInteractive( + args: string[], + options?: ZcaRunOptions, +): Promise { + const fullArgs = buildArgs(args, options); + + return new Promise((resolve) => { + const spawnOpts: SpawnOptions = { + cwd: options?.cwd, + env: { ...process.env }, + stdio: "inherit", + }; + + const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts); + + proc.on("close", (code) => { + resolve({ + ok: code === 0, + stdout: "", + stderr: "", + exitCode: code ?? 1, + }); + }); + + proc.on("error", (err) => { + resolve({ + ok: false, + stdout: "", + stderr: err.message, + exitCode: 1, + }); + }); + }); +} + +export function parseJsonOutput(stdout: string): T | null { + try { + return JSON.parse(stdout) as T; + } catch { + return null; + } +} + +export async function checkZcaInstalled(): Promise { + const result = await runZca(["--version"], { timeout: 5000 }); + return result.ok; +} + +export type ZcaStreamingOptions = ZcaRunOptions & { + onData?: (data: string) => void; + onError?: (err: Error) => void; +}; + +export function runZcaStreaming( + args: string[], + options?: ZcaStreamingOptions, +): { proc: ReturnType; promise: Promise } { + const fullArgs = buildArgs(args, options); + + const spawnOpts: SpawnOptions = { + cwd: options?.cwd, + env: { ...process.env }, + stdio: ["pipe", "pipe", "pipe"], + }; + + const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts); + let stdout = ""; + let stderr = ""; + + proc.stdout?.on("data", (data: Buffer) => { + const text = data.toString(); + stdout += text; + options?.onData?.(text); + }); + + proc.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + const promise = new Promise((resolve) => { + proc.on("close", (code) => { + resolve({ + ok: code === 0, + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: code ?? 1, + }); + }); + + proc.on("error", (err) => { + options?.onError?.(err); + resolve({ + ok: false, + stdout: "", + stderr: err.message, + exitCode: 1, + }); + }); + }); + + return { proc, promise }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71bc8d12c..7205f3c3f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -272,6 +272,12 @@ importers: specifier: 7.18.2 version: 7.18.2 + extensions/zalouser: + dependencies: + '@sinclair/typebox': + specifier: 0.34.47 + version: 0.34.47 + ui: dependencies: dompurify: diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 897a16f95..f2ba9996a 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -4,6 +4,7 @@ import type { RuntimeEnv } from "../../runtime.js"; import type { ChannelAccountSnapshot, ChannelAccountState, + ChannelDirectoryEntry, ChannelGroupContext, ChannelHeartbeatDeps, ChannelLogSink, @@ -219,6 +220,35 @@ export type ChannelHeartbeatAdapter = { }; }; +export type ChannelDirectoryAdapter = { + self?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + runtime: RuntimeEnv; + }) => Promise; + listPeers?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + query?: string | null; + limit?: number | null; + runtime: RuntimeEnv; + }) => Promise; + listGroups?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + query?: string | null; + limit?: number | null; + runtime: RuntimeEnv; + }) => Promise; + listGroupMembers?: (params: { + cfg: ClawdbotConfig; + accountId?: string | null; + groupId: string; + limit?: number | null; + runtime: RuntimeEnv; + }) => Promise; +}; + export type ChannelElevatedAdapter = { allowFromFallback?: (params: { cfg: ClawdbotConfig; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 87213f1a0..227c35169 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -216,6 +216,17 @@ export type ChannelMessagingAdapter = { normalizeTarget?: (raw: string) => string | undefined; }; +export type ChannelDirectoryEntryKind = "user" | "group" | "channel"; + +export type ChannelDirectoryEntry = { + kind: ChannelDirectoryEntryKind; + id: string; + name?: string; + handle?: string; + avatarUrl?: string; + raw?: unknown; +}; + export type ChannelMessageActionName = ChannelMessageActionNameFromList; export type ChannelMessageActionContext = { diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index 661a8483b..da95250ab 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -3,6 +3,7 @@ import type { ChannelAuthAdapter, ChannelCommandAdapter, ChannelConfigAdapter, + ChannelDirectoryAdapter, ChannelElevatedAdapter, ChannelGatewayAdapter, ChannelGroupAdapter, @@ -51,6 +52,7 @@ export type ChannelPlugin = { streaming?: ChannelStreamingAdapter; threading?: ChannelThreadingAdapter; messaging?: ChannelMessagingAdapter; + directory?: ChannelDirectoryAdapter; actions?: ChannelMessageActionAdapter; heartbeat?: ChannelHeartbeatAdapter; // Channel-owned agent tools (login flows, etc.). diff --git a/src/channels/plugins/types.ts b/src/channels/plugins/types.ts index a8f3f72be..696070ba4 100644 --- a/src/channels/plugins/types.ts +++ b/src/channels/plugins/types.ts @@ -8,6 +8,7 @@ export type { ChannelAuthAdapter, ChannelCommandAdapter, ChannelConfigAdapter, + ChannelDirectoryAdapter, ChannelElevatedAdapter, ChannelGatewayAdapter, ChannelGatewayContext, @@ -30,6 +31,8 @@ export type { ChannelAgentTool, ChannelAgentToolFactory, ChannelCapabilities, + ChannelDirectoryEntry, + ChannelDirectoryEntryKind, ChannelGroupContext, ChannelHeartbeatDeps, ChannelId, diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index af6e6a366..2186ce5de 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -156,9 +156,9 @@ export function registerChannelsCli(program: Command) { channels .command("login") - .description("Link a channel account (WhatsApp Web only)") + .description("Link a channel account (if supported)") .option("--channel ", "Channel alias (default: whatsapp)") - .option("--account ", "WhatsApp account id (accountId)") + .option("--account ", "Account id (accountId)") .option("--verbose", "Verbose connection logs", false) .action(async (opts) => { try { diff --git a/src/cli/directory-cli.ts b/src/cli/directory-cli.ts new file mode 100644 index 000000000..9324285fe --- /dev/null +++ b/src/cli/directory-cli.ts @@ -0,0 +1,193 @@ +import type { Command } from "commander"; + +import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; +import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js"; +import { DEFAULT_CHAT_CHANNEL } from "../channels/registry.js"; +import { loadConfig } from "../config/config.js"; +import { danger } from "../globals.js"; +import { defaultRuntime } from "../runtime.js"; +import { formatDocsLink } from "../terminal/links.js"; +import { theme } from "../terminal/theme.js"; + +function parseLimit(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + if (value <= 0) return null; + return Math.floor(value); + } + if (typeof value !== "string") return null; + const raw = value.trim(); + if (!raw) return null; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return null; + return parsed; +} + +function formatEntry(entry: { kind: string; id: string; name?: string | undefined }): string { + const name = entry.name?.trim(); + return name ? `${entry.id}\t${name}` : entry.id; +} + +export function registerDirectoryCli(program: Command) { + const directory = program + .command("directory") + .description("Directory lookups (self, peers, groups) for channels that support it") + .addHelpText( + "after", + () => + `\n${theme.muted("Docs:")} ${formatDocsLink( + "/cli/directory", + "docs.clawd.bot/cli/directory", + )}\n`, + ) + .action(() => { + directory.help({ error: true }); + }); + + const withChannel = (cmd: Command) => + cmd + .option("--channel ", "Channel (default: whatsapp)") + .option("--account ", "Account id (accountId)") + .option("--json", "Output JSON", false); + + const resolve = (opts: { channel?: string; account?: string }) => { + const cfg = loadConfig(); + const channelInput = opts.channel ?? DEFAULT_CHAT_CHANNEL; + const channelId = normalizeChannelId(channelInput); + if (!channelId) { + throw new Error(`Unsupported channel: ${channelInput}`); + } + const plugin = getChannelPlugin(channelId); + if (!plugin?.directory) { + throw new Error(`Channel ${channelId} does not support directory`); + } + const accountId = opts.account?.trim() || resolveChannelDefaultAccountId({ plugin, cfg }); + return { cfg, channelId, accountId, plugin }; + }; + + withChannel(directory.command("self").description("Show the current account user")).action( + async (opts) => { + try { + const { cfg, channelId, accountId, plugin } = resolve({ + channel: opts.channel as string | undefined, + account: opts.account as string | undefined, + }); + const fn = plugin.directory?.self; + if (!fn) throw new Error(`Channel ${channelId} does not support directory self`); + const result = await fn({ cfg, accountId, runtime: defaultRuntime }); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + if (!result) { + defaultRuntime.log("not available"); + return; + } + defaultRuntime.log(formatEntry(result)); + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + } + }, + ); + + const peers = directory.command("peers").description("Peer directory (contacts/users)"); + withChannel(peers.command("list").description("List peers")) + .option("--query ", "Optional search query") + .option("--limit ", "Limit results") + .action(async (opts) => { + try { + const { cfg, channelId, accountId, plugin } = resolve({ + channel: opts.channel as string | undefined, + account: opts.account as string | undefined, + }); + const fn = plugin.directory?.listPeers; + if (!fn) throw new Error(`Channel ${channelId} does not support directory peers`); + const result = await fn({ + cfg, + accountId, + query: (opts.query as string | undefined) ?? null, + limit: parseLimit(opts.limit), + runtime: defaultRuntime, + }); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + for (const entry of result) { + defaultRuntime.log(formatEntry(entry)); + } + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + } + }); + + const groups = directory.command("groups").description("Group directory"); + withChannel(groups.command("list").description("List groups")) + .option("--query ", "Optional search query") + .option("--limit ", "Limit results") + .action(async (opts) => { + try { + const { cfg, channelId, accountId, plugin } = resolve({ + channel: opts.channel as string | undefined, + account: opts.account as string | undefined, + }); + const fn = plugin.directory?.listGroups; + if (!fn) throw new Error(`Channel ${channelId} does not support directory groups`); + const result = await fn({ + cfg, + accountId, + query: (opts.query as string | undefined) ?? null, + limit: parseLimit(opts.limit), + runtime: defaultRuntime, + }); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + for (const entry of result) { + defaultRuntime.log(formatEntry(entry)); + } + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + } + }); + + withChannel( + groups + .command("members") + .description("List group members") + .requiredOption("--group-id ", "Group id"), + ) + .option("--limit ", "Limit results") + .action(async (opts) => { + try { + const { cfg, channelId, accountId, plugin } = resolve({ + channel: opts.channel as string | undefined, + account: opts.account as string | undefined, + }); + const fn = plugin.directory?.listGroupMembers; + if (!fn) throw new Error(`Channel ${channelId} does not support group members listing`); + const groupId = String(opts.groupId ?? "").trim(); + if (!groupId) throw new Error("Missing --group-id"); + const result = await fn({ + cfg, + accountId, + groupId, + limit: parseLimit(opts.limit), + runtime: defaultRuntime, + }); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + for (const entry of result) { + defaultRuntime.log(formatEntry(entry)); + } + } catch (err) { + defaultRuntime.error(danger(String(err))); + defaultRuntime.exit(1); + } + }); +} diff --git a/src/cli/program/register.subclis.ts b/src/cli/program/register.subclis.ts index ad9284108..298b95e65 100644 --- a/src/cli/program/register.subclis.ts +++ b/src/cli/program/register.subclis.ts @@ -5,6 +5,7 @@ import { registerChannelsCli } from "../channels-cli.js"; import { registerCronCli } from "../cron-cli.js"; import { registerDaemonCli } from "../daemon-cli.js"; import { registerDnsCli } from "../dns-cli.js"; +import { registerDirectoryCli } from "../directory-cli.js"; import { registerDocsCli } from "../docs-cli.js"; import { registerGatewayCli } from "../gateway-cli.js"; import { registerHooksCli } from "../hooks-cli.js"; @@ -36,6 +37,7 @@ export function registerSubCliCommands(program: Command) { registerPairingCli(program); registerPluginsCli(program); registerChannelsCli(program); + registerDirectoryCli(program); registerSecurityCli(program); registerSkillsCli(program); registerUpdateCli(program);