Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
120
extensions/zalouser/README.md
Normal file
120
extensions/zalouser/README.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# @openclaw/zalouser
|
||||
|
||||
OpenClaw extension for Zalo Personal Account messaging via native `zca-js` integration.
|
||||
|
||||
> **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 with setup wizard + QR login
|
||||
- In-process listener/sender via `zca-js` (no external CLI)
|
||||
- Multi-account support
|
||||
- Agent tool integration (`zalouser`)
|
||||
- DM/group policy support
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OpenClaw Gateway
|
||||
- Zalo mobile app (for QR login)
|
||||
|
||||
No external `zca`, `openzca`, or `zca-cli` binary is required.
|
||||
|
||||
## Install
|
||||
|
||||
### Option A: npm
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/zalouser
|
||||
```
|
||||
|
||||
### Option B: local source checkout
|
||||
|
||||
```bash
|
||||
PLUGIN_SRC=./path/to/local/zalouser-plugin
|
||||
openclaw plugins install "$PLUGIN_SRC"
|
||||
cd "$PLUGIN_SRC" && pnpm install
|
||||
```
|
||||
|
||||
Restart the Gateway after install.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Login (QR)
|
||||
|
||||
```bash
|
||||
openclaw channels login --channel zalouser
|
||||
```
|
||||
|
||||
Scan the QR code with the Zalo app on your phone.
|
||||
|
||||
### Enable channel
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
zalouser:
|
||||
enabled: true
|
||||
dmPolicy: pairing # pairing | allowlist | open | disabled
|
||||
```
|
||||
|
||||
### Send a message
|
||||
|
||||
```bash
|
||||
openclaw message send --channel zalouser --target <threadId> --message "Hello from OpenClaw"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Basic:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
zalouser:
|
||||
enabled: true
|
||||
dmPolicy: pairing
|
||||
```
|
||||
|
||||
Multi-account:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
zalouser:
|
||||
enabled: true
|
||||
defaultAccount: default
|
||||
accounts:
|
||||
default:
|
||||
enabled: true
|
||||
profile: default
|
||||
work:
|
||||
enabled: true
|
||||
profile: work
|
||||
```
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
openclaw channels login --channel zalouser
|
||||
openclaw channels login --channel zalouser --account work
|
||||
openclaw channels status --probe
|
||||
openclaw channels logout --channel zalouser
|
||||
|
||||
openclaw directory self --channel zalouser
|
||||
openclaw directory peers list --channel zalouser --query "name"
|
||||
openclaw directory groups list --channel zalouser --query "work"
|
||||
openclaw directory groups members --channel zalouser --group-id <id>
|
||||
```
|
||||
|
||||
## Agent tool
|
||||
|
||||
The extension registers a `zalouser` tool for AI agents.
|
||||
|
||||
Available actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Login not persisted: `openclaw channels logout --channel zalouser && openclaw channels login --channel zalouser`
|
||||
- Probe status: `openclaw channels status --probe`
|
||||
- Name resolution issues (allowlist/groups): use numeric IDs or exact Zalo names
|
||||
|
||||
## Credits
|
||||
|
||||
Built on [zca-js](https://github.com/RFS-ADRENO/zca-js).
|
||||
10
extensions/zalouser/api.ts
Normal file
10
extensions/zalouser/api.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Zalouser API module exposes the plugin public contract.
|
||||
export { zalouserPlugin } from "./src/channel.js";
|
||||
export { zalouserSetupPlugin } from "./src/channel.setup.js";
|
||||
export { createZalouserTool } from "./src/tool.js";
|
||||
export { createZalouserSetupWizardProxy, zalouserSetupAdapter } from "./src/setup-core.js";
|
||||
export { zalouserSetupWizard } from "./src/setup-surface.js";
|
||||
export {
|
||||
collectZalouserSecurityAuditFindings,
|
||||
isZalouserMutableGroupEntry,
|
||||
} from "./src/security-audit.js";
|
||||
3
extensions/zalouser/channel-plugin-api.ts
Normal file
3
extensions/zalouser/channel-plugin-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
|
||||
// not drag setup-only or tool runtime surfaces into lightweight plugin loads.
|
||||
export { zalouserPlugin } from "./src/channel.js";
|
||||
2
extensions/zalouser/contract-api.ts
Normal file
2
extensions/zalouser/contract-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Zalouser API module exposes the plugin public contract.
|
||||
export { collectZalouserSecurityAuditFindings } from "./src/security-audit.js";
|
||||
2
extensions/zalouser/doctor-contract-api.ts
Normal file
2
extensions/zalouser/doctor-contract-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Zalouser API module exposes the plugin public contract.
|
||||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
35
extensions/zalouser/index.ts
Normal file
35
extensions/zalouser/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Zalouser plugin entrypoint registers its OpenClaw integration.
|
||||
import {
|
||||
type AnyAgentTool,
|
||||
defineBundledChannelEntry,
|
||||
loadBundledEntryExportSync,
|
||||
} from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
function createZalouserTool(context?: unknown): AnyAgentTool {
|
||||
const createTool = loadBundledEntryExportSync<(context?: unknown) => AnyAgentTool>(
|
||||
import.meta.url,
|
||||
{
|
||||
specifier: "./api.js",
|
||||
exportName: "createZalouserTool",
|
||||
},
|
||||
);
|
||||
return createTool(context);
|
||||
}
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
id: "zalouser",
|
||||
name: "Zalo Personal",
|
||||
description: "Zalo personal account messaging via native zca-js integration",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "zalouserPlugin",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setZalouserRuntime",
|
||||
},
|
||||
registerFull(api) {
|
||||
api.registerTool((ctx) => createZalouserTool(ctx), { name: "zalouser" });
|
||||
},
|
||||
});
|
||||
468
extensions/zalouser/npm-shrinkwrap.json
generated
Normal file
468
extensions/zalouser/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,468 @@
|
||||
{
|
||||
"name": "@openclaw/zalouser",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/zalouser",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"typebox": "1.3.3",
|
||||
"zca-js": "2.1.2",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bignumber.js": {
|
||||
"version": "9.3.1",
|
||||
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz",
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-set-tostringtag": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
|
||||
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.6",
|
||||
"has-tostringtag": "^1.0.2",
|
||||
"hasown": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.6.tgz",
|
||||
"integrity": "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35",
|
||||
"safe-buffer": "^5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-tostringtag": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-symbols": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/json-bigint": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz",
|
||||
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/psl": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||
"integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"punycode": "^2.3.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/lupomontero"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/spark-md5": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/spark-md5/-/spark-md5-3.0.2.tgz",
|
||||
"integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==",
|
||||
"license": "(WTFPL OR MIT)"
|
||||
},
|
||||
"node_modules/tough-cookie": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
|
||||
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"psl": "^1.1.33",
|
||||
"punycode": "^2.1.1",
|
||||
"universalify": "^0.2.0",
|
||||
"url-parse": "^1.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/typebox": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.3.tgz",
|
||||
"integrity": "sha512-URXGUE31PJDQC+PtRMJeLdF4kmmOdFoVPikPCtV2oOIhUpNpppEdIz7W8bH8cFYPYHdDpaRvqwdegMTmHliudg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/universalify": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
|
||||
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/url-parse": {
|
||||
"version": "1.5.10",
|
||||
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
|
||||
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"querystringify": "^2.1.1",
|
||||
"requires-port": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zca-js": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/zca-js/-/zca-js-2.1.2.tgz",
|
||||
"integrity": "sha512-82+zCqoIXnXEF6C9YuN3Kf7WKlyyujY/6Ejl2n8PkwazYkBK0k7kiPd8S7nHvC5Wl7vjwGRhDYeAM8zTHyoRxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"form-data": "^4.0.4",
|
||||
"json-bigint": "^1.0.0",
|
||||
"pako": "^2.1.0",
|
||||
"semver": "^7.6.3",
|
||||
"spark-md5": "^3.0.2",
|
||||
"tough-cookie": "^5.0.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
extensions/zalouser/openclaw.plugin.json
Normal file
21
extensions/zalouser/openclaw.plugin.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "zalouser",
|
||||
"name": "Zalo Personal",
|
||||
"description": "OpenClaw Zalo Personal Account plugin via native zca-js integration.",
|
||||
"icon": "https://cdn.simpleicons.org/zalo",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["zalouser"],
|
||||
"contracts": {
|
||||
"tools": ["zalouser"]
|
||||
},
|
||||
"channelEnvVars": {
|
||||
"zalouser": ["ZALOUSER_PROFILE", "ZCA_PROFILE"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
67
extensions/zalouser/package.json
Normal file
67
extensions/zalouser/package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@openclaw/zalouser",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Zalo Personal Account plugin via native zca-js integration.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"typebox": "1.3.3",
|
||||
"zca-js": "2.1.2",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.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": false,
|
||||
"doctorCapabilities": {
|
||||
"dmAllowFromMode": "topOnly",
|
||||
"groupModel": "hybrid",
|
||||
"groupAllowFromFallbackToAllowFrom": false,
|
||||
"warnOnEmptyGroupSenderAllowlist": false
|
||||
}
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/zalouser",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
63
extensions/zalouser/runtime-api.ts
Normal file
63
extensions/zalouser/runtime-api.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Zalouser API module exposes the plugin public contract.
|
||||
export {
|
||||
collectZalouserSecurityAuditFindings,
|
||||
createZalouserSetupWizardProxy,
|
||||
createZalouserTool,
|
||||
isZalouserMutableGroupEntry,
|
||||
zalouserPlugin,
|
||||
zalouserSetupAdapter,
|
||||
zalouserSetupPlugin,
|
||||
zalouserSetupWizard,
|
||||
} from "./api.js";
|
||||
export { setZalouserRuntime } from "./src/runtime.js";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelStatusIssue,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export type {
|
||||
OpenClawConfig,
|
||||
GroupToolPolicyConfig,
|
||||
MarkdownTableMode,
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
export type {
|
||||
PluginRuntime,
|
||||
AnyAgentTool,
|
||||
ChannelPlugin,
|
||||
OpenClawPluginToolContext,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
buildChannelConfigSchema,
|
||||
normalizeAccountId,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
export {
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
export {
|
||||
mergeAllowlist,
|
||||
summarizeMapping,
|
||||
formatAllowFromLowercase,
|
||||
} from "openclaw/plugin-sdk/allow-from";
|
||||
export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
||||
export { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
export {
|
||||
deliverTextOrMediaReply,
|
||||
isNumericTargetId,
|
||||
resolveSendableOutboundReplyParts,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
type OutboundReplyPayload,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
export { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
4
extensions/zalouser/secret-contract-api.ts
Normal file
4
extensions/zalouser/secret-contract-api.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Zalo User does not expose secret-contract surfaces.
|
||||
export const secretTargetRegistryEntries: readonly [] = [];
|
||||
|
||||
export function collectRuntimeConfigAssignments(): void {}
|
||||
10
extensions/zalouser/setup-entry.ts
Normal file
10
extensions/zalouser/setup-entry.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Zalouser plugin module implements setup entry behavior.
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "zalouserSetupPlugin",
|
||||
},
|
||||
});
|
||||
2
extensions/zalouser/setup-plugin-api.ts
Normal file
2
extensions/zalouser/setup-plugin-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Keep setup-entry imports narrow so setup loads do not pull tool surfaces.
|
||||
export { zalouserSetupPlugin } from "./src/channel.setup.js";
|
||||
2
extensions/zalouser/src/accounts.runtime.ts
Normal file
2
extensions/zalouser/src/accounts.runtime.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Zalouser plugin module implements accounts behavior.
|
||||
export { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js";
|
||||
15
extensions/zalouser/src/accounts.test-mocks.ts
Normal file
15
extensions/zalouser/src/accounts.test-mocks.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Zalouser plugin module implements accounts mocks behavior.
|
||||
import { vi } from "vitest";
|
||||
import { createDefaultResolvedZalouserAccount } from "./test-helpers.js";
|
||||
|
||||
vi.mock("./accounts.js", () => {
|
||||
return {
|
||||
listZalouserAccountIds: () => ["default"],
|
||||
resolveDefaultZalouserAccountId: () => "default",
|
||||
resolveZalouserAccountSync: () => createDefaultResolvedZalouserAccount(),
|
||||
resolveZalouserAccount: async () => createDefaultResolvedZalouserAccount(),
|
||||
listEnabledZalouserAccounts: async () => [createDefaultResolvedZalouserAccount()],
|
||||
getZcaUserInfo: async () => null,
|
||||
checkZcaAuthenticated: async () => false,
|
||||
};
|
||||
});
|
||||
299
extensions/zalouser/src/accounts.test.ts
Normal file
299
extensions/zalouser/src/accounts.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
// Zalouser tests cover accounts plugin behavior.
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
getZcaUserInfo,
|
||||
listEnabledZalouserAccounts,
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccount,
|
||||
resolveZalouserAccountSync,
|
||||
} from "./accounts.js";
|
||||
import { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js";
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
checkZaloAuthenticated: vi.fn(),
|
||||
getZaloUserInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCheckAuthenticated = vi.mocked(checkZaloAuthenticated);
|
||||
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
|
||||
const originalZalouserProfile = process.env.ZALOUSER_PROFILE;
|
||||
const originalZcaProfile = process.env.ZCA_PROFILE;
|
||||
|
||||
function asConfig(value: unknown): OpenClawConfig {
|
||||
return value as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("zalouser account resolution", () => {
|
||||
beforeEach(() => {
|
||||
mockCheckAuthenticated.mockReset();
|
||||
mockGetUserInfo.mockReset();
|
||||
delete process.env.ZALOUSER_PROFILE;
|
||||
delete process.env.ZCA_PROFILE;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalZalouserProfile === undefined) {
|
||||
delete process.env.ZALOUSER_PROFILE;
|
||||
} else {
|
||||
process.env.ZALOUSER_PROFILE = originalZalouserProfile;
|
||||
}
|
||||
if (originalZcaProfile === undefined) {
|
||||
delete process.env.ZCA_PROFILE;
|
||||
} else {
|
||||
process.env.ZCA_PROFILE = originalZcaProfile;
|
||||
}
|
||||
});
|
||||
|
||||
it("returns default account id when no accounts are configured", () => {
|
||||
expect(listZalouserAccountIds(asConfig({}))).toEqual([DEFAULT_ACCOUNT_ID]);
|
||||
});
|
||||
|
||||
it("returns sorted configured account ids", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
work: {},
|
||||
personal: {},
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(listZalouserAccountIds(cfg)).toEqual(["default", "personal", "work"]);
|
||||
});
|
||||
|
||||
it("preserves top-level default account when named accounts are configured", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
profile: "personal",
|
||||
accounts: {
|
||||
work: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(listZalouserAccountIds(cfg)).toEqual(["default", "work"]);
|
||||
expect(resolveDefaultZalouserAccountId(cfg)).toBe("default");
|
||||
expect(resolveZalouserAccountSync({ cfg }).profile).toBe("personal");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when present", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
default: {},
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveDefaultZalouserAccountId(cfg)).toBe("work");
|
||||
});
|
||||
|
||||
it("falls back to default account when configured defaultAccount is missing", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
defaultAccount: "missing",
|
||||
accounts: {
|
||||
default: {},
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveDefaultZalouserAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("falls back to first sorted configured account when default is absent", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
zzz: {},
|
||||
aaa: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveDefaultZalouserAccountId(cfg)).toBe("aaa");
|
||||
});
|
||||
|
||||
it("resolves sync account by merging base + account config", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
dmPolicy: "pairing",
|
||||
accounts: {
|
||||
work: {
|
||||
enabled: false,
|
||||
name: "Work",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["123"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId: "work" });
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.enabled).toBe(false);
|
||||
expect(resolved.name).toBe("Work");
|
||||
expect(resolved.config.dmPolicy).toBe("allowlist");
|
||||
expect(resolved.config.allowFrom).toEqual(["123"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
name: "Work",
|
||||
profile: "work-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = resolveZalouserAccountSync({ cfg });
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.name).toBe("Work");
|
||||
expect(resolved.profile).toBe("work-profile");
|
||||
});
|
||||
|
||||
it("resolves account config when account key casing differs from normalized id", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
Work: {
|
||||
name: "Work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId: "work" });
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.name).toBe("Work");
|
||||
});
|
||||
|
||||
it("defaults group policy to allowlist when unset", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId: "default" });
|
||||
expect(resolved.config.groupPolicy).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("resolves profile precedence correctly", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
process.env.ZALOUSER_PROFILE = "zalo-env";
|
||||
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("zalo-env");
|
||||
|
||||
delete process.env.ZALOUSER_PROFILE;
|
||||
process.env.ZCA_PROFILE = "zca-env";
|
||||
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("zca-env");
|
||||
|
||||
delete process.env.ZCA_PROFILE;
|
||||
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("work");
|
||||
});
|
||||
|
||||
it("uses explicit profile from config over env fallback", () => {
|
||||
process.env.ZALOUSER_PROFILE = "env-profile";
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "explicit-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("explicit-profile");
|
||||
});
|
||||
|
||||
it("checks authentication during async account resolution", async () => {
|
||||
mockCheckAuthenticated.mockResolvedValueOnce(true);
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await resolveZalouserAccount({ cfg, accountId: "default" });
|
||||
expect(mockCheckAuthenticated).toHaveBeenCalledWith("default");
|
||||
expect(resolved.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("filters disabled accounts when listing enabled accounts", async () => {
|
||||
mockCheckAuthenticated.mockResolvedValue(true);
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
default: { enabled: true },
|
||||
work: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const accounts = await listEnabledZalouserAccounts(cfg);
|
||||
expect(accounts.map((account) => account.accountId)).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("maps account info helper from zalo-js", async () => {
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
userId: "123",
|
||||
displayName: "Alice",
|
||||
avatar: "https://example.com/avatar.png",
|
||||
});
|
||||
expect(await getZcaUserInfo("default")).toEqual({
|
||||
userId: "123",
|
||||
displayName: "Alice",
|
||||
});
|
||||
|
||||
mockGetUserInfo.mockResolvedValueOnce(null);
|
||||
expect(await getZcaUserInfo("default")).toBeNull();
|
||||
});
|
||||
});
|
||||
133
extensions/zalouser/src/accounts.ts
Normal file
133
extensions/zalouser/src/accounts.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// Zalouser plugin module implements accounts behavior.
|
||||
import {
|
||||
createAccountListHelpers,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
resolveMergedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js";
|
||||
|
||||
const loadZalouserAccountsRuntime = createLazyRuntimeModule(() => import("./accounts.runtime.js"));
|
||||
|
||||
const {
|
||||
listAccountIds: listZalouserAccountIds,
|
||||
resolveDefaultAccountId: resolveDefaultZalouserAccountId,
|
||||
} = createAccountListHelpers("zalouser", {
|
||||
implicitDefaultAccount: {
|
||||
channelKeys: ["profile"],
|
||||
envVars: ["ZALOUSER_PROFILE", "ZCA_PROFILE"],
|
||||
},
|
||||
});
|
||||
export { listZalouserAccountIds, resolveDefaultZalouserAccountId };
|
||||
|
||||
function mergeZalouserAccountConfig(cfg: OpenClawConfig, accountId: string): ZalouserAccountConfig {
|
||||
const merged = resolveMergedAccountConfig<ZalouserAccountConfig>({
|
||||
channelConfig: cfg.channels?.zalouser as ZalouserAccountConfig | undefined,
|
||||
accounts: (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts as
|
||||
| Record<string, Partial<ZalouserAccountConfig>>
|
||||
| undefined,
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
});
|
||||
return {
|
||||
...merged,
|
||||
// Match Telegram's safe default: groups stay allowlisted unless explicitly opened.
|
||||
groupPolicy: merged.groupPolicy ?? "allowlist",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProfile(config: ZalouserAccountConfig, accountId: string): string {
|
||||
if (config.profile?.trim()) {
|
||||
return config.profile.trim();
|
||||
}
|
||||
if (process.env.ZALOUSER_PROFILE?.trim()) {
|
||||
return process.env.ZALOUSER_PROFILE.trim();
|
||||
}
|
||||
if (process.env.ZCA_PROFILE?.trim()) {
|
||||
return process.env.ZCA_PROFILE.trim();
|
||||
}
|
||||
if (accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return accountId;
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
|
||||
function resolveZalouserAccountBase(params: { cfg: OpenClawConfig; accountId?: string | null }) {
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? resolveDefaultZalouserAccountId(params.cfg),
|
||||
);
|
||||
const baseEnabled =
|
||||
(params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
|
||||
return {
|
||||
accountId,
|
||||
enabled: baseEnabled && merged.enabled !== false,
|
||||
merged,
|
||||
profile: resolveProfile(merged, accountId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveZalouserAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): Promise<ResolvedZalouserAccount> {
|
||||
const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params);
|
||||
const authenticated = await (await loadZalouserAccountsRuntime()).checkZaloAuthenticated(profile);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
enabled,
|
||||
profile,
|
||||
authenticated,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveZalouserAccountSync(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedZalouserAccount {
|
||||
const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
enabled,
|
||||
profile,
|
||||
authenticated: false,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEnabledZalouserAccounts(
|
||||
cfg: OpenClawConfig,
|
||||
): 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 info = await (await loadZalouserAccountsRuntime()).getZaloUserInfo(profile);
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
userId: info.userId,
|
||||
displayName: info.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkZcaAuthenticated(profile: string): Promise<boolean> {
|
||||
return await (await loadZalouserAccountsRuntime()).checkZaloAuthenticated(profile);
|
||||
}
|
||||
|
||||
export type { ResolvedZalouserAccount } from "./types.js";
|
||||
21
extensions/zalouser/src/channel-api.ts
Normal file
21
extensions/zalouser/src/channel-api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Zalouser API module exposes the plugin public contract.
|
||||
export { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
||||
export type {
|
||||
ChannelDirectoryEntry,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export {
|
||||
isNumericTargetId,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
494
extensions/zalouser/src/channel.adapters.ts
Normal file
494
extensions/zalouser/src/channel.adapters.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
// Zalouser plugin module implements channel.adapters behavior.
|
||||
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import {
|
||||
defineChannelMessageAdapter,
|
||||
type ChannelMessageSendResult,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import {
|
||||
createEmptyChannelResult,
|
||||
type ChannelOutboundAdapter,
|
||||
type OutboundDeliveryResult,
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
checkZcaAuthenticated,
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
type ResolvedZalouserAccount,
|
||||
} from "./accounts.js";
|
||||
import type {
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
GroupToolPolicyConfig,
|
||||
OpenClawConfig,
|
||||
} from "./channel-api.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
chunkTextForOutbound,
|
||||
isDangerousNameMatchingEnabled,
|
||||
isNumericTargetId,
|
||||
normalizeAccountId,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
} from "./channel-api.js";
|
||||
import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js";
|
||||
import { resolveZalouserReactionMessageIds } from "./message-sid.js";
|
||||
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
|
||||
import { getZalouserRuntime } from "./runtime.js";
|
||||
import {
|
||||
normalizeZalouserTarget,
|
||||
parseZalouserOutboundTarget,
|
||||
resolveZalouserOutboundSessionRoute,
|
||||
} from "./session-route.js";
|
||||
import type { ZaloSendResult } from "./types.js";
|
||||
|
||||
const loadZalouserChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
|
||||
|
||||
const ZALOUSER_TEXT_CHUNK_LIMIT = 2000;
|
||||
|
||||
type ZalouserSendTextContext = {
|
||||
to: string;
|
||||
text: string;
|
||||
accountId?: string | null;
|
||||
cfg: OpenClawConfig;
|
||||
onDeliveryResult?: (result: ChannelMessageSendResult) => Promise<void> | void;
|
||||
};
|
||||
|
||||
type ZalouserSendMediaContext = ZalouserSendTextContext & {
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
};
|
||||
|
||||
export function resolveZalouserQrProfile(accountId?: string | null): string {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
|
||||
return process.env.ZALOUSER_PROFILE?.trim() || process.env.ZCA_PROFILE?.trim() || "default";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveZalouserOutboundChunkMode(cfg: OpenClawConfig, accountId?: string) {
|
||||
return getZalouserRuntime().channel.text.resolveChunkMode(cfg, "zalouser", accountId);
|
||||
}
|
||||
|
||||
function resolveZalouserOutboundTextChunkLimit(cfg: OpenClawConfig, accountId?: string) {
|
||||
return getZalouserRuntime().channel.text.resolveTextChunkLimit(cfg, "zalouser", accountId, {
|
||||
fallbackLimit: ZALOUSER_TEXT_CHUNK_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
function toZalouserMessageSendResult(result: ZaloSendResult): ChannelMessageSendResult {
|
||||
return {
|
||||
messageId: result.messageId,
|
||||
receipt: result.receipt,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveZalouserGroupPolicyEntry(params: ChannelGroupContext) {
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId ?? undefined,
|
||||
});
|
||||
const groups = account.config.groups ?? {};
|
||||
return findZalouserGroupEntry(
|
||||
groups,
|
||||
buildZalouserGroupCandidates({
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
includeWildcard: true,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(account.config),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveZalouserGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
return resolveZalouserGroupPolicyEntry(params)?.tools;
|
||||
}
|
||||
|
||||
function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
|
||||
const entry = resolveZalouserGroupPolicyEntry(params);
|
||||
if (typeof entry?.requireMention === "boolean") {
|
||||
return entry.requireMention;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendZalouserTextFromContext({
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
cfg,
|
||||
onDeliveryResult,
|
||||
}: ZalouserSendTextContext) {
|
||||
const { sendMessageZalouser } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const target = parseZalouserOutboundTarget(to);
|
||||
const result = await sendMessageZalouser(target.threadId, text, {
|
||||
profile: account.profile,
|
||||
isGroup: target.isGroup,
|
||||
textMode: "markdown",
|
||||
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
|
||||
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
|
||||
onDeliveryResult: async (progress) => {
|
||||
await onDeliveryResult?.(toZalouserMessageSendResult(progress));
|
||||
},
|
||||
});
|
||||
return toZalouserMessageSendResult(result);
|
||||
}
|
||||
|
||||
async function sendZalouserMediaFromContext({
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
accountId,
|
||||
cfg,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
onDeliveryResult,
|
||||
}: ZalouserSendMediaContext) {
|
||||
const { sendMessageZalouser } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const target = parseZalouserOutboundTarget(to);
|
||||
const result = await sendMessageZalouser(target.threadId, text, {
|
||||
profile: account.profile,
|
||||
isGroup: target.isGroup,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
textMode: "markdown",
|
||||
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
|
||||
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
|
||||
onDeliveryResult: async (progress) => {
|
||||
await onDeliveryResult?.(toZalouserMessageSendResult(progress));
|
||||
},
|
||||
});
|
||||
return toZalouserMessageSendResult(result);
|
||||
}
|
||||
|
||||
function adaptZalouserOutboundProgress(
|
||||
onDeliveryResult: ((result: OutboundDeliveryResult) => Promise<void> | void) | undefined,
|
||||
) {
|
||||
return onDeliveryResult
|
||||
? async (result: ChannelMessageSendResult) => {
|
||||
await onDeliveryResult(toZalouserOutboundDeliveryResult(result));
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function toZalouserOutboundDeliveryResult(
|
||||
result: ChannelMessageSendResult,
|
||||
): OutboundDeliveryResult {
|
||||
return createEmptyChannelResult("zalouser", {
|
||||
messageId:
|
||||
result.messageId ??
|
||||
result.receipt.primaryPlatformMessageId ??
|
||||
result.receipt.platformMessageIds[0],
|
||||
receipt: result.receipt,
|
||||
});
|
||||
}
|
||||
|
||||
const zalouserRawSendResultAdapter: Pick<ChannelOutboundAdapter, "sendText" | "sendMedia"> = {
|
||||
sendText: async ({ onDeliveryResult, ...ctx }) =>
|
||||
toZalouserOutboundDeliveryResult(
|
||||
await sendZalouserTextFromContext({
|
||||
...ctx,
|
||||
onDeliveryResult: adaptZalouserOutboundProgress(onDeliveryResult),
|
||||
}),
|
||||
),
|
||||
sendMedia: async ({ onDeliveryResult, ...ctx }) =>
|
||||
toZalouserOutboundDeliveryResult(
|
||||
await sendZalouserMediaFromContext({
|
||||
...ctx,
|
||||
onDeliveryResult: adaptZalouserOutboundProgress(onDeliveryResult),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
export const zalouserMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "zalouser",
|
||||
durableFinal: {
|
||||
capabilities: {
|
||||
text: true,
|
||||
media: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
},
|
||||
send: {
|
||||
text: sendZalouserTextFromContext,
|
||||
media: sendZalouserMediaFromContext,
|
||||
},
|
||||
});
|
||||
|
||||
const resolveZalouserDmPolicy = createScopedDmSecurityResolver<ResolvedZalouserAccount>({
|
||||
channelKey: "zalouser",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw) => raw.trim().replace(/^(zalouser|zlu):/i, ""),
|
||||
});
|
||||
|
||||
export const zalouserGroupsAdapter = {
|
||||
resolveRequireMention: resolveZalouserRequireMention,
|
||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
||||
};
|
||||
|
||||
export const zalouserMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: ({ cfg, accountId }) => {
|
||||
const accounts = accountId
|
||||
? [resolveZalouserAccountSync({ cfg, accountId })].filter((account) => account.enabled)
|
||||
: listZalouserAccountIds(cfg)
|
||||
.map((resolvedAccountId) =>
|
||||
resolveZalouserAccountSync({ cfg, accountId: resolvedAccountId }),
|
||||
)
|
||||
.filter((account) => account.enabled);
|
||||
if (accounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { actions: ["react"] };
|
||||
},
|
||||
supportsAction: ({ action }) => action === "react",
|
||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||
if (action !== "react") {
|
||||
throw new Error(`Zalouser action ${action} not supported`);
|
||||
}
|
||||
const { sendReactionZalouser } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const threadId =
|
||||
(typeof params.threadId === "string" ? params.threadId.trim() : "") ||
|
||||
(typeof params.to === "string" ? params.to.trim() : "") ||
|
||||
(typeof params.chatId === "string" ? params.chatId.trim() : "") ||
|
||||
(toolContext?.currentChannelId?.trim() ?? "");
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser react requires threadId (or to/chatId).");
|
||||
}
|
||||
const emoji = typeof params.emoji === "string" ? params.emoji.trim() : "";
|
||||
if (!emoji) {
|
||||
throw new Error("Zalouser react requires emoji.");
|
||||
}
|
||||
const ids = resolveZalouserReactionMessageIds({
|
||||
messageId: typeof params.messageId === "string" ? params.messageId : undefined,
|
||||
cliMsgId: typeof params.cliMsgId === "string" ? params.cliMsgId : undefined,
|
||||
currentMessageId: toolContext?.currentMessageId,
|
||||
});
|
||||
if (!ids) {
|
||||
throw new Error(
|
||||
"Zalouser react requires messageId + cliMsgId (or a current message context id).",
|
||||
);
|
||||
}
|
||||
const result = await sendReactionZalouser({
|
||||
profile: account.profile,
|
||||
threadId,
|
||||
isGroup: params.isGroup === true,
|
||||
msgId: ids.msgId,
|
||||
cliMsgId: ids.cliMsgId,
|
||||
emoji,
|
||||
remove: params.remove === true,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to react on Zalo message");
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text:
|
||||
params.remove === true
|
||||
? `Removed reaction ${emoji} from ${ids.msgId}`
|
||||
: `Reacted ${emoji} on ${ids.msgId}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
messageId: ids.msgId,
|
||||
cliMsgId: ids.cliMsgId,
|
||||
threadId,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserResolverAdapter = {
|
||||
resolveTargets: async ({
|
||||
cfg,
|
||||
accountId,
|
||||
inputs,
|
||||
kind,
|
||||
runtime,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
inputs: string[];
|
||||
kind: "user" | "group";
|
||||
runtime: RuntimeEnv;
|
||||
}) => {
|
||||
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 runtimeModule = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultZalouserAccountId(cfg),
|
||||
});
|
||||
if (kind === "user") {
|
||||
const friends = await runtimeModule.listZaloFriendsMatching(account.profile, trimmed);
|
||||
const best = friends[0];
|
||||
results.push({
|
||||
input,
|
||||
resolved: Boolean(best?.userId),
|
||||
id: best?.userId,
|
||||
name: best?.displayName,
|
||||
note: friends.length > 1 ? "multiple matches; chose first" : undefined,
|
||||
});
|
||||
} else {
|
||||
const groups = await runtimeModule.listZaloGroupsMatching(account.profile, trimmed);
|
||||
const best =
|
||||
groups.find(
|
||||
(group) =>
|
||||
normalizeLowercaseStringOrEmpty(group.name) ===
|
||||
normalizeLowercaseStringOrEmpty(trimmed),
|
||||
) ?? groups[0];
|
||||
results.push({
|
||||
input,
|
||||
resolved: Boolean(best?.groupId),
|
||||
id: best?.groupId,
|
||||
name: best?.name,
|
||||
note: groups.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;
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserAuthAdapter = {
|
||||
login: async ({
|
||||
cfg,
|
||||
accountId,
|
||||
runtime,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
runtime: RuntimeEnv;
|
||||
}) => {
|
||||
const { startZaloQrLogin, waitForZaloQrLogin } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultZalouserAccountId(cfg),
|
||||
});
|
||||
|
||||
runtime.log(
|
||||
`Generating QR login for Zalo Personal (account: ${account.accountId}, profile: ${account.profile})...`,
|
||||
);
|
||||
|
||||
const started = await startZaloQrLogin({
|
||||
profile: account.profile,
|
||||
timeoutMs: 35_000,
|
||||
});
|
||||
if (!started.qrDataUrl) {
|
||||
throw new Error(started.message || "Failed to start QR login");
|
||||
}
|
||||
|
||||
const qrPath = await writeQrDataUrlToTempFile(started.qrDataUrl, account.profile);
|
||||
if (qrPath) {
|
||||
runtime.log(`Scan QR image: ${qrPath}`);
|
||||
} else {
|
||||
runtime.log("QR generated but could not be written to a temp file.");
|
||||
}
|
||||
|
||||
const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 180_000 });
|
||||
if (!waited.connected) {
|
||||
throw new Error(waited.message || "Zalouser login failed");
|
||||
}
|
||||
|
||||
runtime.log(waited.message);
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserSecurityAdapter = {
|
||||
resolveDmPolicy: resolveZalouserDmPolicy,
|
||||
collectAuditFindings: async (params: {
|
||||
accountId?: string | null;
|
||||
account: ResolvedZalouserAccount;
|
||||
orderedAccountIds: string[];
|
||||
hasExplicitAccountPath: boolean;
|
||||
}) => (await loadZalouserChannelRuntime()).collectZalouserSecurityAuditFindings(params),
|
||||
};
|
||||
|
||||
export const zalouserThreadingAdapter = {
|
||||
resolveReplyToMode: createStaticReplyToModeResolver("off"),
|
||||
};
|
||||
|
||||
export const zalouserPairingTextAdapter = {
|
||||
idLabel: "zalouserUserId",
|
||||
message: "Your pairing request has been approved.",
|
||||
normalizeAllowEntry: createPairingPrefixStripper(/^(zalouser|zlu):/i),
|
||||
notify: async ({ cfg, id, message }: { cfg: OpenClawConfig; id: string; message: string }) => {
|
||||
const { sendMessageZalouser } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg });
|
||||
const authenticated = await checkZcaAuthenticated(account.profile);
|
||||
if (!authenticated) {
|
||||
throw new Error("Zalouser not authenticated");
|
||||
}
|
||||
await sendMessageZalouser(id, message, {
|
||||
profile: account.profile,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserOutboundAdapter = {
|
||||
deliveryMode: "direct" as const,
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "markdown" as const,
|
||||
sendPayload: async (
|
||||
ctx: { payload: object } & Parameters<
|
||||
NonNullable<typeof zalouserRawSendResultAdapter.sendText>
|
||||
>[0],
|
||||
) =>
|
||||
await sendPayloadWithChunkedTextAndMedia({
|
||||
ctx,
|
||||
sendText: (nextCtx) => zalouserRawSendResultAdapter.sendText!(nextCtx),
|
||||
sendMedia: (nextCtx) => zalouserRawSendResultAdapter.sendMedia!(nextCtx),
|
||||
emptyResult: createEmptyChannelResult("zalouser"),
|
||||
}),
|
||||
...zalouserRawSendResultAdapter,
|
||||
};
|
||||
|
||||
export const zalouserMessagingAdapter = {
|
||||
targetPrefixes: ["zalouser", "zlu"],
|
||||
normalizeTarget: (raw: string) => normalizeZalouserTarget(raw),
|
||||
resolveOutboundSessionRoute: (
|
||||
params: Parameters<typeof resolveZalouserOutboundSessionRoute>[0],
|
||||
) => resolveZalouserOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
looksLikeId: (raw: string) => {
|
||||
const normalized = normalizeZalouserTarget(raw);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (/^group:[^\s]+$/i.test(normalized) || /^user:[^\s]+$/i.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return isNumericTargetId(normalized);
|
||||
},
|
||||
hint: "<user:id|group:id>",
|
||||
},
|
||||
};
|
||||
60
extensions/zalouser/src/channel.directory.test.ts
Normal file
60
extensions/zalouser/src/channel.directory.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Zalouser tests cover channelirectory plugin behavior.
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import "./accounts.test-mocks.js";
|
||||
import { listZalouserDirectoryGroupMembers } from "./directory.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import { listZaloGroupMembersMock } from "./zalo-js.test-mocks.js";
|
||||
|
||||
describe("zalouser directory group members", () => {
|
||||
beforeEach(() => {
|
||||
listZaloGroupMembersMock.mockClear();
|
||||
});
|
||||
|
||||
it("accepts prefixed group ids from directory groups list output", async () => {
|
||||
await listZalouserDirectoryGroupMembers(
|
||||
{
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
groupId: "group:1471383327500481391",
|
||||
},
|
||||
{
|
||||
listZaloGroupMembers: async (profile, groupId) =>
|
||||
await listZaloGroupMembersMock(profile, groupId),
|
||||
},
|
||||
);
|
||||
|
||||
expect(listZaloGroupMembersMock).toHaveBeenLastCalledWith("default", "1471383327500481391");
|
||||
});
|
||||
|
||||
it("keeps backward compatibility for raw group ids", async () => {
|
||||
await listZalouserDirectoryGroupMembers(
|
||||
{
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
groupId: "1471383327500481391",
|
||||
},
|
||||
{
|
||||
listZaloGroupMembers: async (profile, groupId) =>
|
||||
await listZaloGroupMembersMock(profile, groupId),
|
||||
},
|
||||
);
|
||||
|
||||
expect(listZaloGroupMembersMock).toHaveBeenLastCalledWith("default", "1471383327500481391");
|
||||
});
|
||||
|
||||
it("accepts provider-native g- group ids without stripping the prefix", async () => {
|
||||
await listZalouserDirectoryGroupMembers(
|
||||
{
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
groupId: "g-1471383327500481391",
|
||||
},
|
||||
{
|
||||
listZaloGroupMembers: async (profile, groupId) =>
|
||||
await listZaloGroupMembersMock(profile, groupId),
|
||||
},
|
||||
);
|
||||
|
||||
expect(listZaloGroupMembersMock).toHaveBeenLastCalledWith("default", "g-1471383327500481391");
|
||||
});
|
||||
});
|
||||
13
extensions/zalouser/src/channel.runtime.ts
Normal file
13
extensions/zalouser/src/channel.runtime.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Zalouser plugin module implements channel behavior.
|
||||
export { probeZalouser } from "./probe.js";
|
||||
export { collectZalouserSecurityAuditFindings } from "./security-audit.js";
|
||||
export { sendMessageZalouser, sendReactionZalouser } from "./send.js";
|
||||
export {
|
||||
listZaloFriendsMatching,
|
||||
listZaloGroupMembers,
|
||||
listZaloGroupsMatching,
|
||||
logoutZaloProfile,
|
||||
startZaloQrLogin,
|
||||
waitForZaloQrLogin,
|
||||
getZaloUserInfo,
|
||||
} from "./zalo-js.js";
|
||||
365
extensions/zalouser/src/channel.sendpayload.test.ts
Normal file
365
extensions/zalouser/src/channel.sendpayload.test.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
// Zalouser tests cover channel.sendpayload plugin behavior.
|
||||
import {
|
||||
installChannelOutboundPayloadContractSuite,
|
||||
primeChannelOutboundSendMock,
|
||||
type OutboundPayloadHarnessParams,
|
||||
} from "openclaw/plugin-sdk/channel-contract-testing";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./accounts.test-mocks.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import type { ReplyPayload } from "../runtime-api.js";
|
||||
import { zalouserPlugin } from "./channel.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import * as sendModule from "./send.js";
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" } as never),
|
||||
sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true } as never),
|
||||
}));
|
||||
|
||||
function baseCtx(payload: ReplyPayload) {
|
||||
return {
|
||||
cfg: {},
|
||||
to: "user:987654321",
|
||||
text: "",
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
type ZalouserOutbound = NonNullable<typeof zalouserPlugin.outbound>;
|
||||
type ZalouserSendPayload = NonNullable<ZalouserOutbound["sendPayload"]>;
|
||||
type ZalouserMessageAdapter = NonNullable<typeof zalouserPlugin.message>;
|
||||
type ZalouserMessageSender = NonNullable<ZalouserMessageAdapter["send"]>;
|
||||
|
||||
function requireZalouserSendPayload(): ZalouserSendPayload {
|
||||
const sendPayload = zalouserPlugin.outbound?.sendPayload;
|
||||
if (!sendPayload) {
|
||||
throw new Error("Expected Zalouser outbound sendPayload");
|
||||
}
|
||||
return sendPayload;
|
||||
}
|
||||
|
||||
function requireZalouserMessageAdapter(): ZalouserMessageAdapter {
|
||||
const adapter = zalouserPlugin.message;
|
||||
if (!adapter) {
|
||||
throw new Error("Expected Zalouser message adapter");
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
function requireZalouserTextSender(
|
||||
adapter: ZalouserMessageAdapter,
|
||||
): NonNullable<ZalouserMessageSender["text"]> {
|
||||
const text = adapter.send?.text;
|
||||
if (!text) {
|
||||
throw new Error("Expected Zalouser message adapter text sender");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function requireZalouserMediaSender(
|
||||
adapter: ZalouserMessageAdapter,
|
||||
): NonNullable<ZalouserMessageSender["media"]> {
|
||||
const media = adapter.send?.media;
|
||||
if (!media) {
|
||||
throw new Error("Expected Zalouser message adapter media sender");
|
||||
}
|
||||
return media;
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`expected ${label} to be a record`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireSendOptions(
|
||||
mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>,
|
||||
): Record<string, unknown> {
|
||||
return requireRecord(requireSendCall(mockedSend)[2], "Zalouser send options");
|
||||
}
|
||||
|
||||
function requireSendCall(
|
||||
mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>,
|
||||
): unknown[] {
|
||||
const [call] = mockedSend.mock.calls as unknown[][];
|
||||
if (!call) {
|
||||
throw new Error("expected Zalouser send call");
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
describe("zalouserPlugin outbound sendPayload", () => {
|
||||
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>;
|
||||
|
||||
beforeEach(() => {
|
||||
setZalouserRuntime({
|
||||
channel: {
|
||||
text: {
|
||||
resolveChunkMode: vi.fn(() => "length"),
|
||||
resolveTextChunkLimit: vi.fn(() => 1200),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
mockedSend = vi.mocked(sendModule.sendMessageZalouser);
|
||||
primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" });
|
||||
});
|
||||
|
||||
it("group target delegates with isGroup=true and stripped threadId", async () => {
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g1" } as never);
|
||||
const sendPayload = requireZalouserSendPayload();
|
||||
|
||||
const result = await sendPayload({
|
||||
...baseCtx({ text: "hello group" }),
|
||||
to: "group:1471383327500481391",
|
||||
});
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledOnce();
|
||||
const sendCall = requireSendCall(mockedSend);
|
||||
expect(sendCall[0]).toBe("1471383327500481391");
|
||||
expect(sendCall[1]).toBe("hello group");
|
||||
const options = requireSendOptions(mockedSend);
|
||||
expect(options.isGroup).toBe(true);
|
||||
expect(options.textMode).toBe("markdown");
|
||||
expect(result.channel).toBe("zalouser");
|
||||
expect(result.messageId).toBe("zlu-g1");
|
||||
});
|
||||
|
||||
it("treats bare numeric targets as direct chats for backward compatibility", async () => {
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-d1" } as never);
|
||||
const sendPayload = requireZalouserSendPayload();
|
||||
|
||||
const result = await sendPayload({
|
||||
...baseCtx({ text: "hello" }),
|
||||
to: "987654321",
|
||||
});
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledOnce();
|
||||
const sendCall = requireSendCall(mockedSend);
|
||||
expect(sendCall[0]).toBe("987654321");
|
||||
expect(sendCall[1]).toBe("hello");
|
||||
const options = requireSendOptions(mockedSend);
|
||||
expect(options.isGroup).toBe(false);
|
||||
expect(options.textMode).toBe("markdown");
|
||||
expect(result.channel).toBe("zalouser");
|
||||
expect(result.messageId).toBe("zlu-d1");
|
||||
});
|
||||
|
||||
it("preserves provider-native group ids when sending to raw g- targets", async () => {
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g-native" } as never);
|
||||
const sendPayload = requireZalouserSendPayload();
|
||||
|
||||
const result = await sendPayload({
|
||||
...baseCtx({ text: "hello native group" }),
|
||||
to: "g-1471383327500481391",
|
||||
});
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledOnce();
|
||||
const sendCall = requireSendCall(mockedSend);
|
||||
expect(sendCall[0]).toBe("g-1471383327500481391");
|
||||
expect(sendCall[1]).toBe("hello native group");
|
||||
const options = requireSendOptions(mockedSend);
|
||||
expect(options.isGroup).toBe(true);
|
||||
expect(options.textMode).toBe("markdown");
|
||||
expect(result.channel).toBe("zalouser");
|
||||
expect(result.messageId).toBe("zlu-g-native");
|
||||
});
|
||||
|
||||
it("passes long markdown through once so formatting happens before chunking", async () => {
|
||||
const text = `**${"a".repeat(2501)}**`;
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" } as never);
|
||||
const sendPayload = requireZalouserSendPayload();
|
||||
|
||||
const result = await sendPayload({
|
||||
...baseCtx({ text }),
|
||||
to: "987654321",
|
||||
});
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledTimes(1);
|
||||
const sendCall = requireSendCall(mockedSend);
|
||||
expect(sendCall[0]).toBe("987654321");
|
||||
expect(sendCall[1]).toBe(text);
|
||||
const options = requireSendOptions(mockedSend);
|
||||
expect(options.isGroup).toBe(false);
|
||||
expect(options.textMode).toBe("markdown");
|
||||
expect(options.textChunkMode).toBe("length");
|
||||
expect(options.textChunkLimit).toBe(1200);
|
||||
expect(result.channel).toBe("zalouser");
|
||||
expect(result.messageId).toBe("zlu-code");
|
||||
});
|
||||
|
||||
it("forwards internal chunk progress through the outbound adapter", async () => {
|
||||
mockedSend.mockImplementationOnce(async (_threadId, _text, options) => {
|
||||
const onDeliveryResult = options?.onDeliveryResult;
|
||||
if (!onDeliveryResult) {
|
||||
throw new Error("missing progress callback");
|
||||
}
|
||||
await onDeliveryResult({ ok: true, messageId: "zlu-part-1" } as never);
|
||||
await onDeliveryResult({ ok: true, messageId: "zlu-part-2" } as never);
|
||||
return { ok: true, messageId: "zlu-part-2" } as never;
|
||||
});
|
||||
const onDeliveryResult = vi.fn();
|
||||
const sendPayload = requireZalouserSendPayload();
|
||||
|
||||
await sendPayload({
|
||||
...baseCtx({ text: "chunked internally" }),
|
||||
to: "987654321",
|
||||
onDeliveryResult,
|
||||
});
|
||||
|
||||
expect(onDeliveryResult.mock.calls.map((call) => call[0]?.messageId)).toEqual([
|
||||
"zlu-part-1",
|
||||
"zlu-part-2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards internal chunk progress through the message adapter", async () => {
|
||||
const receipt = createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "zalouser", messageId: "zlu-message-part" }],
|
||||
kind: "text",
|
||||
});
|
||||
mockedSend.mockImplementationOnce(async (_threadId, _text, options) => {
|
||||
const onDeliveryResult = options?.onDeliveryResult;
|
||||
if (!onDeliveryResult) {
|
||||
throw new Error("missing progress callback");
|
||||
}
|
||||
await onDeliveryResult({ ok: true, messageId: "zlu-message-part", receipt } as never);
|
||||
return { ok: true, messageId: "zlu-message-part", receipt } as never;
|
||||
});
|
||||
const onDeliveryResult = vi.fn();
|
||||
const sendText = requireZalouserTextSender(requireZalouserMessageAdapter());
|
||||
|
||||
await sendText({
|
||||
cfg: {},
|
||||
to: "user:987654321",
|
||||
text: "chunked internally",
|
||||
onDeliveryResult,
|
||||
});
|
||||
|
||||
expect(onDeliveryResult).toHaveBeenCalledOnce();
|
||||
expect(onDeliveryResult.mock.calls[0]?.[0]?.messageId).toBe("zlu-message-part");
|
||||
expect(onDeliveryResult.mock.calls[0]?.[0]?.receipt).toBe(receipt);
|
||||
});
|
||||
|
||||
it("declares message adapter durable text and media with receipt proofs", async () => {
|
||||
mockedSend.mockImplementation(async (_threadId, _text, opts: { mediaUrl?: string } = {}) =>
|
||||
opts.mediaUrl
|
||||
? {
|
||||
ok: true,
|
||||
messageId: "zlu-media-1",
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "zalouser", messageId: "zlu-media-1" }],
|
||||
kind: "media",
|
||||
}),
|
||||
}
|
||||
: {
|
||||
ok: true,
|
||||
messageId: "zlu-text-1",
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "zalouser", messageId: "zlu-text-1" }],
|
||||
kind: "text",
|
||||
}),
|
||||
},
|
||||
);
|
||||
const adapter = requireZalouserMessageAdapter();
|
||||
const sendText = requireZalouserTextSender(adapter);
|
||||
const sendMedia = requireZalouserMediaSender(adapter);
|
||||
|
||||
const proofs = await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "zalouser",
|
||||
adapter,
|
||||
proofs: {
|
||||
text: async () => {
|
||||
const result = await sendText({
|
||||
cfg: {},
|
||||
to: "user:987654321",
|
||||
text: "hello",
|
||||
});
|
||||
expect(result.receipt.platformMessageIds).toEqual(["zlu-text-1"]);
|
||||
},
|
||||
media: async () => {
|
||||
const result = await sendMedia({
|
||||
cfg: {},
|
||||
to: "user:987654321",
|
||||
text: "image",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
});
|
||||
expect(result.receipt.platformMessageIds).toEqual(["zlu-media-1"]);
|
||||
},
|
||||
messageSendingHooks: () => {
|
||||
expect(adapter.durableFinal?.capabilities?.messageSendingHooks).toBe(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
const proofStatusByCapability = new Map(
|
||||
proofs.map((proof) => [proof.capability, proof.status] as const),
|
||||
);
|
||||
expect(proofStatusByCapability.get("text")).toBe("verified");
|
||||
expect(proofStatusByCapability.get("media")).toBe("verified");
|
||||
expect(proofStatusByCapability.get("messageSendingHooks")).toBe("verified");
|
||||
});
|
||||
});
|
||||
|
||||
describe("zalouserPlugin outbound payload contract", () => {
|
||||
function createZalouserHarness(params: OutboundPayloadHarnessParams) {
|
||||
const mockedSend = vi.mocked(sendModule.sendMessageZalouser);
|
||||
setZalouserRuntime({
|
||||
channel: {
|
||||
text: {
|
||||
resolveChunkMode: vi.fn(() => "length"),
|
||||
resolveTextChunkLimit: vi.fn(() => 1200),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, params.sendResults);
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
to: "user:987654321",
|
||||
text: "",
|
||||
payload: params.payload,
|
||||
};
|
||||
const sendPayload = requireZalouserSendPayload();
|
||||
return {
|
||||
run: async () => await sendPayload(ctx),
|
||||
sendMock: mockedSend,
|
||||
to: "987654321",
|
||||
};
|
||||
}
|
||||
|
||||
installChannelOutboundPayloadContractSuite({
|
||||
channel: "zalouser",
|
||||
chunking: { mode: "passthrough", longTextLength: 3000 },
|
||||
createHarness: createZalouserHarness,
|
||||
});
|
||||
});
|
||||
|
||||
describe("zalouserPlugin messaging target normalization", () => {
|
||||
it("normalizes user/group aliases to canonical targets", () => {
|
||||
const normalize = zalouserPlugin.messaging?.normalizeTarget;
|
||||
if (!normalize) {
|
||||
throw new Error("normalizeTarget unavailable");
|
||||
}
|
||||
expect(normalize("zlu:g:30003")).toBe("group:30003");
|
||||
expect(normalize("zalouser:u:20002")).toBe("user:20002");
|
||||
expect(normalize("zlu:g-30003")).toBe("group:g-30003");
|
||||
expect(normalize("zalouser:u-20002")).toBe("user:u-20002");
|
||||
expect(normalize("20002")).toBe("20002");
|
||||
});
|
||||
|
||||
it("treats canonical and provider-native user/group targets as ids", () => {
|
||||
const looksLikeId = zalouserPlugin.messaging?.targetResolver?.looksLikeId;
|
||||
if (!looksLikeId) {
|
||||
throw new Error("looksLikeId unavailable");
|
||||
}
|
||||
expect(looksLikeId("user:20002")).toBe(true);
|
||||
expect(looksLikeId("group:30003")).toBe(true);
|
||||
expect(looksLikeId("g-30003")).toBe(true);
|
||||
expect(looksLikeId("u-20002")).toBe(true);
|
||||
expect(looksLikeId("Alice Nguyen")).toBe(false);
|
||||
});
|
||||
});
|
||||
31
extensions/zalouser/src/channel.setup.test.ts
Normal file
31
extensions/zalouser/src/channel.setup.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Zalouser tests cover channel.setup plugin behavior.
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import { zalouserSetupPlugin } from "./setup-test-helpers.js";
|
||||
|
||||
const zalouserSetupGetStatus = createPluginSetupWizardStatus(zalouserSetupPlugin);
|
||||
|
||||
describe("zalouser setup plugin", () => {
|
||||
it("builds setup status without an initialized runtime", async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-setup-"));
|
||||
|
||||
try {
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
const status = await zalouserSetupGetStatus({
|
||||
cfg: {},
|
||||
accountOverrides: {},
|
||||
});
|
||||
expect(status.channel).toBe("zalouser");
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.statusLines).toEqual(["Zalo Personal: needs QR login"]);
|
||||
});
|
||||
} finally {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
13
extensions/zalouser/src/channel.setup.ts
Normal file
13
extensions/zalouser/src/channel.setup.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Zalouser plugin module implements channel.setup behavior.
|
||||
import type { ResolvedZalouserAccount } from "./accounts.js";
|
||||
import type { ChannelPlugin } from "./channel-api.js";
|
||||
import { zalouserSetupAdapter } from "./setup-core.js";
|
||||
import { zalouserSetupWizard } from "./setup-surface.js";
|
||||
import { createZalouserPluginBase } from "./shared.js";
|
||||
|
||||
export const zalouserSetupPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
...createZalouserPluginBase({
|
||||
setupWizard: zalouserSetupWizard,
|
||||
setup: zalouserSetupAdapter,
|
||||
}),
|
||||
};
|
||||
440
extensions/zalouser/src/channel.test.ts
Normal file
440
extensions/zalouser/src/channel.test.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
// Zalouser tests cover channel plugin behavior.
|
||||
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import {
|
||||
zalouserAuthAdapter,
|
||||
zalouserGroupsAdapter,
|
||||
zalouserMessageActions,
|
||||
zalouserOutboundAdapter,
|
||||
zalouserPairingTextAdapter,
|
||||
zalouserResolverAdapter,
|
||||
zalouserSecurityAdapter,
|
||||
} from "./channel.adapters.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
|
||||
import {
|
||||
listZaloFriendsMatchingMock,
|
||||
startZaloQrLoginMock,
|
||||
waitForZaloQrLoginMock,
|
||||
} from "./zalo-js.test-mocks.js";
|
||||
|
||||
vi.mock("./qr-temp-file.js", () => ({
|
||||
writeQrDataUrlToTempFile: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", async () => {
|
||||
const actual = (await vi.importActual("./send.js")) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
sendMessageZalouser: vi.fn(async () => ({ ok: true, messageId: "mid-1" })),
|
||||
sendReactionZalouser: vi.fn(async () => ({ ok: true })),
|
||||
};
|
||||
});
|
||||
|
||||
const mockSendMessage = vi.mocked(sendMessageZalouser);
|
||||
const mockSendReaction = vi.mocked(sendReactionZalouser);
|
||||
|
||||
function requireZalouserSendText() {
|
||||
const sendText = zalouserOutboundAdapter.sendText;
|
||||
if (!sendText) {
|
||||
throw new Error("zalouser outbound.sendText unavailable");
|
||||
}
|
||||
return sendText;
|
||||
}
|
||||
|
||||
function getResolveToolPolicy() {
|
||||
const resolveToolPolicy = zalouserGroupsAdapter.resolveToolPolicy;
|
||||
if (!resolveToolPolicy) {
|
||||
throw new Error("resolveToolPolicy unavailable");
|
||||
}
|
||||
return resolveToolPolicy;
|
||||
}
|
||||
|
||||
function requireZalouserResolveRequireMention() {
|
||||
const resolveRequireMention = zalouserGroupsAdapter.resolveRequireMention;
|
||||
if (!resolveRequireMention) {
|
||||
throw new Error("resolveRequireMention unavailable");
|
||||
}
|
||||
return resolveRequireMention;
|
||||
}
|
||||
|
||||
function requireZalouserPairingNormalizer() {
|
||||
const normalizeAllowEntry = zalouserPairingTextAdapter.normalizeAllowEntry;
|
||||
if (!normalizeAllowEntry) {
|
||||
throw new Error("pairing.normalizeAllowEntry unavailable");
|
||||
}
|
||||
return normalizeAllowEntry;
|
||||
}
|
||||
|
||||
function resolveGroupToolPolicy(
|
||||
groups: Record<string, { tools: { allow?: string[]; deny?: string[] } }>,
|
||||
groupId: string,
|
||||
) {
|
||||
return getResolveToolPolicy()({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups,
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
groupId,
|
||||
groupChannel: groupId,
|
||||
});
|
||||
}
|
||||
|
||||
describe("zalouser outbound", () => {
|
||||
beforeEach(() => {
|
||||
mockSendMessage.mockClear();
|
||||
setZalouserRuntime({
|
||||
channel: {
|
||||
text: {
|
||||
resolveChunkMode: vi.fn(() => "newline"),
|
||||
resolveTextChunkLimit: vi.fn(() => 10),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("passes markdown chunk settings through sendText", async () => {
|
||||
const sendText = requireZalouserSendText();
|
||||
|
||||
const result = await sendText({
|
||||
cfg: { channels: { zalouser: { enabled: true } } } as never,
|
||||
to: "group:123456",
|
||||
text: "hello world\nthis is a test",
|
||||
accountId: "default",
|
||||
} as never);
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
"123456",
|
||||
"hello world\nthis is a test",
|
||||
expect.objectContaining({
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "newline",
|
||||
textChunkLimit: 10,
|
||||
onDeliveryResult: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
channel: "zalouser",
|
||||
messageId: "mid-1",
|
||||
receipt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the selected account profile for direct outbound messages", async () => {
|
||||
const sendText = requireZalouserSendText();
|
||||
|
||||
const result = await sendText({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "work-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
to: "user:987654",
|
||||
text: "hello user",
|
||||
accountId: "work",
|
||||
} as never);
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
"987654",
|
||||
"hello user",
|
||||
expect.objectContaining({
|
||||
profile: "work-profile",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "newline",
|
||||
textChunkLimit: 10,
|
||||
onDeliveryResult: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
channel: "zalouser",
|
||||
messageId: "mid-1",
|
||||
receipt: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the default account profile for unscoped outbound messages", async () => {
|
||||
const sendText = requireZalouserSendText();
|
||||
|
||||
await sendText({
|
||||
cfg: { channels: { zalouser: { enabled: true } } } as never,
|
||||
to: "user:111222",
|
||||
text: "hello default",
|
||||
} as never);
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
"111222",
|
||||
"hello default",
|
||||
expect.objectContaining({
|
||||
profile: "default",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "newline",
|
||||
textChunkLimit: 10,
|
||||
onDeliveryResult: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zalouser outbound chunking", () => {
|
||||
it("chunks outbound text without requiring Zalouser runtime initialization", () => {
|
||||
const chunker = zalouserOutboundAdapter.chunker;
|
||||
if (!chunker) {
|
||||
throw new Error("zalouser outbound.chunker unavailable");
|
||||
}
|
||||
|
||||
expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zalouser channel policies", () => {
|
||||
beforeEach(() => {
|
||||
mockSendReaction.mockClear();
|
||||
mockSendReaction.mockResolvedValue({ ok: true } as never);
|
||||
});
|
||||
|
||||
it("normalizes dm allowlist entries after trimming channel prefixes", () => {
|
||||
const resolveDmPolicy = zalouserSecurityAdapter.resolveDmPolicy;
|
||||
if (!resolveDmPolicy) {
|
||||
throw new Error("resolveDmPolicy unavailable");
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
zalouser: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [" zlu:123456 "],
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
authenticated: false,
|
||||
profile: "default",
|
||||
config: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [" zlu:123456 "],
|
||||
},
|
||||
} as never;
|
||||
|
||||
const result = resolveDmPolicy({ cfg, account });
|
||||
if (!result) {
|
||||
throw new Error("zalouser resolveDmPolicy returned null");
|
||||
}
|
||||
|
||||
expect(result.policy).toBe("allowlist");
|
||||
expect(result.allowFrom).toEqual([" zlu:123456 "]);
|
||||
expect(result.normalizeEntry?.(" zlu:123456 ")).toBe("123456");
|
||||
});
|
||||
|
||||
it("normalizes pairing allowlist entries after trimming channel prefixes", () => {
|
||||
const normalizeAllowEntry = requireZalouserPairingNormalizer();
|
||||
|
||||
expect(normalizeAllowEntry(" zlu:123456 ")).toBe("123456");
|
||||
expect(normalizeAllowEntry(" zalouser:654321 ")).toBe("654321");
|
||||
});
|
||||
|
||||
it("resolves requireMention from group config", () => {
|
||||
const resolveRequireMention = requireZalouserResolveRequireMention();
|
||||
const requireMention = resolveRequireMention({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups: {
|
||||
"123": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
groupId: "123",
|
||||
groupChannel: "123",
|
||||
});
|
||||
expect(requireMention).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves group tool policy by explicit group id", () => {
|
||||
const policy = resolveGroupToolPolicy({ "123": { tools: { allow: ["search"] } } }, "123");
|
||||
expect(policy).toEqual({ allow: ["search"] });
|
||||
});
|
||||
|
||||
it("falls back to wildcard group policy", () => {
|
||||
const policy = resolveGroupToolPolicy({ "*": { tools: { deny: ["system.run"] } } }, "missing");
|
||||
expect(policy).toEqual({ deny: ["system.run"] });
|
||||
});
|
||||
|
||||
it("handles react action", async () => {
|
||||
const actions = zalouserMessageActions;
|
||||
expect(
|
||||
actions?.describeMessageTool?.({ cfg: { channels: { zalouser: { enabled: true } } } })
|
||||
?.actions,
|
||||
).toEqual(["react"]);
|
||||
const result = await actions?.handleAction?.({
|
||||
channel: "zalouser",
|
||||
action: "react",
|
||||
params: {
|
||||
threadId: "123456",
|
||||
messageId: "111",
|
||||
cliMsgId: "222",
|
||||
emoji: "👍",
|
||||
},
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
profile: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockSendReaction).toHaveBeenCalledWith({
|
||||
profile: "default",
|
||||
threadId: "123456",
|
||||
isGroup: false,
|
||||
msgId: "111",
|
||||
cliMsgId: "222",
|
||||
emoji: "👍",
|
||||
remove: false,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
content: [{ type: "text", text: "Reacted 👍 on 111" }],
|
||||
details: {
|
||||
messageId: "111",
|
||||
cliMsgId: "222",
|
||||
threadId: "123456",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("honors the selected Zalouser account during discovery", () => {
|
||||
const actions = zalouserMessageActions;
|
||||
const cfg = {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
profile: "default",
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: false,
|
||||
profile: "default",
|
||||
},
|
||||
work: {
|
||||
enabled: true,
|
||||
profile: "work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(actions?.describeMessageTool?.({ cfg, accountId: "default" })).toBeNull();
|
||||
expect(actions?.describeMessageTool?.({ cfg, accountId: "work" })?.actions).toEqual(["react"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zalouser account resolution", () => {
|
||||
beforeEach(() => {
|
||||
listZaloFriendsMatchingMock.mockReset();
|
||||
startZaloQrLoginMock.mockReset();
|
||||
waitForZaloQrLoginMock.mockReset();
|
||||
});
|
||||
|
||||
it("uses the configured default account for omitted target lookup", async () => {
|
||||
const resolveTargets = zalouserResolverAdapter.resolveTargets;
|
||||
if (!resolveTargets) {
|
||||
throw new Error("zalouser resolver.resolveTargets unavailable");
|
||||
}
|
||||
|
||||
listZaloFriendsMatchingMock.mockResolvedValue([
|
||||
{ userId: "42", displayName: "Work User" } as never,
|
||||
]);
|
||||
|
||||
const result = await resolveTargets({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "work-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
inputs: ["Work User"],
|
||||
kind: "user",
|
||||
runtime: createNonExitingRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(listZaloFriendsMatchingMock).toHaveBeenCalledWith("work-profile", "Work User");
|
||||
expect(result).toEqual([
|
||||
{
|
||||
input: "Work User",
|
||||
resolved: true,
|
||||
id: "42",
|
||||
name: "Work User",
|
||||
note: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the configured default account for omitted qr login", async () => {
|
||||
const login = zalouserAuthAdapter.login;
|
||||
if (!login) {
|
||||
throw new Error("zalouser auth.login unavailable");
|
||||
}
|
||||
|
||||
startZaloQrLoginMock.mockResolvedValue({
|
||||
message: "qr ready",
|
||||
qrDataUrl: "data:image/png;base64,abc",
|
||||
} as never);
|
||||
waitForZaloQrLoginMock.mockResolvedValue({
|
||||
connected: true,
|
||||
userId: "u-1",
|
||||
displayName: "Work User",
|
||||
} as never);
|
||||
|
||||
const runtime = createNonExitingRuntimeEnv();
|
||||
|
||||
await login({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "work-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(startZaloQrLoginMock).toHaveBeenCalledWith({
|
||||
profile: "work-profile",
|
||||
timeoutMs: 35_000,
|
||||
});
|
||||
expect(waitForZaloQrLoginMock).toHaveBeenCalledWith({
|
||||
profile: "work-profile",
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
222
extensions/zalouser/src/channel.ts
Normal file
222
extensions/zalouser/src/channel.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
// Zalouser plugin module implements channel behavior.
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
createAsyncComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import {
|
||||
checkZcaAuthenticated,
|
||||
resolveZalouserAccountSync,
|
||||
type ResolvedZalouserAccount,
|
||||
} from "./accounts.js";
|
||||
import type { ChannelDirectoryEntry, ChannelPlugin } from "./channel-api.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "./channel-api.js";
|
||||
import {
|
||||
zalouserAuthAdapter,
|
||||
zalouserGroupsAdapter,
|
||||
zalouserMessageAdapter,
|
||||
zalouserMessageActions,
|
||||
zalouserMessagingAdapter,
|
||||
zalouserOutboundAdapter,
|
||||
zalouserPairingTextAdapter,
|
||||
resolveZalouserQrProfile,
|
||||
zalouserResolverAdapter,
|
||||
zalouserSecurityAdapter,
|
||||
zalouserThreadingAdapter,
|
||||
} from "./channel.adapters.js";
|
||||
import { listZalouserDirectoryGroupMembers } from "./directory.js";
|
||||
import type { ZalouserProbeResult } from "./probe.js";
|
||||
import { createZalouserSetupWizardProxy, zalouserSetupAdapter } from "./setup-core.js";
|
||||
import { createZalouserPluginBase } from "./shared.js";
|
||||
import { collectZalouserStatusIssues } from "./status-issues.js";
|
||||
|
||||
const loadZalouserChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
|
||||
const zalouserSetupWizardProxy = createZalouserSetupWizardProxy(
|
||||
async () => (await import("./setup-surface.js")).zalouserSetupWizard,
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount, ZalouserProbeResult> =
|
||||
createChatChannelPlugin({
|
||||
base: {
|
||||
...createZalouserPluginBase({
|
||||
setupWizard: zalouserSetupWizardProxy,
|
||||
setup: zalouserSetupAdapter,
|
||||
}),
|
||||
groups: zalouserGroupsAdapter,
|
||||
actions: zalouserMessageActions,
|
||||
messaging: zalouserMessagingAdapter,
|
||||
directory: {
|
||||
self: async ({ cfg, accountId }) => {
|
||||
const { getZaloUserInfo } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const parsed = await getZaloUserInfo(account.profile);
|
||||
if (!parsed?.userId) {
|
||||
return null;
|
||||
}
|
||||
return mapUser({
|
||||
id: parsed.userId,
|
||||
name: parsed.displayName ?? null,
|
||||
avatarUrl: parsed.avatar ?? null,
|
||||
raw: parsed,
|
||||
});
|
||||
},
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const { listZaloFriendsMatching } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const friends = await listZaloFriendsMatching(account.profile, query);
|
||||
const rows = friends.map((friend) =>
|
||||
mapUser({
|
||||
id: friend.userId,
|
||||
name: friend.displayName ?? null,
|
||||
avatarUrl: friend.avatar ?? null,
|
||||
raw: friend,
|
||||
}),
|
||||
);
|
||||
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const { listZaloGroupsMatching } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const groups = await listZaloGroupsMatching(account.profile, query);
|
||||
const rows = groups.map((group) =>
|
||||
mapGroup({
|
||||
id: `group:${group.groupId}`,
|
||||
name: group.name ?? null,
|
||||
raw: group,
|
||||
}),
|
||||
);
|
||||
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
||||
},
|
||||
listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
|
||||
const { listZaloGroupMembers } = await loadZalouserChannelRuntime();
|
||||
return await listZalouserDirectoryGroupMembers(
|
||||
{
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
groupId,
|
||||
limit: limit ?? undefined,
|
||||
},
|
||||
{ listZaloGroupMembers },
|
||||
);
|
||||
},
|
||||
},
|
||||
resolver: zalouserResolverAdapter,
|
||||
auth: zalouserAuthAdapter,
|
||||
message: zalouserMessageAdapter,
|
||||
status: createAsyncComputedAccountStatusAdapter<ResolvedZalouserAccount, ZalouserProbeResult>(
|
||||
{
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: collectZalouserStatusIssues,
|
||||
buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
(await loadZalouserChannelRuntime()).probeZalouser(account.profile, timeoutMs),
|
||||
resolveAccountSnapshot: async ({ account, runtime }) => {
|
||||
const configured = await checkZcaAuthenticated(account.profile);
|
||||
const configError = "not authenticated";
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
extra: {
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
lastError: configured
|
||||
? (runtime?.lastError ?? null)
|
||||
: (runtime?.lastError ?? configError),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const { getZaloUserInfo } = await loadZalouserChannelRuntime();
|
||||
const account = ctx.account;
|
||||
let userLabel = "";
|
||||
try {
|
||||
const userInfo = await getZaloUserInfo(account.profile);
|
||||
if (userInfo?.displayName) {
|
||||
userLabel = ` (${userInfo.displayName})`;
|
||||
}
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
profile: userInfo,
|
||||
});
|
||||
} catch {
|
||||
// ignore probe errors
|
||||
}
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
|
||||
const { monitorZalouserProvider } = await import("./monitor.js");
|
||||
return monitorZalouserProvider({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
loginWithQrStart: async (params) => {
|
||||
const { startZaloQrLogin } = await loadZalouserChannelRuntime();
|
||||
const profile = resolveZalouserQrProfile(params.accountId);
|
||||
return await startZaloQrLogin({
|
||||
profile,
|
||||
force: params.force,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
},
|
||||
loginWithQrWait: async (params) => {
|
||||
const { waitForZaloQrLogin } = await loadZalouserChannelRuntime();
|
||||
const profile = resolveZalouserQrProfile(params.accountId);
|
||||
return await waitForZaloQrLogin({
|
||||
profile,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
},
|
||||
logoutAccount: async (ctx) =>
|
||||
await (
|
||||
await loadZalouserChannelRuntime()
|
||||
).logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)),
|
||||
},
|
||||
},
|
||||
security: zalouserSecurityAdapter,
|
||||
threading: zalouserThreadingAdapter,
|
||||
pairing: {
|
||||
text: zalouserPairingTextAdapter,
|
||||
},
|
||||
outbound: zalouserOutboundAdapter,
|
||||
});
|
||||
34
extensions/zalouser/src/config-schema.ts
Normal file
34
extensions/zalouser/src/config-schema.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Zalouser helper module supports config schema behavior.
|
||||
import {
|
||||
AllowFromListSchema,
|
||||
buildCatchallMultiAccountChannelSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
ToolPolicySchema,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { z } from "zod";
|
||||
|
||||
const groupConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
});
|
||||
|
||||
const zalouserAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
profile: z.string().optional(),
|
||||
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional(),
|
||||
allowFrom: AllowFromListSchema,
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
groupAllowFrom: AllowFromListSchema,
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
groups: z.object({}).catchall(groupConfigSchema).optional(),
|
||||
messagePrefix: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZalouserConfigSchema = buildCatchallMultiAccountChannelSchema(zalouserAccountSchema);
|
||||
55
extensions/zalouser/src/directory.ts
Normal file
55
extensions/zalouser/src/directory.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Zalouser plugin module implements directory behavior.
|
||||
import { resolveZalouserAccountSync } from "./accounts.js";
|
||||
import type { ChannelDirectoryEntry, OpenClawConfig } from "./channel-api.js";
|
||||
import { parseZalouserDirectoryGroupId } from "./session-route.js";
|
||||
|
||||
type ZalouserDirectoryDeps = {
|
||||
listZaloGroupMembers: (
|
||||
profile: string,
|
||||
groupId: string,
|
||||
) => Promise<
|
||||
Array<{
|
||||
userId: string;
|
||||
displayName?: string | null;
|
||||
avatar?: string | null;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listZalouserDirectoryGroupMembers(
|
||||
params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
groupId: string;
|
||||
limit?: number;
|
||||
},
|
||||
deps: ZalouserDirectoryDeps,
|
||||
) {
|
||||
const account = resolveZalouserAccountSync({ cfg: params.cfg, accountId: params.accountId });
|
||||
const normalizedGroupId = parseZalouserDirectoryGroupId(params.groupId);
|
||||
const members = await deps.listZaloGroupMembers(account.profile, normalizedGroupId);
|
||||
const rows = members.map((member) =>
|
||||
mapUser({
|
||||
id: member.userId,
|
||||
name: member.displayName,
|
||||
avatarUrl: member.avatar ?? null,
|
||||
raw: member,
|
||||
}),
|
||||
);
|
||||
return typeof params.limit === "number" && params.limit > 0 ? rows.slice(0, params.limit) : rows;
|
||||
}
|
||||
157
extensions/zalouser/src/doctor-contract.ts
Normal file
157
extensions/zalouser/src/doctor-contract.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
// Zalouser plugin module implements doctor contract behavior.
|
||||
import type {
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
|
||||
type ZalouserChannelsConfig = NonNullable<OpenClawConfig["channels"]>;
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function hasLegacyZalouserGroupAllowAlias(value: unknown): boolean {
|
||||
const group = asObjectRecord(value);
|
||||
return Boolean(group && typeof group.allow === "boolean");
|
||||
}
|
||||
|
||||
function hasLegacyZalouserGroupAllowAliases(value: unknown): boolean {
|
||||
const groups = asObjectRecord(value);
|
||||
return Boolean(
|
||||
groups && Object.values(groups).some((group) => hasLegacyZalouserGroupAllowAlias(group)),
|
||||
);
|
||||
}
|
||||
|
||||
function hasLegacyZalouserAccountGroupAllowAliases(value: unknown): boolean {
|
||||
const accounts = asObjectRecord(value);
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) => {
|
||||
const accountRecord = asObjectRecord(account);
|
||||
return Boolean(accountRecord && hasLegacyZalouserGroupAllowAliases(accountRecord.groups));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeZalouserGroupAllowAliases(params: {
|
||||
groups: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { groups: Record<string, unknown>; changed: boolean } {
|
||||
let changed = false;
|
||||
const nextGroups: Record<string, unknown> = { ...params.groups };
|
||||
for (const [groupId, groupValue] of Object.entries(params.groups)) {
|
||||
const group = asObjectRecord(groupValue);
|
||||
if (!group || typeof group.allow !== "boolean") {
|
||||
continue;
|
||||
}
|
||||
const nextGroup = { ...group };
|
||||
if (typeof nextGroup.enabled !== "boolean") {
|
||||
nextGroup.enabled = group.allow;
|
||||
}
|
||||
delete nextGroup.allow;
|
||||
nextGroups[groupId] = nextGroup;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.${groupId}.allow → ${params.pathPrefix}.${groupId}.enabled (${String(nextGroup.enabled)}).`,
|
||||
);
|
||||
}
|
||||
return { groups: nextGroups, changed };
|
||||
}
|
||||
|
||||
function normalizeZalouserCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
|
||||
const channels = asObjectRecord(cfg.channels);
|
||||
const zalouser = asObjectRecord(channels?.zalouser);
|
||||
if (!zalouser) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
let updatedZalouser: Record<string, unknown> = zalouser;
|
||||
let changed = false;
|
||||
|
||||
const groups = asObjectRecord(updatedZalouser.groups);
|
||||
if (groups) {
|
||||
const normalized = normalizeZalouserGroupAllowAliases({
|
||||
groups,
|
||||
pathPrefix: "channels.zalouser.groups",
|
||||
changes,
|
||||
});
|
||||
if (normalized.changed) {
|
||||
updatedZalouser = { ...updatedZalouser, groups: normalized.groups };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = asObjectRecord(updatedZalouser.accounts);
|
||||
if (accounts) {
|
||||
let accountsChanged = false;
|
||||
const nextAccounts: Record<string, unknown> = { ...accounts };
|
||||
for (const [accountId, accountValue] of Object.entries(accounts)) {
|
||||
const account = asObjectRecord(accountValue);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const accountGroups = asObjectRecord(account.groups);
|
||||
if (!accountGroups) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeZalouserGroupAllowAliases({
|
||||
groups: accountGroups,
|
||||
pathPrefix: `channels.zalouser.accounts.${accountId}.groups`,
|
||||
changes,
|
||||
});
|
||||
if (!normalized.changed) {
|
||||
continue;
|
||||
}
|
||||
nextAccounts[accountId] = {
|
||||
...account,
|
||||
groups: normalized.groups,
|
||||
};
|
||||
accountsChanged = true;
|
||||
}
|
||||
if (accountsChanged) {
|
||||
updatedZalouser = { ...updatedZalouser, accounts: nextAccounts };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: updatedZalouser as ZalouserChannelsConfig["zalouser"],
|
||||
},
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "zalouser", "groups"],
|
||||
message:
|
||||
'channels.zalouser.groups.<id>.allow is legacy; use channels.zalouser.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
|
||||
match: hasLegacyZalouserGroupAllowAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "zalouser", "accounts"],
|
||||
message:
|
||||
'channels.zalouser.accounts.<id>.groups.<id>.allow is legacy; use channels.zalouser.accounts.<id>.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
|
||||
match: hasLegacyZalouserAccountGroupAllowAliases,
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
return normalizeZalouserCompatibilityConfig(params.cfg);
|
||||
}
|
||||
88
extensions/zalouser/src/doctor.test.ts
Normal file
88
extensions/zalouser/src/doctor.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
// Zalouser tests cover doctor plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { zalouserDoctor } from "./doctor.js";
|
||||
|
||||
function getZaloUserCompatibilityNormalizer(): NonNullable<
|
||||
typeof zalouserDoctor.normalizeCompatibilityConfig
|
||||
> {
|
||||
const normalize = zalouserDoctor.normalizeCompatibilityConfig;
|
||||
if (!normalize) {
|
||||
throw new Error("Expected zalouser doctor to expose normalizeCompatibilityConfig");
|
||||
}
|
||||
return normalize;
|
||||
}
|
||||
|
||||
describe("zalouser doctor", () => {
|
||||
it("warns when mutable group names rely on disabled name matching", async () => {
|
||||
const warnings = await Promise.resolve(
|
||||
zalouserDoctor.collectMutableAllowlistWarnings?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups: {
|
||||
"group:trusted": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}) ?? [],
|
||||
);
|
||||
|
||||
expect(
|
||||
warnings.some((warning: string) =>
|
||||
warning.includes("mutable allowlist entry across zalouser"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
warnings.some((warning: string) =>
|
||||
warning.includes("channels.zalouser.groups: group:trusted"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes legacy group allow aliases to enabled", () => {
|
||||
const normalize = getZaloUserCompatibilityNormalizer();
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups: {
|
||||
"group:trusted": {
|
||||
allow: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
groups: {
|
||||
"group:legacy": {
|
||||
allow: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.zalouser?.groups?.["group:trusted"]).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
result.config.channels?.zalouser?.accounts?.work as
|
||||
| { groups?: Record<string, unknown> }
|
||||
| undefined
|
||||
)?.groups?.["group:legacy"],
|
||||
).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.changes).toEqual([
|
||||
"Moved channels.zalouser.groups.group:trusted.allow → channels.zalouser.groups.group:trusted.enabled (true).",
|
||||
"Moved channels.zalouser.accounts.work.groups.group:legacy.allow → channels.zalouser.accounts.work.groups.group:legacy.enabled (false).",
|
||||
]);
|
||||
});
|
||||
});
|
||||
38
extensions/zalouser/src/doctor.ts
Normal file
38
extensions/zalouser/src/doctor.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Zalouser plugin module implements doctor behavior.
|
||||
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
|
||||
import { isZalouserMutableGroupEntry } from "./security-audit.js";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
const collectZalouserMutableAllowlistWarnings =
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "zalouser",
|
||||
detector: isZalouserMutableGroupEntry,
|
||||
collectLists: (scope) => {
|
||||
const groups = asObjectRecord(scope.account.groups);
|
||||
return groups
|
||||
? [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.groups`,
|
||||
list: Object.keys(groups),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
});
|
||||
|
||||
export const zalouserDoctor: ChannelDoctorAdapter = {
|
||||
dmAllowFromMode: "topOnly",
|
||||
groupModel: "hybrid",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: false,
|
||||
legacyConfigRules,
|
||||
normalizeCompatibilityConfig,
|
||||
collectMutableAllowlistWarnings: collectZalouserMutableAllowlistWarnings,
|
||||
};
|
||||
62
extensions/zalouser/src/group-policy.test.ts
Normal file
62
extensions/zalouser/src/group-policy.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
// Zalouser tests cover group policy plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildZalouserGroupCandidates,
|
||||
findZalouserGroupEntry,
|
||||
isZalouserGroupEntryAllowed,
|
||||
normalizeZalouserGroupSlug,
|
||||
} from "./group-policy.js";
|
||||
|
||||
describe("zalouser group policy helpers", () => {
|
||||
it("normalizes group slug names", () => {
|
||||
expect(normalizeZalouserGroupSlug(" Team Alpha ")).toBe("team-alpha");
|
||||
expect(normalizeZalouserGroupSlug("#Roadmap Updates")).toBe("roadmap-updates");
|
||||
});
|
||||
|
||||
it("builds ordered candidates with optional aliases", () => {
|
||||
expect(
|
||||
buildZalouserGroupCandidates({
|
||||
groupId: "123",
|
||||
groupChannel: "chan-1",
|
||||
groupName: "Team Alpha",
|
||||
includeGroupIdAlias: true,
|
||||
}),
|
||||
).toEqual(["123", "group:123", "chan-1", "Team Alpha", "team-alpha", "*"]);
|
||||
});
|
||||
|
||||
it("builds id-only candidates when name matching is disabled", () => {
|
||||
expect(
|
||||
buildZalouserGroupCandidates({
|
||||
groupId: "123",
|
||||
groupChannel: "chan-1",
|
||||
groupName: "Team Alpha",
|
||||
includeGroupIdAlias: true,
|
||||
allowNameMatching: false,
|
||||
}),
|
||||
).toEqual(["123", "group:123", "*"]);
|
||||
});
|
||||
|
||||
it("finds the first matching group entry", () => {
|
||||
const groups = {
|
||||
"group:123": { enabled: true },
|
||||
"team-alpha": { requireMention: false },
|
||||
"*": { requireMention: true },
|
||||
};
|
||||
const entry = findZalouserGroupEntry(
|
||||
groups,
|
||||
buildZalouserGroupCandidates({
|
||||
groupId: "123",
|
||||
groupName: "Team Alpha",
|
||||
includeGroupIdAlias: true,
|
||||
}),
|
||||
);
|
||||
expect(entry).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("evaluates allow/enable flags", () => {
|
||||
expect(isZalouserGroupEntryAllowed({ enabled: true })).toBe(true);
|
||||
expect(isZalouserGroupEntryAllowed({ allow: false } as never)).toBe(false);
|
||||
expect(isZalouserGroupEntryAllowed({ enabled: false })).toBe(false);
|
||||
expect(isZalouserGroupEntryAllowed(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
84
extensions/zalouser/src/group-policy.ts
Normal file
84
extensions/zalouser/src/group-policy.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Zalouser plugin module implements group policy behavior.
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { ZalouserGroupConfig } from "./types.js";
|
||||
|
||||
type ZalouserGroups = Record<string, ZalouserGroupConfig>;
|
||||
|
||||
function toGroupCandidate(value?: string | null): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function normalizeZalouserGroupSlug(raw?: string | null): string {
|
||||
const trimmed = normalizeOptionalLowercaseString(raw) ?? "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return trimmed
|
||||
.replace(/^#/, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function buildZalouserGroupCandidates(params: {
|
||||
groupId?: string | null;
|
||||
groupChannel?: string | null;
|
||||
groupName?: string | null;
|
||||
includeGroupIdAlias?: boolean;
|
||||
includeWildcard?: boolean;
|
||||
allowNameMatching?: boolean;
|
||||
}): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
const push = (value?: string | null) => {
|
||||
const normalized = toGroupCandidate(value);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
out.push(normalized);
|
||||
};
|
||||
|
||||
const groupId = toGroupCandidate(params.groupId);
|
||||
const groupChannel = toGroupCandidate(params.groupChannel);
|
||||
const groupName = toGroupCandidate(params.groupName);
|
||||
|
||||
push(groupId);
|
||||
if (params.includeGroupIdAlias === true && groupId) {
|
||||
push(`group:${groupId}`);
|
||||
}
|
||||
if (params.allowNameMatching !== false) {
|
||||
push(groupChannel);
|
||||
push(groupName);
|
||||
if (groupName) {
|
||||
push(normalizeZalouserGroupSlug(groupName));
|
||||
}
|
||||
}
|
||||
if (params.includeWildcard !== false) {
|
||||
push("*");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function findZalouserGroupEntry(
|
||||
groups: ZalouserGroups | undefined,
|
||||
candidates: string[],
|
||||
): ZalouserGroupConfig | undefined {
|
||||
if (!groups) {
|
||||
return undefined;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const entry = groups[candidate];
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isZalouserGroupEntryAllowed(entry: ZalouserGroupConfig | undefined): boolean {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
const legacyAllow = (entry as ZalouserGroupConfig & { allow?: unknown }).allow;
|
||||
return legacyAllow !== false && entry.enabled !== false;
|
||||
}
|
||||
67
extensions/zalouser/src/message-sid.test.ts
Normal file
67
extensions/zalouser/src/message-sid.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// Zalouser tests cover message sid plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatZalouserMessageSidFull,
|
||||
parseZalouserMessageSidFull,
|
||||
resolveZalouserMessageSid,
|
||||
resolveZalouserReactionMessageIds,
|
||||
} from "./message-sid.js";
|
||||
|
||||
describe("zalouser message sid helpers", () => {
|
||||
it("parses MessageSidFull pairs", () => {
|
||||
expect(parseZalouserMessageSidFull("111:222")).toEqual({
|
||||
msgId: "111",
|
||||
cliMsgId: "222",
|
||||
});
|
||||
expect(parseZalouserMessageSidFull("111")).toBeNull();
|
||||
expect(parseZalouserMessageSidFull(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves reaction ids from explicit params first", () => {
|
||||
expect(
|
||||
resolveZalouserReactionMessageIds({
|
||||
messageId: "m-1",
|
||||
cliMsgId: "c-1",
|
||||
currentMessageId: "x:y",
|
||||
}),
|
||||
).toEqual({
|
||||
msgId: "m-1",
|
||||
cliMsgId: "c-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves reaction ids from current message sid full", () => {
|
||||
expect(
|
||||
resolveZalouserReactionMessageIds({
|
||||
currentMessageId: "m-2:c-2",
|
||||
}),
|
||||
).toEqual({
|
||||
msgId: "m-2",
|
||||
cliMsgId: "c-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to duplicated current id when no pair is available", () => {
|
||||
expect(
|
||||
resolveZalouserReactionMessageIds({
|
||||
currentMessageId: "solo",
|
||||
}),
|
||||
).toEqual({
|
||||
msgId: "solo",
|
||||
cliMsgId: "solo",
|
||||
});
|
||||
});
|
||||
|
||||
it("formats message sid fields for context payload", () => {
|
||||
expect(formatZalouserMessageSidFull({ msgId: "1", cliMsgId: "2" })).toBe("1:2");
|
||||
expect(formatZalouserMessageSidFull({ msgId: "1" })).toBe("1");
|
||||
expect(formatZalouserMessageSidFull({ cliMsgId: "2" })).toBe("2");
|
||||
expect(formatZalouserMessageSidFull({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves primary message sid with fallback timestamp", () => {
|
||||
expect(resolveZalouserMessageSid({ msgId: "1", cliMsgId: "2", fallback: "t" })).toBe("1");
|
||||
expect(resolveZalouserMessageSid({ cliMsgId: "2", fallback: "t" })).toBe("2");
|
||||
expect(resolveZalouserMessageSid({ fallback: "t" })).toBe("t");
|
||||
});
|
||||
});
|
||||
81
extensions/zalouser/src/message-sid.ts
Normal file
81
extensions/zalouser/src/message-sid.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
// Zalouser plugin module implements message sid behavior.
|
||||
function toMessageSidPart(value?: string | number | null): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(Math.trunc(value));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function parseZalouserMessageSidFull(
|
||||
value?: string | number | null,
|
||||
): { msgId: string; cliMsgId: string } | null {
|
||||
const raw = toMessageSidPart(value);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const [msgIdPart, cliMsgIdPart] = raw.split(":").map((entry) => entry.trim());
|
||||
if (!msgIdPart || !cliMsgIdPart) {
|
||||
return null;
|
||||
}
|
||||
return { msgId: msgIdPart, cliMsgId: cliMsgIdPart };
|
||||
}
|
||||
|
||||
export function resolveZalouserReactionMessageIds(params: {
|
||||
messageId?: string;
|
||||
cliMsgId?: string;
|
||||
currentMessageId?: string | number;
|
||||
}): { msgId: string; cliMsgId: string } | null {
|
||||
const explicitMessageId = toMessageSidPart(params.messageId);
|
||||
const explicitCliMsgId = toMessageSidPart(params.cliMsgId);
|
||||
if (explicitMessageId && explicitCliMsgId) {
|
||||
return { msgId: explicitMessageId, cliMsgId: explicitCliMsgId };
|
||||
}
|
||||
|
||||
const parsedFromCurrent = parseZalouserMessageSidFull(params.currentMessageId);
|
||||
if (parsedFromCurrent) {
|
||||
return parsedFromCurrent;
|
||||
}
|
||||
|
||||
const currentRaw = toMessageSidPart(params.currentMessageId);
|
||||
if (!currentRaw) {
|
||||
return null;
|
||||
}
|
||||
if (explicitMessageId && !explicitCliMsgId) {
|
||||
return { msgId: explicitMessageId, cliMsgId: currentRaw };
|
||||
}
|
||||
if (!explicitMessageId && explicitCliMsgId) {
|
||||
return { msgId: currentRaw, cliMsgId: explicitCliMsgId };
|
||||
}
|
||||
return { msgId: currentRaw, cliMsgId: currentRaw };
|
||||
}
|
||||
|
||||
export function formatZalouserMessageSidFull(params: {
|
||||
msgId?: string | null;
|
||||
cliMsgId?: string | null;
|
||||
}): string | undefined {
|
||||
const msgId = toMessageSidPart(params.msgId);
|
||||
const cliMsgId = toMessageSidPart(params.cliMsgId);
|
||||
if (!msgId && !cliMsgId) {
|
||||
return undefined;
|
||||
}
|
||||
if (msgId && cliMsgId) {
|
||||
return `${msgId}:${cliMsgId}`;
|
||||
}
|
||||
return msgId || cliMsgId || undefined;
|
||||
}
|
||||
|
||||
export function resolveZalouserMessageSid(params: {
|
||||
msgId?: string | null;
|
||||
cliMsgId?: string | null;
|
||||
fallback?: string | null;
|
||||
}): string | undefined {
|
||||
const msgId = toMessageSidPart(params.msgId);
|
||||
const cliMsgId = toMessageSidPart(params.cliMsgId);
|
||||
if (msgId || cliMsgId) {
|
||||
return msgId || cliMsgId;
|
||||
}
|
||||
return toMessageSidPart(params.fallback) || undefined;
|
||||
}
|
||||
123
extensions/zalouser/src/monitor.account-scope.test.ts
Normal file
123
extensions/zalouser/src/monitor.account-scope.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Zalouser tests cover monitor.account scope plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import "./monitor.send.test-mocks.js";
|
||||
import { testing } from "./monitor.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import { sendMessageZalouserMock } from "./monitor.send.test-mocks.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import { createZalouserRuntimeEnv } from "./test-helpers.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`expected ${label} to be a record`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("zalouser monitor pairing account scoping", () => {
|
||||
it("scopes DM pairing-store reads and pairing requests to accountId", async () => {
|
||||
const readAllowFromStore = vi.fn(
|
||||
async (
|
||||
channelOrParams:
|
||||
| string
|
||||
| {
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
},
|
||||
_env?: NodeJS.ProcessEnv,
|
||||
accountId?: string,
|
||||
) => {
|
||||
const scopedAccountId =
|
||||
typeof channelOrParams === "object" && channelOrParams !== null
|
||||
? channelOrParams.accountId
|
||||
: accountId;
|
||||
return scopedAccountId === "beta" ? [] : ["attacker"];
|
||||
},
|
||||
);
|
||||
const upsertPairingRequest = vi.fn(
|
||||
async (_params: { channel: string; id: string; accountId?: string }) => ({
|
||||
code: "PAIRME88",
|
||||
created: true,
|
||||
}),
|
||||
);
|
||||
|
||||
setZalouserRuntime({
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore,
|
||||
upsertPairingRequest,
|
||||
buildPairingReply: vi.fn(() => "pairing reply"),
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn(() => false),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
||||
isControlCommandMessage: vi.fn(() => false),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
const account: ResolvedZalouserAccount = {
|
||||
accountId: "beta",
|
||||
enabled: true,
|
||||
profile: "beta",
|
||||
authenticated: true,
|
||||
config: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
},
|
||||
};
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
alpha: { dmPolicy: "pairing", allowFrom: [] },
|
||||
beta: { dmPolicy: "pairing", allowFrom: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const message: ZaloInboundMessage = {
|
||||
threadId: "chat-1",
|
||||
isGroup: false,
|
||||
senderId: "attacker",
|
||||
senderName: "Attacker",
|
||||
groupName: undefined,
|
||||
timestampMs: Date.now(),
|
||||
msgId: "msg-1",
|
||||
content: "hello",
|
||||
raw: { source: "test" },
|
||||
};
|
||||
|
||||
await testing.processMessage({
|
||||
message,
|
||||
account,
|
||||
config,
|
||||
runtime: createZalouserRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledOnce();
|
||||
const allowStoreParams = requireRecord(
|
||||
readAllowFromStore.mock.calls[0]?.[0],
|
||||
"allow store params",
|
||||
);
|
||||
expect(allowStoreParams.channel).toBe("zalouser");
|
||||
expect(allowStoreParams.accountId).toBe("beta");
|
||||
|
||||
expect(upsertPairingRequest).toHaveBeenCalledOnce();
|
||||
const pairingRequest = requireRecord(
|
||||
upsertPairingRequest.mock.calls[0]?.[0],
|
||||
"pairing request params",
|
||||
);
|
||||
expect(pairingRequest.channel).toBe("zalouser");
|
||||
expect(pairingRequest.id).toBe("attacker");
|
||||
expect(pairingRequest.accountId).toBe("beta");
|
||||
expect(sendMessageZalouserMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
975
extensions/zalouser/src/monitor.group-gating.test.ts
Normal file
975
extensions/zalouser/src/monitor.group-gating.test.ts
Normal file
@@ -0,0 +1,975 @@
|
||||
// Zalouser tests cover monitor.group gating plugin behavior.
|
||||
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import "./monitor.send.test-mocks.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import { resolveZalouserAccountSync } from "./accounts.js";
|
||||
import { testing, monitorZalouserProvider } from "./monitor.js";
|
||||
import {
|
||||
sendDeliveredZalouserMock,
|
||||
sendMessageZalouserMock,
|
||||
sendSeenZalouserMock,
|
||||
sendTypingZalouserMock,
|
||||
} from "./monitor.send.test-mocks.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import { createZalouserRuntimeEnv } from "./test-helpers.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
import {
|
||||
listZaloFriendsMock,
|
||||
listZaloGroupsMock,
|
||||
startZaloListenerMock,
|
||||
} from "./zalo-js.test-mocks.js";
|
||||
|
||||
function createAccount(): ResolvedZalouserAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
profile: "default",
|
||||
authenticated: true,
|
||||
config: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createConfig(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const createRuntimeEnv = () => createZalouserRuntimeEnv();
|
||||
|
||||
type DispatchReplyCallArg = {
|
||||
ctx?: {
|
||||
Body?: string;
|
||||
BodyForCommands?: string;
|
||||
CommandAuthorized?: boolean;
|
||||
CommandBody?: string;
|
||||
InboundHistory?: unknown;
|
||||
OriginatingTo?: string;
|
||||
ReplyToBody?: string;
|
||||
ReplyToId?: string;
|
||||
ReplyToIsQuote?: boolean;
|
||||
SessionKey?: string;
|
||||
To?: string;
|
||||
WasMentioned?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function mockCallArg(mock: unknown, label: string, index = 0) {
|
||||
const call = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls?.at(index);
|
||||
if (!call) {
|
||||
throw new Error(`Expected ${label} call ${index + 1}`);
|
||||
}
|
||||
return call[0];
|
||||
}
|
||||
|
||||
function dispatchReplyCall(mock: unknown, index = 0): DispatchReplyCallArg {
|
||||
return mockCallArg(mock, "dispatch reply", index) as DispatchReplyCallArg;
|
||||
}
|
||||
|
||||
function installRuntime(params: {
|
||||
commandAuthorized?: boolean;
|
||||
replyPayload?: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
|
||||
resolveCommandAuthorizedFromAuthorizers?: (params: {
|
||||
useAccessGroups: boolean;
|
||||
authorizers: Array<{ configured: boolean; allowed: boolean }>;
|
||||
}) => boolean;
|
||||
}) {
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => {
|
||||
await dispatcherOptions.typingCallbacks?.onReplyStart?.();
|
||||
if (params.replyPayload) {
|
||||
await dispatcherOptions.deliver(params.replyPayload);
|
||||
}
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx };
|
||||
});
|
||||
const resolveCommandAuthorizedFromAuthorizers = vi.fn(
|
||||
(input: {
|
||||
useAccessGroups: boolean;
|
||||
authorizers: Array<{ configured: boolean; allowed: boolean }>;
|
||||
}) => {
|
||||
if (params.resolveCommandAuthorizedFromAuthorizers) {
|
||||
return params.resolveCommandAuthorizedFromAuthorizers(input);
|
||||
}
|
||||
return params.commandAuthorized ?? false;
|
||||
},
|
||||
);
|
||||
const resolveAgentRoute = vi.fn((input: { peer?: { kind?: string; id?: string } }) => {
|
||||
const peerKind = input.peer?.kind === "direct" ? "direct" : "group";
|
||||
const peerId = input.peer?.id ?? "1";
|
||||
return {
|
||||
agentId: "main",
|
||||
sessionKey:
|
||||
peerKind === "direct" ? "agent:main:main" : `agent:main:zalouser:${peerKind}:${peerId}`,
|
||||
accountId: "default",
|
||||
mainSessionKey: "agent:main:main",
|
||||
};
|
||||
});
|
||||
const readAllowFromStore = vi.fn(async () => []);
|
||||
const readSessionUpdatedAt = vi.fn(
|
||||
(_params?: { storePath: string; sessionKey: string }): number | undefined => undefined,
|
||||
);
|
||||
type ResolvedTurn = Parameters<PluginRuntime["channel"]["inbound"]["dispatchReply"]>[0];
|
||||
const dispatchAssembled = vi.fn(async (turn: ResolvedTurn) => {
|
||||
await turn.recordInboundSession({
|
||||
storePath: turn.storePath,
|
||||
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
|
||||
ctx: turn.ctxPayload,
|
||||
groupResolution: turn.record?.groupResolution,
|
||||
createIfMissing: turn.record?.createIfMissing,
|
||||
updateLastRoute: turn.record?.updateLastRoute,
|
||||
onRecordError: turn.record?.onRecordError ?? (() => undefined),
|
||||
});
|
||||
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
|
||||
cfg: turn.cfg,
|
||||
agentId: turn.agentId,
|
||||
channel: "zalouser",
|
||||
accountId: turn.accountId,
|
||||
...turn.replyPipeline,
|
||||
});
|
||||
const dispatchResult = await turn.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: turn.ctxPayload,
|
||||
cfg: turn.cfg,
|
||||
dispatcherOptions: {
|
||||
...replyPipeline,
|
||||
...turn.dispatcherOptions,
|
||||
deliver: async (...args: Parameters<typeof turn.delivery.deliver>) => {
|
||||
await turn.delivery.deliver(...args);
|
||||
},
|
||||
onError: turn.delivery.onError,
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
...turn.replyOptions,
|
||||
},
|
||||
replyResolver: turn.replyResolver,
|
||||
});
|
||||
return {
|
||||
admission: { kind: "dispatch" as const },
|
||||
dispatched: true,
|
||||
ctxPayload: turn.ctxPayload,
|
||||
routeSessionKey: turn.routeSessionKey,
|
||||
dispatchResult,
|
||||
};
|
||||
});
|
||||
const buildContext = vi.fn(
|
||||
(paramsLocal: Parameters<PluginRuntime["channel"]["inbound"]["buildContext"]>[0]) =>
|
||||
({
|
||||
Body: paramsLocal.message.body ?? paramsLocal.message.rawBody,
|
||||
BodyForAgent: paramsLocal.message.bodyForAgent ?? paramsLocal.message.rawBody,
|
||||
InboundHistory: paramsLocal.message.inboundHistory,
|
||||
RawBody: paramsLocal.message.rawBody,
|
||||
CommandBody: paramsLocal.message.commandBody ?? paramsLocal.message.rawBody,
|
||||
BodyForCommands: paramsLocal.message.commandBody ?? paramsLocal.message.rawBody,
|
||||
From: paramsLocal.from,
|
||||
To: paramsLocal.reply.to,
|
||||
SessionKey: paramsLocal.route.dispatchSessionKey ?? paramsLocal.route.routeSessionKey,
|
||||
AccountId: paramsLocal.route.accountId ?? paramsLocal.accountId,
|
||||
ChatType: paramsLocal.conversation.kind,
|
||||
ConversationLabel: paramsLocal.conversation.label,
|
||||
SenderName: paramsLocal.sender.name,
|
||||
SenderId: paramsLocal.sender.id,
|
||||
Provider: paramsLocal.provider ?? paramsLocal.channel,
|
||||
Surface: paramsLocal.surface ?? paramsLocal.provider ?? paramsLocal.channel,
|
||||
MessageSid: paramsLocal.messageId,
|
||||
MessageSidFull: paramsLocal.messageIdFull,
|
||||
OriginatingChannel: paramsLocal.channel,
|
||||
OriginatingTo: paramsLocal.reply.originatingTo,
|
||||
...paramsLocal.extra,
|
||||
}) as Awaited<ReturnType<PluginRuntime["channel"]["inbound"]["buildContext"]>>,
|
||||
);
|
||||
const buildAgentSessionKey = vi.fn(
|
||||
(input: {
|
||||
agentId: string;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
peer?: { kind?: string; id?: string };
|
||||
dmScope?: string;
|
||||
}) => {
|
||||
const peerKind = input.peer?.kind === "direct" ? "direct" : "group";
|
||||
const peerId = input.peer?.id ?? "1";
|
||||
if (peerKind === "direct") {
|
||||
if (input.dmScope === "per-account-channel-peer") {
|
||||
return `agent:${input.agentId}:${input.channel}:${input.accountId ?? "default"}:direct:${peerId}`;
|
||||
}
|
||||
if (input.dmScope === "per-peer") {
|
||||
return `agent:${input.agentId}:direct:${peerId}`;
|
||||
}
|
||||
if (input.dmScope === "main" || !input.dmScope) {
|
||||
return "agent:main:main";
|
||||
}
|
||||
}
|
||||
return `agent:${input.agentId}:${input.channel}:${peerKind}:${peerId}`;
|
||||
},
|
||||
);
|
||||
|
||||
setZalouserRuntime({
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore,
|
||||
upsertPairingRequest: vi.fn(async () => ({ code: "PAIR", created: true })),
|
||||
buildPairingReply: vi.fn(() => "pair"),
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn((body: string) => body.trim().startsWith("/")),
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
isControlCommandMessage: vi.fn((body: string) => body.trim().startsWith("/")),
|
||||
shouldHandleTextCommands: vi.fn(() => true),
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes: vi.fn(() => []),
|
||||
matchesMentionWithExplicit: vi.fn(
|
||||
(input) => input.explicit?.isExplicitlyMentioned === true,
|
||||
),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: vi.fn((input) => {
|
||||
const cfg = input.cfg as OpenClawConfig;
|
||||
const groupCfg = cfg.channels?.zalouser?.groups ?? {};
|
||||
const typedGroupCfg = groupCfg as Record<string, { requireMention?: boolean }>;
|
||||
const groupEntry = input.groupId ? typedGroupCfg[input.groupId] : undefined;
|
||||
const defaultEntry = typedGroupCfg["*"];
|
||||
if (typeof groupEntry?.requireMention === "boolean") {
|
||||
return groupEntry.requireMention;
|
||||
}
|
||||
if (typeof defaultEntry?.requireMention === "boolean") {
|
||||
return defaultEntry.requireMention;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
},
|
||||
routing: {
|
||||
buildAgentSessionKey,
|
||||
resolveAgentRoute,
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: vi.fn(() => "/tmp"),
|
||||
readSessionUpdatedAt,
|
||||
recordInboundSession: vi.fn(async () => {}),
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: vi.fn(() => undefined),
|
||||
formatAgentEnvelope: vi.fn(({ body }) => body),
|
||||
finalizeInboundContext: vi.fn((ctx) => ctx),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
},
|
||||
inbound: {
|
||||
dispatchReply:
|
||||
dispatchAssembled as unknown as PluginRuntime["channel"]["inbound"]["dispatchReply"],
|
||||
buildContext:
|
||||
buildContext as unknown as PluginRuntime["channel"]["inbound"]["buildContext"],
|
||||
},
|
||||
text: {
|
||||
resolveMarkdownTableMode: vi.fn(() => "code"),
|
||||
convertMarkdownTables: vi.fn((text: string) => text),
|
||||
resolveChunkMode: vi.fn(() => "length"),
|
||||
resolveTextChunkLimit: vi.fn(() => 1200),
|
||||
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
return {
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
resolveAgentRoute,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
readAllowFromStore,
|
||||
readSessionUpdatedAt,
|
||||
buildAgentSessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
function installGroupCommandAuthRuntime() {
|
||||
return installRuntime({
|
||||
resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
|
||||
useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
|
||||
});
|
||||
}
|
||||
|
||||
async function processGroupControlCommand(params: {
|
||||
account: ResolvedZalouserAccount;
|
||||
content?: string;
|
||||
commandContent?: string;
|
||||
}) {
|
||||
await testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: params.content ?? "/new",
|
||||
commandContent: params.commandContent ?? "/new",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
}),
|
||||
account: params.account,
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
}
|
||||
|
||||
function createGroupMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
|
||||
return {
|
||||
threadId: "g-1",
|
||||
isGroup: true,
|
||||
senderId: "123",
|
||||
senderName: "Alice",
|
||||
groupName: "Team",
|
||||
content: "hello",
|
||||
timestampMs: Date.now(),
|
||||
msgId: "m-1",
|
||||
hasAnyMention: false,
|
||||
wasExplicitlyMentioned: false,
|
||||
canResolveExplicitMention: true,
|
||||
implicitMention: false,
|
||||
raw: { source: "test" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createDmMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
|
||||
return {
|
||||
threadId: "u-1",
|
||||
isGroup: false,
|
||||
senderId: "321",
|
||||
senderName: "Bob",
|
||||
groupName: undefined,
|
||||
content: "hello",
|
||||
timestampMs: Date.now(),
|
||||
msgId: "dm-1",
|
||||
raw: { source: "test" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("zalouser monitor group mention gating", () => {
|
||||
beforeEach(() => {
|
||||
sendMessageZalouserMock.mockClear();
|
||||
sendTypingZalouserMock.mockClear();
|
||||
sendDeliveredZalouserMock.mockClear();
|
||||
sendSeenZalouserMock.mockClear();
|
||||
listZaloFriendsMock.mockReset();
|
||||
listZaloFriendsMock.mockResolvedValue([]);
|
||||
listZaloGroupsMock.mockReset();
|
||||
listZaloGroupsMock.mockResolvedValue([]);
|
||||
startZaloListenerMock.mockReset();
|
||||
startZaloListenerMock.mockResolvedValue({ stop: vi.fn() });
|
||||
});
|
||||
|
||||
async function processMessageWithDefaults(params: {
|
||||
message: ZaloInboundMessage;
|
||||
account?: ResolvedZalouserAccount;
|
||||
historyState?: {
|
||||
historyLimit: number;
|
||||
groupHistories: Map<
|
||||
string,
|
||||
Array<{ sender: string; body: string; timestamp?: number; messageId?: string }>
|
||||
>;
|
||||
};
|
||||
}) {
|
||||
await testing.processMessage({
|
||||
message: params.message,
|
||||
account: params.account ?? createAccount(),
|
||||
config: createConfig(),
|
||||
runtime: createZalouserRuntimeEnv(),
|
||||
historyState: params.historyState,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSkippedGroupMessage(message?: Partial<ZaloInboundMessage>) {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await processMessageWithDefaults({
|
||||
message: createGroupMessage(message),
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
expect(sendTypingZalouserMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function startMonitorForStartupResolution(
|
||||
accountConfig: ResolvedZalouserAccount["config"],
|
||||
) {
|
||||
installRuntime({ commandAuthorized: false });
|
||||
const abortController = new AbortController();
|
||||
abortController.abort();
|
||||
await monitorZalouserProvider({
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: accountConfig,
|
||||
},
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
abortSignal: abortController.signal,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectGroupCommandAuthorizers(params: {
|
||||
accountConfig: ResolvedZalouserAccount["config"];
|
||||
expectedCommandAuthorized: boolean;
|
||||
}) {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
|
||||
installGroupCommandAuthRuntime();
|
||||
await processGroupControlCommand({
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: params.accountConfig,
|
||||
},
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
expect(resolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
|
||||
const callArg = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
|
||||
expect(callArg?.ctx?.CommandAuthorized).toBe(params.expectedCommandAuthorized);
|
||||
}
|
||||
|
||||
async function processOpenDmMessage(params?: {
|
||||
message?: Partial<ZaloInboundMessage>;
|
||||
readSessionUpdatedAt?: (input?: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
}) => number | undefined;
|
||||
}) {
|
||||
const runtime = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
if (params?.readSessionUpdatedAt) {
|
||||
runtime.readSessionUpdatedAt.mockImplementation(params.readSessionUpdatedAt);
|
||||
}
|
||||
const account = createAccount();
|
||||
await processMessageWithDefaults({
|
||||
message: createDmMessage(params?.message),
|
||||
account: {
|
||||
...account,
|
||||
config: {
|
||||
...account.config,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
});
|
||||
return runtime;
|
||||
}
|
||||
|
||||
async function expectDangerousNameMatching(params: {
|
||||
dangerouslyAllowNameMatching?: boolean;
|
||||
expectedDispatches: number;
|
||||
}) {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await processMessageWithDefaults({
|
||||
message: createGroupMessage({
|
||||
threadId: "g-attacker-001",
|
||||
groupName: "Trusted Team",
|
||||
senderId: "666",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
content: "ping @bot",
|
||||
}),
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: {
|
||||
...createAccount().config,
|
||||
...(params.dangerouslyAllowNameMatching ? { dangerouslyAllowNameMatching: true } : {}),
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["*"],
|
||||
groups: {
|
||||
"group:g-trusted-001": { enabled: true },
|
||||
"Trusted Team": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(
|
||||
params.expectedDispatches,
|
||||
);
|
||||
return dispatchReplyWithBufferedBlockDispatcher;
|
||||
}
|
||||
|
||||
async function dispatchGroupMessage(params: {
|
||||
commandAuthorized: boolean;
|
||||
message: Partial<ZaloInboundMessage>;
|
||||
}) {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: params.commandAuthorized,
|
||||
});
|
||||
await processMessageWithDefaults({
|
||||
message: createGroupMessage(params.message),
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
return dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
|
||||
}
|
||||
|
||||
it("skips unmentioned group messages when requireMention=true", async () => {
|
||||
await expectSkippedGroupMessage();
|
||||
});
|
||||
|
||||
it("blocks mentioned group messages by default when groupPolicy is omitted", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId: "default" });
|
||||
|
||||
await testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "ping @bot",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
}),
|
||||
account,
|
||||
config: cfg,
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(account.config.groupPolicy).toBe("allowlist");
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when requireMention=true but mention detection is unavailable", async () => {
|
||||
await expectSkippedGroupMessage({
|
||||
canResolveExplicitMention: false,
|
||||
hasAnyMention: false,
|
||||
wasExplicitlyMentioned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
|
||||
const callArg = await dispatchGroupMessage({
|
||||
commandAuthorized: false,
|
||||
message: {
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
content: "ping @bot",
|
||||
},
|
||||
});
|
||||
expect(callArg?.ctx?.WasMentioned).toBe(true);
|
||||
expect(callArg?.ctx?.To).toBe("zalouser:group:g-1");
|
||||
expect(callArg?.ctx?.OriginatingTo).toBe("zalouser:group:g-1");
|
||||
expect(sendTypingZalouserMock).toHaveBeenCalledWith("g-1", {
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows authorized control commands to bypass mention gating", async () => {
|
||||
const callArg = await dispatchGroupMessage({
|
||||
commandAuthorized: true,
|
||||
message: {
|
||||
content: "/status",
|
||||
hasAnyMention: false,
|
||||
wasExplicitlyMentioned: false,
|
||||
},
|
||||
});
|
||||
expect(callArg?.ctx?.WasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("passes long markdown replies through once so formatting happens before chunking", async () => {
|
||||
const replyText = `**${"a".repeat(2501)}**`;
|
||||
installRuntime({
|
||||
commandAuthorized: false,
|
||||
replyPayload: { text: replyText },
|
||||
});
|
||||
|
||||
await testing.processMessage({
|
||||
message: createDmMessage({
|
||||
content: "hello",
|
||||
}),
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: {
|
||||
...createAccount().config,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(sendMessageZalouserMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageZalouserMock).toHaveBeenCalledWith("u-1", replyText, {
|
||||
isGroup: false,
|
||||
profile: "default",
|
||||
textMode: "markdown",
|
||||
textChunkMode: "length",
|
||||
textChunkLimit: 1200,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows DM senders from static access groups", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await testing.processMessage({
|
||||
message: createDmMessage({ senderId: "321" }),
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: {
|
||||
...createAccount().config,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["accessGroup:operators"],
|
||||
},
|
||||
},
|
||||
config: {
|
||||
...createConfig(),
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: { zalouser: ["321"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses commandContent for mention-prefixed control commands", async () => {
|
||||
const callArg = await dispatchGroupMessage({
|
||||
commandAuthorized: true,
|
||||
message: {
|
||||
content: "@Bot /new",
|
||||
commandContent: "/new",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
},
|
||||
});
|
||||
expect(callArg?.ctx?.CommandBody).toBe("/new");
|
||||
expect(callArg?.ctx?.BodyForCommands).toBe("/new");
|
||||
});
|
||||
|
||||
it("allows group control commands when only allowFrom is configured", async () => {
|
||||
await expectGroupCommandAuthorizers({
|
||||
accountConfig: {
|
||||
...createAccount().config,
|
||||
allowFrom: ["123"],
|
||||
},
|
||||
expectedCommandAuthorized: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks routed allowlist groups without an explicit group sender allowlist", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "ping @bot",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
senderId: "456",
|
||||
}),
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: {
|
||||
...createAccount().config,
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["123"],
|
||||
groups: {
|
||||
"group:g-1": { enabled: true, requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows group senders from static access groups", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "ping @bot",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
senderId: "123",
|
||||
}),
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: {
|
||||
...createAccount().config,
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["accessGroup:operators"],
|
||||
},
|
||||
},
|
||||
config: {
|
||||
...createConfig(),
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: { zalouser: ["123"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks group messages when sender is not in groupAllowFrom", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "ping @bot",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
}),
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: {
|
||||
...createAccount().config,
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["999"],
|
||||
groupAllowFrom: ["999"],
|
||||
},
|
||||
},
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not accept a different group id by matching only the mutable group name by default", async () => {
|
||||
await expectDangerousNameMatching({ expectedDispatches: 0 });
|
||||
});
|
||||
|
||||
it("accepts mutable group-name matches only when dangerouslyAllowNameMatching is enabled", async () => {
|
||||
const dispatchReplyWithBufferedBlockDispatcher = await expectDangerousNameMatching({
|
||||
dangerouslyAllowNameMatching: true,
|
||||
expectedDispatches: 1,
|
||||
});
|
||||
const callArg = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
|
||||
expect(callArg?.ctx?.To).toBe("zalouser:group:g-attacker-001");
|
||||
});
|
||||
|
||||
it("does not resolve mutable allowlist or group names at startup by default", async () => {
|
||||
listZaloFriendsMock.mockResolvedValue([{ userId: "999", displayName: "Alice" }]);
|
||||
listZaloGroupsMock.mockResolvedValue([{ groupId: "g-other", name: "Trusted Team" }]);
|
||||
|
||||
await startMonitorForStartupResolution({
|
||||
...createAccount().config,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["Alice"],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["Alice"],
|
||||
groups: {
|
||||
"Trusted Team": { enabled: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(listZaloFriendsMock).not.toHaveBeenCalled();
|
||||
expect(listZaloGroupsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves mutable allowlist and group names at startup when enabled", async () => {
|
||||
listZaloFriendsMock.mockResolvedValue([{ userId: "123", displayName: "Alice" }]);
|
||||
listZaloGroupsMock.mockResolvedValue([{ groupId: "g-trusted", name: "Trusted Team" }]);
|
||||
|
||||
await startMonitorForStartupResolution({
|
||||
...createAccount().config,
|
||||
dangerouslyAllowNameMatching: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["Alice"],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["Alice"],
|
||||
groups: {
|
||||
"Trusted Team": { enabled: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(listZaloFriendsMock).toHaveBeenCalledWith("default");
|
||||
expect(listZaloGroupsMock).toHaveBeenCalledWith("default");
|
||||
});
|
||||
|
||||
it("allows group control commands when sender is in groupAllowFrom", async () => {
|
||||
await expectGroupCommandAuthorizers({
|
||||
accountConfig: {
|
||||
...createAccount().config,
|
||||
allowFrom: ["999"],
|
||||
groupAllowFrom: ["123"],
|
||||
},
|
||||
expectedCommandAuthorized: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes DM messages with direct peer kind", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher, resolveAgentRoute, buildAgentSessionKey } =
|
||||
await processOpenDmMessage();
|
||||
|
||||
const routeInput = mockCallArg(resolveAgentRoute, "resolve agent route") as {
|
||||
peer?: unknown;
|
||||
};
|
||||
expect(routeInput?.peer).toEqual({ kind: "direct", id: "321" });
|
||||
const sessionKeyInput = mockCallArg(buildAgentSessionKey, "build agent session key") as {
|
||||
dmScope?: string;
|
||||
peer?: unknown;
|
||||
};
|
||||
expect(sessionKeyInput?.peer).toEqual({ kind: "direct", id: "321" });
|
||||
expect(sessionKeyInput?.dmScope).toBe("per-channel-peer");
|
||||
const callArg = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
|
||||
expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:direct:321");
|
||||
});
|
||||
|
||||
it("surfaces quote metadata in inbound reply context", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = await processOpenDmMessage({
|
||||
message: {
|
||||
quotedGlobalMsgId: "987654321234",
|
||||
quotedOwnerId: "555444333",
|
||||
quotedBody: "Previous bot message content",
|
||||
},
|
||||
});
|
||||
|
||||
const callArg = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
|
||||
expect(callArg?.ctx?.ReplyToId).toBe("987654321234");
|
||||
expect(callArg?.ctx?.ReplyToBody).toBe("Previous bot message content");
|
||||
expect(callArg?.ctx?.ReplyToIsQuote).toBe(true);
|
||||
});
|
||||
|
||||
it("reuses the legacy DM session key when only the old group-shaped session exists", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = await processOpenDmMessage({
|
||||
readSessionUpdatedAt: (input?: { storePath: string; sessionKey: string }) =>
|
||||
input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined,
|
||||
});
|
||||
|
||||
const callArg = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
|
||||
expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:group:321");
|
||||
});
|
||||
|
||||
it("skips pairing store read for open DM control commands", async () => {
|
||||
const { readAllowFromStore } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const account = createAccount();
|
||||
await testing.processMessage({
|
||||
message: createDmMessage({ content: "/new", commandContent: "/new" }),
|
||||
account: {
|
||||
...account,
|
||||
config: {
|
||||
...account.config,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(readAllowFromStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips pairing store read for open DM non-command messages", async () => {
|
||||
const { readAllowFromStore } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const account = createAccount();
|
||||
await testing.processMessage({
|
||||
message: createDmMessage({ content: "hello there" }),
|
||||
account: {
|
||||
...account,
|
||||
config: {
|
||||
...account.config,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(readAllowFromStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes skipped group messages as InboundHistory on the next processed message", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const historyState = {
|
||||
historyLimit: 5,
|
||||
groupHistories: new Map<
|
||||
string,
|
||||
Array<{ sender: string; body: string; timestamp?: number; messageId?: string }>
|
||||
>(),
|
||||
};
|
||||
const account = createAccount();
|
||||
const config = createConfig();
|
||||
await testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "first unmentioned line",
|
||||
msgId: "history-1",
|
||||
timestampMs: 1700000000000,
|
||||
hasAnyMention: false,
|
||||
wasExplicitlyMentioned: false,
|
||||
}),
|
||||
account,
|
||||
config,
|
||||
runtime: createRuntimeEnv(),
|
||||
historyState,
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
|
||||
await testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "second line @bot",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
}),
|
||||
account,
|
||||
config,
|
||||
runtime: createRuntimeEnv(),
|
||||
historyState,
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
const firstDispatch = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
|
||||
expect(firstDispatch?.ctx?.InboundHistory).toEqual([
|
||||
{
|
||||
sender: "Alice",
|
||||
body: "first unmentioned line",
|
||||
messageId: "history-1",
|
||||
timestamp: 1700000000000,
|
||||
},
|
||||
]);
|
||||
expect(firstDispatch?.ctx?.Body ?? "").toContain("first unmentioned line");
|
||||
|
||||
await testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "third line @bot",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
}),
|
||||
account,
|
||||
config,
|
||||
runtime: createRuntimeEnv(),
|
||||
historyState,
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2);
|
||||
const secondDispatch = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher, 1);
|
||||
expect(secondDispatch?.ctx?.InboundHistory).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
21
extensions/zalouser/src/monitor.send.test-mocks.ts
Normal file
21
extensions/zalouser/src/monitor.send.test-mocks.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Zalouser plugin module implements monitor.send mocks behavior.
|
||||
import { vi } from "vitest";
|
||||
|
||||
const sendMocks = vi.hoisted(() => ({
|
||||
sendMessageZalouserMock: vi.fn(async () => {}),
|
||||
sendTypingZalouserMock: vi.fn(async () => {}),
|
||||
sendDeliveredZalouserMock: vi.fn(async () => {}),
|
||||
sendSeenZalouserMock: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
export const sendMessageZalouserMock = sendMocks.sendMessageZalouserMock;
|
||||
export const sendTypingZalouserMock = sendMocks.sendTypingZalouserMock;
|
||||
export const sendDeliveredZalouserMock = sendMocks.sendDeliveredZalouserMock;
|
||||
export const sendSeenZalouserMock = sendMocks.sendSeenZalouserMock;
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: sendMessageZalouserMock,
|
||||
sendTypingZalouser: sendTypingZalouserMock,
|
||||
sendDeliveredZalouser: sendDeliveredZalouserMock,
|
||||
sendSeenZalouser: sendSeenZalouserMock,
|
||||
}));
|
||||
1057
extensions/zalouser/src/monitor.ts
Normal file
1057
extensions/zalouser/src/monitor.ts
Normal file
File diff suppressed because it is too large
Load Diff
72
extensions/zalouser/src/probe.test.ts
Normal file
72
extensions/zalouser/src/probe.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Zalouser tests cover probe plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { probeZalouser } from "./probe.js";
|
||||
import { getZaloUserInfo } from "./zalo-js.js";
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
getZaloUserInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
|
||||
|
||||
describe("probeZalouser", () => {
|
||||
beforeEach(() => {
|
||||
mockGetUserInfo.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns ok=true with user when authenticated", async () => {
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
userId: "123",
|
||||
displayName: "Alice",
|
||||
});
|
||||
|
||||
await expect(probeZalouser("default")).resolves.toEqual({
|
||||
ok: true,
|
||||
user: { userId: "123", displayName: "Alice" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns not authenticated when no user info is returned", async () => {
|
||||
mockGetUserInfo.mockResolvedValueOnce(null);
|
||||
await expect(probeZalouser("default")).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "Not authenticated",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns error when user lookup throws", async () => {
|
||||
mockGetUserInfo.mockRejectedValueOnce(new Error("network down"));
|
||||
await expect(probeZalouser("default")).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "network down",
|
||||
});
|
||||
});
|
||||
|
||||
it("times out when lookup takes too long", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockGetUserInfo.mockReturnValueOnce(new Promise(() => {}));
|
||||
|
||||
const pending = probeZalouser("default", 10);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "Not authenticated",
|
||||
});
|
||||
});
|
||||
|
||||
it("caps oversized lookup timeout before scheduling", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockGetUserInfo.mockReturnValueOnce(new Promise(() => {}));
|
||||
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
|
||||
void probeZalouser("default", Number.MAX_SAFE_INTEGER);
|
||||
|
||||
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
});
|
||||
});
|
||||
37
extensions/zalouser/src/probe.ts
Normal file
37
extensions/zalouser/src/probe.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Zalouser plugin module implements probe behavior.
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import type { ZcaUserInfo } from "./types.js";
|
||||
import { getZaloUserInfo } from "./zalo-js.js";
|
||||
|
||||
export type ZalouserProbeResult = BaseProbeResult<string> & {
|
||||
user?: ZcaUserInfo;
|
||||
};
|
||||
|
||||
export async function probeZalouser(
|
||||
profile: string,
|
||||
timeoutMs?: number,
|
||||
): Promise<ZalouserProbeResult> {
|
||||
try {
|
||||
const user = timeoutMs
|
||||
? await Promise.race([
|
||||
getZaloUserInfo(profile),
|
||||
new Promise<null>((resolve) => {
|
||||
setTimeout(() => resolve(null), resolveTimerTimeoutMs(timeoutMs, 1000, 1000));
|
||||
}),
|
||||
])
|
||||
: await getZaloUserInfo(profile);
|
||||
|
||||
if (!user) {
|
||||
return { ok: false, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
return { ok: true, user };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: formatErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
23
extensions/zalouser/src/qr-temp-file.ts
Normal file
23
extensions/zalouser/src/qr-temp-file.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Zalouser plugin module implements qr temp file behavior.
|
||||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
|
||||
export async function writeQrDataUrlToTempFile(
|
||||
qrDataUrl: string,
|
||||
profile: string,
|
||||
): Promise<string | null> {
|
||||
const trimmed = qrDataUrl.trim();
|
||||
const match = trimmed.match(/^data:image\/png;base64,(.+)$/i);
|
||||
const base64 = (match?.[1] ?? "").trim();
|
||||
if (!base64) {
|
||||
return null;
|
||||
}
|
||||
const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
|
||||
const filePath = path.join(
|
||||
resolvePreferredOpenClawTmpDir(),
|
||||
`openclaw-zalouser-qr-${safeProfile}.png`,
|
||||
);
|
||||
await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
|
||||
return filePath;
|
||||
}
|
||||
20
extensions/zalouser/src/reaction.test.ts
Normal file
20
extensions/zalouser/src/reaction.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Zalouser tests cover reaction plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeZaloReactionIcon } from "./reaction.js";
|
||||
|
||||
describe("zalouser reaction alias normalization", () => {
|
||||
it("maps common aliases", () => {
|
||||
expect(normalizeZaloReactionIcon("like")).toBe("/-strong");
|
||||
expect(normalizeZaloReactionIcon("👍")).toBe("/-strong");
|
||||
expect(normalizeZaloReactionIcon("heart")).toBe("/-heart");
|
||||
expect(normalizeZaloReactionIcon("😂")).toBe(":>");
|
||||
});
|
||||
|
||||
it("defaults empty icon to like", () => {
|
||||
expect(normalizeZaloReactionIcon("")).toBe("/-strong");
|
||||
});
|
||||
|
||||
it("passes through unknown custom reactions", () => {
|
||||
expect(normalizeZaloReactionIcon("/custom")).toBe("/custom");
|
||||
});
|
||||
});
|
||||
33
extensions/zalouser/src/reaction.ts
Normal file
33
extensions/zalouser/src/reaction.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Zalouser plugin module implements reaction behavior.
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { Reactions } from "./zca-constants.js";
|
||||
|
||||
const REACTION_ALIAS_MAP = new Map<string, string>([
|
||||
["like", Reactions.LIKE],
|
||||
["👍", Reactions.LIKE],
|
||||
[":+1:", Reactions.LIKE],
|
||||
["heart", Reactions.HEART],
|
||||
["❤️", Reactions.HEART],
|
||||
["<3", Reactions.HEART],
|
||||
["haha", Reactions.HAHA],
|
||||
["laugh", Reactions.HAHA],
|
||||
["😂", Reactions.HAHA],
|
||||
["wow", Reactions.WOW],
|
||||
["😮", Reactions.WOW],
|
||||
["cry", Reactions.CRY],
|
||||
["😢", Reactions.CRY],
|
||||
["angry", Reactions.ANGRY],
|
||||
["😡", Reactions.ANGRY],
|
||||
]);
|
||||
|
||||
export function normalizeZaloReactionIcon(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return Reactions.LIKE;
|
||||
}
|
||||
return (
|
||||
REACTION_ALIAS_MAP.get(normalizeLowercaseStringOrEmpty(trimmed)) ??
|
||||
REACTION_ALIAS_MAP.get(trimmed) ??
|
||||
trimmed
|
||||
);
|
||||
}
|
||||
10
extensions/zalouser/src/runtime.ts
Normal file
10
extensions/zalouser/src/runtime.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Zalouser plugin module implements runtime behavior.
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "zalouser",
|
||||
errorMessage: "Zalouser runtime not initialized",
|
||||
});
|
||||
export { getZalouserRuntime, setZalouserRuntime };
|
||||
84
extensions/zalouser/src/security-audit.test.ts
Normal file
84
extensions/zalouser/src/security-audit.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Zalouser tests cover security audit plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectZalouserSecurityAuditFindings } from "./security-audit.js";
|
||||
import type { ResolvedZalouserAccount, ZalouserAccountConfig } from "./types.js";
|
||||
|
||||
function createAccount(config: ZalouserAccountConfig): ResolvedZalouserAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
profile: "default",
|
||||
authenticated: true,
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Zalouser security audit findings", () => {
|
||||
const cases: Array<{
|
||||
name: string;
|
||||
config: ZalouserAccountConfig;
|
||||
expectedSeverity: "info" | "warn";
|
||||
expectedTitle: string;
|
||||
expectedRemediation: string;
|
||||
detailIncludes: string[];
|
||||
detailExcludes?: string[];
|
||||
}> = [
|
||||
{
|
||||
name: "warns when group routing contains mutable group entries",
|
||||
config: {
|
||||
enabled: true,
|
||||
groups: {
|
||||
"Ops Room": { enabled: true },
|
||||
"group:g-123": { enabled: true },
|
||||
},
|
||||
} satisfies ZalouserAccountConfig,
|
||||
expectedSeverity: "warn",
|
||||
expectedTitle: "Zalouser group routing contains mutable group entries",
|
||||
expectedRemediation:
|
||||
"Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
|
||||
detailIncludes: ["channels.zalouser.groups:Ops Room"],
|
||||
detailExcludes: ["group:g-123"],
|
||||
},
|
||||
{
|
||||
name: "marks mutable group routing as break-glass when dangerous matching is enabled",
|
||||
config: {
|
||||
enabled: true,
|
||||
dangerouslyAllowNameMatching: true,
|
||||
groups: {
|
||||
"Ops Room": { enabled: true },
|
||||
},
|
||||
} satisfies ZalouserAccountConfig,
|
||||
expectedSeverity: "info",
|
||||
expectedTitle: "Zalouser group routing uses break-glass name matching",
|
||||
expectedRemediation:
|
||||
"Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching.",
|
||||
detailIncludes: ["out-of-scope"],
|
||||
},
|
||||
];
|
||||
|
||||
it.each(cases)("$name", (testCase) => {
|
||||
const findings = collectZalouserSecurityAuditFindings({
|
||||
account: createAccount(testCase.config),
|
||||
accountId: "default",
|
||||
orderedAccountIds: ["default"],
|
||||
hasExplicitAccountPath: false,
|
||||
});
|
||||
const finding = findings.find(
|
||||
(entry) => entry.checkId === "channels.zalouser.groups.mutable_entries",
|
||||
);
|
||||
|
||||
if (!finding) {
|
||||
throw new Error("expected mutable Zalo User group finding");
|
||||
}
|
||||
expect(finding.checkId).toBe("channels.zalouser.groups.mutable_entries");
|
||||
expect(finding.severity).toBe(testCase.expectedSeverity);
|
||||
expect(finding.title).toBe(testCase.expectedTitle);
|
||||
expect(finding.remediation).toBe(testCase.expectedRemediation);
|
||||
for (const snippet of testCase.detailIncludes) {
|
||||
expect(finding.detail).toContain(snippet);
|
||||
}
|
||||
for (const snippet of testCase.detailExcludes ?? []) {
|
||||
expect(finding.detail).not.toContain(snippet);
|
||||
}
|
||||
});
|
||||
});
|
||||
72
extensions/zalouser/src/security-audit.ts
Normal file
72
extensions/zalouser/src/security-audit.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Zalouser plugin module implements security audit behavior.
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
import type { ResolvedZalouserAccount } from "./accounts.js";
|
||||
|
||||
export function isZalouserMutableGroupEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
const normalized = text
|
||||
.replace(/^(zalouser|zlu):/i, "")
|
||||
.replace(/^group:/i, "")
|
||||
.trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (/^\d+$/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return !/^g-\S+$/i.test(normalized);
|
||||
}
|
||||
|
||||
export function collectZalouserSecurityAuditFindings(params: {
|
||||
accountId?: string | null;
|
||||
account: ResolvedZalouserAccount;
|
||||
orderedAccountIds: string[];
|
||||
hasExplicitAccountPath: boolean;
|
||||
}) {
|
||||
const zalouserCfg = params.account.config ?? {};
|
||||
const accountId = params.accountId?.trim() || params.account.accountId || "default";
|
||||
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(zalouserCfg);
|
||||
const zalouserPathPrefix =
|
||||
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
|
||||
? `channels.zalouser.accounts.${accountId}`
|
||||
: "channels.zalouser";
|
||||
const mutableGroupEntries = new Set<string>();
|
||||
const groups = zalouserCfg.groups;
|
||||
if (groups && typeof groups === "object" && !Array.isArray(groups)) {
|
||||
for (const key of Object.keys(groups as Record<string, unknown>)) {
|
||||
if (!isZalouserMutableGroupEntry(key)) {
|
||||
continue;
|
||||
}
|
||||
mutableGroupEntries.add(`${zalouserPathPrefix}.groups:${key}`);
|
||||
}
|
||||
}
|
||||
if (mutableGroupEntries.size === 0) {
|
||||
return [];
|
||||
}
|
||||
const examples = Array.from(mutableGroupEntries).slice(0, 5);
|
||||
const more =
|
||||
mutableGroupEntries.size > examples.length
|
||||
? ` (+${mutableGroupEntries.size - examples.length} more)`
|
||||
: "";
|
||||
const severity: "info" | "warn" = dangerousNameMatchingEnabled ? "info" : "warn";
|
||||
return [
|
||||
{
|
||||
checkId: "channels.zalouser.groups.mutable_entries",
|
||||
severity,
|
||||
title: dangerousNameMatchingEnabled
|
||||
? "Zalouser group routing uses break-glass name matching"
|
||||
: "Zalouser group routing contains mutable group entries",
|
||||
detail: dangerousNameMatchingEnabled
|
||||
? "Zalouser group-name routing is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
|
||||
`Found: ${examples.join(", ")}${more}.`
|
||||
: "Zalouser group auth is ID-only by default, so unresolved group-name or slug entries are ignored for auth and can drift from the intended trusted group. " +
|
||||
`Found: ${examples.join(", ")}${more}.`,
|
||||
remediation: dangerousNameMatchingEnabled
|
||||
? "Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching."
|
||||
: "Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
|
||||
},
|
||||
];
|
||||
}
|
||||
32
extensions/zalouser/src/send-receipt.ts
Normal file
32
extensions/zalouser/src/send-receipt.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Zalouser plugin module implements send receipt behavior.
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type MessageReceipt,
|
||||
type MessageReceiptPartKind,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
|
||||
export function createZalouserSendReceipt(params: {
|
||||
messageId?: string;
|
||||
platformMessageIds?: readonly (string | null | undefined)[];
|
||||
threadId?: string;
|
||||
kind?: MessageReceiptPartKind;
|
||||
}): MessageReceipt {
|
||||
const platformMessageIds = (params.platformMessageIds ?? [params.messageId])
|
||||
.map((messageId) => messageId?.trim())
|
||||
.filter((messageId): messageId is string => Boolean(messageId));
|
||||
const threadId = params.threadId?.trim();
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results: platformMessageIds.map((messageId) => {
|
||||
const result: { channel: string; messageId: string; conversationId?: string } = {
|
||||
channel: "zalouser",
|
||||
messageId,
|
||||
};
|
||||
if (threadId) {
|
||||
result.conversationId = threadId;
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
...(threadId ? { threadId } : {}),
|
||||
kind: params.kind ?? "unknown",
|
||||
});
|
||||
}
|
||||
445
extensions/zalouser/src/send.test.ts
Normal file
445
extensions/zalouser/src/send.test.ts
Normal file
@@ -0,0 +1,445 @@
|
||||
// Zalouser tests cover send plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createZalouserSendReceipt } from "./send-receipt.js";
|
||||
import {
|
||||
sendDeliveredZalouser,
|
||||
sendImageZalouser,
|
||||
sendLinkZalouser,
|
||||
sendMessageZalouser,
|
||||
sendReactionZalouser,
|
||||
sendSeenZalouser,
|
||||
sendTypingZalouser,
|
||||
} from "./send.js";
|
||||
import { parseZalouserTextStyles } from "./text-styles.js";
|
||||
import {
|
||||
sendZaloDeliveredEvent,
|
||||
sendZaloLink,
|
||||
sendZaloReaction,
|
||||
sendZaloSeenEvent,
|
||||
sendZaloTextMessage,
|
||||
sendZaloTypingEvent,
|
||||
} from "./zalo-js.js";
|
||||
import { TextStyle } from "./zca-constants.js";
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
sendZaloTextMessage: vi.fn(),
|
||||
sendZaloLink: vi.fn(),
|
||||
sendZaloTypingEvent: vi.fn(),
|
||||
sendZaloReaction: vi.fn(),
|
||||
sendZaloDeliveredEvent: vi.fn(),
|
||||
sendZaloSeenEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSendText = vi.mocked(sendZaloTextMessage);
|
||||
const mockSendLink = vi.mocked(sendZaloLink);
|
||||
const mockSendTyping = vi.mocked(sendZaloTypingEvent);
|
||||
const mockSendReaction = vi.mocked(sendZaloReaction);
|
||||
const mockSendDelivered = vi.mocked(sendZaloDeliveredEvent);
|
||||
const mockSendSeen = vi.mocked(sendZaloSeenEvent);
|
||||
|
||||
function sendResult(
|
||||
messageId: string,
|
||||
threadId = "thread",
|
||||
): {
|
||||
ok: true;
|
||||
messageId: string;
|
||||
receipt: ReturnType<typeof createZalouserSendReceipt>;
|
||||
} {
|
||||
return {
|
||||
ok: true,
|
||||
messageId,
|
||||
receipt: createZalouserSendReceipt({ messageId, threadId, kind: "text" }),
|
||||
};
|
||||
}
|
||||
|
||||
function sendFailure(error: string, threadId = "thread") {
|
||||
return {
|
||||
ok: false,
|
||||
error,
|
||||
receipt: createZalouserSendReceipt({ threadId, kind: "unknown" }),
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
throw new Error(`${label} was not an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function expectRecordFields(record: Record<string, unknown>, fields: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
expect(record[key]).toEqual(value);
|
||||
}
|
||||
}
|
||||
|
||||
function expectResultFields(result: unknown, fields: Record<string, unknown>) {
|
||||
expectRecordFields(requireRecord(result, "send result"), fields);
|
||||
}
|
||||
|
||||
function requireSendTextCall(callIndex: number): unknown[] {
|
||||
const call = (mockSendText.mock.calls as unknown[][])[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`expected send text call ${callIndex + 1}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function requireSendTextOptions(callIndex: number): Record<string, unknown> {
|
||||
return requireRecord(
|
||||
requireSendTextCall(callIndex)[2],
|
||||
`send text call ${callIndex + 1} options`,
|
||||
);
|
||||
}
|
||||
|
||||
function expectSendTextOptions(callIndex: number, fields: Record<string, unknown>) {
|
||||
expectRecordFields(requireSendTextOptions(callIndex), fields);
|
||||
}
|
||||
|
||||
describe("zalouser send helpers", () => {
|
||||
beforeEach(() => {
|
||||
mockSendText.mockReset();
|
||||
mockSendLink.mockReset();
|
||||
mockSendTyping.mockReset();
|
||||
mockSendReaction.mockReset();
|
||||
mockSendDelivered.mockReset();
|
||||
mockSendSeen.mockReset();
|
||||
});
|
||||
|
||||
it("keeps plain text literal by default", async () => {
|
||||
mockSendText.mockResolvedValueOnce(sendResult("mid-1", "thread-1"));
|
||||
|
||||
const result = await sendMessageZalouser("thread-1", "**hello**", {
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
});
|
||||
|
||||
expect(requireSendTextCall(0)[0]).toBe("thread-1");
|
||||
expect(requireSendTextCall(0)[1]).toBe("**hello**");
|
||||
expectSendTextOptions(0, { profile: "default", isGroup: true });
|
||||
expectResultFields(result, { ok: true, messageId: "mid-1" });
|
||||
expect(result.receipt.primaryPlatformMessageId).toBe("mid-1");
|
||||
});
|
||||
|
||||
it("formats markdown text when markdown mode is enabled", async () => {
|
||||
mockSendText.mockResolvedValueOnce(sendResult("mid-1b", "thread-1"));
|
||||
|
||||
await sendMessageZalouser("thread-1", "**hello**", {
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(requireSendTextCall(0)[0]).toBe("thread-1");
|
||||
expect(requireSendTextCall(0)[1]).toBe("hello");
|
||||
expectSendTextOptions(0, {
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
textMode: "markdown",
|
||||
textStyles: [{ start: 0, len: 5, st: TextStyle.Bold }],
|
||||
});
|
||||
});
|
||||
|
||||
it("formats image captions in markdown mode", async () => {
|
||||
mockSendText.mockResolvedValueOnce(sendResult("mid-2", "thread-2"));
|
||||
|
||||
await sendImageZalouser("thread-2", "https://example.com/a.png", {
|
||||
profile: "p2",
|
||||
caption: "_cap_",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(requireSendTextCall(0)[0]).toBe("thread-2");
|
||||
expect(requireSendTextCall(0)[1]).toBe("cap");
|
||||
expectSendTextOptions(0, {
|
||||
profile: "p2",
|
||||
caption: undefined,
|
||||
isGroup: false,
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
textMode: "markdown",
|
||||
textStyles: [{ start: 0, len: 3, st: TextStyle.Italic }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not keep the raw markdown caption as a media fallback after formatting", async () => {
|
||||
mockSendText.mockResolvedValueOnce(sendResult("mid-2b", "thread-2"));
|
||||
|
||||
await sendImageZalouser("thread-2", "https://example.com/a.png", {
|
||||
profile: "p2",
|
||||
caption: "```\n```",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(requireSendTextCall(0)[0]).toBe("thread-2");
|
||||
expect(requireSendTextCall(0)[1]).toBe("");
|
||||
expectSendTextOptions(0, {
|
||||
profile: "p2",
|
||||
caption: undefined,
|
||||
isGroup: false,
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
textMode: "markdown",
|
||||
textStyles: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rechunks normalized markdown text before sending to avoid transport truncation", async () => {
|
||||
const text = "\t".repeat(500) + "a".repeat(1500);
|
||||
const formatted = parseZalouserTextStyles(text);
|
||||
mockSendText
|
||||
.mockResolvedValueOnce(sendResult("mid-2c-1", "thread-2c"))
|
||||
.mockResolvedValueOnce(sendResult("mid-2c-2", "thread-2c"));
|
||||
|
||||
const result = await sendMessageZalouser("thread-2c", text, {
|
||||
profile: "p2c",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(formatted.text.length).toBeGreaterThan(2000);
|
||||
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
|
||||
expect(
|
||||
mockSendText.mock.calls
|
||||
.map((call, index) => ({ index, length: call[1].length }))
|
||||
.filter((call) => call.length > 2000),
|
||||
).toStrictEqual([]);
|
||||
expectResultFields(result, { ok: true, messageId: "mid-2c-2" });
|
||||
});
|
||||
|
||||
it("reports each completed internal chunk before a later chunk fails", async () => {
|
||||
const firstResult = sendResult("mid-progress-1", "thread-progress");
|
||||
mockSendText
|
||||
.mockResolvedValueOnce(firstResult)
|
||||
.mockResolvedValueOnce(sendFailure("second chunk failed", "thread-progress"));
|
||||
const onDeliveryResult = vi.fn();
|
||||
|
||||
await expect(
|
||||
sendMessageZalouser("thread-progress", "a".repeat(2001), {
|
||||
textChunkLimit: 2000,
|
||||
onDeliveryResult,
|
||||
}),
|
||||
).rejects.toThrow("second chunk failed");
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||
expect(onDeliveryResult).toHaveBeenCalledOnce();
|
||||
expect(onDeliveryResult).toHaveBeenCalledWith(firstResult);
|
||||
expect(requireSendTextOptions(0)).not.toHaveProperty("onDeliveryResult");
|
||||
});
|
||||
|
||||
it("preserves text styles when splitting long formatted markdown", async () => {
|
||||
const text = `**${"a".repeat(2501)}**`;
|
||||
mockSendText
|
||||
.mockResolvedValueOnce(sendResult("mid-2d-1", "thread-2d"))
|
||||
.mockResolvedValueOnce(sendResult("mid-2d-2", "thread-2d"));
|
||||
|
||||
const result = await sendMessageZalouser("thread-2d", text, {
|
||||
profile: "p2d",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(requireSendTextCall(0)[0]).toBe("thread-2d");
|
||||
expect(requireSendTextCall(0)[1]).toBe("a".repeat(2000));
|
||||
expectSendTextOptions(0, {
|
||||
profile: "p2d",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textStyles: [{ start: 0, len: 2000, st: TextStyle.Bold }],
|
||||
});
|
||||
expect(requireSendTextCall(1)[0]).toBe("thread-2d");
|
||||
expect(requireSendTextCall(1)[1]).toBe("a".repeat(501));
|
||||
expectSendTextOptions(1, {
|
||||
profile: "p2d",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textStyles: [{ start: 0, len: 501, st: TextStyle.Bold }],
|
||||
});
|
||||
expectResultFields(result, { ok: true, messageId: "mid-2d-2" });
|
||||
});
|
||||
|
||||
it("preserves formatted text and styles when newline chunk mode splits after parsing", async () => {
|
||||
const text = `**${"a".repeat(1995)}**\n\nsecond paragraph`;
|
||||
const formatted = parseZalouserTextStyles(text);
|
||||
mockSendText
|
||||
.mockResolvedValueOnce(sendResult("mid-2d-3", "thread-2d-2"))
|
||||
.mockResolvedValueOnce(sendResult("mid-2d-4", "thread-2d-2"));
|
||||
|
||||
const result = await sendMessageZalouser("thread-2d-2", text, {
|
||||
profile: "p2d-2",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "newline",
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
|
||||
expect(requireSendTextCall(0)[0]).toBe("thread-2d-2");
|
||||
expect(requireSendTextCall(0)[1]).toBe(`${"a".repeat(1995)}\n\n`);
|
||||
expectSendTextOptions(0, {
|
||||
profile: "p2d-2",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "newline",
|
||||
textStyles: [{ start: 0, len: 1995, st: TextStyle.Bold }],
|
||||
});
|
||||
expect(requireSendTextCall(1)[0]).toBe("thread-2d-2");
|
||||
expect(requireSendTextCall(1)[1]).toBe("second paragraph");
|
||||
expectSendTextOptions(1, {
|
||||
profile: "p2d-2",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "newline",
|
||||
textStyles: undefined,
|
||||
});
|
||||
expectResultFields(result, { ok: true, messageId: "mid-2d-4" });
|
||||
});
|
||||
|
||||
it("respects an explicit text chunk limit when splitting formatted markdown", async () => {
|
||||
const text = `**${"a".repeat(1501)}**`;
|
||||
mockSendText
|
||||
.mockResolvedValueOnce(sendResult("mid-2d-5", "thread-2d-3"))
|
||||
.mockResolvedValueOnce(sendResult("mid-2d-6", "thread-2d-3"));
|
||||
|
||||
const result = await sendMessageZalouser("thread-2d-3", text, {
|
||||
profile: "p2d-3",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkLimit: 1200,
|
||||
} as never);
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||
expect(requireSendTextCall(0)[0]).toBe("thread-2d-3");
|
||||
expect(requireSendTextCall(0)[1]).toBe("a".repeat(1200));
|
||||
expectSendTextOptions(0, {
|
||||
profile: "p2d-3",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkLimit: 1200,
|
||||
textStyles: [{ start: 0, len: 1200, st: TextStyle.Bold }],
|
||||
});
|
||||
expect(requireSendTextCall(1)[0]).toBe("thread-2d-3");
|
||||
expect(requireSendTextCall(1)[1]).toBe("a".repeat(301));
|
||||
expectSendTextOptions(1, {
|
||||
profile: "p2d-3",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkLimit: 1200,
|
||||
textStyles: [{ start: 0, len: 301, st: TextStyle.Bold }],
|
||||
});
|
||||
expectResultFields(result, { ok: true, messageId: "mid-2d-6" });
|
||||
});
|
||||
|
||||
it("sends overflow markdown captions as follow-up text after the media message", async () => {
|
||||
const caption = "\t".repeat(500) + "a".repeat(1500);
|
||||
const formatted = parseZalouserTextStyles(caption);
|
||||
mockSendText
|
||||
.mockResolvedValueOnce(sendResult("mid-2e-1", "thread-2e"))
|
||||
.mockResolvedValueOnce(sendResult("mid-2e-2", "thread-2e"));
|
||||
|
||||
const result = await sendImageZalouser("thread-2e", "https://example.com/long.png", {
|
||||
profile: "p2e",
|
||||
caption,
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
|
||||
expect(requireSendTextCall(0)[0]).toBe("thread-2e");
|
||||
expect(typeof requireSendTextCall(0)[1]).toBe("string");
|
||||
expectSendTextOptions(0, {
|
||||
profile: "p2e",
|
||||
caption: undefined,
|
||||
isGroup: false,
|
||||
mediaUrl: "https://example.com/long.png",
|
||||
textMode: "markdown",
|
||||
});
|
||||
expect(requireSendTextCall(1)[0]).toBe("thread-2e");
|
||||
expect(typeof requireSendTextCall(1)[1]).toBe("string");
|
||||
expect(requireSendTextOptions(1).mediaUrl).toBeUndefined();
|
||||
expectResultFields(result, { ok: true, messageId: "mid-2e-2" });
|
||||
});
|
||||
|
||||
it("delegates link helper to JS transport", async () => {
|
||||
mockSendLink.mockResolvedValueOnce(sendFailure("boom", "thread-3"));
|
||||
|
||||
const result = await sendLinkZalouser("thread-3", "https://openclaw.ai", {
|
||||
profile: "p3",
|
||||
isGroup: true,
|
||||
});
|
||||
|
||||
expect(mockSendLink).toHaveBeenCalledWith("thread-3", "https://openclaw.ai", {
|
||||
profile: "p3",
|
||||
isGroup: true,
|
||||
});
|
||||
expectResultFields(result, { ok: false, error: "boom" });
|
||||
});
|
||||
|
||||
it("delegates typing helper to JS transport", async () => {
|
||||
await sendTypingZalouser("thread-4", { profile: "p4", isGroup: true });
|
||||
|
||||
expect(mockSendTyping).toHaveBeenCalledWith("thread-4", {
|
||||
profile: "p4",
|
||||
isGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates reaction helper to JS transport", async () => {
|
||||
mockSendReaction.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await sendReactionZalouser({
|
||||
threadId: "thread-5",
|
||||
profile: "p5",
|
||||
isGroup: true,
|
||||
msgId: "100",
|
||||
cliMsgId: "200",
|
||||
emoji: "👍",
|
||||
});
|
||||
|
||||
expect(mockSendReaction).toHaveBeenCalledWith({
|
||||
profile: "p5",
|
||||
threadId: "thread-5",
|
||||
isGroup: true,
|
||||
msgId: "100",
|
||||
cliMsgId: "200",
|
||||
emoji: "👍",
|
||||
remove: undefined,
|
||||
});
|
||||
expectResultFields(result, { ok: true, error: undefined });
|
||||
expect(result.receipt.platformMessageIds).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("delegates delivered+seen helpers to JS transport", async () => {
|
||||
mockSendDelivered.mockResolvedValueOnce();
|
||||
mockSendSeen.mockResolvedValueOnce();
|
||||
|
||||
const message = {
|
||||
msgId: "100",
|
||||
cliMsgId: "200",
|
||||
uidFrom: "1",
|
||||
idTo: "2",
|
||||
msgType: "webchat",
|
||||
st: 1,
|
||||
at: 0,
|
||||
cmd: 0,
|
||||
ts: "123",
|
||||
};
|
||||
|
||||
await sendDeliveredZalouser({ profile: "p6", isGroup: true, message, isSeen: false });
|
||||
await sendSeenZalouser({ profile: "p6", isGroup: true, message });
|
||||
|
||||
expect(mockSendDelivered).toHaveBeenCalledWith({
|
||||
profile: "p6",
|
||||
isGroup: true,
|
||||
message,
|
||||
isSeen: false,
|
||||
});
|
||||
expect(mockSendSeen).toHaveBeenCalledWith({
|
||||
profile: "p6",
|
||||
isGroup: true,
|
||||
message,
|
||||
});
|
||||
});
|
||||
});
|
||||
286
extensions/zalouser/src/send.ts
Normal file
286
extensions/zalouser/src/send.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
// Zalouser plugin module implements send behavior.
|
||||
import { createZalouserSendReceipt } from "./send-receipt.js";
|
||||
import { parseZalouserTextStyles } from "./text-styles.js";
|
||||
import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js";
|
||||
import {
|
||||
sendZaloDeliveredEvent,
|
||||
sendZaloLink,
|
||||
sendZaloReaction,
|
||||
sendZaloSeenEvent,
|
||||
sendZaloTextMessage,
|
||||
sendZaloTypingEvent,
|
||||
} from "./zalo-js.js";
|
||||
import { TextStyle } from "./zca-constants.js";
|
||||
|
||||
type ZalouserSendOptions = ZaloSendOptions & {
|
||||
/** Persist each concrete platform send before the next internal chunk starts. */
|
||||
onDeliveryResult?: (result: ZaloSendResult) => Promise<void> | void;
|
||||
};
|
||||
type ZalouserSendResult = ZaloSendResult;
|
||||
|
||||
const ZALO_TEXT_LIMIT = 2000;
|
||||
const DEFAULT_TEXT_CHUNK_MODE = "length";
|
||||
|
||||
type StyledTextChunk = {
|
||||
text: string;
|
||||
styles?: ZaloSendOptions["textStyles"];
|
||||
};
|
||||
|
||||
type TextChunkMode = NonNullable<ZaloSendOptions["textChunkMode"]>;
|
||||
|
||||
export async function sendMessageZalouser(
|
||||
threadId: string,
|
||||
text: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
const { onDeliveryResult, ...transportOptions } = options;
|
||||
const prepared =
|
||||
transportOptions.textMode === "markdown"
|
||||
? parseZalouserTextStyles(text)
|
||||
: { text, styles: transportOptions.textStyles };
|
||||
const textChunkLimit = transportOptions.textChunkLimit ?? ZALO_TEXT_LIMIT;
|
||||
const chunks = splitStyledText(
|
||||
prepared.text,
|
||||
(prepared.styles?.length ?? 0) > 0 ? prepared.styles : undefined,
|
||||
textChunkLimit,
|
||||
transportOptions.textChunkMode,
|
||||
);
|
||||
|
||||
let lastResult: ZalouserSendResult | null = null;
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
const chunkOptions =
|
||||
index === 0
|
||||
? { ...transportOptions, textStyles: chunk.styles }
|
||||
: {
|
||||
...transportOptions,
|
||||
caption: undefined,
|
||||
mediaLocalRoots: undefined,
|
||||
mediaUrl: undefined,
|
||||
textStyles: chunk.styles,
|
||||
};
|
||||
const result = await sendZaloTextMessage(threadId, chunk.text, chunkOptions);
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to send Zalouser message");
|
||||
}
|
||||
await onDeliveryResult?.(result);
|
||||
lastResult = result;
|
||||
}
|
||||
|
||||
return (
|
||||
lastResult ?? {
|
||||
ok: false,
|
||||
error: "No message content provided",
|
||||
receipt: createZalouserSendReceipt({ threadId, kind: "text" }),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendImageZalouser(
|
||||
threadId: string,
|
||||
imageUrl: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
return await sendMessageZalouser(threadId, options.caption ?? "", {
|
||||
...options,
|
||||
caption: undefined,
|
||||
mediaUrl: imageUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendLinkZalouser(
|
||||
threadId: string,
|
||||
url: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
return await sendZaloLink(threadId, url, options);
|
||||
}
|
||||
|
||||
export async function sendTypingZalouser(
|
||||
threadId: string,
|
||||
options: Pick<ZalouserSendOptions, "profile" | "isGroup"> = {},
|
||||
): Promise<void> {
|
||||
await sendZaloTypingEvent(threadId, options);
|
||||
}
|
||||
|
||||
export async function sendReactionZalouser(params: {
|
||||
threadId: string;
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
emoji: string;
|
||||
remove?: boolean;
|
||||
profile?: string;
|
||||
isGroup?: boolean;
|
||||
}): Promise<ZalouserSendResult> {
|
||||
const result = await sendZaloReaction({
|
||||
profile: params.profile,
|
||||
threadId: params.threadId,
|
||||
isGroup: params.isGroup,
|
||||
msgId: params.msgId,
|
||||
cliMsgId: params.cliMsgId,
|
||||
emoji: params.emoji,
|
||||
remove: params.remove,
|
||||
});
|
||||
return {
|
||||
ok: result.ok,
|
||||
error: result.error,
|
||||
receipt: createZalouserSendReceipt({ threadId: params.threadId, kind: "unknown" }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendDeliveredZalouser(params: {
|
||||
profile?: string;
|
||||
isGroup?: boolean;
|
||||
message: ZaloEventMessage;
|
||||
isSeen?: boolean;
|
||||
}): Promise<void> {
|
||||
await sendZaloDeliveredEvent(params);
|
||||
}
|
||||
|
||||
export async function sendSeenZalouser(params: {
|
||||
profile?: string;
|
||||
isGroup?: boolean;
|
||||
message: ZaloEventMessage;
|
||||
}): Promise<void> {
|
||||
await sendZaloSeenEvent(params);
|
||||
}
|
||||
|
||||
function splitStyledText(
|
||||
text: string,
|
||||
styles: ZaloSendOptions["textStyles"],
|
||||
limit: number,
|
||||
mode: ZaloSendOptions["textChunkMode"],
|
||||
): StyledTextChunk[] {
|
||||
if (text.length === 0) {
|
||||
return [{ text, styles: undefined }];
|
||||
}
|
||||
|
||||
const chunks: StyledTextChunk[] = [];
|
||||
for (const range of splitTextRanges(text, limit, mode ?? DEFAULT_TEXT_CHUNK_MODE)) {
|
||||
const { start, end } = range;
|
||||
chunks.push({
|
||||
text: text.slice(start, end),
|
||||
styles: sliceTextStyles(styles, start, end),
|
||||
});
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function sliceTextStyles(
|
||||
styles: ZaloSendOptions["textStyles"],
|
||||
start: number,
|
||||
end: number,
|
||||
): ZaloSendOptions["textStyles"] {
|
||||
if (!styles || styles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chunkStyles = styles
|
||||
.map((style) => {
|
||||
const overlapStart = Math.max(style.start, start);
|
||||
const overlapEnd = Math.min(style.start + style.len, end);
|
||||
if (overlapEnd <= overlapStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (style.st === TextStyle.Indent) {
|
||||
return {
|
||||
start: overlapStart - start,
|
||||
len: overlapEnd - overlapStart,
|
||||
st: style.st,
|
||||
indentSize: style.indentSize,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
start: overlapStart - start,
|
||||
len: overlapEnd - overlapStart,
|
||||
st: style.st,
|
||||
};
|
||||
})
|
||||
.filter((style): style is NonNullable<typeof style> => style !== null);
|
||||
|
||||
return chunkStyles.length > 0 ? chunkStyles : undefined;
|
||||
}
|
||||
|
||||
function splitTextRanges(
|
||||
text: string,
|
||||
limit: number,
|
||||
mode: TextChunkMode,
|
||||
): Array<{ start: number; end: number }> {
|
||||
if (mode === "newline") {
|
||||
return splitTextRangesByPreferredBreaks(text, limit);
|
||||
}
|
||||
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
for (let start = 0; start < text.length; start += limit) {
|
||||
ranges.push({
|
||||
start,
|
||||
end: Math.min(text.length, start + limit),
|
||||
});
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function splitTextRangesByPreferredBreaks(
|
||||
text: string,
|
||||
limit: number,
|
||||
): Array<{ start: number; end: number }> {
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
let start = 0;
|
||||
|
||||
while (start < text.length) {
|
||||
const maxEnd = Math.min(text.length, start + limit);
|
||||
let end = maxEnd;
|
||||
if (maxEnd < text.length) {
|
||||
end =
|
||||
findParagraphBreak(text, start, maxEnd) ??
|
||||
findLastBreak(text, "\n", start, maxEnd) ??
|
||||
findLastWhitespaceBreak(text, start, maxEnd) ??
|
||||
maxEnd;
|
||||
}
|
||||
|
||||
if (end <= start) {
|
||||
end = maxEnd;
|
||||
}
|
||||
|
||||
ranges.push({ start, end });
|
||||
start = end;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function findParagraphBreak(text: string, start: number, end: number): number | undefined {
|
||||
const slice = text.slice(start, end);
|
||||
const matches = slice.matchAll(/\n[\t ]*\n+/g);
|
||||
let lastMatch: RegExpMatchArray | undefined;
|
||||
for (const match of matches) {
|
||||
lastMatch = match;
|
||||
}
|
||||
if (!lastMatch || lastMatch.index === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return start + lastMatch.index + lastMatch[0].length;
|
||||
}
|
||||
|
||||
function findLastBreak(
|
||||
text: string,
|
||||
marker: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): number | undefined {
|
||||
const index = text.lastIndexOf(marker, end - 1);
|
||||
if (index < start) {
|
||||
return undefined;
|
||||
}
|
||||
return index + marker.length;
|
||||
}
|
||||
|
||||
function findLastWhitespaceBreak(text: string, start: number, end: number): number | undefined {
|
||||
for (let index = end - 1; index > start; index -= 1) {
|
||||
if (/\s/.test(text[index])) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
122
extensions/zalouser/src/session-route.ts
Normal file
122
extensions/zalouser/src/session-route.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Zalouser plugin module implements session route behavior.
|
||||
import {
|
||||
buildChannelOutboundSessionRoute,
|
||||
type ChannelOutboundSessionRouteParams,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
function stripZalouserTargetPrefix(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^(zalouser|zlu):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function normalizeZalouserTarget(raw: string): string | undefined {
|
||||
const trimmed = stripZalouserTargetPrefix(raw);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
if (lower.startsWith("group:")) {
|
||||
const id = trimmed.slice("group:".length).trim();
|
||||
return id ? `group:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("g:")) {
|
||||
const id = trimmed.slice("g:".length).trim();
|
||||
return id ? `group:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("dm:")) {
|
||||
const id = trimmed.slice("dm:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("u:")) {
|
||||
const id = trimmed.slice("u:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (/^g-\S+$/i.test(trimmed)) {
|
||||
return `group:${trimmed}`;
|
||||
}
|
||||
if (/^u-\S+$/i.test(trimmed)) {
|
||||
return `user:${trimmed}`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function parseZalouserOutboundTarget(raw: string): {
|
||||
threadId: string;
|
||||
isGroup: boolean;
|
||||
} {
|
||||
const normalized = normalizeZalouserTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("Zalouser target is required");
|
||||
}
|
||||
const lowered = normalizeLowercaseStringOrEmpty(normalized);
|
||||
if (lowered.startsWith("group:")) {
|
||||
const threadId = normalized.slice("group:".length).trim();
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser group target is missing group id");
|
||||
}
|
||||
return { threadId, isGroup: true };
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
const threadId = normalized.slice("user:".length).trim();
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser user target is missing user id");
|
||||
}
|
||||
return { threadId, isGroup: false };
|
||||
}
|
||||
// Backward-compatible fallback for bare IDs.
|
||||
// Group sends should use explicit `group:<id>` targets.
|
||||
return { threadId: normalized, isGroup: false };
|
||||
}
|
||||
|
||||
export function parseZalouserDirectoryGroupId(raw: string): string {
|
||||
const normalized = normalizeZalouserTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("Zalouser group target is required");
|
||||
}
|
||||
const lowered = normalizeLowercaseStringOrEmpty(normalized);
|
||||
if (lowered.startsWith("group:")) {
|
||||
const groupId = normalized.slice("group:".length).trim();
|
||||
if (!groupId) {
|
||||
throw new Error("Zalouser group target is missing group id");
|
||||
}
|
||||
return groupId;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
throw new Error("Zalouser group members lookup requires a group target (group:<id>)");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveZalouserOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
|
||||
const normalized = normalizeZalouserTarget(params.target);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const isGroup = (normalizeOptionalLowercaseString(normalized) ?? "").startsWith("group:");
|
||||
const peerId = normalized.replace(/^(group|user):/i, "").trim();
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "zalouser",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `zalouser:group:${peerId}` : `zalouser:${peerId}`,
|
||||
to: `zalouser:${peerId}`,
|
||||
});
|
||||
}
|
||||
37
extensions/zalouser/src/setup-core.ts
Normal file
37
extensions/zalouser/src/setup-core.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Zalouser plugin module implements setup core behavior.
|
||||
import {
|
||||
createDelegatedSetupWizardProxy,
|
||||
createPatchedAccountSetupAdapter,
|
||||
createSetupTranslator,
|
||||
type ChannelSetupWizard,
|
||||
} from "openclaw/plugin-sdk/setup-runtime";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "zalouser" as const;
|
||||
|
||||
export const zalouserSetupAdapter = createPatchedAccountSetupAdapter({
|
||||
channelKey: channel,
|
||||
validateInput: () => null,
|
||||
buildPatch: () => ({}),
|
||||
});
|
||||
|
||||
export function createZalouserSetupWizardProxy(
|
||||
loadWizard: () => Promise<ChannelSetupWizard>,
|
||||
): ChannelSetupWizard {
|
||||
return createDelegatedSetupWizardProxy({
|
||||
channel,
|
||||
loadWizard,
|
||||
status: {
|
||||
configuredLabel: t("wizard.channels.statusLoggedIn"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsQrLogin"),
|
||||
configuredHint: t("wizard.channels.statusRecommendedLoggedIn"),
|
||||
unconfiguredHint: t("wizard.channels.statusRecommendedQrLogin"),
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 15,
|
||||
},
|
||||
credentials: [],
|
||||
delegatePrepare: true,
|
||||
delegateFinalize: true,
|
||||
});
|
||||
}
|
||||
370
extensions/zalouser/src/setup-surface.test.ts
Normal file
370
extensions/zalouser/src/setup-surface.test.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// Zalouser tests cover setup surface plugin behavior.
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import { zalouserSetupWizard } from "./setup-surface.js";
|
||||
import { zalouserSetupPlugin } from "./setup-test-helpers.js";
|
||||
|
||||
const zalouserConfigure = createPluginSetupWizardConfigure(zalouserSetupPlugin);
|
||||
|
||||
async function runSetup(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
prompter: ReturnType<typeof createTestWizardPrompter>;
|
||||
options?: Record<string, unknown>;
|
||||
forceAllowFrom?: boolean;
|
||||
}) {
|
||||
return await runSetupWizardConfigure({
|
||||
configure: zalouserConfigure,
|
||||
cfg: params.cfg,
|
||||
prompter: params.prompter,
|
||||
options: params.options,
|
||||
forceAllowFrom: params.forceAllowFrom,
|
||||
});
|
||||
}
|
||||
|
||||
describe("zalouser setup wizard", () => {
|
||||
function expectEnabledDefaultSetup(
|
||||
result: Awaited<ReturnType<typeof runSetup>>,
|
||||
dmPolicy?: "pairing" | "allowlist",
|
||||
) {
|
||||
expect(result.accountId).toBe("default");
|
||||
const channelConfig = result.cfg.channels?.zalouser;
|
||||
if (!channelConfig) {
|
||||
throw new Error("expected Zalo Personal channel config");
|
||||
}
|
||||
const pluginEntry = result.cfg.plugins?.entries?.zalouser;
|
||||
if (!pluginEntry) {
|
||||
throw new Error("expected Zalo Personal plugin entry");
|
||||
}
|
||||
expect(channelConfig.enabled).toBe(true);
|
||||
expect(pluginEntry.enabled).toBe(true);
|
||||
if (dmPolicy) {
|
||||
expect(channelConfig.dmPolicy).toBe(dmPolicy);
|
||||
}
|
||||
}
|
||||
|
||||
function createQuickstartPrompter(params?: {
|
||||
note?: ReturnType<typeof createTestWizardPrompter>["note"];
|
||||
seen?: string[];
|
||||
dmPolicy?: "pairing" | "allowlist";
|
||||
groupAccess?: boolean;
|
||||
groupPolicy?: "allowlist";
|
||||
textByMessage?: Record<string, string>;
|
||||
}) {
|
||||
const select = vi.fn(
|
||||
async ({ message, options }: { message: string; options: Array<{ value: string }> }) => {
|
||||
const first = options[0];
|
||||
if (!first) {
|
||||
throw new Error("no options");
|
||||
}
|
||||
params?.seen?.push(message);
|
||||
if (message === "Zalo Personal DM policy" && params?.dmPolicy) {
|
||||
return params.dmPolicy;
|
||||
}
|
||||
if (message === "Zalo groups access" && params?.groupPolicy) {
|
||||
return params.groupPolicy;
|
||||
}
|
||||
return first.value;
|
||||
},
|
||||
) as ReturnType<typeof createTestWizardPrompter>["select"];
|
||||
const text = vi.fn(
|
||||
async ({ message }: { message: string }) => params?.textByMessage?.[message] ?? "",
|
||||
) as ReturnType<typeof createTestWizardPrompter>["text"];
|
||||
return createTestWizardPrompter({
|
||||
...(params?.note ? { note: params.note } : {}),
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
params?.seen?.push(message);
|
||||
if (message === "Login via QR code now?") {
|
||||
return false;
|
||||
}
|
||||
if (message === "Configure Zalo groups access?") {
|
||||
return params?.groupAccess ?? false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
select,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
it("enables the account without forcing QR login", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Login via QR code now?") {
|
||||
return false;
|
||||
}
|
||||
if (message === "Configure Zalo groups access?") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSetup({ prompter });
|
||||
|
||||
expectEnabledDefaultSetup(result);
|
||||
});
|
||||
|
||||
it("prompts DM policy before group access in quickstart", async () => {
|
||||
const seen: string[] = [];
|
||||
const prompter = createQuickstartPrompter({ seen, dmPolicy: "pairing" });
|
||||
|
||||
const result = await runSetup({
|
||||
prompter,
|
||||
options: { quickstartDefaults: true },
|
||||
});
|
||||
|
||||
expectEnabledDefaultSetup(result, "pairing");
|
||||
expect(seen.indexOf("Zalo Personal DM policy")).toBeGreaterThanOrEqual(0);
|
||||
expect(seen.indexOf("Configure Zalo groups access?")).toBeGreaterThanOrEqual(0);
|
||||
expect(seen.indexOf("Zalo Personal DM policy")).toBeLessThan(
|
||||
seen.indexOf("Configure Zalo groups access?"),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows an empty quickstart DM allowlist with a warning", async () => {
|
||||
const note = vi.fn(async (_message: string, _title?: string) => {});
|
||||
const prompter = createQuickstartPrompter({
|
||||
note,
|
||||
dmPolicy: "allowlist",
|
||||
textByMessage: {
|
||||
"Zalouser allowFrom (name or user id)": "",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup({
|
||||
prompter,
|
||||
options: { quickstartDefaults: true },
|
||||
});
|
||||
|
||||
expectEnabledDefaultSetup(result, "allowlist");
|
||||
expect(result.cfg.channels?.zalouser?.allowFrom).toStrictEqual([]);
|
||||
expect(
|
||||
note.mock.calls.some(([message]) => message.includes("No DM allowlist entries added yet.")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows an empty group allowlist with a warning", async () => {
|
||||
const note = vi.fn(async (_message: string, _title?: string) => {});
|
||||
const prompter = createQuickstartPrompter({
|
||||
note,
|
||||
groupAccess: true,
|
||||
groupPolicy: "allowlist",
|
||||
textByMessage: {
|
||||
"Zalo groups allowlist (comma-separated)": "",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup({ prompter });
|
||||
|
||||
expect(result.cfg.channels?.zalouser?.groupPolicy).toBe("allowlist");
|
||||
expect(result.cfg.channels?.zalouser?.groups).toStrictEqual({});
|
||||
expect(
|
||||
note.mock.calls.some(([message]) =>
|
||||
message.includes("No group allowlist entries added yet."),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("writes canonical enabled entries for configured groups", async () => {
|
||||
const prompter = createQuickstartPrompter({
|
||||
groupAccess: true,
|
||||
groupPolicy: "allowlist",
|
||||
textByMessage: {
|
||||
"Zalo groups allowlist (comma-separated)": "Family, Work",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup({ prompter });
|
||||
|
||||
expect(result.cfg.channels?.zalouser?.groups).toEqual({
|
||||
Family: { enabled: true, requireMention: true },
|
||||
Work: { enabled: true, requireMention: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves non-quickstart forceAllowFrom behavior", async () => {
|
||||
const note = vi.fn(async (_message: string, _title?: string) => {});
|
||||
const seen: string[] = [];
|
||||
const prompter = createTestWizardPrompter({
|
||||
note,
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
seen.push(message);
|
||||
if (message === "Login via QR code now?") {
|
||||
return false;
|
||||
}
|
||||
if (message === "Configure Zalo groups access?") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
seen.push(message);
|
||||
if (message === "Zalouser allowFrom (name or user id)") {
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}) as ReturnType<typeof createTestWizardPrompter>["text"],
|
||||
});
|
||||
|
||||
const result = await runSetup({ prompter, forceAllowFrom: true });
|
||||
|
||||
expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("allowlist");
|
||||
expect(result.cfg.channels?.zalouser?.allowFrom).toStrictEqual([]);
|
||||
expect(seen).not.toContain("Zalo Personal DM policy");
|
||||
expect(seen).toContain("Zalouser allowFrom (name or user id)");
|
||||
expect(
|
||||
note.mock.calls.some(([message]) => message.includes("No DM allowlist entries added yet.")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allowlists the plugin when a plugin allowlist already exists", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Login via QR code now?") {
|
||||
return false;
|
||||
}
|
||||
if (message === "Configure Zalo groups access?") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSetup({
|
||||
cfg: {
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true);
|
||||
expect(result.cfg.plugins?.allow).toEqual(["telegram", "zalouser"]);
|
||||
});
|
||||
|
||||
it("reads the named-account DM policy instead of the channel root", () => {
|
||||
expect(
|
||||
zalouserSetupWizard.dmPolicy?.getCurrent(
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
dmPolicy: "disabled",
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "work",
|
||||
dmPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
"work",
|
||||
),
|
||||
).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("reports account-scoped config keys for named accounts", () => {
|
||||
expect(zalouserSetupWizard.dmPolicy?.resolveConfigKeys?.({} as OpenClawConfig, "work")).toEqual(
|
||||
{
|
||||
policyKey: "channels.zalouser.accounts.work.dmPolicy",
|
||||
allowFromKey: "channels.zalouser.accounts.work.allowFrom",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted DM policy account context", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
zalouser: {
|
||||
defaultAccount: "work",
|
||||
dmPolicy: "disabled",
|
||||
allowFrom: ["123456789"],
|
||||
accounts: {
|
||||
work: {
|
||||
dmPolicy: "allowlist",
|
||||
profile: "work-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(zalouserSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist");
|
||||
expect(zalouserSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({
|
||||
policyKey: "channels.zalouser.accounts.work.dmPolicy",
|
||||
allowFromKey: "channels.zalouser.accounts.work.allowFrom",
|
||||
});
|
||||
|
||||
const next = zalouserSetupWizard.dmPolicy?.setPolicy(cfg, "open");
|
||||
expect(next?.channels?.zalouser?.dmPolicy).toBe("disabled");
|
||||
const workAccount = next?.channels?.zalouser?.accounts?.work as
|
||||
| { dmPolicy?: string; allowFrom?: Array<string | number> }
|
||||
| undefined;
|
||||
expect(workAccount?.dmPolicy).toBe("open");
|
||||
});
|
||||
|
||||
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', () => {
|
||||
const next = zalouserSetupWizard.dmPolicy?.setPolicy(
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
allowFrom: ["123456789"],
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
"open",
|
||||
"work",
|
||||
);
|
||||
|
||||
expect(next?.channels?.zalouser?.dmPolicy).toBeUndefined();
|
||||
const workAccount = next?.channels?.zalouser?.accounts?.work as
|
||||
| { dmPolicy?: string; allowFrom?: Array<string | number> }
|
||||
| undefined;
|
||||
expect(workAccount?.dmPolicy).toBe("open");
|
||||
expect(workAccount?.allowFrom).toEqual(["123456789", "*"]);
|
||||
});
|
||||
|
||||
it("shows the account-scoped current DM policy in quickstart notes", async () => {
|
||||
const note = vi.fn(async (_message: string, _title?: string) => {});
|
||||
const prompter = createQuickstartPrompter({ note, dmPolicy: "pairing" });
|
||||
|
||||
await runSetupWizardConfigure({
|
||||
configure: zalouserConfigure,
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
dmPolicy: "disabled",
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "work",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["123456789"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
prompter,
|
||||
options: { quickstartDefaults: true },
|
||||
accountOverrides: { zalouser: "work" },
|
||||
});
|
||||
|
||||
expect(
|
||||
note.mock.calls.some(([message]) =>
|
||||
message.includes("Current: dmPolicy=allowlist, allowFrom=123456789"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
484
extensions/zalouser/src/setup-surface.ts
Normal file
484
extensions/zalouser/src/setup-surface.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
// Zalouser plugin module implements setup surface behavior.
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatCliCommand,
|
||||
formatDocsLink,
|
||||
formatResolvedUnresolvedNote,
|
||||
mergeAllowFromEntries,
|
||||
normalizeAccountId,
|
||||
patchScopedAccountConfig,
|
||||
createSetupTranslator,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
type DmPolicy,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
checkZcaAuthenticated,
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
} from "./accounts.js";
|
||||
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
|
||||
import {
|
||||
logoutZaloProfile,
|
||||
resolveZaloAllowFromEntries,
|
||||
resolveZaloGroupsByEntries,
|
||||
startZaloQrLogin,
|
||||
waitForZaloQrLogin,
|
||||
} from "./zalo-js.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "zalouser" as const;
|
||||
const ZALOUSER_ALLOW_FROM_PLACEHOLDER = t("wizard.zalouser.allowFromPlaceholder");
|
||||
const ZALOUSER_GROUPS_PLACEHOLDER = t("wizard.zalouser.groupsPlaceholder");
|
||||
const ZALOUSER_DM_ACCESS_TITLE = t("wizard.zalouser.dmAccessTitle");
|
||||
const ZALOUSER_ALLOWLIST_TITLE = t("wizard.zalouser.allowlistTitle");
|
||||
const ZALOUSER_GROUPS_TITLE = t("wizard.zalouser.groupsTitle");
|
||||
|
||||
function parseZalouserEntries(raw: string): string[] {
|
||||
return normalizeStringEntries(raw.split(/[\n,;]+/g));
|
||||
}
|
||||
|
||||
function setZalouserAccountScopedConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
defaultPatch: Record<string, unknown>,
|
||||
accountPatch: Record<string, unknown> = defaultPatch,
|
||||
): OpenClawConfig {
|
||||
return patchScopedAccountConfig({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: defaultPatch,
|
||||
accountPatch,
|
||||
});
|
||||
}
|
||||
|
||||
function setZalouserDmPolicy(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
policy: DmPolicy,
|
||||
): OpenClawConfig {
|
||||
const resolvedAccountId = normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID;
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId: resolvedAccountId });
|
||||
return setZalouserAccountScopedConfig(
|
||||
cfg,
|
||||
resolvedAccountId,
|
||||
{
|
||||
dmPolicy: policy,
|
||||
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) } : {}),
|
||||
},
|
||||
{
|
||||
dmPolicy: policy,
|
||||
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function setZalouserGroupPolicy(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
groupPolicy: "open" | "allowlist" | "disabled",
|
||||
): OpenClawConfig {
|
||||
return setZalouserAccountScopedConfig(cfg, accountId, {
|
||||
groupPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
function setZalouserGroupAllowlist(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
groupKeys: string[],
|
||||
): OpenClawConfig {
|
||||
const groups = Object.fromEntries(
|
||||
groupKeys.map((key) => [key, { enabled: true, requireMention: true }]),
|
||||
);
|
||||
return setZalouserAccountScopedConfig(cfg, accountId, {
|
||||
groups,
|
||||
});
|
||||
}
|
||||
|
||||
function ensureZalouserPluginEnabled(cfg: OpenClawConfig): OpenClawConfig {
|
||||
const next: OpenClawConfig = {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
zalouser: {
|
||||
...cfg.plugins?.entries?.zalouser,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const allow = next.plugins?.allow;
|
||||
if (!Array.isArray(allow) || allow.includes(channel)) {
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
plugins: {
|
||||
...next.plugins,
|
||||
allow: [...allow, channel],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function noteZalouserHelp(
|
||||
prompter: Parameters<NonNullable<ChannelSetupWizard["prepare"]>>[0]["prompter"],
|
||||
): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
t("wizard.zalouser.helpQrLogin"),
|
||||
"",
|
||||
t("wizard.zalouser.helpZcaJs"),
|
||||
"",
|
||||
`Docs: ${formatDocsLink("/channels/zalouser", "zalouser")}`,
|
||||
].join("\n"),
|
||||
t("wizard.zalouser.setupTitle"),
|
||||
);
|
||||
}
|
||||
|
||||
async function promptZalouserAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
|
||||
accountId: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
|
||||
while (true) {
|
||||
const entry = await prompter.text({
|
||||
message: t("wizard.zalouser.allowFromPrompt"),
|
||||
placeholder: ZALOUSER_ALLOW_FROM_PLACEHOLDER,
|
||||
initialValue: existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : undefined,
|
||||
});
|
||||
const parts = parseZalouserEntries(entry);
|
||||
if (parts.length === 0) {
|
||||
await prompter.note(
|
||||
[
|
||||
t("wizard.zalouser.noDmAllowlist"),
|
||||
t("wizard.zalouser.directChatsBlocked"),
|
||||
t("wizard.zalouser.peersLookupTip", {
|
||||
command: formatCliCommand("openclaw directory peers list --channel zalouser"),
|
||||
}),
|
||||
].join("\n"),
|
||||
ZALOUSER_ALLOWLIST_TITLE,
|
||||
);
|
||||
return setZalouserAccountScopedConfig(cfg, accountId, {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
});
|
||||
}
|
||||
const resolvedEntries = await resolveZaloAllowFromEntries({
|
||||
profile: resolved.profile,
|
||||
entries: parts,
|
||||
});
|
||||
|
||||
const unresolved = resolvedEntries.filter((item) => !item.resolved).map((item) => item.input);
|
||||
if (unresolved.length > 0) {
|
||||
await prompter.note(
|
||||
t("wizard.zalouser.couldNotResolve", { entries: unresolved.join(", ") }),
|
||||
ZALOUSER_ALLOWLIST_TITLE,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedIds = resolvedEntries
|
||||
.filter((item) => item.resolved && item.id)
|
||||
.map((item) => item.id as string);
|
||||
const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds);
|
||||
|
||||
const notes = resolvedEntries
|
||||
.filter((item) => item.note)
|
||||
.map((item) => `${item.input} -> ${item.id} (${item.note})`);
|
||||
if (notes.length > 0) {
|
||||
await prompter.note(notes.join("\n"), ZALOUSER_ALLOWLIST_TITLE);
|
||||
}
|
||||
|
||||
return setZalouserAccountScopedConfig(cfg, accountId, {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const zalouserDmPolicy: ChannelSetupDmPolicy = {
|
||||
label: "Zalo Personal",
|
||||
channel,
|
||||
policyKey: "channels.zalouser.dmPolicy",
|
||||
allowFromKey: "channels.zalouser.allowFrom",
|
||||
resolveConfigKeys: (cfg, accountId) =>
|
||||
(accountId ?? resolveDefaultZalouserAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
|
||||
? {
|
||||
policyKey: `channels.zalouser.accounts.${accountId ?? resolveDefaultZalouserAccountId(cfg)}.dmPolicy`,
|
||||
allowFromKey: `channels.zalouser.accounts.${accountId ?? resolveDefaultZalouserAccountId(cfg)}.allowFrom`,
|
||||
}
|
||||
: {
|
||||
policyKey: "channels.zalouser.dmPolicy",
|
||||
allowFromKey: "channels.zalouser.allowFrom",
|
||||
},
|
||||
getCurrent: (cfg, accountId) =>
|
||||
resolveZalouserAccountSync({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultZalouserAccountId(cfg),
|
||||
}).config.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy, accountId) =>
|
||||
setZalouserDmPolicy(cfg, accountId ?? resolveDefaultZalouserAccountId(cfg), policy),
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const id =
|
||||
accountId && normalizeAccountId(accountId)
|
||||
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
|
||||
: resolveDefaultZalouserAccountId(cfg);
|
||||
return await promptZalouserAllowFrom({
|
||||
cfg,
|
||||
prompter,
|
||||
accountId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async function promptZalouserQuickstartDmPolicy(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: Parameters<NonNullable<ChannelSetupWizard["prepare"]>>[0]["prompter"];
|
||||
accountId: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const existingPolicy = resolved.config.dmPolicy ?? "pairing";
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
t("wizard.zalouser.dmHelpSeparate"),
|
||||
t("wizard.zalouser.dmHelpPairing"),
|
||||
t("wizard.zalouser.dmHelpAllowlist"),
|
||||
t("wizard.zalouser.dmHelpOpen"),
|
||||
t("wizard.zalouser.dmHelpDisabled"),
|
||||
"",
|
||||
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
|
||||
t("wizard.zalouser.dmHelpAllowlistEmpty"),
|
||||
].join("\n"),
|
||||
ZALOUSER_DM_ACCESS_TITLE,
|
||||
);
|
||||
|
||||
const policy = (await prompter.select({
|
||||
message: t("wizard.zalouser.dmPolicyPrompt"),
|
||||
options: [
|
||||
{ value: "pairing", label: t("wizard.channels.dmPolicyPairing") },
|
||||
{ value: "allowlist", label: t("wizard.channels.dmPolicyAllowlistOption") },
|
||||
{ value: "open", label: t("wizard.channels.dmPolicyOpenOption") },
|
||||
{ value: "disabled", label: t("wizard.channels.dmPolicyDisabledOption") },
|
||||
],
|
||||
initialValue: existingPolicy,
|
||||
})) as DmPolicy;
|
||||
|
||||
if (policy === "allowlist") {
|
||||
return await promptZalouserAllowFrom({
|
||||
cfg,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
return setZalouserDmPolicy(cfg, accountId, policy);
|
||||
}
|
||||
|
||||
export { zalouserSetupAdapter } from "./setup-core.js";
|
||||
|
||||
export const zalouserSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: {
|
||||
configuredLabel: t("wizard.channels.statusLoggedIn"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsQrLogin"),
|
||||
configuredHint: t("wizard.channels.statusRecommendedLoggedIn"),
|
||||
unconfiguredHint: t("wizard.channels.statusRecommendedQrLogin"),
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 15,
|
||||
resolveConfigured: async ({ cfg, accountId }) => {
|
||||
const ids = accountId ? [accountId] : listZalouserAccountIds(cfg);
|
||||
for (const resolvedAccountId of ids) {
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId: resolvedAccountId });
|
||||
if (await checkZcaAuthenticated(account.profile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
resolveStatusLines: async ({ cfg, accountId, configured }) => {
|
||||
void cfg;
|
||||
const label =
|
||||
accountId && accountId !== DEFAULT_ACCOUNT_ID
|
||||
? `Zalo Personal (${accountId})`
|
||||
: "Zalo Personal";
|
||||
return [`${label}: ${configured ? "logged in" : "needs QR login"}`];
|
||||
},
|
||||
},
|
||||
prepare: async ({ cfg, accountId, prompter, options }) => {
|
||||
let next = cfg;
|
||||
const account = resolveZalouserAccountSync({ cfg: next, accountId });
|
||||
const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
|
||||
|
||||
if (!alreadyAuthenticated) {
|
||||
await noteZalouserHelp(prompter);
|
||||
const wantsLogin = await prompter.confirm({
|
||||
message: t("wizard.zalouser.loginQrPrompt"),
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (wantsLogin) {
|
||||
const start = await startZaloQrLogin({ profile: account.profile, timeoutMs: 35_000 });
|
||||
if (start.qrDataUrl) {
|
||||
const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile);
|
||||
await prompter.note(
|
||||
[
|
||||
start.message,
|
||||
qrPath
|
||||
? t("wizard.zalouser.qrImageSaved", { path: qrPath })
|
||||
: t("wizard.zalouser.qrImageWriteFailed"),
|
||||
t("wizard.zalouser.scanApproveContinue"),
|
||||
].join("\n"),
|
||||
t("wizard.zalouser.qrLoginTitle"),
|
||||
);
|
||||
const scanned = await prompter.confirm({
|
||||
message: t("wizard.zalouser.qrScannedPrompt"),
|
||||
initialValue: true,
|
||||
});
|
||||
if (scanned) {
|
||||
const waited = await waitForZaloQrLogin({
|
||||
profile: account.profile,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
await prompter.note(
|
||||
waited.message,
|
||||
waited.connected ? t("common.done") : t("wizard.zalouser.loginPendingTitle"),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await prompter.note(start.message, t("wizard.zalouser.loginPendingTitle"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const keepSession = await prompter.confirm({
|
||||
message: t("wizard.zalouser.keepSessionPrompt"),
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keepSession) {
|
||||
await logoutZaloProfile(account.profile);
|
||||
const start = await startZaloQrLogin({
|
||||
profile: account.profile,
|
||||
force: true,
|
||||
timeoutMs: 35_000,
|
||||
});
|
||||
if (start.qrDataUrl) {
|
||||
const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile);
|
||||
await prompter.note(
|
||||
[
|
||||
start.message,
|
||||
qrPath ? t("wizard.zalouser.qrImageSaved", { path: qrPath }) : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
t("wizard.zalouser.qrLoginTitle"),
|
||||
);
|
||||
const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 120_000 });
|
||||
await prompter.note(
|
||||
waited.message,
|
||||
waited.connected ? t("common.done") : t("wizard.zalouser.loginPendingTitle"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next = setZalouserAccountScopedConfig(
|
||||
next,
|
||||
accountId,
|
||||
{ profile: account.profile !== "default" ? account.profile : undefined },
|
||||
{ profile: account.profile, enabled: true },
|
||||
);
|
||||
|
||||
if (options?.quickstartDefaults) {
|
||||
next = await promptZalouserQuickstartDmPolicy({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
return { cfg: next };
|
||||
},
|
||||
credentials: [],
|
||||
groupAccess: {
|
||||
label: "Zalo groups",
|
||||
placeholder: ZALOUSER_GROUPS_PLACEHOLDER,
|
||||
currentPolicy: ({ cfg, accountId }) =>
|
||||
resolveZalouserAccountSync({ cfg, accountId }).config.groupPolicy ?? "allowlist",
|
||||
currentEntries: ({ cfg, accountId }) =>
|
||||
Object.keys(resolveZalouserAccountSync({ cfg, accountId }).config.groups ?? {}),
|
||||
updatePrompt: ({ cfg, accountId }) =>
|
||||
Boolean(resolveZalouserAccountSync({ cfg, accountId }).config.groups),
|
||||
setPolicy: ({ cfg, accountId, policy }) => setZalouserGroupPolicy(cfg, accountId, policy),
|
||||
resolveAllowlist: async ({ cfg, accountId, entries, prompter }) => {
|
||||
if (entries.length === 0) {
|
||||
await prompter.note(
|
||||
[
|
||||
t("wizard.zalouser.noGroupAllowlist"),
|
||||
t("wizard.zalouser.groupChatsBlocked"),
|
||||
t("wizard.zalouser.groupsLookupTip", {
|
||||
command: formatCliCommand("openclaw directory groups list --channel zalouser"),
|
||||
}),
|
||||
t("wizard.zalouser.groupMentionRequirement"),
|
||||
].join("\n"),
|
||||
ZALOUSER_GROUPS_TITLE,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const updatedAccount = resolveZalouserAccountSync({ cfg, accountId });
|
||||
try {
|
||||
const resolved = await resolveZaloGroupsByEntries({
|
||||
profile: updatedAccount.profile,
|
||||
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);
|
||||
const keys = [...resolvedIds, ...normalizeStringEntries(unresolved)];
|
||||
const resolution = formatResolvedUnresolvedNote({
|
||||
resolved: resolvedIds,
|
||||
unresolved,
|
||||
});
|
||||
if (resolution) {
|
||||
await prompter.note(resolution, ZALOUSER_GROUPS_TITLE);
|
||||
}
|
||||
return keys;
|
||||
} catch (err) {
|
||||
await prompter.note(
|
||||
t("wizard.zalouser.groupLookupFailed", { error: String(err) }),
|
||||
ZALOUSER_GROUPS_TITLE,
|
||||
);
|
||||
return normalizeStringEntries(entries);
|
||||
}
|
||||
},
|
||||
applyAllowlist: ({ cfg, accountId, resolved }) =>
|
||||
setZalouserGroupAllowlist(cfg, accountId, resolved as string[]),
|
||||
},
|
||||
finalize: async ({ cfg, accountId, forceAllowFrom, options, prompter }) => {
|
||||
let next = cfg;
|
||||
if (forceAllowFrom && !options?.quickstartDefaults) {
|
||||
next = await promptZalouserAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
return { cfg: ensureZalouserPluginEnabled(next) };
|
||||
},
|
||||
dmPolicy: zalouserDmPolicy,
|
||||
};
|
||||
43
extensions/zalouser/src/setup-test-helpers.ts
Normal file
43
extensions/zalouser/src/setup-test-helpers.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Zalouser helper module supports setup test helpers behavior.
|
||||
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
} from "./accounts.js";
|
||||
import { zalouserSetupAdapter } from "./setup-core.js";
|
||||
import { zalouserSetupWizard } from "./setup-surface.js";
|
||||
|
||||
export const zalouserSetupPlugin = {
|
||||
id: "zalouser",
|
||||
meta: {
|
||||
id: "zalouser",
|
||||
label: "ZaloUser",
|
||||
selectionLabel: "ZaloUser",
|
||||
docsPath: "/channels/zalouser",
|
||||
blurb: "Unofficial Zalo personal account connector.",
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
|
||||
},
|
||||
config: {
|
||||
listAccountIds: (cfg: unknown) => listZalouserAccountIds(cfg as never),
|
||||
defaultAccountId: (cfg: unknown) => resolveDefaultZalouserAccountId(cfg as never),
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
|
||||
resolveZalouserAccountSync({ cfg, accountId }),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: createScopedDmSecurityResolver({
|
||||
channelKey: "zalouser",
|
||||
resolvePolicy: (account: ReturnType<typeof resolveZalouserAccountSync>) =>
|
||||
account.config.dmPolicy,
|
||||
resolveAllowFrom: (account: ReturnType<typeof resolveZalouserAccountSync>) =>
|
||||
account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw: string) => raw.trim().replace(/^(zalouser|zlu):/i, ""),
|
||||
}),
|
||||
},
|
||||
setup: zalouserSetupAdapter,
|
||||
setupWizard: zalouserSetupWizard,
|
||||
} as const;
|
||||
93
extensions/zalouser/src/shared.ts
Normal file
93
extensions/zalouser/src/shared.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// Zalouser plugin module implements shared behavior.
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import {
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
checkZcaAuthenticated,
|
||||
type ResolvedZalouserAccount,
|
||||
} from "./accounts.js";
|
||||
import type { ChannelPlugin } from "./channel-api.js";
|
||||
import { buildChannelConfigSchema, formatAllowFromLowercase } from "./channel-api.js";
|
||||
import { ZalouserConfigSchema } from "./config-schema.js";
|
||||
import { zalouserDoctor } from "./doctor.js";
|
||||
|
||||
const zalouserMeta: ChannelPlugin<ResolvedZalouserAccount>["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: false,
|
||||
};
|
||||
|
||||
const zalouserConfigAdapter = createScopedChannelConfigAdapter<ResolvedZalouserAccount>({
|
||||
sectionKey: "zalouser",
|
||||
listAccountIds: listZalouserAccountIds,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveZalouserAccountSync),
|
||||
defaultAccountId: resolveDefaultZalouserAccountId,
|
||||
clearBaseFields: [
|
||||
"profile",
|
||||
"name",
|
||||
"dmPolicy",
|
||||
"allowFrom",
|
||||
"historyLimit",
|
||||
"groupAllowFrom",
|
||||
"groupPolicy",
|
||||
"groups",
|
||||
"messagePrefix",
|
||||
],
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
|
||||
});
|
||||
|
||||
export function createZalouserPluginBase(params: {
|
||||
setupWizard: NonNullable<ChannelPlugin<ResolvedZalouserAccount>["setupWizard"]>;
|
||||
setup: NonNullable<ChannelPlugin<ResolvedZalouserAccount>["setup"]>;
|
||||
}): Pick<
|
||||
ChannelPlugin<ResolvedZalouserAccount>,
|
||||
| "id"
|
||||
| "meta"
|
||||
| "setupWizard"
|
||||
| "capabilities"
|
||||
| "doctor"
|
||||
| "reload"
|
||||
| "configSchema"
|
||||
| "config"
|
||||
| "setup"
|
||||
> {
|
||||
return {
|
||||
id: "zalouser",
|
||||
meta: zalouserMeta,
|
||||
setupWizard: params.setupWizard,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
reactions: true,
|
||||
threads: false,
|
||||
polls: false,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
doctor: zalouserDoctor,
|
||||
reload: { configPrefixes: ["channels.zalouser"] },
|
||||
configSchema: buildChannelConfigSchema(ZalouserConfigSchema),
|
||||
config: {
|
||||
...zalouserConfigAdapter,
|
||||
isConfigured: async (account) => await checkZcaAuthenticated(account.profile),
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
}),
|
||||
},
|
||||
setup: params.setup,
|
||||
};
|
||||
}
|
||||
32
extensions/zalouser/src/status-issues.test.ts
Normal file
32
extensions/zalouser/src/status-issues.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Zalouser tests cover status issues plugin behavior.
|
||||
import { expectOpenDmPolicyConfigIssue } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectZalouserStatusIssues } from "./status-issues.js";
|
||||
|
||||
describe("collectZalouserStatusIssues", () => {
|
||||
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", () => {
|
||||
expectOpenDmPolicyConfigIssue({
|
||||
collectIssues: collectZalouserStatusIssues,
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
59
extensions/zalouser/src/status-issues.ts
Normal file
59
extensions/zalouser/src/status-issues.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Zalouser plugin module implements status issues behavior.
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelStatusIssue,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
coerceStatusIssueAccountId,
|
||||
readStatusIssueFields,
|
||||
} from "openclaw/plugin-sdk/extension-shared";
|
||||
|
||||
const ZALOUSER_STATUS_FIELDS = [
|
||||
"accountId",
|
||||
"enabled",
|
||||
"configured",
|
||||
"dmPolicy",
|
||||
"lastError",
|
||||
] as const;
|
||||
|
||||
export function collectZalouserStatusIssues(
|
||||
accounts: ChannelAccountSnapshot[],
|
||||
): ChannelStatusIssue[] {
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
for (const entry of accounts) {
|
||||
const account = readStatusIssueFields(entry, ZALOUSER_STATUS_FIELDS);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const accountId = coerceStatusIssueAccountId(account.accountId) ?? "default";
|
||||
const enabled = account.enabled !== false;
|
||||
if (!enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const configured = account.configured === true;
|
||||
|
||||
if (!configured) {
|
||||
issues.push({
|
||||
channel: "zalouser",
|
||||
accountId,
|
||||
kind: "auth",
|
||||
message: "Not authenticated (no saved Zalo session).",
|
||||
fix: "Run: openclaw 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;
|
||||
}
|
||||
27
extensions/zalouser/src/test-helpers.ts
Normal file
27
extensions/zalouser/src/test-helpers.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Zalouser helper module supports test helpers behavior.
|
||||
import type { RuntimeEnv } from "../runtime-api.js";
|
||||
import type { ResolvedZalouserAccount } from "./types.js";
|
||||
|
||||
export function createZalouserRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultResolvedZalouserAccount(
|
||||
overrides: Partial<ResolvedZalouserAccount> = {},
|
||||
): ResolvedZalouserAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
profile: "default",
|
||||
name: "test",
|
||||
enabled: true,
|
||||
authenticated: true,
|
||||
config: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
204
extensions/zalouser/src/text-styles.test.ts
Normal file
204
extensions/zalouser/src/text-styles.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
// Zalouser tests cover text styles plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseZalouserTextStyles } from "./text-styles.js";
|
||||
import { TextStyle } from "./zca-constants.js";
|
||||
|
||||
describe("parseZalouserTextStyles", () => {
|
||||
it("renders inline markdown emphasis as Zalo style ranges", () => {
|
||||
expect(parseZalouserTextStyles("**bold** *italic* ~~strike~~")).toEqual({
|
||||
text: "bold italic strike",
|
||||
styles: [
|
||||
{ start: 0, len: 4, st: TextStyle.Bold },
|
||||
{ start: 5, len: 6, st: TextStyle.Italic },
|
||||
{ start: 12, len: 6, st: TextStyle.StrikeThrough },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps inline code and plain math markers literal", () => {
|
||||
expect(parseZalouserTextStyles("before `inline *code*` after\n2 * 3 * 4")).toEqual({
|
||||
text: "before `inline *code*` after\n2 * 3 * 4",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves backslash escapes inside code spans and fenced code blocks", () => {
|
||||
expect(parseZalouserTextStyles("before `\\*` after\n```ts\n\\*\\_\\\\\n```")).toEqual({
|
||||
text: "before `\\*` after\n\\*\\_\\\\",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("closes fenced code blocks when the input uses CRLF newlines", () => {
|
||||
expect(parseZalouserTextStyles("```\r\n*code*\r\n```\r\n**after**")).toEqual({
|
||||
text: "*code*\nafter",
|
||||
styles: [{ start: 7, len: 5, st: TextStyle.Bold }],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps headings, block quotes, and lists into line styles", () => {
|
||||
expect(parseZalouserTextStyles(["# Title", "> quoted", " - nested"].join("\n"))).toEqual({
|
||||
text: "Title\nquoted\nnested",
|
||||
styles: [
|
||||
{ start: 0, len: 5, st: TextStyle.Bold },
|
||||
{ start: 0, len: 5, st: TextStyle.Big },
|
||||
{ start: 6, len: 6, st: TextStyle.Indent, indentSize: 1 },
|
||||
{ start: 13, len: 6, st: TextStyle.UnorderedList },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats 1-3 leading spaces as markdown padding for headings and lists", () => {
|
||||
expect(parseZalouserTextStyles(" # Title\n 1. item\n - bullet")).toEqual({
|
||||
text: "Title\nitem\nbullet",
|
||||
styles: [
|
||||
{ start: 0, len: 5, st: TextStyle.Bold },
|
||||
{ start: 0, len: 5, st: TextStyle.Big },
|
||||
{ start: 6, len: 4, st: TextStyle.OrderedList },
|
||||
{ start: 11, len: 6, st: TextStyle.UnorderedList },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips fenced code markers and preserves leading indentation with nbsp", () => {
|
||||
expect(parseZalouserTextStyles("```ts\n const x = 1\n\treturn x\n```")).toEqual({
|
||||
text: "\u00A0\u00A0const x = 1\n\u00A0\u00A0\u00A0\u00A0return x",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats tilde fences as literal code blocks", () => {
|
||||
expect(parseZalouserTextStyles("~~~bash\n*cmd*\n~~~")).toEqual({
|
||||
text: "*cmd*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats fences indented under list items as literal code blocks", () => {
|
||||
expect(parseZalouserTextStyles(" ```\n*cmd*\n ```")).toEqual({
|
||||
text: "*cmd*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats quoted backtick fences as literal code blocks", () => {
|
||||
expect(parseZalouserTextStyles("> ```js\n> *cmd*\n> ```")).toEqual({
|
||||
text: "*cmd*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats quoted tilde fences as literal code blocks", () => {
|
||||
expect(parseZalouserTextStyles("> ~~~\n> *cmd*\n> ~~~")).toEqual({
|
||||
text: "*cmd*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves quote-prefixed lines inside normal fenced code blocks", () => {
|
||||
expect(parseZalouserTextStyles("```\n> prompt\n```")).toEqual({
|
||||
text: "> prompt",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat quote-prefixed fence text inside code as a closing fence", () => {
|
||||
expect(parseZalouserTextStyles("```\n> ```\n*still code*\n```")).toEqual({
|
||||
text: "> ```\n*still code*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats indented blockquotes as quoted lines", () => {
|
||||
expect(parseZalouserTextStyles(" > quoted")).toEqual({
|
||||
text: "quoted",
|
||||
styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats spaced nested blockquotes as deeper quoted lines", () => {
|
||||
expect(parseZalouserTextStyles("> > quoted")).toEqual({
|
||||
text: "quoted",
|
||||
styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 2 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats indented quoted fences as literal code blocks", () => {
|
||||
expect(parseZalouserTextStyles(" > ```\n > *cmd*\n > ```")).toEqual({
|
||||
text: "*cmd*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats spaced nested quoted fences as literal code blocks", () => {
|
||||
expect(parseZalouserTextStyles("> > ```\n> > code\n> > ```")).toEqual({
|
||||
text: "code",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves inner quote markers inside quoted fenced code blocks", () => {
|
||||
expect(parseZalouserTextStyles("> ```\n>> prompt\n> ```")).toEqual({
|
||||
text: "> prompt",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps quote indentation on heading lines", () => {
|
||||
expect(parseZalouserTextStyles("> # Title")).toEqual({
|
||||
text: "Title",
|
||||
styles: [
|
||||
{ start: 0, len: 5, st: TextStyle.Bold },
|
||||
{ start: 0, len: 5, st: TextStyle.Big },
|
||||
{ start: 0, len: 5, st: TextStyle.Indent, indentSize: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unmatched fences literal", () => {
|
||||
expect(parseZalouserTextStyles("```python")).toEqual({
|
||||
text: "```python",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unclosed fenced blocks literal until eof", () => {
|
||||
expect(parseZalouserTextStyles("```python\n\\*not italic*\n_next_")).toEqual({
|
||||
text: "```python\n\\*not italic*\n_next_",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("supports nested markdown and tag styles regardless of order", () => {
|
||||
expect(parseZalouserTextStyles("**{red}x{/red}** {red}**y**{/red}")).toEqual({
|
||||
text: "x y",
|
||||
styles: [
|
||||
{ start: 0, len: 1, st: TextStyle.Bold },
|
||||
{ start: 0, len: 1, st: TextStyle.Red },
|
||||
{ start: 2, len: 1, st: TextStyle.Red },
|
||||
{ start: 2, len: 1, st: TextStyle.Bold },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats small text tags as normal text", () => {
|
||||
expect(parseZalouserTextStyles("{small}tiny{/small}")).toEqual({
|
||||
text: "tiny",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps escaped markers literal", () => {
|
||||
expect(parseZalouserTextStyles("\\*literal\\* \\{underline}tag{/underline}")).toEqual({
|
||||
text: "*literal* {underline}tag{/underline}",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps indented code blocks literal", () => {
|
||||
expect(parseZalouserTextStyles(" *cmd*")).toEqual({
|
||||
text: "\u00A0\u00A0\u00A0\u00A0*cmd*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
541
extensions/zalouser/src/text-styles.ts
Normal file
541
extensions/zalouser/src/text-styles.ts
Normal file
@@ -0,0 +1,541 @@
|
||||
// Zalouser plugin module implements text styles behavior.
|
||||
import { TextStyle, type Style } from "./zca-constants.js";
|
||||
|
||||
const ESCAPE_SENTINEL_START = "\u0001";
|
||||
const ESCAPE_SENTINEL_END = "\u0002";
|
||||
|
||||
type InlineStyle = (typeof TextStyle)[keyof typeof TextStyle];
|
||||
|
||||
type LineStyle = {
|
||||
lineIndex: number;
|
||||
style: InlineStyle;
|
||||
indentSize?: number;
|
||||
};
|
||||
|
||||
type Segment = {
|
||||
text: string;
|
||||
styles: InlineStyle[];
|
||||
};
|
||||
|
||||
type InlineMarker = {
|
||||
pattern: RegExp;
|
||||
extractText: (match: RegExpExecArray) => string;
|
||||
resolveStyles?: (match: RegExpExecArray) => InlineStyle[];
|
||||
literal?: boolean;
|
||||
};
|
||||
|
||||
type ResolvedInlineMatch = {
|
||||
match: RegExpExecArray;
|
||||
marker: InlineMarker;
|
||||
styles: InlineStyle[];
|
||||
text: string;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
type FenceMarker = {
|
||||
char: "`" | "~";
|
||||
length: number;
|
||||
indent: number;
|
||||
};
|
||||
|
||||
type ActiveFence = FenceMarker & {
|
||||
quoteIndent: number;
|
||||
};
|
||||
|
||||
const TAG_STYLE_MAP: Record<string, InlineStyle | null> = {
|
||||
red: TextStyle.Red,
|
||||
orange: TextStyle.Orange,
|
||||
yellow: TextStyle.Yellow,
|
||||
green: TextStyle.Green,
|
||||
small: null,
|
||||
big: TextStyle.Big,
|
||||
underline: TextStyle.Underline,
|
||||
};
|
||||
|
||||
const INLINE_MARKERS: InlineMarker[] = [
|
||||
{
|
||||
pattern: /`([^`\n]+)`/g,
|
||||
extractText: (match) => match[0],
|
||||
literal: true,
|
||||
},
|
||||
{
|
||||
pattern: /\\([*_~#\\{}>+\-`])/g,
|
||||
extractText: (match) => match[1],
|
||||
literal: true,
|
||||
},
|
||||
{
|
||||
pattern: new RegExp(`\\{(${Object.keys(TAG_STYLE_MAP).join("|")})\\}(.+?)\\{/\\1\\}`, "g"),
|
||||
extractText: (match) => match[2],
|
||||
resolveStyles: (match) => {
|
||||
const style = TAG_STYLE_MAP[match[1]];
|
||||
return style ? [style] : [];
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /(?<!\*)\*\*\*(?=\S)([^\n]*?\S)(?<!\*)\*\*\*(?!\*)/g,
|
||||
extractText: (match) => match[1],
|
||||
resolveStyles: () => [TextStyle.Bold, TextStyle.Italic],
|
||||
},
|
||||
{
|
||||
pattern: /(?<!\*)\*\*(?![\s*])([^\n]*?\S)(?<!\*)\*\*(?!\*)/g,
|
||||
extractText: (match) => match[1],
|
||||
resolveStyles: () => [TextStyle.Bold],
|
||||
},
|
||||
{
|
||||
pattern: /(?<![\w_])__(?![\s_])([^\n]*?\S)(?<!_)__(?![\w_])/g,
|
||||
extractText: (match) => match[1],
|
||||
resolveStyles: () => [TextStyle.Bold],
|
||||
},
|
||||
{
|
||||
pattern: /(?<!~)~~(?=\S)([^\n]*?\S)(?<!~)~~(?!~)/g,
|
||||
extractText: (match) => match[1],
|
||||
resolveStyles: () => [TextStyle.StrikeThrough],
|
||||
},
|
||||
{
|
||||
pattern: /(?<!\*)\*(?![\s*])([^\n]*?\S)(?<!\*)\*(?!\*)/g,
|
||||
extractText: (match) => match[1],
|
||||
resolveStyles: () => [TextStyle.Italic],
|
||||
},
|
||||
{
|
||||
pattern: /(?<![\w_])_(?![\s_])([^\n]*?\S)(?<!_)_(?![\w_])/g,
|
||||
extractText: (match) => match[1],
|
||||
resolveStyles: () => [TextStyle.Italic],
|
||||
},
|
||||
];
|
||||
|
||||
export function parseZalouserTextStyles(input: string): { text: string; styles: Style[] } {
|
||||
const allStyles: Style[] = [];
|
||||
|
||||
const escapeMap: string[] = [];
|
||||
const lines = input.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineStyles: LineStyle[] = [];
|
||||
const processedLines: string[] = [];
|
||||
let activeFence: ActiveFence | null = null;
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
||||
const rawLine = lines[lineIndex];
|
||||
const { text: unquotedLine, indent: baseIndent } = stripQuotePrefix(rawLine);
|
||||
|
||||
if (activeFence) {
|
||||
const codeLine =
|
||||
activeFence.quoteIndent > 0
|
||||
? stripQuotePrefix(rawLine, activeFence.quoteIndent).text
|
||||
: rawLine;
|
||||
if (isClosingFence(codeLine, activeFence)) {
|
||||
activeFence = null;
|
||||
continue;
|
||||
}
|
||||
processedLines.push(
|
||||
escapeLiteralText(
|
||||
normalizeCodeBlockLeadingWhitespace(stripCodeFenceIndent(codeLine, activeFence.indent)),
|
||||
escapeMap,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const line = unquotedLine;
|
||||
const openingFence = resolveOpeningFence(rawLine);
|
||||
if (openingFence) {
|
||||
const fenceLine = openingFence.quoteIndent > 0 ? unquotedLine : rawLine;
|
||||
if (!hasClosingFence(lines, lineIndex + 1, openingFence)) {
|
||||
processedLines.push(escapeLiteralText(fenceLine, escapeMap));
|
||||
activeFence = openingFence;
|
||||
continue;
|
||||
}
|
||||
activeFence = openingFence;
|
||||
continue;
|
||||
}
|
||||
|
||||
const outputLineIndex = processedLines.length;
|
||||
if (isIndentedCodeBlockLine(line)) {
|
||||
if (baseIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: baseIndent,
|
||||
});
|
||||
}
|
||||
processedLines.push(escapeLiteralText(normalizeCodeBlockLeadingWhitespace(line), escapeMap));
|
||||
continue;
|
||||
}
|
||||
|
||||
const { text: markdownLine, size: markdownPadding } = stripOptionalMarkdownPadding(line);
|
||||
|
||||
const headingMatch = markdownLine.match(/^(#{1,4})\s(.*)$/);
|
||||
if (headingMatch) {
|
||||
const depth = headingMatch[1].length;
|
||||
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Bold });
|
||||
if (depth === 1) {
|
||||
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Big });
|
||||
}
|
||||
if (baseIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: baseIndent,
|
||||
});
|
||||
}
|
||||
processedLines.push(headingMatch[2]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const indentMatch = markdownLine.match(/^(\s+)(.*)$/);
|
||||
let indentLevel = 0;
|
||||
let content = markdownLine;
|
||||
if (indentMatch) {
|
||||
indentLevel = clampIndent(indentMatch[1].length);
|
||||
content = indentMatch[2];
|
||||
}
|
||||
const totalIndent = Math.min(5, baseIndent + indentLevel);
|
||||
|
||||
if (/^[-*+]\s\[[ xX]\]\s/.test(content)) {
|
||||
if (totalIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: totalIndent,
|
||||
});
|
||||
}
|
||||
processedLines.push(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedListMatch = content.match(/^(\d+)\.\s(.*)$/);
|
||||
if (orderedListMatch) {
|
||||
if (totalIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: totalIndent,
|
||||
});
|
||||
}
|
||||
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.OrderedList });
|
||||
processedLines.push(orderedListMatch[2]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const unorderedListMatch = content.match(/^[-*+]\s(.*)$/);
|
||||
if (unorderedListMatch) {
|
||||
if (totalIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: totalIndent,
|
||||
});
|
||||
}
|
||||
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.UnorderedList });
|
||||
processedLines.push(unorderedListMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (markdownPadding > 0) {
|
||||
if (baseIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: baseIndent,
|
||||
});
|
||||
}
|
||||
processedLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (totalIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: totalIndent,
|
||||
});
|
||||
processedLines.push(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
processedLines.push(line);
|
||||
}
|
||||
|
||||
const segments = parseInlineSegments(processedLines.join("\n"));
|
||||
|
||||
let plainText = "";
|
||||
for (const segment of segments) {
|
||||
const start = plainText.length;
|
||||
plainText += segment.text;
|
||||
for (const style of segment.styles) {
|
||||
allStyles.push({ start, len: segment.text.length, st: style } as Style);
|
||||
}
|
||||
}
|
||||
|
||||
if (escapeMap.length > 0) {
|
||||
const escapeRegex = new RegExp(`${ESCAPE_SENTINEL_START}(\\d+)${ESCAPE_SENTINEL_END}`, "g");
|
||||
const shifts: Array<{ pos: number; delta: number }> = [];
|
||||
let cumulativeDelta = 0;
|
||||
|
||||
for (const match of plainText.matchAll(escapeRegex)) {
|
||||
const escapeIndex = Number.parseInt(match[1], 10);
|
||||
cumulativeDelta += match[0].length - escapeMap[escapeIndex].length;
|
||||
shifts.push({ pos: (match.index ?? 0) + match[0].length, delta: cumulativeDelta });
|
||||
}
|
||||
|
||||
for (const style of allStyles) {
|
||||
let startDelta = 0;
|
||||
let endDelta = 0;
|
||||
const end = style.start + style.len;
|
||||
for (const shift of shifts) {
|
||||
if (shift.pos <= style.start) {
|
||||
startDelta = shift.delta;
|
||||
}
|
||||
if (shift.pos <= end) {
|
||||
endDelta = shift.delta;
|
||||
}
|
||||
}
|
||||
style.start -= startDelta;
|
||||
style.len -= endDelta - startDelta;
|
||||
}
|
||||
|
||||
plainText = plainText.replace(
|
||||
escapeRegex,
|
||||
(_match, index) => escapeMap[Number.parseInt(index, 10)],
|
||||
);
|
||||
}
|
||||
|
||||
const finalLines = plainText.split("\n");
|
||||
let offset = 0;
|
||||
for (let lineIndex = 0; lineIndex < finalLines.length; lineIndex += 1) {
|
||||
const lineLength = finalLines[lineIndex].length;
|
||||
if (lineLength > 0) {
|
||||
for (const lineStyle of lineStyles) {
|
||||
if (lineStyle.lineIndex !== lineIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lineStyle.style === TextStyle.Indent) {
|
||||
allStyles.push({
|
||||
start: offset,
|
||||
len: lineLength,
|
||||
st: TextStyle.Indent,
|
||||
indentSize: lineStyle.indentSize,
|
||||
});
|
||||
} else {
|
||||
allStyles.push({ start: offset, len: lineLength, st: lineStyle.style } as Style);
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += lineLength + 1;
|
||||
}
|
||||
|
||||
return { text: plainText, styles: allStyles };
|
||||
}
|
||||
|
||||
function clampIndent(spaceCount: number): number {
|
||||
return Math.min(5, Math.max(1, Math.floor(spaceCount / 2)));
|
||||
}
|
||||
|
||||
function stripOptionalMarkdownPadding(line: string): { text: string; size: number } {
|
||||
const match = line.match(/^( {1,3})(?=\S)/);
|
||||
if (!match) {
|
||||
return { text: line, size: 0 };
|
||||
}
|
||||
return {
|
||||
text: line.slice(match[1].length),
|
||||
size: match[1].length,
|
||||
};
|
||||
}
|
||||
|
||||
function hasClosingFence(lines: string[], startIndex: number, fence: ActiveFence): boolean {
|
||||
for (let index = startIndex; index < lines.length; index += 1) {
|
||||
const candidate =
|
||||
fence.quoteIndent > 0 ? stripQuotePrefix(lines[index], fence.quoteIndent).text : lines[index];
|
||||
if (isClosingFence(candidate, fence)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveOpeningFence(line: string): ActiveFence | null {
|
||||
const directFence = parseFenceMarker(line);
|
||||
if (directFence) {
|
||||
return { ...directFence, quoteIndent: 0 };
|
||||
}
|
||||
|
||||
const quoted = stripQuotePrefix(line);
|
||||
if (quoted.indent === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quotedFence = parseFenceMarker(quoted.text);
|
||||
if (!quotedFence) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...quotedFence,
|
||||
quoteIndent: quoted.indent,
|
||||
};
|
||||
}
|
||||
|
||||
function stripQuotePrefix(
|
||||
line: string,
|
||||
maxDepth = Number.POSITIVE_INFINITY,
|
||||
): { text: string; indent: number } {
|
||||
let cursor = 0;
|
||||
while (cursor < line.length && cursor < 3 && line[cursor] === " ") {
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
let removedDepth = 0;
|
||||
let consumedCursor = cursor;
|
||||
while (removedDepth < maxDepth && consumedCursor < line.length && line[consumedCursor] === ">") {
|
||||
removedDepth += 1;
|
||||
consumedCursor += 1;
|
||||
if (line[consumedCursor] === " ") {
|
||||
consumedCursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedDepth === 0) {
|
||||
return { text: line, indent: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
text: line.slice(consumedCursor),
|
||||
indent: Math.min(5, removedDepth),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFenceMarker(line: string): FenceMarker | null {
|
||||
const match = line.match(/^([ ]{0,3})(`{3,}|~{3,})(.*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const marker = match[2];
|
||||
const char = marker[0];
|
||||
if (char !== "`" && char !== "~") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
char,
|
||||
length: marker.length,
|
||||
indent: match[1].length,
|
||||
};
|
||||
}
|
||||
|
||||
function isClosingFence(line: string, fence: FenceMarker): boolean {
|
||||
const match = line.match(/^([ ]{0,3})(`{3,}|~{3,})[ \t]*$/);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
return match[2][0] === fence.char && match[2].length >= fence.length;
|
||||
}
|
||||
|
||||
function escapeLiteralText(input: string, escapeMap: string[]): string {
|
||||
return input.replace(/[\\*_~{}`]/g, (ch) => {
|
||||
const index = escapeMap.length;
|
||||
escapeMap.push(ch);
|
||||
return `\x01${index}\x02`;
|
||||
});
|
||||
}
|
||||
|
||||
function parseInlineSegments(text: string, inheritedStyles: InlineStyle[] = []): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const nextMatch = findNextInlineMatch(text, cursor);
|
||||
if (!nextMatch) {
|
||||
pushSegment(segments, text.slice(cursor), inheritedStyles);
|
||||
break;
|
||||
}
|
||||
|
||||
if (nextMatch.match.index > cursor) {
|
||||
pushSegment(segments, text.slice(cursor, nextMatch.match.index), inheritedStyles);
|
||||
}
|
||||
|
||||
const combinedStyles = [...inheritedStyles, ...nextMatch.styles];
|
||||
if (nextMatch.marker.literal) {
|
||||
pushSegment(segments, nextMatch.text, combinedStyles);
|
||||
} else {
|
||||
segments.push(...parseInlineSegments(nextMatch.text, combinedStyles));
|
||||
}
|
||||
|
||||
cursor = nextMatch.match.index + nextMatch.match[0].length;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function findNextInlineMatch(text: string, startIndex: number): ResolvedInlineMatch | null {
|
||||
let bestMatch: ResolvedInlineMatch | null = null;
|
||||
|
||||
for (const [priority, marker] of INLINE_MARKERS.entries()) {
|
||||
const regex = new RegExp(marker.pattern.source, marker.pattern.flags);
|
||||
regex.lastIndex = startIndex;
|
||||
const match = regex.exec(text);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
bestMatch &&
|
||||
(match.index > bestMatch.match.index ||
|
||||
(match.index === bestMatch.match.index && priority > bestMatch.priority))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bestMatch = {
|
||||
match,
|
||||
marker,
|
||||
text: marker.extractText(match),
|
||||
styles: marker.resolveStyles?.(match) ?? [],
|
||||
priority,
|
||||
};
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function pushSegment(segments: Segment[], text: string, styles: InlineStyle[]): void {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSegment = segments.at(-1);
|
||||
if (lastSegment && sameStyles(lastSegment.styles, styles)) {
|
||||
lastSegment.text += text;
|
||||
return;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
text,
|
||||
styles: [...styles],
|
||||
});
|
||||
}
|
||||
|
||||
function sameStyles(left: InlineStyle[], right: InlineStyle[]): boolean {
|
||||
return left.length === right.length && left.every((style, index) => style === right[index]);
|
||||
}
|
||||
|
||||
function normalizeCodeBlockLeadingWhitespace(line: string): string {
|
||||
return line.replace(/^[ \t]+/, (leadingWhitespace) =>
|
||||
leadingWhitespace.replace(/\t/g, "\u00A0\u00A0\u00A0\u00A0").replace(/ /g, "\u00A0"),
|
||||
);
|
||||
}
|
||||
|
||||
function isIndentedCodeBlockLine(line: string): boolean {
|
||||
return /^(?: {4,}|\t)/.test(line);
|
||||
}
|
||||
|
||||
function stripCodeFenceIndent(line: string, indent: number): string {
|
||||
let consumed = 0;
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < line.length && consumed < indent && line[cursor] === " ") {
|
||||
cursor += 1;
|
||||
consumed += 1;
|
||||
}
|
||||
|
||||
return line.slice(cursor);
|
||||
}
|
||||
213
extensions/zalouser/src/tool.test.ts
Normal file
213
extensions/zalouser/src/tool.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
// Zalouser tests cover tool plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
|
||||
import { createZalouserTool, executeZalouserTool } from "./tool.js";
|
||||
import {
|
||||
checkZaloAuthenticated,
|
||||
getZaloUserInfo,
|
||||
listZaloFriendsMatching,
|
||||
listZaloGroupsMatching,
|
||||
} from "./zalo-js.js";
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: vi.fn(),
|
||||
sendImageZalouser: vi.fn(),
|
||||
sendLinkZalouser: vi.fn(),
|
||||
sendReactionZalouser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
checkZaloAuthenticated: vi.fn(),
|
||||
getZaloUserInfo: vi.fn(),
|
||||
listZaloFriendsMatching: vi.fn(),
|
||||
listZaloGroupsMatching: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSendMessage = vi.mocked(sendMessageZalouser);
|
||||
const mockSendImage = vi.mocked(sendImageZalouser);
|
||||
const mockSendLink = vi.mocked(sendLinkZalouser);
|
||||
const mockCheckAuth = vi.mocked(checkZaloAuthenticated);
|
||||
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
|
||||
const mockListFriends = vi.mocked(listZaloFriendsMatching);
|
||||
const mockListGroups = vi.mocked(listZaloGroupsMatching);
|
||||
|
||||
function extractDetails(result: { content?: Array<{ type: string; text?: string }> }): unknown {
|
||||
const text = result.content?.[0]?.text ?? "{}";
|
||||
return JSON.parse(text) as unknown;
|
||||
}
|
||||
|
||||
describe("executeZalouserTool", () => {
|
||||
beforeEach(() => {
|
||||
mockSendMessage.mockReset();
|
||||
mockSendImage.mockReset();
|
||||
mockSendLink.mockReset();
|
||||
mockCheckAuth.mockReset();
|
||||
mockGetUserInfo.mockReset();
|
||||
mockListFriends.mockReset();
|
||||
mockListGroups.mockReset();
|
||||
});
|
||||
|
||||
it("returns error when send action is missing required fields", async () => {
|
||||
const result = await executeZalouserTool("tool-1", { action: "send" });
|
||||
expect(extractDetails(result)).toEqual({
|
||||
error: "threadId and message required for send action",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends text message for send action", async () => {
|
||||
mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-1" } as never);
|
||||
const result = await executeZalouserTool("tool-1", {
|
||||
action: "send",
|
||||
threadId: "t-1",
|
||||
message: "hello",
|
||||
profile: "work",
|
||||
isGroup: true,
|
||||
});
|
||||
expect(mockSendMessage).toHaveBeenCalledWith("t-1", "hello", {
|
||||
profile: "work",
|
||||
isGroup: true,
|
||||
});
|
||||
expect(extractDetails(result)).toEqual({ success: true, messageId: "m-1" });
|
||||
});
|
||||
|
||||
it("defaults send routing from ambient deliveryContext target", async () => {
|
||||
mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-ambient" } as never);
|
||||
const tool = createZalouserTool({
|
||||
deliveryContext: {
|
||||
channel: "zalouser",
|
||||
to: "zalouser:g-ambient",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("tool-1", {
|
||||
action: "send",
|
||||
message: "hello",
|
||||
});
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith("g-ambient", "hello", {
|
||||
profile: undefined,
|
||||
isGroup: true,
|
||||
});
|
||||
expect(extractDetails(result)).toEqual({ success: true, messageId: "m-ambient" });
|
||||
});
|
||||
|
||||
it("keeps explicit threadId over ambient delivery defaults", async () => {
|
||||
mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-explicit" } as never);
|
||||
const tool = createZalouserTool({
|
||||
deliveryContext: {
|
||||
channel: "zalouser",
|
||||
to: "zalouser:g-ambient",
|
||||
},
|
||||
});
|
||||
|
||||
await tool.execute("tool-1", {
|
||||
action: "send",
|
||||
threadId: "u-explicit",
|
||||
message: "hello",
|
||||
isGroup: false,
|
||||
});
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith("u-explicit", "hello", {
|
||||
profile: undefined,
|
||||
isGroup: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not route send actions from foreign ambient thread defaults", async () => {
|
||||
const tool = createZalouserTool({
|
||||
deliveryContext: {
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
threadId: "1710000000.000100",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("tool-1", {
|
||||
action: "send",
|
||||
message: "hello",
|
||||
});
|
||||
|
||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||
expect(extractDetails(result)).toEqual({
|
||||
error: "threadId and message required for send action",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns tool error when send action fails", async () => {
|
||||
mockSendMessage.mockResolvedValueOnce({ ok: false, error: "blocked" } as never);
|
||||
const result = await executeZalouserTool("tool-1", {
|
||||
action: "send",
|
||||
threadId: "t-1",
|
||||
message: "hello",
|
||||
});
|
||||
expect(extractDetails(result)).toEqual({ error: "blocked" });
|
||||
});
|
||||
|
||||
it("routes image and link actions to correct helpers", async () => {
|
||||
mockSendImage.mockResolvedValueOnce({ ok: true, messageId: "img-1" } as never);
|
||||
const imageResult = await executeZalouserTool("tool-1", {
|
||||
action: "image",
|
||||
threadId: "g-1",
|
||||
url: "https://example.com/image.jpg",
|
||||
message: "caption",
|
||||
isGroup: true,
|
||||
});
|
||||
expect(mockSendImage).toHaveBeenCalledWith("g-1", "https://example.com/image.jpg", {
|
||||
profile: undefined,
|
||||
caption: "caption",
|
||||
isGroup: true,
|
||||
});
|
||||
expect(extractDetails(imageResult)).toEqual({ success: true, messageId: "img-1" });
|
||||
|
||||
mockSendLink.mockResolvedValueOnce({ ok: true, messageId: "lnk-1" } as never);
|
||||
const linkResult = await executeZalouserTool("tool-1", {
|
||||
action: "link",
|
||||
threadId: "t-2",
|
||||
url: "https://openclaw.ai",
|
||||
message: "read this",
|
||||
});
|
||||
expect(mockSendLink).toHaveBeenCalledWith("t-2", "https://openclaw.ai", {
|
||||
profile: undefined,
|
||||
caption: "read this",
|
||||
isGroup: undefined,
|
||||
});
|
||||
expect(extractDetails(linkResult)).toEqual({ success: true, messageId: "lnk-1" });
|
||||
});
|
||||
|
||||
it("returns friends/groups lists", async () => {
|
||||
mockListFriends.mockResolvedValueOnce([{ userId: "1", displayName: "Alice" }]);
|
||||
mockListGroups.mockResolvedValueOnce([{ groupId: "2", name: "Work" }]);
|
||||
|
||||
const friends = await executeZalouserTool("tool-1", {
|
||||
action: "friends",
|
||||
profile: "work",
|
||||
query: "ali",
|
||||
});
|
||||
expect(mockListFriends).toHaveBeenCalledWith("work", "ali");
|
||||
expect(extractDetails(friends)).toEqual([{ userId: "1", displayName: "Alice" }]);
|
||||
|
||||
const groups = await executeZalouserTool("tool-1", {
|
||||
action: "groups",
|
||||
profile: "work",
|
||||
query: "wrk",
|
||||
});
|
||||
expect(mockListGroups).toHaveBeenCalledWith("work", "wrk");
|
||||
expect(extractDetails(groups)).toEqual([{ groupId: "2", name: "Work" }]);
|
||||
});
|
||||
|
||||
it("reports me + status actions", async () => {
|
||||
mockGetUserInfo.mockResolvedValueOnce({ userId: "7", displayName: "Me" });
|
||||
mockCheckAuth.mockResolvedValueOnce(true);
|
||||
|
||||
const me = await executeZalouserTool("tool-1", { action: "me", profile: "work" });
|
||||
expect(mockGetUserInfo).toHaveBeenCalledWith("work");
|
||||
expect(extractDetails(me)).toEqual({ userId: "7", displayName: "Me" });
|
||||
|
||||
const status = await executeZalouserTool("tool-1", { action: "status", profile: "work" });
|
||||
expect(mockCheckAuth).toHaveBeenCalledWith("work");
|
||||
expect(extractDetails(status)).toEqual({
|
||||
authenticated: true,
|
||||
output: "authenticated",
|
||||
});
|
||||
});
|
||||
});
|
||||
190
extensions/zalouser/src/tool.ts
Normal file
190
extensions/zalouser/src/tool.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// Zalouser plugin module implements tool behavior.
|
||||
import { stringEnum } from "openclaw/plugin-sdk/channel-actions";
|
||||
import type { AnyAgentTool, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { jsonResult as json, type AgentToolResult } from "openclaw/plugin-sdk/tool-results";
|
||||
import { Type } from "typebox";
|
||||
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
|
||||
import { parseZalouserOutboundTarget } from "./session-route.js";
|
||||
import {
|
||||
checkZaloAuthenticated,
|
||||
getZaloUserInfo,
|
||||
listZaloFriendsMatching,
|
||||
listZaloGroupsMatching,
|
||||
} from "./zalo-js.js";
|
||||
|
||||
const ACTIONS = ["send", "image", "link", "friends", "groups", "me", "status"] as const;
|
||||
|
||||
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 ZalouserToolContext = Pick<OpenClawPluginToolContext, "deliveryContext">;
|
||||
|
||||
function resolveAmbientZalouserTarget(context?: ZalouserToolContext): {
|
||||
threadId?: string;
|
||||
isGroup?: boolean;
|
||||
} {
|
||||
const deliveryContext = context?.deliveryContext;
|
||||
const rawTarget = deliveryContext?.to;
|
||||
if (
|
||||
(deliveryContext?.channel === undefined || deliveryContext.channel === "zalouser") &&
|
||||
typeof rawTarget === "string" &&
|
||||
rawTarget.trim()
|
||||
) {
|
||||
try {
|
||||
return parseZalouserOutboundTarget(rawTarget);
|
||||
} catch {
|
||||
// Ignore unrelated delivery targets; explicit tool params still win.
|
||||
}
|
||||
}
|
||||
if (deliveryContext?.channel && deliveryContext.channel !== "zalouser") {
|
||||
return {};
|
||||
}
|
||||
const ambientThreadId = deliveryContext?.threadId;
|
||||
if (typeof ambientThreadId === "string" && ambientThreadId.trim()) {
|
||||
return { threadId: ambientThreadId.trim() };
|
||||
}
|
||||
if (typeof ambientThreadId === "number" && Number.isFinite(ambientThreadId)) {
|
||||
return { threadId: String(ambientThreadId) };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveZalouserSendTarget(params: ToolParams, context?: ZalouserToolContext) {
|
||||
const explicitThreadId = typeof params.threadId === "string" ? params.threadId.trim() : "";
|
||||
const ambientTarget = resolveAmbientZalouserTarget(context);
|
||||
return {
|
||||
threadId: explicitThreadId || ambientTarget.threadId,
|
||||
isGroup: typeof params.isGroup === "boolean" ? params.isGroup : ambientTarget.isGroup,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeZalouserTool(
|
||||
_toolCallId: string,
|
||||
params: ToolParams,
|
||||
_signal?: AbortSignal,
|
||||
_onUpdate?: unknown,
|
||||
context?: ZalouserToolContext,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
try {
|
||||
switch (params.action) {
|
||||
case "send": {
|
||||
const target = resolveZalouserSendTarget(params, context);
|
||||
if (!target.threadId || !params.message) {
|
||||
throw new Error("threadId and message required for send action");
|
||||
}
|
||||
const result = await sendMessageZalouser(target.threadId, params.message, {
|
||||
profile: params.profile,
|
||||
isGroup: target.isGroup,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to send message");
|
||||
}
|
||||
return json({ success: true, messageId: result.messageId });
|
||||
}
|
||||
|
||||
case "image": {
|
||||
const target = resolveZalouserSendTarget(params, context);
|
||||
if (!target.threadId) {
|
||||
throw new Error("threadId required for image action");
|
||||
}
|
||||
if (!params.url) {
|
||||
throw new Error("url required for image action");
|
||||
}
|
||||
const result = await sendImageZalouser(target.threadId, params.url, {
|
||||
profile: params.profile,
|
||||
caption: params.message,
|
||||
isGroup: target.isGroup,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to send image");
|
||||
}
|
||||
return json({ success: true, messageId: result.messageId });
|
||||
}
|
||||
|
||||
case "link": {
|
||||
const target = resolveZalouserSendTarget(params, context);
|
||||
if (!target.threadId || !params.url) {
|
||||
throw new Error("threadId and url required for link action");
|
||||
}
|
||||
const result = await sendLinkZalouser(target.threadId, params.url, {
|
||||
profile: params.profile,
|
||||
caption: params.message,
|
||||
isGroup: target.isGroup,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to send link");
|
||||
}
|
||||
return json({ success: true, messageId: result.messageId });
|
||||
}
|
||||
|
||||
case "friends": {
|
||||
const rows = await listZaloFriendsMatching(params.profile, params.query);
|
||||
return json(rows);
|
||||
}
|
||||
|
||||
case "groups": {
|
||||
const rows = await listZaloGroupsMatching(params.profile, params.query);
|
||||
return json(rows);
|
||||
}
|
||||
|
||||
case "me": {
|
||||
const info = await getZaloUserInfo(params.profile);
|
||||
return json(info ?? { error: "Not authenticated" });
|
||||
}
|
||||
|
||||
case "status": {
|
||||
const authenticated = await checkZaloAuthenticated(params.profile);
|
||||
return json({
|
||||
authenticated,
|
||||
output: authenticated ? "authenticated" : "not authenticated",
|
||||
});
|
||||
}
|
||||
|
||||
default: {
|
||||
params.action satisfies never;
|
||||
throw new Error(
|
||||
`Unknown action: ${String(params.action)}. Valid actions: send, image, link, friends, groups, me, status`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return json({
|
||||
error: formatErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createZalouserTool(context?: ZalouserToolContext): AnyAgentTool {
|
||||
return {
|
||||
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: async (toolCallId, params, signal, onUpdate) =>
|
||||
await executeZalouserTool(toolCallId, params as ToolParams, signal, onUpdate, context),
|
||||
} satisfies AnyAgentTool;
|
||||
}
|
||||
131
extensions/zalouser/src/types.ts
Normal file
131
extensions/zalouser/src/types.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// Zalouser type declarations define plugin contracts.
|
||||
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { Style } from "./zca-constants.js";
|
||||
|
||||
export type ZcaFriend = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type ZaloGroup = {
|
||||
groupId: string;
|
||||
name: string;
|
||||
memberCount?: number;
|
||||
};
|
||||
|
||||
export type ZaloGroupMember = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type ZaloEventMessage = {
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
uidFrom: string;
|
||||
idTo: string;
|
||||
msgType: string;
|
||||
st: number;
|
||||
at: number;
|
||||
cmd: number;
|
||||
ts: string | number;
|
||||
};
|
||||
|
||||
export type ZaloInboundMessage = {
|
||||
threadId: string;
|
||||
isGroup: boolean;
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
groupName?: string;
|
||||
content: string;
|
||||
commandContent?: string;
|
||||
timestampMs: number;
|
||||
msgId?: string;
|
||||
cliMsgId?: string;
|
||||
hasAnyMention?: boolean;
|
||||
wasExplicitlyMentioned?: boolean;
|
||||
canResolveExplicitMention?: boolean;
|
||||
implicitMention?: boolean;
|
||||
quotedGlobalMsgId?: string;
|
||||
quotedOwnerId?: string;
|
||||
quotedBody?: string;
|
||||
eventMessage?: ZaloEventMessage;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
export type ZcaUserInfo = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type ZaloSendOptions = {
|
||||
profile?: string;
|
||||
mediaUrl?: string;
|
||||
caption?: string;
|
||||
isGroup?: boolean;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
textMode?: "markdown" | "plain";
|
||||
textChunkMode?: "length" | "newline";
|
||||
textChunkLimit?: number;
|
||||
textStyles?: Style[];
|
||||
};
|
||||
|
||||
export type ZaloSendResult = {
|
||||
ok: boolean;
|
||||
messageId?: string;
|
||||
receipt: MessageReceipt;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ZaloGroupContext = {
|
||||
groupId: string;
|
||||
name?: string;
|
||||
members?: string[];
|
||||
};
|
||||
|
||||
export type ZaloAuthStatus = {
|
||||
connected: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type ZalouserToolConfig = { allow?: string[]; deny?: string[] };
|
||||
|
||||
export type ZalouserGroupConfig = {
|
||||
enabled?: boolean;
|
||||
requireMention?: boolean;
|
||||
tools?: ZalouserToolConfig;
|
||||
};
|
||||
|
||||
type ZalouserSharedConfig = {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
profile?: string;
|
||||
dangerouslyAllowNameMatching?: boolean;
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
historyLimit?: number;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
groups?: Record<string, ZalouserGroupConfig>;
|
||||
messagePrefix?: string;
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type ZalouserAccountConfig = ZalouserSharedConfig;
|
||||
|
||||
export type ZalouserConfig = ZalouserSharedConfig & {
|
||||
defaultAccount?: string;
|
||||
accounts?: Record<string, ZalouserAccountConfig>;
|
||||
};
|
||||
|
||||
export type ResolvedZalouserAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
profile: string;
|
||||
authenticated: boolean;
|
||||
config: ZalouserAccountConfig;
|
||||
};
|
||||
491
extensions/zalouser/src/zalo-js.credentials.test.ts
Normal file
491
extensions/zalouser/src/zalo-js.credentials.test.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
// Zalouser tests cover zalo js.credentials plugin behavior.
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
stat,
|
||||
symlink,
|
||||
utimes,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { API, Credentials, LoginQRCallbackEvent } from "./zca-client.js";
|
||||
import { LoginQRCallbackEventType } from "./zca-constants.js";
|
||||
|
||||
const createZaloMock = vi.hoisted(() => vi.fn());
|
||||
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|
||||
|
||||
vi.mock("./zca-client.js", () => ({
|
||||
createZalo: createZaloMock,
|
||||
TextStyle: { Indent: 9 },
|
||||
}));
|
||||
|
||||
import {
|
||||
checkZaloAuthenticated,
|
||||
listZaloFriends,
|
||||
sendZaloLink,
|
||||
sendZaloReaction,
|
||||
startZaloQrLogin,
|
||||
waitForZaloQrLogin,
|
||||
} from "./zalo-js.js";
|
||||
|
||||
type StoredCredentialFile = {
|
||||
imei: string;
|
||||
cookie: Credentials["cookie"];
|
||||
userAgent: string;
|
||||
language?: string;
|
||||
createdAt?: string;
|
||||
lastUsedAt?: string;
|
||||
};
|
||||
|
||||
function credentialPath(stateDir: string, profile: string): string {
|
||||
const trimmed = profile.trim().toLowerCase();
|
||||
const filename =
|
||||
!trimmed || trimmed === "default"
|
||||
? "credentials.json"
|
||||
: `credentials-${encodeURIComponent(trimmed)}.json`;
|
||||
return path.join(stateDir, "credentials", "zalouser", filename);
|
||||
}
|
||||
|
||||
async function readStoredCredentials(
|
||||
stateDir: string,
|
||||
profile: string,
|
||||
): Promise<StoredCredentialFile> {
|
||||
return JSON.parse(
|
||||
await readFile(credentialPath(stateDir, profile), "utf8"),
|
||||
) as StoredCredentialFile;
|
||||
}
|
||||
|
||||
function createMockApi(params: {
|
||||
imei: string;
|
||||
userAgent: string;
|
||||
language?: string;
|
||||
cookies: unknown[] | (() => unknown[]);
|
||||
getAllFriends?: API["getAllFriends"];
|
||||
}): API {
|
||||
return {
|
||||
getContext: () => ({
|
||||
imei: params.imei,
|
||||
userAgent: params.userAgent,
|
||||
language: params.language,
|
||||
}),
|
||||
getCookie: () => ({
|
||||
toJSON: () => ({
|
||||
cookies: typeof params.cookies === "function" ? params.cookies() : params.cookies,
|
||||
}),
|
||||
}),
|
||||
fetchAccountInfo: async () => ({
|
||||
userId: "user-1",
|
||||
username: "user-1",
|
||||
displayName: "Zalo User",
|
||||
zaloName: "Zalo User",
|
||||
avatar: "",
|
||||
}),
|
||||
getAllFriends: params.getAllFriends ?? vi.fn(async () => []),
|
||||
listener: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
},
|
||||
} as unknown as API;
|
||||
}
|
||||
|
||||
describe("zalouser credential persistence", () => {
|
||||
beforeEach(() => {
|
||||
createZaloMock.mockReset();
|
||||
});
|
||||
|
||||
it("persists the final API cookie jar after QR login", async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
|
||||
const profile = "qr-refresh";
|
||||
const callbackCookie = [{ key: "zpsid", value: "callback", domain: "chat.zalo.me" }];
|
||||
const refreshedCookie = [{ key: "zpsid", value: "refreshed", domain: "chat.zalo.me" }];
|
||||
const api = createMockApi({
|
||||
imei: "api-imei",
|
||||
userAgent: "api-user-agent",
|
||||
language: "vi",
|
||||
cookies: refreshedCookie,
|
||||
});
|
||||
|
||||
createZaloMock.mockResolvedValueOnce({
|
||||
loginQR: async (_options: unknown, callback?: (event: LoginQRCallbackEvent) => unknown) => {
|
||||
callback?.({
|
||||
type: LoginQRCallbackEventType.QRCodeGenerated,
|
||||
data: {
|
||||
code: "qr-code",
|
||||
image: "data:image/png;base64,abc123",
|
||||
},
|
||||
actions: {
|
||||
saveToFile: vi.fn(async () => undefined),
|
||||
retry: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
},
|
||||
});
|
||||
callback?.({
|
||||
type: LoginQRCallbackEventType.GotLoginInfo,
|
||||
data: {
|
||||
cookie: callbackCookie,
|
||||
imei: "callback-imei",
|
||||
userAgent: "callback-user-agent",
|
||||
},
|
||||
actions: null,
|
||||
});
|
||||
return api;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
await startZaloQrLogin({ profile, timeoutMs: 1000 });
|
||||
|
||||
const loginResult = await waitForZaloQrLogin({ profile, timeoutMs: 1000 });
|
||||
expect(loginResult.connected).toBe(true);
|
||||
|
||||
const stored = await readStoredCredentials(stateDir, profile);
|
||||
expect(stored.imei).toBe("api-imei");
|
||||
expect(stored.userAgent).toBe("api-user-agent");
|
||||
expect(stored.language).toBe("vi");
|
||||
expect(stored.cookie).toEqual(refreshedCookie);
|
||||
});
|
||||
} finally {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("caps oversized QR start timeout before computing the polling deadline", async () => {
|
||||
createZaloMock.mockResolvedValueOnce({
|
||||
loginQR: async () => new Promise(() => {}),
|
||||
});
|
||||
const nowSpy = vi.spyOn(Date, "now");
|
||||
nowSpy
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValueOnce(0)
|
||||
.mockReturnValueOnce(MAX_TIMER_TIMEOUT_MS + 1);
|
||||
try {
|
||||
const result = await startZaloQrLogin({
|
||||
profile: "qr-timeout-cap",
|
||||
timeoutMs: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
|
||||
expect(result.message).toBe(
|
||||
"Still preparing QR. Call wait to continue checking login status.",
|
||||
);
|
||||
expect(nowSpy).toHaveBeenCalledTimes(3);
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("rewrites restored sessions with cookies refreshed by zca-js login", async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
|
||||
const profile = "restore-refresh";
|
||||
const storedCookie = [{ key: "zpsid", value: "stored", domain: "chat.zalo.me" }];
|
||||
const refreshedCookie = [{ key: "zpsid", value: "refreshed", domain: "chat.zalo.me" }];
|
||||
const filePath = credentialPath(stateDir, profile);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
imei: "stored-imei",
|
||||
cookie: storedCookie,
|
||||
userAgent: "stored-user-agent",
|
||||
createdAt: "2026-04-01T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
const api = createMockApi({
|
||||
imei: "stored-imei",
|
||||
userAgent: "stored-user-agent",
|
||||
language: "vi",
|
||||
cookies: refreshedCookie,
|
||||
});
|
||||
const login = vi.fn(async () => api);
|
||||
createZaloMock.mockResolvedValueOnce({ login });
|
||||
|
||||
try {
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
await expect(checkZaloAuthenticated(profile)).resolves.toBe(true);
|
||||
|
||||
expect(login).toHaveBeenCalledWith({
|
||||
imei: "stored-imei",
|
||||
cookie: storedCookie,
|
||||
userAgent: "stored-user-agent",
|
||||
language: undefined,
|
||||
});
|
||||
const stored = await readStoredCredentials(stateDir, profile);
|
||||
expect(stored.cookie).toEqual(refreshedCookie);
|
||||
expect(stored.createdAt).toBe("2026-04-01T00:00:00.000Z");
|
||||
expect(stored.lastUsedAt).toMatch(ISO_TIMESTAMP_RE);
|
||||
});
|
||||
} finally {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("persists cookie changes after a successful API call", async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
|
||||
const profile = "api-refresh";
|
||||
const storedCookie: unknown[] = [{ key: "zpsid", value: "stored", domain: "chat.zalo.me" }];
|
||||
const loginCookie: unknown[] = [{ key: "zpsid", value: "login", domain: "chat.zalo.me" }];
|
||||
const refreshedCookie: unknown[] = [
|
||||
{ key: "zpsid", value: "api-refreshed", domain: "chat.zalo.me" },
|
||||
];
|
||||
const filePath = credentialPath(stateDir, profile);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
imei: "stored-imei",
|
||||
cookie: storedCookie,
|
||||
userAgent: "stored-user-agent",
|
||||
createdAt: "2026-04-01T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
let currentCookie = loginCookie;
|
||||
const api = createMockApi({
|
||||
imei: "stored-imei",
|
||||
userAgent: "stored-user-agent",
|
||||
language: "vi",
|
||||
cookies: () => currentCookie,
|
||||
getAllFriends: vi.fn(async () => {
|
||||
currentCookie = refreshedCookie;
|
||||
return [
|
||||
{
|
||||
userId: "friend-1",
|
||||
username: "friend-1",
|
||||
displayName: "Friend One",
|
||||
zaloName: "Friend One",
|
||||
avatar: "",
|
||||
},
|
||||
];
|
||||
}),
|
||||
});
|
||||
createZaloMock.mockResolvedValueOnce({ login: vi.fn(async () => api) });
|
||||
|
||||
try {
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
await expect(listZaloFriends(profile)).resolves.toEqual([
|
||||
{
|
||||
userId: "friend-1",
|
||||
displayName: "Friend One",
|
||||
avatar: undefined,
|
||||
},
|
||||
]);
|
||||
|
||||
const stored = await readStoredCredentials(stateDir, profile);
|
||||
expect(stored.cookie).toEqual(refreshedCookie);
|
||||
expect(stored.createdAt).toBe("2026-04-01T00:00:00.000Z");
|
||||
expect(stored.lastUsedAt).toMatch(ISO_TIMESTAMP_RE);
|
||||
});
|
||||
} finally {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not rewrite credentials when the live cookie jar only reorders cookies", async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
|
||||
const profile = "api-stable";
|
||||
const cookieA: unknown[] = [
|
||||
{ key: "zpsid", value: "same", domain: "chat.zalo.me" },
|
||||
{ key: "zpw", value: "same-secondary", domain: "chat.zalo.me" },
|
||||
];
|
||||
const cookieB = [...cookieA].toReversed();
|
||||
const filePath = credentialPath(stateDir, profile);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
imei: "stored-imei",
|
||||
cookie: cookieA,
|
||||
userAgent: "stored-user-agent",
|
||||
createdAt: "2026-04-01T00:00:00.000Z",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
let currentCookie = cookieA;
|
||||
const api = createMockApi({
|
||||
imei: "stored-imei",
|
||||
userAgent: "stored-user-agent",
|
||||
language: "vi",
|
||||
cookies: () => currentCookie,
|
||||
getAllFriends: vi.fn(async () => []),
|
||||
});
|
||||
createZaloMock.mockResolvedValueOnce({ login: vi.fn(async () => api) });
|
||||
|
||||
try {
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
await expect(listZaloFriends(profile)).resolves.toStrictEqual([]);
|
||||
const firstRaw = await readFile(filePath, "utf8");
|
||||
const stableMtime = new Date("2026-04-01T00:00:10.000Z");
|
||||
await utimes(filePath, stableMtime, stableMtime);
|
||||
const firstMtimeMs = (await stat(filePath)).mtimeMs;
|
||||
|
||||
currentCookie = cookieB;
|
||||
|
||||
await expect(listZaloFriends(profile)).resolves.toStrictEqual([]);
|
||||
expect(await readFile(filePath, "utf8")).toBe(firstRaw);
|
||||
expect((await stat(filePath)).mtimeMs).toBe(firstMtimeMs);
|
||||
});
|
||||
} finally {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function expectMissingSessionResult(result: { ok: boolean; error?: string }) {
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.error).toContain("No saved Zalo session");
|
||||
}
|
||||
|
||||
it("keeps reaction sends non-throwing when session restore fails", async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
|
||||
|
||||
try {
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
const result = await sendZaloReaction({
|
||||
profile: "missing-session",
|
||||
threadId: "thread-1",
|
||||
msgId: "msg-1",
|
||||
cliMsgId: "cli-1",
|
||||
emoji: "like",
|
||||
});
|
||||
expectMissingSessionResult(result);
|
||||
});
|
||||
} finally {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps link sends non-throwing when session restore fails", async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
|
||||
|
||||
try {
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
const result = await sendZaloLink("thread-1", "https://example.com", {
|
||||
profile: "missing-session",
|
||||
});
|
||||
expectMissingSessionResult(result);
|
||||
});
|
||||
} finally {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"writes credentials with private permissions",
|
||||
async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
|
||||
const profile = "private-mode";
|
||||
const api = createMockApi({
|
||||
imei: "api-imei",
|
||||
userAgent: "api-user-agent",
|
||||
cookies: [{ key: "zpsid", value: "private", domain: "chat.zalo.me" }],
|
||||
});
|
||||
|
||||
createZaloMock.mockResolvedValueOnce({
|
||||
loginQR: async (_options: unknown, callback?: (event: LoginQRCallbackEvent) => unknown) => {
|
||||
callback?.({
|
||||
type: LoginQRCallbackEventType.QRCodeGenerated,
|
||||
data: {
|
||||
code: "qr-code",
|
||||
image: "data:image/png;base64,abc123",
|
||||
},
|
||||
actions: {
|
||||
saveToFile: vi.fn(async () => undefined),
|
||||
retry: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
},
|
||||
});
|
||||
return api;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
await startZaloQrLogin({ profile, timeoutMs: 1000 });
|
||||
const loginResult = await waitForZaloQrLogin({ profile, timeoutMs: 1000 });
|
||||
expect(loginResult.connected).toBe(true);
|
||||
|
||||
const filePath = credentialPath(stateDir, profile);
|
||||
const dirMode = (await stat(path.dirname(filePath))).mode & 0o777;
|
||||
const fileMode = (await stat(filePath)).mode & 0o777;
|
||||
expect(dirMode).toBe(0o700);
|
||||
expect(fileMode).toBe(0o600);
|
||||
});
|
||||
} finally {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"refuses to write credentials through a symlinked file",
|
||||
async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
|
||||
const profile = "symlink-target";
|
||||
const filePath = credentialPath(stateDir, profile);
|
||||
const targetPath = path.join(stateDir, "outside.json");
|
||||
const api = createMockApi({
|
||||
imei: "api-imei",
|
||||
userAgent: "api-user-agent",
|
||||
cookies: [{ key: "zpsid", value: "symlink", domain: "chat.zalo.me" }],
|
||||
});
|
||||
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(targetPath, "sentinel", "utf8");
|
||||
await symlink(targetPath, filePath);
|
||||
|
||||
createZaloMock.mockResolvedValueOnce({
|
||||
loginQR: async (_options: unknown, callback?: (event: LoginQRCallbackEvent) => unknown) => {
|
||||
callback?.({
|
||||
type: LoginQRCallbackEventType.QRCodeGenerated,
|
||||
data: {
|
||||
code: "qr-code",
|
||||
image: "data:image/png;base64,abc123",
|
||||
},
|
||||
actions: {
|
||||
saveToFile: vi.fn(async () => undefined),
|
||||
retry: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
},
|
||||
});
|
||||
return api;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
const started = await startZaloQrLogin({ profile, timeoutMs: 1000 });
|
||||
const waited = await waitForZaloQrLogin({ profile, timeoutMs: 1000 });
|
||||
expect(`${started.message} ${waited.message}`).toMatch(
|
||||
/Refusing to write Zalo credentials to symlinked path|private store target must be a regular file/,
|
||||
);
|
||||
});
|
||||
|
||||
expect(await readFile(targetPath, "utf8")).toBe("sentinel");
|
||||
expect((await lstat(filePath)).isSymbolicLink()).toBe(true);
|
||||
} finally {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
83
extensions/zalouser/src/zalo-js.test-mocks.ts
Normal file
83
extensions/zalouser/src/zalo-js.test-mocks.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Zalouser plugin module implements zalo js mocks behavior.
|
||||
import { vi, type Mock } from "vitest";
|
||||
|
||||
type ZaloJsModule = typeof import("./zalo-js.js");
|
||||
type ZaloJsMocks = {
|
||||
checkZaloAuthenticatedMock: Mock<ZaloJsModule["checkZaloAuthenticated"]>;
|
||||
getZaloUserInfoMock: Mock<ZaloJsModule["getZaloUserInfo"]>;
|
||||
listZaloFriendsMock: Mock<ZaloJsModule["listZaloFriends"]>;
|
||||
listZaloFriendsMatchingMock: Mock<ZaloJsModule["listZaloFriendsMatching"]>;
|
||||
listZaloGroupMembersMock: Mock<ZaloJsModule["listZaloGroupMembers"]>;
|
||||
listZaloGroupsMock: Mock<ZaloJsModule["listZaloGroups"]>;
|
||||
listZaloGroupsMatchingMock: Mock<ZaloJsModule["listZaloGroupsMatching"]>;
|
||||
logoutZaloProfileMock: Mock<ZaloJsModule["logoutZaloProfile"]>;
|
||||
resolveZaloAllowFromEntriesMock: Mock<ZaloJsModule["resolveZaloAllowFromEntries"]>;
|
||||
resolveZaloGroupContextMock: Mock<ZaloJsModule["resolveZaloGroupContext"]>;
|
||||
resolveZaloGroupsByEntriesMock: Mock<ZaloJsModule["resolveZaloGroupsByEntries"]>;
|
||||
startZaloListenerMock: Mock<ZaloJsModule["startZaloListener"]>;
|
||||
startZaloQrLoginMock: Mock<ZaloJsModule["startZaloQrLogin"]>;
|
||||
waitForZaloQrLoginMock: Mock<ZaloJsModule["waitForZaloQrLogin"]>;
|
||||
};
|
||||
|
||||
const zaloJsMocks = vi.hoisted(
|
||||
(): ZaloJsMocks => ({
|
||||
checkZaloAuthenticatedMock: vi.fn(async () => false),
|
||||
getZaloUserInfoMock: vi.fn(async () => null),
|
||||
listZaloFriendsMock: vi.fn(async () => []),
|
||||
listZaloFriendsMatchingMock: vi.fn(async () => []),
|
||||
listZaloGroupMembersMock: vi.fn(async () => []),
|
||||
listZaloGroupsMock: vi.fn(async () => []),
|
||||
listZaloGroupsMatchingMock: vi.fn(async () => []),
|
||||
logoutZaloProfileMock: vi.fn(async () => ({
|
||||
cleared: true,
|
||||
loggedOut: true,
|
||||
message: "Logged out and cleared local session.",
|
||||
})),
|
||||
resolveZaloAllowFromEntriesMock: vi.fn(async ({ entries }: { entries: string[] }) =>
|
||||
entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })),
|
||||
),
|
||||
resolveZaloGroupContextMock: vi.fn(async (_profile, groupId) => ({
|
||||
groupId,
|
||||
name: undefined,
|
||||
members: [],
|
||||
})),
|
||||
resolveZaloGroupsByEntriesMock: vi.fn(async ({ entries }: { entries: string[] }) =>
|
||||
entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })),
|
||||
),
|
||||
startZaloListenerMock: vi.fn(async () => ({ stop: vi.fn() })),
|
||||
startZaloQrLoginMock: vi.fn(async () => ({
|
||||
message: "qr pending",
|
||||
qrDataUrl: undefined,
|
||||
})),
|
||||
waitForZaloQrLoginMock: vi.fn(async () => ({
|
||||
connected: false,
|
||||
message: "login pending",
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
export const listZaloFriendsMock = zaloJsMocks.listZaloFriendsMock;
|
||||
export const listZaloFriendsMatchingMock = zaloJsMocks.listZaloFriendsMatchingMock;
|
||||
export const listZaloGroupMembersMock = zaloJsMocks.listZaloGroupMembersMock;
|
||||
export const listZaloGroupsMock = zaloJsMocks.listZaloGroupsMock;
|
||||
export const startZaloListenerMock: Mock<ZaloJsModule["startZaloListener"]> =
|
||||
zaloJsMocks.startZaloListenerMock;
|
||||
export const startZaloQrLoginMock = zaloJsMocks.startZaloQrLoginMock;
|
||||
export const waitForZaloQrLoginMock = zaloJsMocks.waitForZaloQrLoginMock;
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
checkZaloAuthenticated: zaloJsMocks.checkZaloAuthenticatedMock,
|
||||
getZaloUserInfo: zaloJsMocks.getZaloUserInfoMock,
|
||||
listZaloFriends: listZaloFriendsMock,
|
||||
listZaloFriendsMatching: listZaloFriendsMatchingMock,
|
||||
listZaloGroupMembers: listZaloGroupMembersMock,
|
||||
listZaloGroups: listZaloGroupsMock,
|
||||
listZaloGroupsMatching: zaloJsMocks.listZaloGroupsMatchingMock,
|
||||
logoutZaloProfile: zaloJsMocks.logoutZaloProfileMock,
|
||||
resolveZaloAllowFromEntries: zaloJsMocks.resolveZaloAllowFromEntriesMock,
|
||||
resolveZaloGroupContext: zaloJsMocks.resolveZaloGroupContextMock,
|
||||
resolveZaloGroupsByEntries: zaloJsMocks.resolveZaloGroupsByEntriesMock,
|
||||
startZaloListener: startZaloListenerMock,
|
||||
startZaloQrLogin: startZaloQrLoginMock,
|
||||
waitForZaloQrLogin: waitForZaloQrLoginMock,
|
||||
}));
|
||||
1935
extensions/zalouser/src/zalo-js.ts
Normal file
1935
extensions/zalouser/src/zalo-js.ts
Normal file
File diff suppressed because it is too large
Load Diff
106
extensions/zalouser/src/zalo-quote-metadata.test.ts
Normal file
106
extensions/zalouser/src/zalo-quote-metadata.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// Zalouser tests cover zalo quote metadata plugin behavior.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { __testing as zaloTesting } from "./zalo-js.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Zalo quote metadata extraction (#86851)", () => {
|
||||
it("extracts quote id, owner, and body from zca-js message data", () => {
|
||||
const message = zaloTesting.toInboundMessage(
|
||||
{
|
||||
type: 0,
|
||||
data: {
|
||||
uidFrom: "123456789",
|
||||
idTo: "987654321",
|
||||
content: "ok",
|
||||
ts: 1_764_000_000_000,
|
||||
quote: {
|
||||
globalMsgId: 987654321234,
|
||||
ownerId: "555444333_2",
|
||||
msg: "Previous bot message content",
|
||||
},
|
||||
},
|
||||
} as unknown as Parameters<typeof zaloTesting.toInboundMessage>[0],
|
||||
"555444333",
|
||||
);
|
||||
|
||||
expect(message?.quotedGlobalMsgId).toBe("987654321234");
|
||||
expect(message?.quotedOwnerId).toBe("555444333");
|
||||
expect(message?.quotedBody).toBe("Previous bot message content");
|
||||
expect(message?.implicitMention).toBe(true);
|
||||
});
|
||||
|
||||
it("omits quote metadata when the zca-js quote object is absent", () => {
|
||||
const message = zaloTesting.toInboundMessage({
|
||||
type: 0,
|
||||
data: {
|
||||
uidFrom: "123456789",
|
||||
idTo: "987654321",
|
||||
content: "plain message",
|
||||
ts: 1_764_000_000_000,
|
||||
},
|
||||
} as unknown as Parameters<typeof zaloTesting.toInboundMessage>[0]);
|
||||
|
||||
expect(message?.quotedGlobalMsgId).toBeUndefined();
|
||||
expect(message?.quotedOwnerId).toBeUndefined();
|
||||
expect(message?.quotedBody).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zalo inbound timestamp normalization", () => {
|
||||
function inboundTimestamp(ts: unknown): number | undefined {
|
||||
return zaloTesting.toInboundMessage({
|
||||
type: 0,
|
||||
data: {
|
||||
uidFrom: "123456789",
|
||||
idTo: "987654321",
|
||||
content: "plain message",
|
||||
ts,
|
||||
},
|
||||
} as unknown as Parameters<typeof zaloTesting.toInboundMessage>[0])?.timestampMs;
|
||||
}
|
||||
|
||||
it("normalizes second and millisecond timestamps", () => {
|
||||
expect(inboundTimestamp(1_764_000_000)).toBe(1_764_000_000_000);
|
||||
expect(inboundTimestamp("1764000000.5")).toBe(1_764_000_000_500);
|
||||
expect(inboundTimestamp(1_764_000_000_000)).toBe(1_764_000_000_000);
|
||||
});
|
||||
|
||||
it("falls back for partial or unsafe timestamps", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1_700_000_000_000);
|
||||
|
||||
expect(inboundTimestamp("1764000000abc")).toBe(1_700_000_000_000);
|
||||
expect(inboundTimestamp("9007199254740993")).toBe(1_700_000_000_000);
|
||||
expect(inboundTimestamp(8_640_000_000_000_001)).toBe(1_700_000_000_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zalo group context cache", () => {
|
||||
afterEach(() => {
|
||||
zaloTesting.clearCachedGroupContext("cache-profile");
|
||||
});
|
||||
|
||||
it("drops cached group context when the current clock is invalid", () => {
|
||||
zaloTesting.writeCachedGroupContext("cache-profile", {
|
||||
groupId: "group-invalid-clock",
|
||||
name: "Cached",
|
||||
});
|
||||
vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
|
||||
expect(zaloTesting.readCachedGroupContext("cache-profile", "group-invalid-clock")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not cache group context when ttl expiry exceeds the Date range", () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
|
||||
|
||||
zaloTesting.writeCachedGroupContext("cache-profile", {
|
||||
groupId: "group-overflow",
|
||||
name: "Overflow",
|
||||
});
|
||||
|
||||
expect(zaloTesting.readCachedGroupContext("cache-profile", "group-overflow")).toBeNull();
|
||||
});
|
||||
});
|
||||
28
extensions/zalouser/src/zca-client.test.ts
Normal file
28
extensions/zalouser/src/zca-client.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Zalouser tests cover zca client plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("zca-client runtime loading", () => {
|
||||
it("does not import zca-js until a session is created", async () => {
|
||||
vi.clearAllMocks();
|
||||
let constructedOptions: { logging?: boolean; selfListen?: boolean } | undefined;
|
||||
function MockZalo(options?: { logging?: boolean; selfListen?: boolean }) {
|
||||
constructedOptions = options;
|
||||
}
|
||||
const runtimeFactory = vi.fn(() => ({
|
||||
Zalo: MockZalo,
|
||||
}));
|
||||
|
||||
vi.doMock("zca-js", runtimeFactory);
|
||||
|
||||
const zcaClient = await import("./zca-client.js");
|
||||
expect(runtimeFactory).not.toHaveBeenCalled();
|
||||
|
||||
await zcaClient.createZalo({ logging: false, selfListen: true });
|
||||
|
||||
expect(runtimeFactory).toHaveBeenCalledTimes(1);
|
||||
expect(constructedOptions).toEqual({
|
||||
logging: false,
|
||||
selfListen: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
259
extensions/zalouser/src/zca-client.ts
Normal file
259
extensions/zalouser/src/zca-client.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
// Zalouser plugin module implements zca client behavior.
|
||||
import {
|
||||
LoginQRCallbackEventType,
|
||||
Reactions,
|
||||
TextStyle,
|
||||
ThreadType,
|
||||
type Style,
|
||||
} from "./zca-constants.js";
|
||||
|
||||
type ZcaJsRuntime = {
|
||||
Zalo: unknown;
|
||||
};
|
||||
|
||||
// Keep zca-js behind a runtime boundary so bundled metadata/contracts can load
|
||||
// without resolving its optional WebSocket dependency tree.
|
||||
const loadZcaJsRuntime = createLazyRuntimeModule(() =>
|
||||
import("zca-js").then((mod) => mod as unknown as ZcaJsRuntime),
|
||||
);
|
||||
|
||||
export { LoginQRCallbackEventType, Reactions, TextStyle, ThreadType };
|
||||
export type { Style };
|
||||
|
||||
export type Credentials = {
|
||||
imei: string;
|
||||
cookie: unknown;
|
||||
userAgent: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
zaloName: string;
|
||||
avatar: string;
|
||||
};
|
||||
|
||||
export type GroupInfo = {
|
||||
groupId: string;
|
||||
name: string;
|
||||
totalMember?: number;
|
||||
memberIds?: unknown[];
|
||||
currentMems?: Array<{
|
||||
id?: unknown;
|
||||
dName?: string;
|
||||
zaloName?: string;
|
||||
avatar?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
type: number;
|
||||
threadId: string;
|
||||
isSelf: boolean;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type LoginQRCallbackEvent =
|
||||
| {
|
||||
type: 0;
|
||||
data: {
|
||||
code: string;
|
||||
image: string;
|
||||
};
|
||||
actions: {
|
||||
saveToFile: (qrPath?: string) => Promise<unknown>;
|
||||
retry: () => unknown;
|
||||
abort: () => unknown;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 1;
|
||||
data: null;
|
||||
actions: {
|
||||
retry: () => unknown;
|
||||
abort: () => unknown;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 2;
|
||||
data: {
|
||||
avatar: string;
|
||||
display_name: string;
|
||||
};
|
||||
actions: {
|
||||
retry: () => unknown;
|
||||
abort: () => unknown;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 3;
|
||||
data: {
|
||||
code: string;
|
||||
};
|
||||
actions: {
|
||||
retry: () => unknown;
|
||||
abort: () => unknown;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 4;
|
||||
data: {
|
||||
cookie: unknown;
|
||||
imei: string;
|
||||
userAgent: string;
|
||||
};
|
||||
actions: null;
|
||||
};
|
||||
|
||||
export type Listener = {
|
||||
on(event: "message", callback: (message: Message) => void): void;
|
||||
on(event: "error", callback: (error: unknown) => void): void;
|
||||
on(event: "closed", callback: (code: number, reason: string) => void): void;
|
||||
off(event: "message", callback: (message: Message) => void): void;
|
||||
off(event: "error", callback: (error: unknown) => void): void;
|
||||
off(event: "closed", callback: (code: number, reason: string) => void): void;
|
||||
start(opts?: { retryOnClose?: boolean }): void;
|
||||
stop(): void;
|
||||
};
|
||||
|
||||
type DeliveryEventMessage = {
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
uidFrom: string;
|
||||
idTo: string;
|
||||
msgType: string;
|
||||
st: number;
|
||||
at: number;
|
||||
cmd: number;
|
||||
ts: string | number;
|
||||
};
|
||||
|
||||
type DeliveryEventMessages = DeliveryEventMessage | DeliveryEventMessage[];
|
||||
|
||||
export type API = {
|
||||
listener: Listener;
|
||||
getContext(): {
|
||||
imei: string;
|
||||
userAgent: string;
|
||||
language?: string;
|
||||
};
|
||||
getCookie(): {
|
||||
toJSON(): {
|
||||
cookies: unknown[];
|
||||
};
|
||||
};
|
||||
fetchAccountInfo(): Promise<User | { profile: User }>;
|
||||
getAllFriends(): Promise<User[]>;
|
||||
getOwnId(): string;
|
||||
getAllGroups(): Promise<{
|
||||
gridVerMap: Record<string, string>;
|
||||
}>;
|
||||
getGroupInfo(groupId: string | string[]): Promise<{
|
||||
gridInfoMap: Record<string, GroupInfo & { memVerList?: unknown }>;
|
||||
}>;
|
||||
getGroupMembersInfo(memberId: string | string[]): Promise<{
|
||||
profiles: Record<
|
||||
string,
|
||||
{
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
zaloName?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
>;
|
||||
}>;
|
||||
sendMessage(
|
||||
message: string | Record<string, unknown>,
|
||||
threadId: string,
|
||||
type?: number,
|
||||
): Promise<{
|
||||
msgId?: string | number;
|
||||
message?: { msgId?: string | number } | null;
|
||||
attachment?: Array<{ msgId?: string | number }>;
|
||||
}>;
|
||||
uploadAttachment(
|
||||
sources:
|
||||
| string
|
||||
| {
|
||||
data: Buffer;
|
||||
filename: `${string}.${string}`;
|
||||
metadata: {
|
||||
totalSize: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
}
|
||||
| Array<
|
||||
| string
|
||||
| {
|
||||
data: Buffer;
|
||||
filename: `${string}.${string}`;
|
||||
metadata: {
|
||||
totalSize: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
}
|
||||
>,
|
||||
threadId: string,
|
||||
type?: number,
|
||||
): Promise<
|
||||
Array<{
|
||||
fileType: "image" | "video" | "others";
|
||||
fileUrl?: string;
|
||||
msgId?: string | number;
|
||||
fileId?: string;
|
||||
fileName?: string;
|
||||
}>
|
||||
>;
|
||||
sendVoice(
|
||||
options: {
|
||||
voiceUrl: string;
|
||||
ttl?: number;
|
||||
},
|
||||
threadId: string,
|
||||
type?: number,
|
||||
): Promise<{ msgId?: string | number }>;
|
||||
sendLink(
|
||||
payload: { link: string; msg?: string },
|
||||
threadId: string,
|
||||
type?: number,
|
||||
): Promise<{ msgId?: string | number }>;
|
||||
sendTypingEvent(threadId: string, type?: number, destType?: number): Promise<{ status: number }>;
|
||||
addReaction(
|
||||
icon: string | { rType: number; source: number; icon: string },
|
||||
dest: {
|
||||
data: {
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
};
|
||||
threadId: string;
|
||||
type: number;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
sendDeliveredEvent(
|
||||
isSeen: boolean,
|
||||
messages: DeliveryEventMessages,
|
||||
type?: number,
|
||||
): Promise<unknown>;
|
||||
sendSeenEvent(messages: DeliveryEventMessages, type?: number): Promise<unknown>;
|
||||
};
|
||||
|
||||
type ZaloCtor = new (options?: { logging?: boolean; selfListen?: boolean }) => {
|
||||
login(credentials: Credentials): Promise<API>;
|
||||
loginQR(
|
||||
options?: { userAgent?: string; language?: string; qrPath?: string },
|
||||
callback?: (event: LoginQRCallbackEvent) => unknown,
|
||||
): Promise<API>;
|
||||
};
|
||||
|
||||
export async function createZalo(
|
||||
options?: ConstructorParameters<ZaloCtor>[0],
|
||||
): Promise<InstanceType<ZaloCtor>> {
|
||||
const zcaJs = await loadZcaJsRuntime();
|
||||
const Zalo = zcaJs.Zalo as ZaloCtor;
|
||||
return new Zalo(options);
|
||||
}
|
||||
56
extensions/zalouser/src/zca-constants.ts
Normal file
56
extensions/zalouser/src/zca-constants.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Zalouser plugin module implements zca constants behavior.
|
||||
export const ThreadType = {
|
||||
User: 0,
|
||||
Group: 1,
|
||||
} as const;
|
||||
|
||||
export const LoginQRCallbackEventType = {
|
||||
QRCodeGenerated: 0,
|
||||
QRCodeExpired: 1,
|
||||
QRCodeScanned: 2,
|
||||
QRCodeDeclined: 3,
|
||||
GotLoginInfo: 4,
|
||||
} as const;
|
||||
|
||||
export const Reactions = {
|
||||
HEART: "/-heart",
|
||||
LIKE: "/-strong",
|
||||
HAHA: ":>",
|
||||
WOW: ":o",
|
||||
CRY: ":-((",
|
||||
ANGRY: ":-h",
|
||||
NONE: "",
|
||||
} as const;
|
||||
|
||||
// Mirror zca-js sendMessage style constants locally because the package root
|
||||
// typing surface does not consistently expose TextStyle/Style to tsgo.
|
||||
export const TextStyle = {
|
||||
Bold: "b",
|
||||
Italic: "i",
|
||||
Underline: "u",
|
||||
StrikeThrough: "s",
|
||||
Red: "c_db342e",
|
||||
Orange: "c_f27806",
|
||||
Yellow: "c_f7b503",
|
||||
Green: "c_15a85f",
|
||||
Small: "f_13",
|
||||
Big: "f_18",
|
||||
UnorderedList: "lst_1",
|
||||
OrderedList: "lst_2",
|
||||
Indent: "ind_$",
|
||||
} as const;
|
||||
|
||||
type TextStyleValue = (typeof TextStyle)[keyof typeof TextStyle];
|
||||
|
||||
export type Style =
|
||||
| {
|
||||
start: number;
|
||||
len: number;
|
||||
st: Exclude<TextStyleValue, typeof TextStyle.Indent>;
|
||||
}
|
||||
| {
|
||||
start: number;
|
||||
len: number;
|
||||
st: typeof TextStyle.Indent;
|
||||
indentSize?: number;
|
||||
};
|
||||
23
extensions/zalouser/src/zca-js-exports.d.ts
vendored
Normal file
23
extensions/zalouser/src/zca-js-exports.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
// Zalouser type declarations define plugin contracts.
|
||||
declare module "zca-js" {
|
||||
export const ThreadType: {
|
||||
User: number;
|
||||
Group: number;
|
||||
};
|
||||
|
||||
export const LoginQRCallbackEventType: {
|
||||
QRCodeGenerated: number;
|
||||
QRCodeExpired: number;
|
||||
QRCodeScanned: number;
|
||||
QRCodeDeclined: number;
|
||||
GotLoginInfo: number;
|
||||
};
|
||||
|
||||
export const Reactions: Record<string, string>;
|
||||
|
||||
export class Zalo {
|
||||
constructor(options?: { logging?: boolean; selfListen?: boolean });
|
||||
login(credentials: unknown): Promise<unknown>;
|
||||
loginQR(options?: unknown, callback?: (event: unknown) => unknown): Promise<unknown>;
|
||||
}
|
||||
}
|
||||
22
extensions/zalouser/test-api.ts
Normal file
22
extensions/zalouser/test-api.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// Zalouser API module exposes the plugin public contract.
|
||||
export { sendMessageZalouser } from "./src/send.js";
|
||||
export { parseZalouserOutboundTarget } from "./src/session-route.js";
|
||||
export {
|
||||
checkZcaAuthenticated,
|
||||
getZcaUserInfo,
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
} from "./src/accounts.js";
|
||||
export {
|
||||
checkZaloAuthenticated,
|
||||
getZaloUserInfo,
|
||||
listZaloFriendsMatching,
|
||||
listZaloGroupMembers,
|
||||
listZaloGroupsMatching,
|
||||
logoutZaloProfile,
|
||||
resolveZaloAllowFromEntries,
|
||||
resolveZaloGroupsByEntries,
|
||||
startZaloQrLogin,
|
||||
waitForZaloQrLogin,
|
||||
} from "./src/zalo-js.js";
|
||||
16
extensions/zalouser/tsconfig.json
Normal file
16
extensions/zalouser/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user