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:
@@ -53,6 +53,7 @@
|
|||||||
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
|
- 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 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.
|
- 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
|
### Fixes
|
||||||
- Messages: make `/stop` clear queued followups and pending session lane work for a hard abort.
|
- Messages: make `/stop` clear queued followups and pending session lane work for a hard abort.
|
||||||
|
|||||||
@@ -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).
|
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
|
||||||
- [Matrix](/channels/matrix) — Matrix protocol (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](/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.
|
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|||||||
98
docs/channels/zalouser.md
Normal file
98
docs/channels/zalouser.md
Normal file
@@ -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 <code>`
|
||||||
|
|
||||||
|
## 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`
|
||||||
38
docs/cli/directory.md
Normal file
38
docs/cli/directory.md
Normal file
@@ -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 <name>`: channel id/alias (default: `whatsapp`)
|
||||||
|
- `--account <id>`: 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 <id>
|
||||||
|
```
|
||||||
|
|
||||||
@@ -792,6 +792,7 @@
|
|||||||
"cli/health",
|
"cli/health",
|
||||||
"cli/sessions",
|
"cli/sessions",
|
||||||
"cli/channels",
|
"cli/channels",
|
||||||
|
"cli/directory",
|
||||||
"cli/skills",
|
"cli/skills",
|
||||||
"cli/plugins",
|
"cli/plugins",
|
||||||
"cli/memory",
|
"cli/memory",
|
||||||
@@ -907,6 +908,7 @@
|
|||||||
"channels/msteams",
|
"channels/msteams",
|
||||||
"channels/matrix",
|
"channels/matrix",
|
||||||
"channels/zalo",
|
"channels/zalo",
|
||||||
|
"channels/zalouser",
|
||||||
"broadcast-groups",
|
"broadcast-groups",
|
||||||
"channels/troubleshooting",
|
"channels/troubleshooting",
|
||||||
"channels/location"
|
"channels/location"
|
||||||
@@ -945,6 +947,7 @@
|
|||||||
"tools",
|
"tools",
|
||||||
"plugin",
|
"plugin",
|
||||||
"plugins/voice-call",
|
"plugins/voice-call",
|
||||||
|
"plugins/zalouser",
|
||||||
"tools/exec",
|
"tools/exec",
|
||||||
"tools/web",
|
"tools/web",
|
||||||
"tools/apply-patch",
|
"tools/apply-patch",
|
||||||
|
|||||||
@@ -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.
|
- 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`
|
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||||
|
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||||
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
- [Zalo](/channels/zalo) — `@clawdbot/zalo`
|
||||||
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
|
||||||
|
|||||||
75
docs/plugins/zalouser.md
Normal file
75
docs/plugins/zalouser.md
Normal file
@@ -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 <threadId> --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`
|
||||||
221
extensions/zalouser/README.md
Normal file
221
extensions/zalouser/README.md
Normal 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).
|
||||||
28
extensions/zalouser/index.ts
Normal file
28
extensions/zalouser/index.ts
Normal 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;
|
||||||
12
extensions/zalouser/package.json
Normal file
12
extensions/zalouser/package.json
Normal 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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
121
extensions/zalouser/src/accounts.ts
Normal file
121
extensions/zalouser/src/accounts.ts
Normal 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";
|
||||||
18
extensions/zalouser/src/channel.test.ts
Normal file
18
extensions/zalouser/src/channel.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
525
extensions/zalouser/src/channel.ts
Normal file
525
extensions/zalouser/src/channel.ts
Normal 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 };
|
||||||
171
extensions/zalouser/src/core-bridge.ts
Normal file
171
extensions/zalouser/src/core-bridge.ts
Normal 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;
|
||||||
|
}
|
||||||
348
extensions/zalouser/src/monitor.ts
Normal file
348
extensions/zalouser/src/monitor.ts
Normal 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 };
|
||||||
|
}
|
||||||
312
extensions/zalouser/src/onboarding.ts
Normal file
312
extensions/zalouser/src/onboarding.ts
Normal 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 };
|
||||||
|
},
|
||||||
|
};
|
||||||
150
extensions/zalouser/src/send.ts
Normal file
150
extensions/zalouser/src/send.ts
Normal 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;
|
||||||
|
}
|
||||||
156
extensions/zalouser/src/tool.ts
Normal file
156
extensions/zalouser/src/tool.ts
Normal 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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
109
extensions/zalouser/src/types.ts
Normal file
109
extensions/zalouser/src/types.ts
Normal 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;
|
||||||
|
};
|
||||||
183
extensions/zalouser/src/zca.ts
Normal file
183
extensions/zalouser/src/zca.ts
Normal 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 };
|
||||||
|
}
|
||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -272,6 +272,12 @@ importers:
|
|||||||
specifier: 7.18.2
|
specifier: 7.18.2
|
||||||
version: 7.18.2
|
version: 7.18.2
|
||||||
|
|
||||||
|
extensions/zalouser:
|
||||||
|
dependencies:
|
||||||
|
'@sinclair/typebox':
|
||||||
|
specifier: 0.34.47
|
||||||
|
version: 0.34.47
|
||||||
|
|
||||||
ui:
|
ui:
|
||||||
dependencies:
|
dependencies:
|
||||||
dompurify:
|
dompurify:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { RuntimeEnv } from "../../runtime.js";
|
|||||||
import type {
|
import type {
|
||||||
ChannelAccountSnapshot,
|
ChannelAccountSnapshot,
|
||||||
ChannelAccountState,
|
ChannelAccountState,
|
||||||
|
ChannelDirectoryEntry,
|
||||||
ChannelGroupContext,
|
ChannelGroupContext,
|
||||||
ChannelHeartbeatDeps,
|
ChannelHeartbeatDeps,
|
||||||
ChannelLogSink,
|
ChannelLogSink,
|
||||||
@@ -219,6 +220,35 @@ export type ChannelHeartbeatAdapter = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ChannelDirectoryAdapter = {
|
||||||
|
self?: (params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
}) => Promise<ChannelDirectoryEntry | null>;
|
||||||
|
listPeers?: (params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
query?: string | null;
|
||||||
|
limit?: number | null;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
}) => Promise<ChannelDirectoryEntry[]>;
|
||||||
|
listGroups?: (params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
query?: string | null;
|
||||||
|
limit?: number | null;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
}) => Promise<ChannelDirectoryEntry[]>;
|
||||||
|
listGroupMembers?: (params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
accountId?: string | null;
|
||||||
|
groupId: string;
|
||||||
|
limit?: number | null;
|
||||||
|
runtime: RuntimeEnv;
|
||||||
|
}) => Promise<ChannelDirectoryEntry[]>;
|
||||||
|
};
|
||||||
|
|
||||||
export type ChannelElevatedAdapter = {
|
export type ChannelElevatedAdapter = {
|
||||||
allowFromFallback?: (params: {
|
allowFromFallback?: (params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
|
|||||||
@@ -216,6 +216,17 @@ export type ChannelMessagingAdapter = {
|
|||||||
normalizeTarget?: (raw: string) => string | undefined;
|
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 ChannelMessageActionName = ChannelMessageActionNameFromList;
|
||||||
|
|
||||||
export type ChannelMessageActionContext = {
|
export type ChannelMessageActionContext = {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
ChannelAuthAdapter,
|
ChannelAuthAdapter,
|
||||||
ChannelCommandAdapter,
|
ChannelCommandAdapter,
|
||||||
ChannelConfigAdapter,
|
ChannelConfigAdapter,
|
||||||
|
ChannelDirectoryAdapter,
|
||||||
ChannelElevatedAdapter,
|
ChannelElevatedAdapter,
|
||||||
ChannelGatewayAdapter,
|
ChannelGatewayAdapter,
|
||||||
ChannelGroupAdapter,
|
ChannelGroupAdapter,
|
||||||
@@ -51,6 +52,7 @@ export type ChannelPlugin<ResolvedAccount = any> = {
|
|||||||
streaming?: ChannelStreamingAdapter;
|
streaming?: ChannelStreamingAdapter;
|
||||||
threading?: ChannelThreadingAdapter;
|
threading?: ChannelThreadingAdapter;
|
||||||
messaging?: ChannelMessagingAdapter;
|
messaging?: ChannelMessagingAdapter;
|
||||||
|
directory?: ChannelDirectoryAdapter;
|
||||||
actions?: ChannelMessageActionAdapter;
|
actions?: ChannelMessageActionAdapter;
|
||||||
heartbeat?: ChannelHeartbeatAdapter;
|
heartbeat?: ChannelHeartbeatAdapter;
|
||||||
// Channel-owned agent tools (login flows, etc.).
|
// Channel-owned agent tools (login flows, etc.).
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type {
|
|||||||
ChannelAuthAdapter,
|
ChannelAuthAdapter,
|
||||||
ChannelCommandAdapter,
|
ChannelCommandAdapter,
|
||||||
ChannelConfigAdapter,
|
ChannelConfigAdapter,
|
||||||
|
ChannelDirectoryAdapter,
|
||||||
ChannelElevatedAdapter,
|
ChannelElevatedAdapter,
|
||||||
ChannelGatewayAdapter,
|
ChannelGatewayAdapter,
|
||||||
ChannelGatewayContext,
|
ChannelGatewayContext,
|
||||||
@@ -30,6 +31,8 @@ export type {
|
|||||||
ChannelAgentTool,
|
ChannelAgentTool,
|
||||||
ChannelAgentToolFactory,
|
ChannelAgentToolFactory,
|
||||||
ChannelCapabilities,
|
ChannelCapabilities,
|
||||||
|
ChannelDirectoryEntry,
|
||||||
|
ChannelDirectoryEntryKind,
|
||||||
ChannelGroupContext,
|
ChannelGroupContext,
|
||||||
ChannelHeartbeatDeps,
|
ChannelHeartbeatDeps,
|
||||||
ChannelId,
|
ChannelId,
|
||||||
|
|||||||
@@ -156,9 +156,9 @@ export function registerChannelsCli(program: Command) {
|
|||||||
|
|
||||||
channels
|
channels
|
||||||
.command("login")
|
.command("login")
|
||||||
.description("Link a channel account (WhatsApp Web only)")
|
.description("Link a channel account (if supported)")
|
||||||
.option("--channel <channel>", "Channel alias (default: whatsapp)")
|
.option("--channel <channel>", "Channel alias (default: whatsapp)")
|
||||||
.option("--account <id>", "WhatsApp account id (accountId)")
|
.option("--account <id>", "Account id (accountId)")
|
||||||
.option("--verbose", "Verbose connection logs", false)
|
.option("--verbose", "Verbose connection logs", false)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
193
src/cli/directory-cli.ts
Normal file
193
src/cli/directory-cli.ts
Normal file
@@ -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 <name>", "Channel (default: whatsapp)")
|
||||||
|
.option("--account <id>", "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 <text>", "Optional search query")
|
||||||
|
.option("--limit <n>", "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 <text>", "Optional search query")
|
||||||
|
.option("--limit <n>", "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 <id>", "Group id"),
|
||||||
|
)
|
||||||
|
.option("--limit <n>", "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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { registerChannelsCli } from "../channels-cli.js";
|
|||||||
import { registerCronCli } from "../cron-cli.js";
|
import { registerCronCli } from "../cron-cli.js";
|
||||||
import { registerDaemonCli } from "../daemon-cli.js";
|
import { registerDaemonCli } from "../daemon-cli.js";
|
||||||
import { registerDnsCli } from "../dns-cli.js";
|
import { registerDnsCli } from "../dns-cli.js";
|
||||||
|
import { registerDirectoryCli } from "../directory-cli.js";
|
||||||
import { registerDocsCli } from "../docs-cli.js";
|
import { registerDocsCli } from "../docs-cli.js";
|
||||||
import { registerGatewayCli } from "../gateway-cli.js";
|
import { registerGatewayCli } from "../gateway-cli.js";
|
||||||
import { registerHooksCli } from "../hooks-cli.js";
|
import { registerHooksCli } from "../hooks-cli.js";
|
||||||
@@ -36,6 +37,7 @@ export function registerSubCliCommands(program: Command) {
|
|||||||
registerPairingCli(program);
|
registerPairingCli(program);
|
||||||
registerPluginsCli(program);
|
registerPluginsCli(program);
|
||||||
registerChannelsCli(program);
|
registerChannelsCli(program);
|
||||||
|
registerDirectoryCli(program);
|
||||||
registerSecurityCli(program);
|
registerSecurityCli(program);
|
||||||
registerSkillsCli(program);
|
registerSkillsCli(program);
|
||||||
registerUpdateCli(program);
|
registerUpdateCli(program);
|
||||||
|
|||||||
Reference in New Issue
Block a user