feat: add zalouser channel + directory CLI (#1032) (thanks @suminhthanh)
- Unified UX: channels login + message send; no plugin-specific top-level command\n- Added generic directory CLI for channel identity/groups\n- Docs: channel + plugin pages
This commit is contained in:
221
extensions/zalouser/README.md
Normal file
221
extensions/zalouser/README.md
Normal file
@@ -0,0 +1,221 @@
|
||||
# @clawdbot/zalouser
|
||||
|
||||
Clawdbot extension for Zalo Personal Account messaging via [zca-cli](https://zca-cli.dev).
|
||||
|
||||
> **Warning:** Using Zalo automation may result in account suspension or ban. Use at your own risk. This is an unofficial integration.
|
||||
|
||||
## Features
|
||||
|
||||
- **Channel Plugin Integration**: Appears in onboarding wizard with QR login
|
||||
- **Gateway Integration**: Real-time message listening via the gateway
|
||||
- **Multi-Account Support**: Manage multiple Zalo personal accounts
|
||||
- **CLI Commands**: Full command-line interface for messaging
|
||||
- **Agent Tool**: AI agent integration for automated messaging
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Install `zca` CLI and ensure it's in your PATH:
|
||||
|
||||
|
||||
**macOS / Linux:**
|
||||
```bash
|
||||
curl -fsSL https://get.zca-cli.dev/install.sh | bash
|
||||
|
||||
# Or with custom install directory
|
||||
ZCA_INSTALL_DIR=~/.local/bin curl -fsSL https://get.zca-cli.dev/install.sh | bash
|
||||
|
||||
# Install specific version
|
||||
curl -fsSL https://get.zca-cli.dev/install.sh | bash -s v1.0.0
|
||||
|
||||
# Uninstall
|
||||
curl -fsSL https://get.zca-cli.dev/install.sh | bash -s uninstall
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
irm https://get.zca-cli.dev/install.ps1 | iex
|
||||
|
||||
# Or with custom install directory
|
||||
$env:ZCA_INSTALL_DIR = "C:\Tools\zca"; irm https://get.zca-cli.dev/install.ps1 | iex
|
||||
|
||||
# Install specific version
|
||||
iex "& { $(irm https://get.zca-cli.dev/install.ps1) } -Version v1.0.0"
|
||||
|
||||
# Uninstall
|
||||
iex "& { $(irm https://get.zca-cli.dev/install.ps1) } -Uninstall"
|
||||
```
|
||||
|
||||
### Manual Download
|
||||
|
||||
Download binary directly:
|
||||
|
||||
**macOS / Linux:**
|
||||
```bash
|
||||
curl -fsSL https://get.zca-cli.dev/latest/zca-darwin-arm64 -o zca && chmod +x zca
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Invoke-WebRequest -Uri https://get.zca-cli.dev/latest/zca-windows-x64.exe -OutFile zca.exe
|
||||
```
|
||||
|
||||
Available binaries:
|
||||
- `zca-darwin-arm64` - macOS Apple Silicon
|
||||
- `zca-darwin-x64` - macOS Intel
|
||||
- `zca-linux-arm64` - Linux ARM64
|
||||
- `zca-linux-x64` - Linux x86_64
|
||||
- `zca-windows-x64.exe` - Windows
|
||||
|
||||
See [zca-cli](https://zca-cli.dev) for manual download (binaries for macOS/Linux/Windows) or building from source.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option 1: Onboarding Wizard (Recommended)
|
||||
|
||||
```bash
|
||||
clawdbot onboard
|
||||
# Select "Zalo Personal" from channel list
|
||||
# Follow QR code login flow
|
||||
```
|
||||
|
||||
### Option 2: Login (QR, on the Gateway machine)
|
||||
|
||||
```bash
|
||||
clawdbot channels login --channel zalouser
|
||||
# Scan QR code with Zalo app
|
||||
```
|
||||
|
||||
### Send a Message
|
||||
|
||||
```bash
|
||||
clawdbot message send --channel zalouser --to <threadId> --message "Hello from Clawdbot!"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
After onboarding, your config will include:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
zalouser:
|
||||
enabled: true
|
||||
dmPolicy: pairing # pairing | allowlist | open | disabled
|
||||
```
|
||||
|
||||
For multi-account:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
zalouser:
|
||||
enabled: true
|
||||
defaultAccount: default
|
||||
accounts:
|
||||
default:
|
||||
enabled: true
|
||||
profile: default
|
||||
work:
|
||||
enabled: true
|
||||
profile: work
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
clawdbot channels login --channel zalouser # Login via QR
|
||||
clawdbot channels login --channel zalouser --account work
|
||||
clawdbot channels status --probe
|
||||
clawdbot channels logout --channel zalouser
|
||||
```
|
||||
|
||||
### Directory (IDs, contacts, groups)
|
||||
|
||||
```bash
|
||||
clawdbot directory self --channel zalouser
|
||||
clawdbot directory peers list --channel zalouser --query "name"
|
||||
clawdbot directory groups list --channel zalouser --query "work"
|
||||
clawdbot directory groups members --channel zalouser --group-id <id>
|
||||
```
|
||||
|
||||
### Account Management
|
||||
|
||||
```bash
|
||||
zca account list # List all profiles
|
||||
zca account current # Show active profile
|
||||
zca account switch <profile>
|
||||
zca account remove <profile>
|
||||
zca account label <profile> "Work Account"
|
||||
```
|
||||
|
||||
### Messaging
|
||||
|
||||
```bash
|
||||
# Text
|
||||
clawdbot message send --channel zalouser --to <threadId> --message "message"
|
||||
|
||||
# Media (URL)
|
||||
clawdbot message send --channel zalouser --to <threadId> --message "caption" --media-url "https://example.com/img.jpg"
|
||||
```
|
||||
|
||||
### Listener
|
||||
|
||||
The listener runs inside the Gateway when the channel is enabled. For debugging,
|
||||
use `clawdbot channels logs --channel zalouser` or run `zca listen` directly.
|
||||
|
||||
### Data Access
|
||||
|
||||
```bash
|
||||
# Friends
|
||||
zca friend list
|
||||
zca friend list -j # JSON output
|
||||
zca friend find "name"
|
||||
zca friend online
|
||||
|
||||
# Groups
|
||||
zca group list
|
||||
zca group info <groupId>
|
||||
zca group members <groupId>
|
||||
|
||||
# Profile
|
||||
zca me info
|
||||
zca me id
|
||||
```
|
||||
|
||||
## Multi-Account Support
|
||||
|
||||
Use `--profile` or `-p` to work with multiple accounts:
|
||||
|
||||
```bash
|
||||
clawdbot channels login --channel zalouser --account work
|
||||
clawdbot message send --channel zalouser --account work --to <id> --message "Hello"
|
||||
ZCA_PROFILE=work zca listen
|
||||
```
|
||||
|
||||
Profile resolution order: `--profile` flag > `ZCA_PROFILE` env > default
|
||||
|
||||
## Agent Tool
|
||||
|
||||
The extension registers a `zalouser` tool for AI agents:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "send",
|
||||
"threadId": "123456",
|
||||
"message": "Hello from AI!",
|
||||
"isGroup": false,
|
||||
"profile": "default"
|
||||
}
|
||||
```
|
||||
|
||||
Available actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Login Issues:** Run `zca auth logout` then `zca auth login`
|
||||
- **API Errors:** Try `zca auth cache-refresh` or re-login
|
||||
- **File Uploads:** Check size (max 100MB) and path accessibility
|
||||
|
||||
## Credits
|
||||
|
||||
Built on [zca-cli](https://zca-cli.dev) which uses [zca-js](https://github.com/RFS-ADRENO/zca-js).
|
||||
28
extensions/zalouser/index.ts
Normal file
28
extensions/zalouser/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
|
||||
|
||||
import { zalouserPlugin } from "./src/channel.js";
|
||||
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
|
||||
|
||||
const plugin = {
|
||||
id: "zalouser",
|
||||
name: "Zalo Personal",
|
||||
description: "Zalo personal account messaging via zca-cli",
|
||||
register(api: ClawdbotPluginApi) {
|
||||
// Register channel plugin (for onboarding & gateway)
|
||||
api.registerChannel(zalouserPlugin);
|
||||
|
||||
// Register agent tool
|
||||
api.registerTool({
|
||||
name: "zalouser",
|
||||
label: "Zalo Personal",
|
||||
description:
|
||||
"Send messages and access data via Zalo personal account. " +
|
||||
"Actions: send (text message), image (send image URL), link (send link), " +
|
||||
"friends (list/search friends), groups (list groups), me (profile info), status (auth check).",
|
||||
parameters: ZalouserToolSchema,
|
||||
execute: executeZalouserTool,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
12
extensions/zalouser/package.json
Normal file
12
extensions/zalouser/package.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@clawdbot/zalouser",
|
||||
"version": "2026.1.15",
|
||||
"type": "module",
|
||||
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.47"
|
||||
},
|
||||
"clawdbot": {
|
||||
"extensions": ["./index.ts"]
|
||||
}
|
||||
}
|
||||
121
extensions/zalouser/src/accounts.ts
Normal file
121
extensions/zalouser/src/accounts.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { runZca, parseJsonOutput } from "./zca.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
type CoreConfig,
|
||||
type ResolvedZalouserAccount,
|
||||
type ZalouserAccountConfig,
|
||||
type ZalouserConfig,
|
||||
} from "./types.js";
|
||||
|
||||
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
|
||||
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return [];
|
||||
return Object.keys(accounts).filter(Boolean);
|
||||
}
|
||||
|
||||
export function listZalouserAccountIds(cfg: CoreConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
||||
return ids.sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultZalouserAccountId(cfg: CoreConfig): string {
|
||||
const zalouserConfig = cfg.channels?.zalouser as ZalouserConfig | undefined;
|
||||
if (zalouserConfig?.defaultAccount?.trim()) return zalouserConfig.defaultAccount.trim();
|
||||
const ids = listZalouserAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function normalizeAccountId(accountId?: string | null): string {
|
||||
const trimmed = accountId?.trim();
|
||||
if (!trimmed) return DEFAULT_ACCOUNT_ID;
|
||||
return trimmed.toLowerCase();
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
): ZalouserAccountConfig | undefined {
|
||||
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as ZalouserAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeZalouserAccountConfig(cfg: CoreConfig, accountId: string): ZalouserAccountConfig {
|
||||
const raw = (cfg.channels?.zalouser ?? {}) as ZalouserConfig;
|
||||
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
function resolveZcaProfile(config: ZalouserAccountConfig, accountId: string): string {
|
||||
if (config.profile?.trim()) return config.profile.trim();
|
||||
if (process.env.ZCA_PROFILE?.trim()) return process.env.ZCA_PROFILE.trim();
|
||||
if (accountId !== DEFAULT_ACCOUNT_ID) return accountId;
|
||||
return "default";
|
||||
}
|
||||
|
||||
export async function checkZcaAuthenticated(profile: string): Promise<boolean> {
|
||||
const result = await runZca(["auth", "status"], { profile, timeout: 5000 });
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
export async function resolveZalouserAccount(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): Promise<ResolvedZalouserAccount> {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = (params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const profile = resolveZcaProfile(merged, accountId);
|
||||
const authenticated = await checkZcaAuthenticated(profile);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: merged.name?.trim() || undefined,
|
||||
enabled,
|
||||
profile,
|
||||
authenticated,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveZalouserAccountSync(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedZalouserAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = (params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const profile = resolveZcaProfile(merged, accountId);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: merged.name?.trim() || undefined,
|
||||
enabled,
|
||||
profile,
|
||||
authenticated: false, // unknown without async check
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEnabledZalouserAccounts(cfg: CoreConfig): Promise<ResolvedZalouserAccount[]> {
|
||||
const ids = listZalouserAccountIds(cfg);
|
||||
const accounts = await Promise.all(
|
||||
ids.map((accountId) => resolveZalouserAccount({ cfg, accountId }))
|
||||
);
|
||||
return accounts.filter((account) => account.enabled);
|
||||
}
|
||||
|
||||
export async function getZcaUserInfo(profile: string): Promise<{ userId?: string; displayName?: string } | null> {
|
||||
const result = await runZca(["me", "info", "-j"], { profile, timeout: 10000 });
|
||||
if (!result.ok) return null;
|
||||
return parseJsonOutput<{ userId?: string; displayName?: string }>(result.stdout);
|
||||
}
|
||||
|
||||
export type { ResolvedZalouserAccount } from "./types.js";
|
||||
18
extensions/zalouser/src/channel.test.ts
Normal file
18
extensions/zalouser/src/channel.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { zalouserPlugin } from "./channel.js";
|
||||
|
||||
describe("zalouser outbound chunker", () => {
|
||||
it("chunks without empty strings and respects limit", () => {
|
||||
const chunker = zalouserPlugin.outbound?.chunker;
|
||||
expect(chunker).toBeTypeOf("function");
|
||||
if (!chunker) return;
|
||||
|
||||
const limit = 10;
|
||||
const chunks = chunker("hello world\nthis is a test", limit);
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(chunks.every((c) => c.length > 0)).toBe(true);
|
||||
expect(chunks.every((c) => c.length <= limit)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
525
extensions/zalouser/src/channel.ts
Normal file
525
extensions/zalouser/src/channel.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
import type { ChannelPlugin } from "../../../src/channels/plugins/types.plugin.js";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDirectoryEntry,
|
||||
} from "../../../src/channels/plugins/types.core.js";
|
||||
|
||||
import { formatPairingApproveHint } from "../../../src/channels/plugins/helpers.js";
|
||||
import {
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
getZcaUserInfo,
|
||||
checkZcaAuthenticated,
|
||||
type ResolvedZalouserAccount,
|
||||
} from "./accounts.js";
|
||||
import { zalouserOnboardingAdapter } from "./onboarding.js";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
type CoreConfig,
|
||||
type ZalouserConfig,
|
||||
type ZcaFriend,
|
||||
type ZcaGroup,
|
||||
type ZcaUserInfo,
|
||||
} from "./types.js";
|
||||
|
||||
const meta = {
|
||||
id: "zalouser",
|
||||
label: "Zalo Personal",
|
||||
selectionLabel: "Zalo (Personal Account)",
|
||||
docsPath: "/channels/zalouser",
|
||||
docsLabel: "zalouser",
|
||||
blurb: "Zalo personal account via QR code login.",
|
||||
aliases: ["zlu"],
|
||||
order: 85,
|
||||
quickstartAllowFrom: true,
|
||||
};
|
||||
|
||||
function resolveZalouserQrProfile(accountId?: string | null): string {
|
||||
const normalized = String(accountId ?? "").trim();
|
||||
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
|
||||
return process.env.ZCA_PROFILE?.trim() || "default";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function mapUser(params: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
raw?: unknown;
|
||||
}): ChannelDirectoryEntry {
|
||||
return {
|
||||
kind: "user",
|
||||
id: params.id,
|
||||
name: params.name ?? undefined,
|
||||
avatarUrl: params.avatarUrl ?? undefined,
|
||||
raw: params.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function mapGroup(params: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
raw?: unknown;
|
||||
}): ChannelDirectoryEntry {
|
||||
return {
|
||||
kind: "group",
|
||||
id: params.id,
|
||||
name: params.name ?? undefined,
|
||||
raw: params.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function deleteAccountFromConfigSection(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
}): CoreConfig {
|
||||
const { cfg, accountId } = params;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const { zalouser: _removed, ...restChannels } = cfg.channels ?? {};
|
||||
return { ...cfg, channels: restChannels };
|
||||
}
|
||||
const accounts = { ...(cfg.channels?.zalouser?.accounts ?? {}) };
|
||||
delete accounts[accountId];
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
accounts,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setAccountEnabledInConfigSection(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
}): CoreConfig {
|
||||
const { cfg, accountId, enabled } = params;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
accounts: {
|
||||
...(cfg.channels?.zalouser?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
id: "zalouser",
|
||||
meta,
|
||||
onboarding: zalouserOnboardingAdapter,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
reactions: true,
|
||||
threads: false,
|
||||
polls: false,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.zalouser"] },
|
||||
config: {
|
||||
listAccountIds: (cfg) => listZalouserAccountIds(cfg as CoreConfig),
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as CoreConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId,
|
||||
enabled,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId,
|
||||
}),
|
||||
isConfigured: async (account) => {
|
||||
// Check if zca auth status is OK for this profile
|
||||
const result = await runZca(["auth", "status"], {
|
||||
profile: account.profile,
|
||||
timeout: 5000,
|
||||
});
|
||||
return result.ok;
|
||||
},
|
||||
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: undefined,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.replace(/^(zalouser|zlu):/i, ""))
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(
|
||||
(cfg as CoreConfig).channels?.zalouser?.accounts?.[resolvedAccountId],
|
||||
);
|
||||
const basePath = useAccountPath
|
||||
? `channels.zalouser.accounts.${resolvedAccountId}.`
|
||||
: "channels.zalouser.";
|
||||
return {
|
||||
policy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPath: `${basePath}dmPolicy`,
|
||||
allowFromPath: basePath,
|
||||
approveHint: formatPairingApproveHint("zalouser"),
|
||||
normalizeEntry: (raw) => raw.replace(/^(zalouser|zlu):/i, ""),
|
||||
};
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: (raw) => {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.replace(/^(zalouser|zlu):/i, "");
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async ({ cfg, accountId, runtime }) => {
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const result = await runZca(["me", "info", "-j"], { profile: account.profile, timeout: 10000 });
|
||||
if (!result.ok) {
|
||||
runtime.error(result.stderr || "Failed to fetch profile");
|
||||
return null;
|
||||
}
|
||||
const parsed = parseJsonOutput<ZcaUserInfo>(result.stdout);
|
||||
if (!parsed?.userId) return null;
|
||||
return mapUser({
|
||||
id: String(parsed.userId),
|
||||
name: parsed.displayName ?? null,
|
||||
avatarUrl: parsed.avatar ?? null,
|
||||
raw: parsed,
|
||||
});
|
||||
},
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const args = query?.trim()
|
||||
? ["friend", "find", query.trim()]
|
||||
: ["friend", "list", "-j"];
|
||||
const result = await runZca(args, { profile: account.profile, timeout: 15000 });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to list peers");
|
||||
}
|
||||
const parsed = parseJsonOutput<ZcaFriend[]>(result.stdout);
|
||||
const rows = Array.isArray(parsed)
|
||||
? parsed.map((f) =>
|
||||
mapUser({
|
||||
id: String(f.userId),
|
||||
name: f.displayName ?? null,
|
||||
avatarUrl: f.avatar ?? null,
|
||||
raw: f,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to list groups");
|
||||
}
|
||||
const parsed = parseJsonOutput<ZcaGroup[]>(result.stdout);
|
||||
let rows = Array.isArray(parsed)
|
||||
? parsed.map((g) =>
|
||||
mapGroup({
|
||||
id: String(g.groupId),
|
||||
name: g.name ?? null,
|
||||
raw: g,
|
||||
}),
|
||||
)
|
||||
: [];
|
||||
const q = query?.trim().toLowerCase();
|
||||
if (q) {
|
||||
rows = rows.filter((g) => (g.name ?? "").toLowerCase().includes(q) || g.id.includes(q));
|
||||
}
|
||||
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
||||
},
|
||||
listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const result = await runZca(["group", "members", groupId, "-j"], {
|
||||
profile: account.profile,
|
||||
timeout: 20000,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to list group members");
|
||||
}
|
||||
const parsed = parseJsonOutput<Array<Partial<ZcaFriend> & { userId?: string | number }>>(result.stdout);
|
||||
const rows = Array.isArray(parsed)
|
||||
? parsed
|
||||
.map((m) => {
|
||||
const id = m.userId ?? (m as { id?: string | number }).id;
|
||||
if (!id) return null;
|
||||
return mapUser({
|
||||
id: String(id),
|
||||
name: (m as { displayName?: string }).displayName ?? null,
|
||||
avatarUrl: (m as { avatar?: string }).avatar ?? null,
|
||||
raw: m,
|
||||
});
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const sliced = typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
||||
return sliced as ChannelDirectoryEntry[];
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
idLabel: "zalouserUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig });
|
||||
const authenticated = await checkZcaAuthenticated(account.profile);
|
||||
if (!authenticated) throw new Error("Zalouser not authenticated");
|
||||
await sendMessageZalouser(id, "Your pairing request has been approved.", {
|
||||
profile: account.profile,
|
||||
});
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
login: async ({ cfg, accountId, runtime }) => {
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
const ok = await checkZcaInstalled();
|
||||
if (!ok) {
|
||||
throw new Error(
|
||||
"Missing dependency: `zca` not found in PATH. See docs.clawd.bot/channels/zalouser",
|
||||
);
|
||||
}
|
||||
runtime.log(
|
||||
`Scan the QR code in this terminal to link Zalo Personal (account: ${account.accountId}, profile: ${account.profile}).`,
|
||||
);
|
||||
const result = await runZcaInteractive(["auth", "login"], { profile: account.profile });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Zalouser login failed");
|
||||
}
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => {
|
||||
if (!text) return [];
|
||||
if (limit <= 0 || text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > limit) {
|
||||
const window = remaining.slice(0, limit);
|
||||
const lastNewline = window.lastIndexOf("\n");
|
||||
const lastSpace = window.lastIndexOf(" ");
|
||||
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
|
||||
if (breakIdx <= 0) breakIdx = limit;
|
||||
const rawChunk = remaining.slice(0, breakIdx);
|
||||
const chunk = rawChunk.trimEnd();
|
||||
if (chunk.length > 0) chunks.push(chunk);
|
||||
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
|
||||
remaining = remaining.slice(nextStart).trimStart();
|
||||
}
|
||||
if (remaining.length) chunks.push(remaining);
|
||||
return chunks;
|
||||
},
|
||||
textChunkLimit: 2000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error("Delivering to Zalouser requires --to <threadId>"),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ to, text, accountId, cfg }) => {
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const result = await sendMessageZalouser(to, text, { profile: account.profile });
|
||||
return {
|
||||
channel: "zalouser",
|
||||
ok: result.ok,
|
||||
messageId: result.messageId ?? "",
|
||||
error: result.error ? new Error(result.error) : undefined,
|
||||
};
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const result = await sendMessageZalouser(to, text, {
|
||||
profile: account.profile,
|
||||
mediaUrl,
|
||||
});
|
||||
return {
|
||||
channel: "zalouser",
|
||||
ok: result.ok,
|
||||
messageId: result.messageId ?? "",
|
||||
error: result.error ? new Error(result.error) : undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) => {
|
||||
const result = await runZca(["me", "info", "-j"], {
|
||||
profile: account.profile,
|
||||
timeout: timeoutMs,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: result.stderr };
|
||||
}
|
||||
try {
|
||||
return { ok: true, user: JSON.parse(result.stdout) };
|
||||
} catch {
|
||||
return { ok: false, error: "Failed to parse user info" };
|
||||
}
|
||||
},
|
||||
buildAccountSnapshot: async ({ account, runtime }) => {
|
||||
const configured = await checkZcaAuthenticated(account.profile);
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: configured ? (runtime?.lastError ?? null) : "not configured",
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
let userLabel = "";
|
||||
try {
|
||||
const userInfo = await getZcaUserInfo(account.profile);
|
||||
if (userInfo?.displayName) userLabel = ` (${userInfo.displayName})`;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
user: userInfo,
|
||||
});
|
||||
} catch {
|
||||
// ignore probe errors
|
||||
}
|
||||
ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
|
||||
const { monitorZalouserProvider } = await import("./monitor.js");
|
||||
return monitorZalouserProvider({
|
||||
account,
|
||||
config: ctx.cfg as CoreConfig,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
});
|
||||
},
|
||||
loginWithQrStart: async (params) => {
|
||||
const profile = resolveZalouserQrProfile(params.accountId);
|
||||
// Start login and get QR code
|
||||
const result = await runZca(["auth", "login", "--qr-base64"], {
|
||||
profile,
|
||||
timeout: params.timeoutMs ?? 30000,
|
||||
});
|
||||
if (!result.ok) {
|
||||
return { message: result.stderr || "Failed to start QR login" };
|
||||
}
|
||||
// The stdout should contain the base64 QR data URL
|
||||
const qrMatch = result.stdout.match(/data:image\/png;base64,[A-Za-z0-9+/=]+/);
|
||||
if (qrMatch) {
|
||||
return { qrDataUrl: qrMatch[0], message: "Scan QR code with Zalo app" };
|
||||
}
|
||||
return { message: result.stdout || "QR login started" };
|
||||
},
|
||||
loginWithQrWait: async (params) => {
|
||||
const profile = resolveZalouserQrProfile(params.accountId);
|
||||
// Check if already authenticated
|
||||
const statusResult = await runZca(["auth", "status"], {
|
||||
profile,
|
||||
timeout: params.timeoutMs ?? 60000,
|
||||
});
|
||||
return {
|
||||
connected: statusResult.ok,
|
||||
message: statusResult.ok ? "Login successful" : statusResult.stderr || "Login pending",
|
||||
};
|
||||
},
|
||||
logoutAccount: async (ctx) => {
|
||||
const result = await runZca(["auth", "logout"], {
|
||||
profile: ctx.account.profile,
|
||||
timeout: 10000,
|
||||
});
|
||||
return {
|
||||
cleared: result.ok,
|
||||
loggedOut: result.ok,
|
||||
message: result.ok ? "Logged out" : result.stderr,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type { ResolvedZalouserAccount };
|
||||
171
extensions/zalouser/src/core-bridge.ts
Normal file
171
extensions/zalouser/src/core-bridge.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
export type CoreChannelDeps = {
|
||||
chunkMarkdownText: (text: string, limit: number) => string[];
|
||||
formatAgentEnvelope: (params: {
|
||||
channel: string;
|
||||
from: string;
|
||||
timestamp?: number;
|
||||
body: string;
|
||||
}) => string;
|
||||
dispatchReplyWithBufferedBlockDispatcher: (params: {
|
||||
ctx: unknown;
|
||||
cfg: unknown;
|
||||
dispatcherOptions: {
|
||||
deliver: (payload: unknown) => Promise<void>;
|
||||
onError?: (err: unknown, info: { kind: string }) => void;
|
||||
};
|
||||
}) => Promise<void>;
|
||||
resolveAgentRoute: (params: {
|
||||
cfg: unknown;
|
||||
channel: string;
|
||||
accountId: string;
|
||||
peer: { kind: "dm" | "group" | "channel"; id: string };
|
||||
}) => { sessionKey: string; accountId: string };
|
||||
buildPairingReply: (params: { channel: string; idLine: string; code: string }) => string;
|
||||
readChannelAllowFromStore: (channel: string) => Promise<string[]>;
|
||||
upsertChannelPairingRequest: (params: {
|
||||
channel: string;
|
||||
id: string;
|
||||
meta?: { name?: string };
|
||||
}) => Promise<{ code: string; created: boolean }>;
|
||||
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
|
||||
saveMediaBuffer: (
|
||||
buffer: Buffer,
|
||||
contentType: string | undefined,
|
||||
type: "inbound" | "outbound",
|
||||
maxBytes: number,
|
||||
) => Promise<{ path: string; contentType: string }>;
|
||||
shouldLogVerbose: () => boolean;
|
||||
};
|
||||
|
||||
let coreRootCache: string | null = null;
|
||||
let coreDepsPromise: Promise<CoreChannelDeps> | null = null;
|
||||
|
||||
function findPackageRoot(startDir: string, name: string): string | null {
|
||||
let dir = startDir;
|
||||
for (;;) {
|
||||
const pkgPath = path.join(dir, "package.json");
|
||||
try {
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const raw = fs.readFileSync(pkgPath, "utf8");
|
||||
const pkg = JSON.parse(raw) as { name?: string };
|
||||
if (pkg.name === name) return dir;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClawdbotRoot(): string {
|
||||
if (coreRootCache) return coreRootCache;
|
||||
const override = process.env.CLAWDBOT_ROOT?.trim();
|
||||
if (override) {
|
||||
coreRootCache = override;
|
||||
return override;
|
||||
}
|
||||
|
||||
const candidates = new Set<string>();
|
||||
if (process.argv[1]) {
|
||||
candidates.add(path.dirname(process.argv[1]));
|
||||
}
|
||||
candidates.add(process.cwd());
|
||||
try {
|
||||
const urlPath = fileURLToPath(import.meta.url);
|
||||
candidates.add(path.dirname(urlPath));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
for (const start of candidates) {
|
||||
const found = findPackageRoot(start, "clawdbot");
|
||||
if (found) {
|
||||
coreRootCache = found;
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.",
|
||||
);
|
||||
}
|
||||
|
||||
async function importCoreModule<T>(relativePath: string): Promise<T> {
|
||||
const root = resolveClawdbotRoot();
|
||||
const distPath = path.join(root, "dist", relativePath);
|
||||
if (!fs.existsSync(distPath)) {
|
||||
throw new Error(
|
||||
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
|
||||
);
|
||||
}
|
||||
return (await import(pathToFileURL(distPath).href)) as T;
|
||||
}
|
||||
|
||||
export async function loadCoreChannelDeps(): Promise<CoreChannelDeps> {
|
||||
if (coreDepsPromise) return coreDepsPromise;
|
||||
|
||||
coreDepsPromise = (async () => {
|
||||
const [
|
||||
chunk,
|
||||
envelope,
|
||||
dispatcher,
|
||||
routing,
|
||||
pairingMessages,
|
||||
pairingStore,
|
||||
mediaFetch,
|
||||
mediaStore,
|
||||
globals,
|
||||
] = await Promise.all([
|
||||
importCoreModule<{ chunkMarkdownText: CoreChannelDeps["chunkMarkdownText"] }>(
|
||||
"auto-reply/chunk.js",
|
||||
),
|
||||
importCoreModule<{ formatAgentEnvelope: CoreChannelDeps["formatAgentEnvelope"] }>(
|
||||
"auto-reply/envelope.js",
|
||||
),
|
||||
importCoreModule<{
|
||||
dispatchReplyWithBufferedBlockDispatcher: CoreChannelDeps["dispatchReplyWithBufferedBlockDispatcher"];
|
||||
}>("auto-reply/reply/provider-dispatcher.js"),
|
||||
importCoreModule<{ resolveAgentRoute: CoreChannelDeps["resolveAgentRoute"] }>(
|
||||
"routing/resolve-route.js",
|
||||
),
|
||||
importCoreModule<{ buildPairingReply: CoreChannelDeps["buildPairingReply"] }>(
|
||||
"pairing/pairing-messages.js",
|
||||
),
|
||||
importCoreModule<{
|
||||
readChannelAllowFromStore: CoreChannelDeps["readChannelAllowFromStore"];
|
||||
upsertChannelPairingRequest: CoreChannelDeps["upsertChannelPairingRequest"];
|
||||
}>("pairing/pairing-store.js"),
|
||||
importCoreModule<{ fetchRemoteMedia: CoreChannelDeps["fetchRemoteMedia"] }>(
|
||||
"media/fetch.js",
|
||||
),
|
||||
importCoreModule<{ saveMediaBuffer: CoreChannelDeps["saveMediaBuffer"] }>(
|
||||
"media/store.js",
|
||||
),
|
||||
importCoreModule<{ shouldLogVerbose: CoreChannelDeps["shouldLogVerbose"] }>(
|
||||
"globals.js",
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
chunkMarkdownText: chunk.chunkMarkdownText,
|
||||
formatAgentEnvelope: envelope.formatAgentEnvelope,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
dispatcher.dispatchReplyWithBufferedBlockDispatcher,
|
||||
resolveAgentRoute: routing.resolveAgentRoute,
|
||||
buildPairingReply: pairingMessages.buildPairingReply,
|
||||
readChannelAllowFromStore: pairingStore.readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest: pairingStore.upsertChannelPairingRequest,
|
||||
fetchRemoteMedia: mediaFetch.fetchRemoteMedia,
|
||||
saveMediaBuffer: mediaStore.saveMediaBuffer,
|
||||
shouldLogVerbose: globals.shouldLogVerbose,
|
||||
};
|
||||
})();
|
||||
|
||||
return coreDepsPromise;
|
||||
}
|
||||
348
extensions/zalouser/src/monitor.ts
Normal file
348
extensions/zalouser/src/monitor.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
import type { RuntimeEnv } from "../../../src/runtime.js";
|
||||
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
|
||||
import { sendMessageZalouser } from "./send.js";
|
||||
import type { CoreConfig, ResolvedZalouserAccount, ZcaMessage } from "./types.js";
|
||||
import { runZcaStreaming } from "./zca.js";
|
||||
|
||||
export type ZalouserMonitorOptions = {
|
||||
account: ResolvedZalouserAccount;
|
||||
config: CoreConfig;
|
||||
runtime: RuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
export type ZalouserMonitorResult = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
const ZALOUSER_TEXT_LIMIT = 2000;
|
||||
|
||||
function logVerbose(deps: CoreChannelDeps, runtime: RuntimeEnv, message: string): void {
|
||||
if (deps.shouldLogVerbose()) {
|
||||
runtime.log(`[zalouser] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const normalizedSenderId = senderId.toLowerCase();
|
||||
return allowFrom.some((entry) => {
|
||||
const normalized = entry.toLowerCase().replace(/^(zalouser|zlu):/i, "");
|
||||
return normalized === normalizedSenderId;
|
||||
});
|
||||
}
|
||||
|
||||
function startZcaListener(
|
||||
runtime: RuntimeEnv,
|
||||
profile: string,
|
||||
onMessage: (msg: ZcaMessage) => void,
|
||||
onError: (err: Error) => void,
|
||||
abortSignal: AbortSignal,
|
||||
): ChildProcess {
|
||||
let buffer = "";
|
||||
|
||||
const { proc, promise } = runZcaStreaming(["listen", "-r", "-k"], {
|
||||
profile,
|
||||
onData: (chunk) => {
|
||||
buffer += chunk;
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as ZcaMessage;
|
||||
onMessage(parsed);
|
||||
} catch {
|
||||
// ignore non-JSON lines
|
||||
}
|
||||
}
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
const text = data.toString().trim();
|
||||
if (text) runtime.error(`[zalouser] zca stderr: ${text}`);
|
||||
});
|
||||
|
||||
void promise.then((result) => {
|
||||
if (!result.ok && !abortSignal.aborted) {
|
||||
onError(new Error(result.stderr || `zca listen exited with code ${result.exitCode}`));
|
||||
}
|
||||
});
|
||||
|
||||
abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
proc.kill("SIGTERM");
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
|
||||
return proc;
|
||||
}
|
||||
|
||||
async function processMessage(
|
||||
message: ZcaMessage,
|
||||
account: ResolvedZalouserAccount,
|
||||
config: CoreConfig,
|
||||
deps: CoreChannelDeps,
|
||||
runtime: RuntimeEnv,
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||
): Promise<void> {
|
||||
const { threadId, content, timestamp, metadata } = message;
|
||||
if (!content?.trim()) return;
|
||||
|
||||
const isGroup = metadata?.isGroup ?? false;
|
||||
const senderId = metadata?.fromId ?? threadId;
|
||||
const senderName = metadata?.senderName ?? "";
|
||||
const chatId = threadId;
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose(deps, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const storeAllowFrom = await deps.readChannelAllowFromStore("zalouser").catch(() => []);
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await deps.upsertChannelPairingRequest({
|
||||
channel: "zalouser",
|
||||
id: senderId,
|
||||
meta: { name: senderName || undefined },
|
||||
});
|
||||
|
||||
if (created) {
|
||||
logVerbose(deps, runtime, `zalouser pairing request sender=${senderId}`);
|
||||
try {
|
||||
await sendMessageZalouser(
|
||||
chatId,
|
||||
deps.buildPairingReply({
|
||||
channel: "zalouser",
|
||||
idLine: `Your Zalo user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
{ profile: account.profile },
|
||||
);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
deps,
|
||||
runtime,
|
||||
`zalouser pairing reply failed for ${senderId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
deps,
|
||||
runtime,
|
||||
`Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
|
||||
|
||||
const route = deps.resolveAgentRoute({
|
||||
cfg: config,
|
||||
channel: "zalouser",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
// Use "group" kind to avoid dmScope=main collapsing all DMs into the main session.
|
||||
kind: peer.kind,
|
||||
id: peer.id,
|
||||
},
|
||||
});
|
||||
|
||||
const rawBody = content.trim();
|
||||
const fromLabel = isGroup
|
||||
? `group:${chatId} from ${senderName || senderId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const body = deps.formatAgentEnvelope({
|
||||
channel: "Zalo Personal",
|
||||
from: fromLabel,
|
||||
timestamp: timestamp ? timestamp * 1000 : undefined,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const ctxPayload = {
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: isGroup ? `group:${chatId}` : `zalouser:${senderId}`,
|
||||
To: `zalouser:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
Provider: "zalouser",
|
||||
Surface: "zalouser",
|
||||
MessageSid: message.msgId ?? `${timestamp}`,
|
||||
OriginatingChannel: "zalouser",
|
||||
OriginatingTo: `zalouser:${chatId}`,
|
||||
};
|
||||
|
||||
await deps.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
await deliverZalouserReply({
|
||||
payload: payload as { text?: string; mediaUrls?: string[]; mediaUrl?: string },
|
||||
profile: account.profile,
|
||||
chatId,
|
||||
isGroup,
|
||||
runtime,
|
||||
deps,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error(
|
||||
`[${account.accountId}] Zalouser ${info.kind} reply failed: ${String(err)}`,
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverZalouserReply(params: {
|
||||
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
|
||||
profile: string;
|
||||
chatId: string;
|
||||
isGroup: boolean;
|
||||
runtime: RuntimeEnv;
|
||||
deps: CoreChannelDeps;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
}): Promise<void> {
|
||||
const { payload, profile, chatId, isGroup, runtime, deps, statusSink } = params;
|
||||
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
|
||||
if (mediaList.length > 0) {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? payload.text : undefined;
|
||||
first = false;
|
||||
try {
|
||||
logVerbose(deps, runtime, `Sending media to ${chatId}`);
|
||||
await sendMessageZalouser(chatId, caption ?? "", {
|
||||
profile,
|
||||
mediaUrl,
|
||||
isGroup,
|
||||
});
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error(`Zalouser media send failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.text) {
|
||||
const chunks = deps.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
|
||||
logVerbose(deps, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendMessageZalouser(chatId, chunk, { profile, isGroup });
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error(`Zalouser message send failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorZalouserProvider(
|
||||
options: ZalouserMonitorOptions,
|
||||
): Promise<ZalouserMonitorResult> {
|
||||
const { account, config, abortSignal, statusSink, runtime } = options;
|
||||
|
||||
const deps = await loadCoreChannelDeps();
|
||||
let stopped = false;
|
||||
let proc: ChildProcess | null = null;
|
||||
let restartTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let resolveRunning: (() => void) | null = null;
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
if (restartTimer) {
|
||||
clearTimeout(restartTimer);
|
||||
restartTimer = null;
|
||||
}
|
||||
if (proc) {
|
||||
proc.kill("SIGTERM");
|
||||
proc = null;
|
||||
}
|
||||
resolveRunning?.();
|
||||
};
|
||||
|
||||
const startListener = () => {
|
||||
if (stopped || abortSignal.aborted) {
|
||||
resolveRunning?.();
|
||||
return;
|
||||
}
|
||||
|
||||
logVerbose(
|
||||
deps,
|
||||
runtime,
|
||||
`[${account.accountId}] starting zca listener (profile=${account.profile})`,
|
||||
);
|
||||
|
||||
proc = startZcaListener(
|
||||
runtime,
|
||||
account.profile,
|
||||
(msg) => {
|
||||
logVerbose(deps, runtime, `[${account.accountId}] inbound message`);
|
||||
statusSink?.({ lastInboundAt: Date.now() });
|
||||
processMessage(msg, account, config, deps, runtime, statusSink).catch((err) => {
|
||||
runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
|
||||
});
|
||||
},
|
||||
(err) => {
|
||||
runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
|
||||
if (!stopped && !abortSignal.aborted) {
|
||||
logVerbose(deps, runtime, `[${account.accountId}] restarting listener in 5s...`);
|
||||
restartTimer = setTimeout(startListener, 5000);
|
||||
} else {
|
||||
resolveRunning?.();
|
||||
}
|
||||
},
|
||||
abortSignal,
|
||||
);
|
||||
};
|
||||
|
||||
// Create a promise that stays pending until abort or stop
|
||||
const runningPromise = new Promise<void>((resolve) => {
|
||||
resolveRunning = resolve;
|
||||
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
|
||||
startListener();
|
||||
|
||||
// Wait for the running promise to resolve (on abort/stop)
|
||||
await runningPromise;
|
||||
|
||||
return { stop };
|
||||
}
|
||||
312
extensions/zalouser/src/onboarding.ts
Normal file
312
extensions/zalouser/src/onboarding.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
|
||||
import {
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
normalizeAccountId,
|
||||
checkZcaAuthenticated,
|
||||
} from "./accounts.js";
|
||||
import { runZcaInteractive, checkZcaInstalled } from "./zca.js";
|
||||
import { DEFAULT_ACCOUNT_ID, type CoreConfig } from "./types.js";
|
||||
|
||||
const channel = "zalouser" as const;
|
||||
|
||||
function setZalouserDmPolicy(
|
||||
cfg: CoreConfig,
|
||||
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
|
||||
): CoreConfig {
|
||||
const allowFrom =
|
||||
dmPolicy === "open"
|
||||
? [...(cfg.channels?.zalouser?.allowFrom ?? []), "*"].filter(
|
||||
(v, i, a) => a.indexOf(v) === i,
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"Zalo Personal Account login via QR code.",
|
||||
"",
|
||||
"Prerequisites:",
|
||||
"1) Install zca-cli",
|
||||
"2) You'll scan a QR code with your Zalo app",
|
||||
"",
|
||||
"Docs: https://docs.clawd.bot/channels/zalouser",
|
||||
].join("\n"),
|
||||
"Zalo Personal Setup",
|
||||
);
|
||||
}
|
||||
|
||||
async function promptZalouserAllowFrom(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<CoreConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
const entry = await prompter.text({
|
||||
message: "Zalouser allowFrom (user id)",
|
||||
placeholder: "123456789",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = String(entry).trim();
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: {
|
||||
...cfg.channels?.zalouser,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(cfg.channels?.zalouser?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
||||
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
async function promptAccountId(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
label: string;
|
||||
currentId: string;
|
||||
listAccountIds: (cfg: CoreConfig) => string[];
|
||||
defaultAccountId: string;
|
||||
}): Promise<string> {
|
||||
const { cfg, prompter, label, currentId, listAccountIds, defaultAccountId } = params;
|
||||
const existingIds = listAccountIds(cfg);
|
||||
const options = [
|
||||
...existingIds.map((id) => ({
|
||||
value: id,
|
||||
label: id === defaultAccountId ? `${id} (default)` : id,
|
||||
})),
|
||||
{ value: "__new__", label: "Create new account" },
|
||||
];
|
||||
|
||||
const selected = await prompter.select({
|
||||
message: `${label} account`,
|
||||
options,
|
||||
initialValue: currentId,
|
||||
});
|
||||
|
||||
if (selected === "__new__") {
|
||||
const newId = await prompter.text({
|
||||
message: "New account ID",
|
||||
placeholder: "work",
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim().toLowerCase();
|
||||
if (!raw) return "Required";
|
||||
if (!/^[a-z0-9_-]+$/.test(raw)) return "Use lowercase alphanumeric, dash, or underscore";
|
||||
if (existingIds.includes(raw)) return "Account already exists";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
return String(newId).trim().toLowerCase();
|
||||
}
|
||||
|
||||
return selected as string;
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Zalo Personal",
|
||||
channel,
|
||||
policyKey: "channels.zalouser.dmPolicy",
|
||||
allowFromKey: "channels.zalouser.allowFrom",
|
||||
getCurrent: (cfg) => ((cfg as CoreConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
|
||||
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as CoreConfig, policy),
|
||||
};
|
||||
|
||||
export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
dmPolicy,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const ids = listZalouserAccountIds(cfg as CoreConfig);
|
||||
let configured = false;
|
||||
for (const accountId of ids) {
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
|
||||
const isAuth = await checkZcaAuthenticated(account.profile);
|
||||
if (isAuth) {
|
||||
configured = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`],
|
||||
selectionHint: configured ? "recommended · logged in" : "recommended · QR login",
|
||||
quickstartScore: configured ? 1 : 15,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds, forceAllowFrom }) => {
|
||||
// Check zca is installed
|
||||
const zcaInstalled = await checkZcaInstalled();
|
||||
if (!zcaInstalled) {
|
||||
await prompter.note(
|
||||
[
|
||||
"The `zca` binary was not found in PATH.",
|
||||
"",
|
||||
"Install zca-cli, then re-run onboarding:",
|
||||
"Docs: https://docs.clawd.bot/channels/zalouser",
|
||||
].join("\n"),
|
||||
"Missing Dependency",
|
||||
);
|
||||
return { cfg, accountId: DEFAULT_ACCOUNT_ID };
|
||||
}
|
||||
|
||||
const zalouserOverride = accountOverrides.zalouser?.trim();
|
||||
const defaultAccountId = resolveDefaultZalouserAccountId(cfg as CoreConfig);
|
||||
let accountId = zalouserOverride
|
||||
? normalizeAccountId(zalouserOverride)
|
||||
: defaultAccountId;
|
||||
|
||||
if (shouldPromptAccountIds && !zalouserOverride) {
|
||||
accountId = await promptAccountId({
|
||||
cfg: cfg as CoreConfig,
|
||||
prompter,
|
||||
label: "Zalo Personal",
|
||||
currentId: accountId,
|
||||
listAccountIds: listZalouserAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg as CoreConfig;
|
||||
const account = resolveZalouserAccountSync({ cfg: next, accountId });
|
||||
const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
|
||||
|
||||
if (!alreadyAuthenticated) {
|
||||
await noteZalouserHelp(prompter);
|
||||
|
||||
const wantsLogin = await prompter.confirm({
|
||||
message: "Login via QR code now?",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (wantsLogin) {
|
||||
await prompter.note(
|
||||
"A QR code will appear in your terminal.\nScan it with your Zalo app to login.",
|
||||
"QR Login",
|
||||
);
|
||||
|
||||
// Run interactive login
|
||||
const result = await runZcaInteractive(["auth", "login"], {
|
||||
profile: account.profile,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
await prompter.note(
|
||||
`Login failed: ${result.stderr || "Unknown error"}`,
|
||||
"Error",
|
||||
);
|
||||
} else {
|
||||
const isNowAuth = await checkZcaAuthenticated(account.profile);
|
||||
if (isNowAuth) {
|
||||
await prompter.note("Login successful!", "Success");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const keepSession = await prompter.confirm({
|
||||
message: "Zalo Personal already logged in. Keep session?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keepSession) {
|
||||
await runZcaInteractive(["auth", "logout"], { profile: account.profile });
|
||||
await runZcaInteractive(["auth", "login"], { profile: account.profile });
|
||||
}
|
||||
}
|
||||
|
||||
// Enable the channel
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalouser: {
|
||||
...next.channels?.zalouser,
|
||||
enabled: true,
|
||||
profile: account.profile !== "default" ? account.profile : undefined,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalouser: {
|
||||
...next.channels?.zalouser,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(next.channels?.zalouser?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(next.channels?.zalouser?.accounts?.[accountId] ?? {}),
|
||||
enabled: true,
|
||||
profile: account.profile,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
if (forceAllowFrom) {
|
||||
next = await promptZalouserAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
};
|
||||
150
extensions/zalouser/src/send.ts
Normal file
150
extensions/zalouser/src/send.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { runZca } from "./zca.js";
|
||||
|
||||
export type ZalouserSendOptions = {
|
||||
profile?: string;
|
||||
mediaUrl?: string;
|
||||
caption?: string;
|
||||
isGroup?: boolean;
|
||||
};
|
||||
|
||||
export type ZalouserSendResult = {
|
||||
ok: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function sendMessageZalouser(
|
||||
threadId: string,
|
||||
text: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
const profile = options.profile || process.env.ZCA_PROFILE || "default";
|
||||
|
||||
if (!threadId?.trim()) {
|
||||
return { ok: false, error: "No threadId provided" };
|
||||
}
|
||||
|
||||
// Handle media sending
|
||||
if (options.mediaUrl) {
|
||||
return sendMediaZalouser(threadId, options.mediaUrl, {
|
||||
...options,
|
||||
caption: text || options.caption,
|
||||
});
|
||||
}
|
||||
|
||||
// Send text message
|
||||
const args = ["msg", "send", threadId.trim(), text.slice(0, 2000)];
|
||||
if (options.isGroup) args.push("-g");
|
||||
|
||||
try {
|
||||
const result = await runZca(args, { profile });
|
||||
|
||||
if (result.ok) {
|
||||
return { ok: true, messageId: extractMessageId(result.stdout) };
|
||||
}
|
||||
|
||||
return { ok: false, error: result.stderr || "Failed to send message" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMediaZalouser(
|
||||
threadId: string,
|
||||
mediaUrl: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
const profile = options.profile || process.env.ZCA_PROFILE || "default";
|
||||
|
||||
if (!threadId?.trim()) {
|
||||
return { ok: false, error: "No threadId provided" };
|
||||
}
|
||||
|
||||
if (!mediaUrl?.trim()) {
|
||||
return { ok: false, error: "No media URL provided" };
|
||||
}
|
||||
|
||||
// Determine media type from URL
|
||||
const lowerUrl = mediaUrl.toLowerCase();
|
||||
let command: string;
|
||||
if (lowerUrl.match(/\.(mp4|mov|avi|webm)$/)) {
|
||||
command = "video";
|
||||
} else if (lowerUrl.match(/\.(mp3|wav|ogg|m4a)$/)) {
|
||||
command = "voice";
|
||||
} else {
|
||||
command = "image";
|
||||
}
|
||||
|
||||
const args = ["msg", command, threadId.trim(), "-u", mediaUrl.trim()];
|
||||
if (options.caption) {
|
||||
args.push("-m", options.caption.slice(0, 2000));
|
||||
}
|
||||
if (options.isGroup) args.push("-g");
|
||||
|
||||
try {
|
||||
const result = await runZca(args, { profile });
|
||||
|
||||
if (result.ok) {
|
||||
return { ok: true, messageId: extractMessageId(result.stdout) };
|
||||
}
|
||||
|
||||
return { ok: false, error: result.stderr || `Failed to send ${command}` };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendImageZalouser(
|
||||
threadId: string,
|
||||
imageUrl: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
const profile = options.profile || process.env.ZCA_PROFILE || "default";
|
||||
const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()];
|
||||
if (options.caption) {
|
||||
args.push("-m", options.caption.slice(0, 2000));
|
||||
}
|
||||
if (options.isGroup) args.push("-g");
|
||||
|
||||
try {
|
||||
const result = await runZca(args, { profile });
|
||||
if (result.ok) {
|
||||
return { ok: true, messageId: extractMessageId(result.stdout) };
|
||||
}
|
||||
return { ok: false, error: result.stderr || "Failed to send image" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendLinkZalouser(
|
||||
threadId: string,
|
||||
url: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
const profile = options.profile || process.env.ZCA_PROFILE || "default";
|
||||
const args = ["msg", "link", threadId.trim(), url.trim()];
|
||||
if (options.isGroup) args.push("-g");
|
||||
|
||||
try {
|
||||
const result = await runZca(args, { profile });
|
||||
if (result.ok) {
|
||||
return { ok: true, messageId: extractMessageId(result.stdout) };
|
||||
}
|
||||
return { ok: false, error: result.stderr || "Failed to send link" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
function extractMessageId(stdout: string): string | undefined {
|
||||
// Try to extract message ID from output
|
||||
const match = stdout.match(/message[_\s]?id[:\s]+(\S+)/i);
|
||||
if (match) return match[1];
|
||||
// Return first word if it looks like an ID
|
||||
const firstWord = stdout.trim().split(/\s+/)[0];
|
||||
if (firstWord && /^[a-zA-Z0-9_-]+$/.test(firstWord)) {
|
||||
return firstWord;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
156
extensions/zalouser/src/tool.ts
Normal file
156
extensions/zalouser/src/tool.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
import { runZca, parseJsonOutput } from "./zca.js";
|
||||
|
||||
const ACTIONS = ["send", "image", "link", "friends", "groups", "me", "status"] as const;
|
||||
|
||||
function stringEnum<T extends readonly string[]>(
|
||||
values: T,
|
||||
options: { description?: string } = {},
|
||||
) {
|
||||
return Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
// Tool schema - avoiding Type.Union per tool schema guardrails
|
||||
export const ZalouserToolSchema = Type.Object({
|
||||
action: stringEnum(ACTIONS, { description: `Action to perform: ${ACTIONS.join(", ")}` }),
|
||||
threadId: Type.Optional(
|
||||
Type.String({ description: "Thread ID for messaging" }),
|
||||
),
|
||||
message: Type.Optional(Type.String({ description: "Message text" })),
|
||||
isGroup: Type.Optional(Type.Boolean({ description: "Is group chat" })),
|
||||
profile: Type.Optional(Type.String({ description: "Profile name" })),
|
||||
query: Type.Optional(Type.String({ description: "Search query" })),
|
||||
url: Type.Optional(Type.String({ description: "URL for media/link" })),
|
||||
}, { additionalProperties: false });
|
||||
|
||||
type ToolParams = {
|
||||
action: (typeof ACTIONS)[number];
|
||||
threadId?: string;
|
||||
message?: string;
|
||||
isGroup?: boolean;
|
||||
profile?: string;
|
||||
query?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type ToolResult = {
|
||||
content: Array<{ type: string; text: string }>;
|
||||
details: unknown;
|
||||
};
|
||||
|
||||
function json(payload: unknown): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
||||
details: payload,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeZalouserTool(
|
||||
_toolCallId: string,
|
||||
params: ToolParams,
|
||||
): Promise<ToolResult> {
|
||||
try {
|
||||
switch (params.action) {
|
||||
case "send": {
|
||||
if (!params.threadId || !params.message) {
|
||||
throw new Error("threadId and message required for send action");
|
||||
}
|
||||
const args = ["msg", "send", params.threadId, params.message];
|
||||
if (params.isGroup) args.push("-g");
|
||||
const result = await runZca(args, { profile: params.profile });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to send message");
|
||||
}
|
||||
return json({ success: true, output: result.stdout });
|
||||
}
|
||||
|
||||
case "image": {
|
||||
if (!params.threadId) {
|
||||
throw new Error("threadId required for image action");
|
||||
}
|
||||
if (!params.url) {
|
||||
throw new Error("url required for image action");
|
||||
}
|
||||
const args = ["msg", "image", params.threadId, "-u", params.url];
|
||||
if (params.message) args.push("-m", params.message);
|
||||
if (params.isGroup) args.push("-g");
|
||||
const result = await runZca(args, { profile: params.profile });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to send image");
|
||||
}
|
||||
return json({ success: true, output: result.stdout });
|
||||
}
|
||||
|
||||
case "link": {
|
||||
if (!params.threadId || !params.url) {
|
||||
throw new Error("threadId and url required for link action");
|
||||
}
|
||||
const args = ["msg", "link", params.threadId, params.url];
|
||||
if (params.isGroup) args.push("-g");
|
||||
const result = await runZca(args, { profile: params.profile });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to send link");
|
||||
}
|
||||
return json({ success: true, output: result.stdout });
|
||||
}
|
||||
|
||||
case "friends": {
|
||||
const args = params.query
|
||||
? ["friend", "find", params.query]
|
||||
: ["friend", "list", "-j"];
|
||||
const result = await runZca(args, { profile: params.profile });
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to get friends");
|
||||
}
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
return json(parsed ?? { raw: result.stdout });
|
||||
}
|
||||
|
||||
case "groups": {
|
||||
const result = await runZca(["group", "list", "-j"], {
|
||||
profile: params.profile,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to get groups");
|
||||
}
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
return json(parsed ?? { raw: result.stdout });
|
||||
}
|
||||
|
||||
case "me": {
|
||||
const result = await runZca(["me", "info", "-j"], {
|
||||
profile: params.profile,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.stderr || "Failed to get profile");
|
||||
}
|
||||
const parsed = parseJsonOutput(result.stdout);
|
||||
return json(parsed ?? { raw: result.stdout });
|
||||
}
|
||||
|
||||
case "status": {
|
||||
const result = await runZca(["auth", "status"], {
|
||||
profile: params.profile,
|
||||
});
|
||||
return json({
|
||||
authenticated: result.ok,
|
||||
output: result.stdout || result.stderr,
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(
|
||||
`Unknown action: ${params.action}. Valid actions: send, image, link, friends, groups, me, status`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
return json({
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
109
extensions/zalouser/src/types.ts
Normal file
109
extensions/zalouser/src/types.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// zca-cli wrapper types
|
||||
export type ZcaRunOptions = {
|
||||
profile?: string;
|
||||
cwd?: string;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export type ZcaResult = {
|
||||
ok: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
};
|
||||
|
||||
export type ZcaProfile = {
|
||||
name: string;
|
||||
label?: string;
|
||||
isDefault?: boolean;
|
||||
};
|
||||
|
||||
export type ZcaFriend = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type ZcaGroup = {
|
||||
groupId: string;
|
||||
name: string;
|
||||
memberCount?: number;
|
||||
};
|
||||
|
||||
export type ZcaMessage = {
|
||||
threadId: string;
|
||||
msgId?: string;
|
||||
cliMsgId?: string;
|
||||
type: number;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
metadata?: {
|
||||
isGroup: boolean;
|
||||
threadName?: string;
|
||||
senderName?: string;
|
||||
fromId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type ZcaUserInfo = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type CommonOptions = {
|
||||
profile?: string;
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type SendOptions = CommonOptions & {
|
||||
group?: boolean;
|
||||
};
|
||||
|
||||
export type ListenOptions = CommonOptions & {
|
||||
raw?: boolean;
|
||||
keepAlive?: boolean;
|
||||
webhook?: string;
|
||||
echo?: boolean;
|
||||
prefix?: string;
|
||||
};
|
||||
|
||||
// Channel plugin config types
|
||||
export const DEFAULT_ACCOUNT_ID = "default";
|
||||
|
||||
export type ZalouserAccountConfig = {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
profile?: string;
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
messagePrefix?: string;
|
||||
};
|
||||
|
||||
export type ZalouserConfig = {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
profile?: string;
|
||||
defaultAccount?: string;
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
messagePrefix?: string;
|
||||
accounts?: Record<string, ZalouserAccountConfig>;
|
||||
};
|
||||
|
||||
export type CoreConfig = {
|
||||
channels?: {
|
||||
zalouser?: ZalouserConfig;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ResolvedZalouserAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
profile: string;
|
||||
authenticated: boolean;
|
||||
config: ZalouserAccountConfig;
|
||||
};
|
||||
183
extensions/zalouser/src/zca.ts
Normal file
183
extensions/zalouser/src/zca.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { spawn, type SpawnOptions } from "node:child_process";
|
||||
|
||||
import type { ZcaResult, ZcaRunOptions } from "./types.js";
|
||||
|
||||
const ZCA_BINARY = "zca";
|
||||
const DEFAULT_TIMEOUT = 30000;
|
||||
|
||||
function buildArgs(args: string[], options?: ZcaRunOptions): string[] {
|
||||
const result: string[] = [];
|
||||
// Profile flag comes first (before subcommand)
|
||||
const profile = options?.profile || process.env.ZCA_PROFILE;
|
||||
if (profile) {
|
||||
result.push("--profile", profile);
|
||||
}
|
||||
result.push(...args);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function runZca(
|
||||
args: string[],
|
||||
options?: ZcaRunOptions,
|
||||
): Promise<ZcaResult> {
|
||||
const fullArgs = buildArgs(args, options);
|
||||
const timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const spawnOpts: SpawnOptions = {
|
||||
cwd: options?.cwd,
|
||||
env: { ...process.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
};
|
||||
|
||||
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proc.kill("SIGTERM");
|
||||
}, timeout);
|
||||
|
||||
proc.stdout?.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (timedOut) {
|
||||
resolve({
|
||||
ok: false,
|
||||
stdout,
|
||||
stderr: stderr || "Command timed out",
|
||||
exitCode: code ?? 124,
|
||||
});
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: code ?? 1,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: err.message,
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function runZcaInteractive(
|
||||
args: string[],
|
||||
options?: ZcaRunOptions,
|
||||
): Promise<ZcaResult> {
|
||||
const fullArgs = buildArgs(args, options);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const spawnOpts: SpawnOptions = {
|
||||
cwd: options?.cwd,
|
||||
env: { ...process.env },
|
||||
stdio: "inherit",
|
||||
};
|
||||
|
||||
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
exitCode: code ?? 1,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
resolve({
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: err.message,
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseJsonOutput<T>(stdout: string): T | null {
|
||||
try {
|
||||
return JSON.parse(stdout) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkZcaInstalled(): Promise<boolean> {
|
||||
const result = await runZca(["--version"], { timeout: 5000 });
|
||||
return result.ok;
|
||||
}
|
||||
|
||||
export type ZcaStreamingOptions = ZcaRunOptions & {
|
||||
onData?: (data: string) => void;
|
||||
onError?: (err: Error) => void;
|
||||
};
|
||||
|
||||
export function runZcaStreaming(
|
||||
args: string[],
|
||||
options?: ZcaStreamingOptions,
|
||||
): { proc: ReturnType<typeof spawn>; promise: Promise<ZcaResult> } {
|
||||
const fullArgs = buildArgs(args, options);
|
||||
|
||||
const spawnOpts: SpawnOptions = {
|
||||
cwd: options?.cwd,
|
||||
env: { ...process.env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
};
|
||||
|
||||
const proc = spawn(ZCA_BINARY, fullArgs, spawnOpts);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
proc.stdout?.on("data", (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
stdout += text;
|
||||
options?.onData?.(text);
|
||||
});
|
||||
|
||||
proc.stderr?.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
const promise = new Promise<ZcaResult>((resolve) => {
|
||||
proc.on("close", (code) => {
|
||||
resolve({
|
||||
ok: code === 0,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
exitCode: code ?? 1,
|
||||
});
|
||||
});
|
||||
|
||||
proc.on("error", (err) => {
|
||||
options?.onError?.(err);
|
||||
resolve({
|
||||
ok: false,
|
||||
stdout: "",
|
||||
stderr: err.message,
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return { proc, promise };
|
||||
}
|
||||
Reference in New Issue
Block a user