Add ez-assistant and kerberos service folders
This commit is contained in:
33
docker-compose/ez-assistant/extensions/zalouser/CHANGELOG.md
Normal file
33
docker-compose/ez-assistant/extensions/zalouser/CHANGELOG.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.23
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
- Initial version with full channel plugin support
|
||||
- QR code login via zca-cli
|
||||
- Multi-account support
|
||||
- Agent tool for sending messages
|
||||
- Group and DM policy support
|
||||
- ChannelDock for lightweight shared metadata
|
||||
- Zod-based config schema validation
|
||||
- Setup adapter for programmatic configuration
|
||||
- Dedicated probe and status issues modules
|
||||
221
docker-compose/ez-assistant/extensions/zalouser/README.md
Normal file
221
docker-compose/ez-assistant/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 --target <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 --target <threadId> --message "message"
|
||||
|
||||
# Media (URL)
|
||||
clawdbot message send --channel zalouser --target <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 --target <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).
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "zalouser",
|
||||
"channels": [
|
||||
"zalouser"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
32
docker-compose/ez-assistant/extensions/zalouser/index.ts
Normal file
32
docker-compose/ez-assistant/extensions/zalouser/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { zalouserDock, zalouserPlugin } from "./src/channel.js";
|
||||
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
|
||||
import { setZalouserRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "zalouser",
|
||||
name: "Zalo Personal",
|
||||
description: "Zalo personal account messaging via zca-cli",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: MoltbotPluginApi) {
|
||||
setZalouserRuntime(api.runtime);
|
||||
// Register channel plugin (for onboarding & gateway)
|
||||
api.registerChannel({ plugin: zalouserPlugin, dock: zalouserDock });
|
||||
|
||||
// Register agent tool
|
||||
api.registerTool({
|
||||
name: "zalouser",
|
||||
label: "Zalo Personal",
|
||||
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;
|
||||
33
docker-compose/ez-assistant/extensions/zalouser/package.json
Normal file
33
docker-compose/ez-assistant/extensions/zalouser/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@moltbot/zalouser",
|
||||
"version": "2026.1.26",
|
||||
"type": "module",
|
||||
"description": "Moltbot Zalo Personal Account plugin via zca-cli",
|
||||
"dependencies": {
|
||||
"moltbot": "workspace:*",
|
||||
"@sinclair/typebox": "0.34.47"
|
||||
},
|
||||
"moltbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"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
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@moltbot/zalouser",
|
||||
"localPath": "extensions/zalouser",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
}
|
||||
}
|
||||
117
docker-compose/ez-assistant/extensions/zalouser/src/accounts.ts
Normal file
117
docker-compose/ez-assistant/extensions/zalouser/src/accounts.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { runZca, parseJsonOutput } from "./zca.js";
|
||||
import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js";
|
||||
|
||||
function listConfiguredAccountIds(cfg: MoltbotConfig): 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: MoltbotConfig): 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: MoltbotConfig): 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;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: MoltbotConfig,
|
||||
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: MoltbotConfig,
|
||||
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: MoltbotConfig;
|
||||
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: MoltbotConfig;
|
||||
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: MoltbotConfig,
|
||||
): 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";
|
||||
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
641
docker-compose/ez-assistant/extensions/zalouser/src/channel.ts
Normal file
641
docker-compose/ez-assistant/extensions/zalouser/src/channel.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelDock,
|
||||
ChannelGroupContext,
|
||||
ChannelPlugin,
|
||||
MoltbotConfig,
|
||||
GroupToolPolicyConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
formatPairingApproveHint,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
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 type { ZcaFriend, ZcaGroup, ZcaUserInfo } from "./types.js";
|
||||
import { ZalouserConfigSchema } from "./config-schema.js";
|
||||
import { collectZalouserStatusIssues } from "./status-issues.js";
|
||||
import { probeZalouser } from "./probe.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 = normalizeAccountId(accountId);
|
||||
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 resolveZalouserGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg: params.cfg as MoltbotConfig,
|
||||
accountId: params.accountId ?? undefined,
|
||||
});
|
||||
const groups = account.config.groups ?? {};
|
||||
const groupId = params.groupId?.trim();
|
||||
const groupChannel = params.groupChannel?.trim();
|
||||
const candidates = [groupId, groupChannel, "*"].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
for (const key of candidates) {
|
||||
const entry = groups[key];
|
||||
if (entry?.tools) return entry.tools;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const zalouserDock: ChannelDock = {
|
||||
id: "zalouser",
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 2000 },
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveZalouserAccountSync({ cfg: cfg as MoltbotConfig, 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()),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
},
|
||||
};
|
||||
|
||||
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"] },
|
||||
configSchema: buildChannelConfigSchema(ZalouserConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listZalouserAccountIds(cfg as MoltbotConfig),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
resolveZalouserAccountSync({ cfg: cfg as MoltbotConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as MoltbotConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
sectionKey: "zalouser",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
sectionKey: "zalouser",
|
||||
accountId,
|
||||
clearBaseFields: ["profile", "name", "dmPolicy", "allowFrom", "groupPolicy", "groups", "messagePrefix"],
|
||||
}),
|
||||
isConfigured: async (account) => {
|
||||
// Check if zca auth status is OK for this profile
|
||||
const result = await runZca(["auth", "status"], {
|
||||
profile: account.profile,
|
||||
timeout: 5000,
|
||||
});
|
||||
return result.ok;
|
||||
},
|
||||
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: undefined,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveZalouserAccountSync({ cfg: cfg as MoltbotConfig, 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 MoltbotConfig).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,
|
||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
channelKey: "zalouser",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: () => null,
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
channelKey: "zalouser",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: "zalouser",
|
||||
})
|
||||
: namedConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalouser: {
|
||||
...next.channels?.zalouser,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as MoltbotConfig;
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalouser: {
|
||||
...next.channels?.zalouser,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(next.channels?.zalouser?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(next.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as MoltbotConfig;
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: (raw) => {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.replace(/^(zalouser|zlu):/i, "");
|
||||
},
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
},
|
||||
hint: "<threadId>",
|
||||
},
|
||||
},
|
||||
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 MoltbotConfig, 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 MoltbotConfig, 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 MoltbotConfig, 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 MoltbotConfig, 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[];
|
||||
},
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ cfg, accountId, inputs, kind, runtime }) => {
|
||||
const results = [];
|
||||
for (const input of inputs) {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
results.push({ input, resolved: false, note: "empty input" });
|
||||
continue;
|
||||
}
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
results.push({ input, resolved: true, id: trimmed });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
const args =
|
||||
kind === "user"
|
||||
? trimmed
|
||||
? ["friend", "find", trimmed]
|
||||
: ["friend", "list", "-j"]
|
||||
: ["group", "list", "-j"];
|
||||
const result = await runZca(args, { profile: account.profile, timeout: 15000 });
|
||||
if (!result.ok) throw new Error(result.stderr || "zca lookup failed");
|
||||
if (kind === "user") {
|
||||
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
|
||||
const matches = Array.isArray(parsed)
|
||||
? parsed.map((f) => ({
|
||||
id: String(f.userId),
|
||||
name: f.displayName ?? undefined,
|
||||
}))
|
||||
: [];
|
||||
const best = matches[0];
|
||||
results.push({
|
||||
input,
|
||||
resolved: Boolean(best?.id),
|
||||
id: best?.id,
|
||||
name: best?.name,
|
||||
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
|
||||
});
|
||||
} else {
|
||||
const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
|
||||
const matches = Array.isArray(parsed)
|
||||
? parsed.map((g) => ({
|
||||
id: String(g.groupId),
|
||||
name: g.name ?? undefined,
|
||||
}))
|
||||
: [];
|
||||
const best = matches.find((g) => g.name?.toLowerCase() === trimmed.toLowerCase()) ?? matches[0];
|
||||
results.push({
|
||||
input,
|
||||
resolved: Boolean(best?.id),
|
||||
id: best?.id,
|
||||
name: best?.name,
|
||||
note: matches.length > 1 ? "multiple matches; chose first" : undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(`zalouser resolve failed: ${String(err)}`);
|
||||
results.push({ input, resolved: false, note: "lookup failed" });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
idLabel: "zalouserUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as MoltbotConfig });
|
||||
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 MoltbotConfig,
|
||||
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) {
|
||||
throw new Error(
|
||||
"Missing dependency: `zca` not found in PATH. See docs.molt.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;
|
||||
},
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 2000,
|
||||
sendText: async ({ to, text, accountId, cfg }) => {
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as MoltbotConfig, 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 MoltbotConfig, 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,
|
||||
},
|
||||
collectStatusIssues: collectZalouserStatusIssues,
|
||||
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 }) =>
|
||||
probeZalouser(account.profile, timeoutMs),
|
||||
buildAccountSnapshot: async ({ account, runtime }) => {
|
||||
const zcaInstalled = await checkZcaInstalled();
|
||||
const configured = zcaInstalled ? await checkZcaAuthenticated(account.profile) : false;
|
||||
const configError = zcaInstalled ? "not authenticated" : "zca CLI not found in PATH";
|
||||
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) : runtime?.lastError ?? configError,
|
||||
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 MoltbotConfig,
|
||||
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 };
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const groupConfigSchema = z.object({
|
||||
allow: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
});
|
||||
|
||||
const zalouserAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
profile: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
|
||||
groups: z.object({}).catchall(groupConfigSchema).optional(),
|
||||
messagePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZalouserConfigSchema = zalouserAccountSchema.extend({
|
||||
accounts: z.object({}).catchall(zalouserAccountSchema).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
});
|
||||
574
docker-compose/ez-assistant/extensions/zalouser/src/monitor.ts
Normal file
574
docker-compose/ez-assistant/extensions/zalouser/src/monitor.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
import type { MoltbotConfig, MarkdownTableMode, RuntimeEnv } from "clawdbot/plugin-sdk";
|
||||
import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import type {
|
||||
ResolvedZalouserAccount,
|
||||
ZcaFriend,
|
||||
ZcaGroup,
|
||||
ZcaMessage,
|
||||
} from "./types.js";
|
||||
import { getZalouserRuntime } from "./runtime.js";
|
||||
import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js";
|
||||
|
||||
export type ZalouserMonitorOptions = {
|
||||
account: ResolvedZalouserAccount;
|
||||
config: MoltbotConfig;
|
||||
runtime: RuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
export type ZalouserMonitorResult = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
const ZALOUSER_TEXT_LIMIT = 2000;
|
||||
|
||||
function normalizeZalouserEntry(entry: string): string {
|
||||
return entry.replace(/^(zalouser|zlu):/i, "").trim();
|
||||
}
|
||||
|
||||
function buildNameIndex<T>(
|
||||
items: T[],
|
||||
nameFn: (item: T) => string | undefined,
|
||||
): Map<string, T[]> {
|
||||
const index = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const name = nameFn(item)?.trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
const list = index.get(name) ?? [];
|
||||
list.push(item);
|
||||
index.set(name, list);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
type ZalouserCoreRuntime = ReturnType<typeof getZalouserRuntime>;
|
||||
|
||||
function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: string): void {
|
||||
if (core.logging.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 normalizeGroupSlug(raw?: string | null): string {
|
||||
const trimmed = raw?.trim().toLowerCase() ?? "";
|
||||
if (!trimmed) return "";
|
||||
return trimmed
|
||||
.replace(/^#/, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function isGroupAllowed(params: {
|
||||
groupId: string;
|
||||
groupName?: string | null;
|
||||
groups: Record<string, { allow?: boolean; enabled?: boolean }>;
|
||||
}): boolean {
|
||||
const groups = params.groups ?? {};
|
||||
const keys = Object.keys(groups);
|
||||
if (keys.length === 0) return false;
|
||||
const candidates = [
|
||||
params.groupId,
|
||||
`group:${params.groupId}`,
|
||||
params.groupName ?? "",
|
||||
normalizeGroupSlug(params.groupName ?? ""),
|
||||
].filter(Boolean);
|
||||
for (const candidate of candidates) {
|
||||
const entry = groups[candidate];
|
||||
if (!entry) continue;
|
||||
return entry.allow !== false && entry.enabled !== false;
|
||||
}
|
||||
const wildcard = groups["*"];
|
||||
if (wildcard) return wildcard.allow !== false && wildcard.enabled !== false;
|
||||
return false;
|
||||
}
|
||||
|
||||
function startZcaListener(
|
||||
runtime: RuntimeEnv,
|
||||
profile: string,
|
||||
onMessage: (msg: ZcaMessage) => void,
|
||||
onError: (err: Error) => void,
|
||||
abortSignal: AbortSignal,
|
||||
): ChildProcess {
|
||||
let buffer = "";
|
||||
|
||||
const { proc, promise } = runZcaStreaming(["listen", "-r", "-k"], {
|
||||
profile,
|
||||
onData: (chunk) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as ZcaMessage;
|
||||
onMessage(parsed);
|
||||
} catch {
|
||||
// ignore non-JSON lines
|
||||
}
|
||||
}
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString().trim();
|
||||
if (text) runtime.error(`[zalouser] zca stderr: ${text}`);
|
||||
});
|
||||
|
||||
void promise.then((result) => {
|
||||
if (!result.ok && !abortSignal.aborted) {
|
||||
onError(new Error(result.stderr || `zca listen exited with code ${result.exitCode}`));
|
||||
}
|
||||
});
|
||||
|
||||
abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
proc.kill("SIGTERM");
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
return proc;
|
||||
}
|
||||
|
||||
async function processMessage(
|
||||
message: ZcaMessage,
|
||||
account: ResolvedZalouserAccount,
|
||||
config: MoltbotConfig,
|
||||
core: ZalouserCoreRuntime,
|
||||
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 groupName = metadata?.threadName ?? "";
|
||||
const chatId = threadId;
|
||||
|
||||
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "open";
|
||||
const groups = account.config.groups ?? {};
|
||||
if (isGroup) {
|
||||
if (groupPolicy === "disabled") {
|
||||
logVerbose(core, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
const allowed = isGroupAllowed({ groupId: chatId, groupName, groups });
|
||||
if (!allowed) {
|
||||
logVerbose(core, runtime, `zalouser: drop group ${chatId} (not allowlisted)`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||
const rawBody = content.trim();
|
||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(
|
||||
rawBody,
|
||||
config,
|
||||
);
|
||||
const storeAllowFrom =
|
||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
? await core.channel.pairing.readAllowFromStore("zalouser").catch(() => [])
|
||||
: [];
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose(core, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const allowed = senderAllowedForCommands;
|
||||
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "zalouser",
|
||||
id: senderId,
|
||||
meta: { name: senderName || undefined },
|
||||
});
|
||||
|
||||
if (created) {
|
||||
logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`);
|
||||
try {
|
||||
await sendMessageZalouser(
|
||||
chatId,
|
||||
core.channel.pairing.buildPairingReply({
|
||||
channel: "zalouser",
|
||||
idLine: `Your Zalo user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
{ profile: account.profile },
|
||||
);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`zalouser pairing reply failed for ${senderId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
isGroup &&
|
||||
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
||||
commandAuthorized !== true
|
||||
) {
|
||||
logVerbose(core, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
|
||||
|
||||
const route = core.channel.routing.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 fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Zalo Personal",
|
||||
from: fromLabel,
|
||||
timestamp: timestamp ? timestamp * 1000 : undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: isGroup ? `zalouser:group:${chatId}` : `zalouser:${senderId}`,
|
||||
To: `zalouser:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
Provider: "zalouser",
|
||||
Surface: "zalouser",
|
||||
MessageSid: message.msgId ?? `${timestamp}`,
|
||||
OriginatingChannel: "zalouser",
|
||||
OriginatingTo: `zalouser:${chatId}`,
|
||||
});
|
||||
|
||||
await core.channel.session.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
onRecordError: (err) => {
|
||||
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
await core.channel.reply.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,
|
||||
core,
|
||||
config,
|
||||
accountId: account.accountId,
|
||||
statusSink,
|
||||
tableMode: core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: config,
|
||||
channel: "zalouser",
|
||||
accountId: account.accountId,
|
||||
}),
|
||||
});
|
||||
},
|
||||
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;
|
||||
core: ZalouserCoreRuntime;
|
||||
config: MoltbotConfig;
|
||||
accountId?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): Promise<void> {
|
||||
const { payload, profile, chatId, isGroup, runtime, core, config, accountId, statusSink } =
|
||||
params;
|
||||
const tableMode = params.tableMode ?? "code";
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
|
||||
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 ? text : undefined;
|
||||
first = false;
|
||||
try {
|
||||
logVerbose(core, 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 (text) {
|
||||
const chunkMode = core.channel.text.resolveChunkMode(config, "zalouser", accountId);
|
||||
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
||||
text,
|
||||
ZALOUSER_TEXT_LIMIT,
|
||||
chunkMode,
|
||||
);
|
||||
logVerbose(core, 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> {
|
||||
let { account, config } = options;
|
||||
const { abortSignal, statusSink, runtime } = options;
|
||||
|
||||
const core = getZalouserRuntime();
|
||||
let stopped = false;
|
||||
let proc: ChildProcess | null = null;
|
||||
let restartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let resolveRunning: (() => void) | null = null;
|
||||
|
||||
try {
|
||||
const profile = account.profile;
|
||||
const allowFromEntries = (account.config.allowFrom ?? [])
|
||||
.map((entry) => normalizeZalouserEntry(String(entry)))
|
||||
.filter((entry) => entry && entry !== "*");
|
||||
|
||||
if (allowFromEntries.length > 0) {
|
||||
const result = await runZca(["friend", "list", "-j"], { profile, timeout: 15000 });
|
||||
if (result.ok) {
|
||||
const friends = parseJsonOutput<ZcaFriend[]>(result.stdout) ?? [];
|
||||
const byName = buildNameIndex(friends, (friend) => friend.displayName);
|
||||
const additions: string[] = [];
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
for (const entry of allowFromEntries) {
|
||||
if (/^\d+$/.test(entry)) {
|
||||
additions.push(entry);
|
||||
continue;
|
||||
}
|
||||
const matches = byName.get(entry.toLowerCase()) ?? [];
|
||||
const match = matches[0];
|
||||
const id = match?.userId ? String(match.userId) : undefined;
|
||||
if (id) {
|
||||
additions.push(id);
|
||||
mapping.push(`${entry}→${id}`);
|
||||
} else {
|
||||
unresolved.push(entry);
|
||||
}
|
||||
}
|
||||
const allowFrom = mergeAllowlist({ existing: account.config.allowFrom, additions });
|
||||
account = {
|
||||
...account,
|
||||
config: {
|
||||
...account.config,
|
||||
allowFrom,
|
||||
},
|
||||
};
|
||||
summarizeMapping("zalouser users", mapping, unresolved, runtime);
|
||||
} else {
|
||||
runtime.log?.(`zalouser user resolve failed; using config entries. ${result.stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
const groupsConfig = account.config.groups ?? {};
|
||||
const groupKeys = Object.keys(groupsConfig).filter((key) => key !== "*");
|
||||
if (groupKeys.length > 0) {
|
||||
const result = await runZca(["group", "list", "-j"], { profile, timeout: 15000 });
|
||||
if (result.ok) {
|
||||
const groups = parseJsonOutput<ZcaGroup[]>(result.stdout) ?? [];
|
||||
const byName = buildNameIndex(groups, (group) => group.name);
|
||||
const mapping: string[] = [];
|
||||
const unresolved: string[] = [];
|
||||
const nextGroups = { ...groupsConfig };
|
||||
for (const entry of groupKeys) {
|
||||
const cleaned = normalizeZalouserEntry(entry);
|
||||
if (/^\d+$/.test(cleaned)) {
|
||||
if (!nextGroups[cleaned]) nextGroups[cleaned] = groupsConfig[entry];
|
||||
mapping.push(`${entry}→${cleaned}`);
|
||||
continue;
|
||||
}
|
||||
const matches = byName.get(cleaned.toLowerCase()) ?? [];
|
||||
const match = matches[0];
|
||||
const id = match?.groupId ? String(match.groupId) : undefined;
|
||||
if (id) {
|
||||
if (!nextGroups[id]) nextGroups[id] = groupsConfig[entry];
|
||||
mapping.push(`${entry}→${id}`);
|
||||
} else {
|
||||
unresolved.push(entry);
|
||||
}
|
||||
}
|
||||
account = {
|
||||
...account,
|
||||
config: {
|
||||
...account.config,
|
||||
groups: nextGroups,
|
||||
},
|
||||
};
|
||||
summarizeMapping("zalouser groups", mapping, unresolved, runtime);
|
||||
} else {
|
||||
runtime.log?.(`zalouser group resolve failed; using config entries. ${result.stderr}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.log?.(`zalouser resolve failed; using config entries. ${String(err)}`);
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
if (restartTimer) {
|
||||
clearTimeout(restartTimer);
|
||||
restartTimer = null;
|
||||
}
|
||||
if (proc) {
|
||||
proc.kill("SIGTERM");
|
||||
proc = null;
|
||||
}
|
||||
resolveRunning?.();
|
||||
};
|
||||
|
||||
const startListener = () => {
|
||||
if (stopped || abortSignal.aborted) {
|
||||
resolveRunning?.();
|
||||
return;
|
||||
}
|
||||
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`[${account.accountId}] starting zca listener (profile=${account.profile})`,
|
||||
);
|
||||
|
||||
proc = startZcaListener(
|
||||
runtime,
|
||||
account.profile,
|
||||
(msg) => {
|
||||
logVerbose(core, runtime, `[${account.accountId}] inbound message`);
|
||||
statusSink?.({ lastInboundAt: Date.now() });
|
||||
processMessage(msg, account, config, core, runtime, statusSink).catch((err) => {
|
||||
runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
|
||||
});
|
||||
},
|
||||
(err) => {
|
||||
runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
|
||||
if (!stopped && !abortSignal.aborted) {
|
||||
logVerbose(core, runtime, `[${account.accountId}] restarting listener in 5s...`);
|
||||
restartTimer = setTimeout(startListener, 5000);
|
||||
} else {
|
||||
resolveRunning?.();
|
||||
}
|
||||
},
|
||||
abortSignal,
|
||||
);
|
||||
};
|
||||
|
||||
// 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 };
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
MoltbotConfig,
|
||||
WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
promptAccountId,
|
||||
promptChannelAccessConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
checkZcaAuthenticated,
|
||||
} from "./accounts.js";
|
||||
import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
|
||||
import type { ZcaFriend, ZcaGroup } from "./types.js";
|
||||
|
||||
const channel = "zalouser" as const;
|
||||
|
||||
function setZalouserDmPolicy(
|
||||
cfg: MoltbotConfig,
|
||||
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
|
||||
): MoltbotConfig {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
? addWildcardAllowFrom(cfg.channels?.zalouser?.allowFrom)
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
} as MoltbotConfig;
|
||||
}
|
||||
|
||||
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.molt.bot/channels/zalouser",
|
||||
].join("\n"),
|
||||
"Zalo Personal Setup",
|
||||
);
|
||||
}
|
||||
|
||||
async function promptZalouserAllowFrom(params: {
|
||||
cfg: MoltbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<MoltbotConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
const parseInput = (raw: string) =>
|
||||
raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const resolveUserId = async (input: string): Promise<string | null> => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
if (/^\d+$/.test(trimmed)) return trimmed;
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) return null;
|
||||
const result = await runZca(["friend", "find", trimmed], {
|
||||
profile: resolved.profile,
|
||||
timeout: 15000,
|
||||
});
|
||||
if (!result.ok) return null;
|
||||
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
|
||||
const rows = Array.isArray(parsed) ? parsed : [];
|
||||
const match = rows[0];
|
||||
if (!match?.userId) return null;
|
||||
if (rows.length > 1) {
|
||||
await prompter.note(
|
||||
`Multiple matches for "${trimmed}", using ${match.displayName ?? match.userId}.`,
|
||||
"Zalo Personal allowlist",
|
||||
);
|
||||
}
|
||||
return String(match.userId);
|
||||
};
|
||||
|
||||
while (true) {
|
||||
const entry = await prompter.text({
|
||||
message: "Zalouser allowFrom (username or user id)",
|
||||
placeholder: "Alice, 123456789",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
const parts = parseInput(String(entry));
|
||||
const results = await Promise.all(parts.map((part) => resolveUserId(part)));
|
||||
const unresolved = parts.filter((_, idx) => !results[idx]);
|
||||
if (unresolved.length > 0) {
|
||||
await prompter.note(
|
||||
`Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or ensure zca is available.`,
|
||||
"Zalo Personal allowlist",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
...(results.filter(Boolean) as string[]),
|
||||
];
|
||||
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 MoltbotConfig;
|
||||
}
|
||||
|
||||
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 MoltbotConfig;
|
||||
}
|
||||
}
|
||||
|
||||
function setZalouserGroupPolicy(
|
||||
cfg: MoltbotConfig,
|
||||
accountId: string,
|
||||
groupPolicy: "open" | "allowlist" | "disabled",
|
||||
): MoltbotConfig {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
groupPolicy,
|
||||
},
|
||||
},
|
||||
} as MoltbotConfig;
|
||||
}
|
||||
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,
|
||||
groupPolicy,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as MoltbotConfig;
|
||||
}
|
||||
|
||||
function setZalouserGroupAllowlist(
|
||||
cfg: MoltbotConfig,
|
||||
accountId: string,
|
||||
groupKeys: string[],
|
||||
): MoltbotConfig {
|
||||
const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }]));
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
groups,
|
||||
},
|
||||
},
|
||||
} as MoltbotConfig;
|
||||
}
|
||||
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,
|
||||
groups,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as MoltbotConfig;
|
||||
}
|
||||
|
||||
async function resolveZalouserGroups(params: {
|
||||
cfg: MoltbotConfig;
|
||||
accountId: string;
|
||||
entries: string[];
|
||||
}): Promise<Array<{ input: string; resolved: boolean; id?: string }>> {
|
||||
const account = resolveZalouserAccountSync({ cfg: params.cfg, accountId: params.accountId });
|
||||
const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 });
|
||||
if (!result.ok) throw new Error(result.stderr || "Failed to list groups");
|
||||
const groups = (parseJsonOutput<ZcaGroup[]>(result.stdout) ?? []).filter(
|
||||
(group) => Boolean(group.groupId),
|
||||
);
|
||||
const byName = new Map<string, ZcaGroup[]>();
|
||||
for (const group of groups) {
|
||||
const name = group.name?.trim().toLowerCase();
|
||||
if (!name) continue;
|
||||
const list = byName.get(name) ?? [];
|
||||
list.push(group);
|
||||
byName.set(name, list);
|
||||
}
|
||||
|
||||
return params.entries.map((input) => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return { input, resolved: false };
|
||||
if (/^\d+$/.test(trimmed)) return { input, resolved: true, id: trimmed };
|
||||
const matches = byName.get(trimmed.toLowerCase()) ?? [];
|
||||
const match = matches[0];
|
||||
return match?.groupId
|
||||
? { input, resolved: true, id: String(match.groupId) }
|
||||
: { input, resolved: false };
|
||||
});
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Zalo Personal",
|
||||
channel,
|
||||
policyKey: "channels.zalouser.dmPolicy",
|
||||
allowFromKey: "channels.zalouser.allowFrom",
|
||||
getCurrent: (cfg) => ((cfg as MoltbotConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
|
||||
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as MoltbotConfig, policy),
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const id =
|
||||
accountId && normalizeAccountId(accountId)
|
||||
? normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID
|
||||
: resolveDefaultZalouserAccountId(cfg as MoltbotConfig);
|
||||
return promptZalouserAllowFrom({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
prompter,
|
||||
accountId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
dmPolicy,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const ids = listZalouserAccountIds(cfg as MoltbotConfig);
|
||||
let configured = false;
|
||||
for (const accountId of ids) {
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as MoltbotConfig, 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.molt.bot/channels/zalouser",
|
||||
].join("\n"),
|
||||
"Missing Dependency",
|
||||
);
|
||||
return { cfg, accountId: DEFAULT_ACCOUNT_ID };
|
||||
}
|
||||
|
||||
const zalouserOverride = accountOverrides.zalouser?.trim();
|
||||
const defaultAccountId = resolveDefaultZalouserAccountId(cfg as MoltbotConfig);
|
||||
let accountId = zalouserOverride
|
||||
? normalizeAccountId(zalouserOverride)
|
||||
: defaultAccountId;
|
||||
|
||||
if (shouldPromptAccountIds && !zalouserOverride) {
|
||||
accountId = await promptAccountId({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
prompter,
|
||||
label: "Zalo Personal",
|
||||
currentId: accountId,
|
||||
listAccountIds: listZalouserAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg as MoltbotConfig;
|
||||
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 MoltbotConfig;
|
||||
} 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 MoltbotConfig;
|
||||
}
|
||||
|
||||
if (forceAllowFrom) {
|
||||
next = await promptZalouserAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const accessConfig = await promptChannelAccessConfig({
|
||||
prompter,
|
||||
label: "Zalo groups",
|
||||
currentPolicy: account.config.groupPolicy ?? "open",
|
||||
currentEntries: Object.keys(account.config.groups ?? {}),
|
||||
placeholder: "Family, Work, 123456789",
|
||||
updatePrompt: Boolean(account.config.groups),
|
||||
});
|
||||
if (accessConfig) {
|
||||
if (accessConfig.policy !== "allowlist") {
|
||||
next = setZalouserGroupPolicy(next, accountId, accessConfig.policy);
|
||||
} else {
|
||||
let keys = accessConfig.entries;
|
||||
if (accessConfig.entries.length > 0) {
|
||||
try {
|
||||
const resolved = await resolveZalouserGroups({
|
||||
cfg: next,
|
||||
accountId,
|
||||
entries: accessConfig.entries,
|
||||
});
|
||||
const resolvedIds = resolved
|
||||
.filter((entry) => entry.resolved && entry.id)
|
||||
.map((entry) => entry.id as string);
|
||||
const unresolved = resolved
|
||||
.filter((entry) => !entry.resolved)
|
||||
.map((entry) => entry.input);
|
||||
keys = [
|
||||
...resolvedIds,
|
||||
...unresolved.map((entry) => entry.trim()).filter(Boolean),
|
||||
];
|
||||
if (resolvedIds.length > 0 || unresolved.length > 0) {
|
||||
await prompter.note(
|
||||
[
|
||||
resolvedIds.length > 0 ? `Resolved: ${resolvedIds.join(", ")}` : undefined,
|
||||
unresolved.length > 0
|
||||
? `Unresolved (kept as typed): ${unresolved.join(", ")}`
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"Zalo groups",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
await prompter.note(
|
||||
`Group lookup failed; keeping entries as typed. ${String(err)}`,
|
||||
"Zalo groups",
|
||||
);
|
||||
}
|
||||
}
|
||||
next = setZalouserGroupPolicy(next, accountId, "allowlist");
|
||||
next = setZalouserGroupAllowlist(next, accountId, keys);
|
||||
}
|
||||
}
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
};
|
||||
28
docker-compose/ez-assistant/extensions/zalouser/src/probe.ts
Normal file
28
docker-compose/ez-assistant/extensions/zalouser/src/probe.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { runZca, parseJsonOutput } from "./zca.js";
|
||||
import type { ZcaUserInfo } from "./types.js";
|
||||
|
||||
export interface ZalouserProbeResult {
|
||||
ok: boolean;
|
||||
user?: ZcaUserInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function probeZalouser(
|
||||
profile: string,
|
||||
timeoutMs?: number,
|
||||
): Promise<ZalouserProbeResult> {
|
||||
const result = await runZca(["me", "info", "-j"], {
|
||||
profile,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: result.stderr || "Failed to probe" };
|
||||
}
|
||||
|
||||
const user = parseJsonOutput<ZcaUserInfo>(result.stdout);
|
||||
if (!user) {
|
||||
return { ok: false, error: "Failed to parse user info" };
|
||||
}
|
||||
return { ok: true, user };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setZalouserRuntime(next: PluginRuntime): void {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getZalouserRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("Zalouser runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
150
docker-compose/ez-assistant/extensions/zalouser/src/send.ts
Normal file
150
docker-compose/ez-assistant/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;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { collectZalouserStatusIssues } from "./status-issues.js";
|
||||
|
||||
describe("collectZalouserStatusIssues", () => {
|
||||
it("flags missing zca when configured is false", () => {
|
||||
const issues = collectZalouserStatusIssues([
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
lastError: "zca CLI not found in PATH",
|
||||
},
|
||||
]);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.kind).toBe("runtime");
|
||||
expect(issues[0]?.message).toMatch(/zca CLI not found/i);
|
||||
});
|
||||
|
||||
it("flags missing auth when configured is false", () => {
|
||||
const issues = collectZalouserStatusIssues([
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
lastError: "not authenticated",
|
||||
},
|
||||
]);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.kind).toBe("auth");
|
||||
expect(issues[0]?.message).toMatch(/Not authenticated/i);
|
||||
});
|
||||
|
||||
it("warns when dmPolicy is open", () => {
|
||||
const issues = collectZalouserStatusIssues([
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
]);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.kind).toBe("config");
|
||||
});
|
||||
|
||||
it("skips disabled accounts", () => {
|
||||
const issues = collectZalouserStatusIssues([
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: false,
|
||||
configured: false,
|
||||
lastError: "zca CLI not found in PATH",
|
||||
},
|
||||
]);
|
||||
expect(issues).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "clawdbot/plugin-sdk";
|
||||
|
||||
type ZalouserAccountStatus = {
|
||||
accountId?: unknown;
|
||||
enabled?: unknown;
|
||||
configured?: unknown;
|
||||
dmPolicy?: unknown;
|
||||
lastError?: unknown;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object");
|
||||
|
||||
const asString = (value: unknown): string | undefined =>
|
||||
typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
|
||||
|
||||
function readZalouserAccountStatus(value: ChannelAccountSnapshot): ZalouserAccountStatus | null {
|
||||
if (!isRecord(value)) return null;
|
||||
return {
|
||||
accountId: value.accountId,
|
||||
enabled: value.enabled,
|
||||
configured: value.configured,
|
||||
dmPolicy: value.dmPolicy,
|
||||
lastError: value.lastError,
|
||||
};
|
||||
}
|
||||
|
||||
function isMissingZca(lastError?: string): boolean {
|
||||
if (!lastError) return false;
|
||||
const lower = lastError.toLowerCase();
|
||||
return lower.includes("zca") && (lower.includes("not found") || lower.includes("enoent"));
|
||||
}
|
||||
|
||||
export function collectZalouserStatusIssues(
|
||||
accounts: ChannelAccountSnapshot[],
|
||||
): ChannelStatusIssue[] {
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
for (const entry of accounts) {
|
||||
const account = readZalouserAccountStatus(entry);
|
||||
if (!account) continue;
|
||||
const accountId = asString(account.accountId) ?? "default";
|
||||
const enabled = account.enabled !== false;
|
||||
if (!enabled) continue;
|
||||
|
||||
const configured = account.configured === true;
|
||||
const lastError = asString(account.lastError)?.trim();
|
||||
|
||||
if (!configured) {
|
||||
if (isMissingZca(lastError)) {
|
||||
issues.push({
|
||||
channel: "zalouser",
|
||||
accountId,
|
||||
kind: "runtime",
|
||||
message: "zca CLI not found in PATH.",
|
||||
fix: "Install zca-cli and ensure it is on PATH for the Gateway process.",
|
||||
});
|
||||
} else {
|
||||
issues.push({
|
||||
channel: "zalouser",
|
||||
accountId,
|
||||
kind: "auth",
|
||||
message: "Not authenticated (no zca session).",
|
||||
fix: "Run: moltbot channels login --channel zalouser",
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (account.dmPolicy === "open") {
|
||||
issues.push({
|
||||
channel: "zalouser",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message:
|
||||
'Zalo Personal dmPolicy is "open", allowing any user to message the bot without pairing.',
|
||||
fix: 'Set channels.zalouser.dmPolicy to "pairing" or "allowlist" to restrict access.',
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
156
docker-compose/ez-assistant/extensions/zalouser/src/tool.ts
Normal file
156
docker-compose/ez-assistant/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),
|
||||
});
|
||||
}
|
||||
}
|
||||
102
docker-compose/ez-assistant/extensions/zalouser/src/types.ts
Normal file
102
docker-compose/ez-assistant/extensions/zalouser/src/types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// 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;
|
||||
};
|
||||
|
||||
export type ZalouserAccountConfig = {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
profile?: string;
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
groups?: Record<string, { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }>;
|
||||
messagePrefix?: string;
|
||||
};
|
||||
|
||||
export type ZalouserConfig = {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
profile?: string;
|
||||
defaultAccount?: string;
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
groups?: Record<string, { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }>;
|
||||
messagePrefix?: string;
|
||||
accounts?: Record<string, ZalouserAccountConfig>;
|
||||
};
|
||||
|
||||
export type ResolvedZalouserAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
profile: string;
|
||||
authenticated: boolean;
|
||||
config: ZalouserAccountConfig;
|
||||
};
|
||||
208
docker-compose/ez-assistant/extensions/zalouser/src/zca.ts
Normal file
208
docker-compose/ez-assistant/extensions/zalouser/src/zca.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stripAnsi(str: string): string {
|
||||
return str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
|
||||
}
|
||||
|
||||
export function parseJsonOutput<T>(stdout: string): T | null {
|
||||
try {
|
||||
return JSON.parse(stdout) as T;
|
||||
} catch {
|
||||
const cleaned = stripAnsi(stdout);
|
||||
|
||||
try {
|
||||
return JSON.parse(cleaned) as T;
|
||||
} catch {
|
||||
// zca may prefix output with INFO/log lines, try to find JSON
|
||||
const lines = cleaned.split("\n");
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (line.startsWith("{") || line.startsWith("[")) {
|
||||
// Try parsing from this line to the end
|
||||
const jsonCandidate = lines.slice(i).join("\n").trim();
|
||||
try {
|
||||
return JSON.parse(jsonCandidate) as T;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkZcaInstalled(): Promise<boolean> {
|
||||
const result = await runZca(["--version"], { timeout: 5000 });
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
export type ZcaStreamingOptions = ZcaRunOptions & {
|
||||
onData?: (data: string) => void;
|
||||
onError?: (err: Error) => void;
|
||||
};
|
||||
|
||||
export function runZcaStreaming(
|
||||
args: string[],
|
||||
options?: ZcaStreamingOptions,
|
||||
): { proc: ReturnType<typeof spawn>; promise: Promise<ZcaResult> } {
|
||||
const fullArgs = buildArgs(args, options);
|
||||
|
||||
const spawnOpts: SpawnOptions = {
|
||||
cwd: options?.cwd,
|
||||
env: { ...process.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
};
|
||||
|
||||
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stdout += text;
|
||||
options?.onData?.(text);
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
const promise = new Promise<ZcaResult>((resolve) => {
|
||||
proc.on("close", (code) => {
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: code ?? 1,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
options?.onError?.(err);
|
||||
resolve({
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: err.message,
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { proc, promise };
|
||||
}
|
||||
Reference in New Issue
Block a user