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:
50
extensions/zalo/README.md
Normal file
50
extensions/zalo/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# @openclaw/zalo
|
||||
|
||||
Zalo channel plugin for OpenClaw (Bot API).
|
||||
|
||||
## Install (local checkout)
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./path/to/local/zalo-plugin
|
||||
```
|
||||
|
||||
## Install (npm)
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/zalo
|
||||
```
|
||||
|
||||
Onboarding: select Zalo and confirm the install prompt to fetch the plugin automatically.
|
||||
|
||||
## Config
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing",
|
||||
proxy: "http://proxy.local:8080",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Webhook mode
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalo: {
|
||||
webhookUrl: "https://example.com/zalo-webhook",
|
||||
webhookSecret: "your-secret-8-plus-chars",
|
||||
webhookPath: "/zalo-webhook",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If `webhookPath` is omitted, the plugin uses the webhook URL path.
|
||||
|
||||
Restart the gateway after config changes.
|
||||
9
extensions/zalo/api.ts
Normal file
9
extensions/zalo/api.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Zalo API module exposes the plugin public contract.
|
||||
export { zaloPlugin } from "./src/channel.js";
|
||||
export {
|
||||
createZaloSetupWizardProxy,
|
||||
resolveZaloRuntimeGroupPolicy,
|
||||
zaloDmPolicy,
|
||||
zaloSetupAdapter,
|
||||
zaloSetupWizard,
|
||||
} from "./setup-api.js";
|
||||
2
extensions/zalo/channel-plugin-api.ts
Normal file
2
extensions/zalo/channel-plugin-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Zalo API module exposes the plugin public contract.
|
||||
export { zaloPlugin } from "./src/channel.js";
|
||||
2
extensions/zalo/contract-api.ts
Normal file
2
extensions/zalo/contract-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Zalo API module exposes the plugin public contract.
|
||||
export { resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";
|
||||
16
extensions/zalo/index.test.ts
Normal file
16
extensions/zalo/index.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Zalo tests cover index plugin behavior.
|
||||
import { assertBundledChannelEntries } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { describe } from "vitest";
|
||||
import entry from "./index.js";
|
||||
import setupEntry from "./setup-entry.js";
|
||||
|
||||
describe("zalo bundled entries", () => {
|
||||
assertBundledChannelEntries({
|
||||
entry,
|
||||
expectedId: "zalo",
|
||||
expectedName: "Zalo",
|
||||
setupEntry,
|
||||
channelMessage: "declares the channel plugin without a runtime-barrel cycle",
|
||||
setupMessage: "declares the setup plugin without a runtime-barrel cycle",
|
||||
});
|
||||
});
|
||||
21
extensions/zalo/index.ts
Normal file
21
extensions/zalo/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Zalo plugin entrypoint registers its OpenClaw integration.
|
||||
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
id: "zalo",
|
||||
name: "Zalo",
|
||||
description: "Zalo channel plugin",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "zaloPlugin",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setZaloRuntime",
|
||||
},
|
||||
});
|
||||
32
extensions/zalo/npm-shrinkwrap.json
generated
Normal file
32
extensions/zalo/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@openclaw/zalo",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/zalo",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.6.11"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
extensions/zalo/openclaw.plugin.json
Normal file
18
extensions/zalo/openclaw.plugin.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"id": "zalo",
|
||||
"name": "Zalo",
|
||||
"description": "OpenClaw Zalo channel plugin for bot and webhook chats.",
|
||||
"icon": "https://cdn.simpleicons.org/zalo",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["zalo"],
|
||||
"channelEnvVars": {
|
||||
"zalo": ["ZALO_BOT_TOKEN", "ZALO_WEBHOOK_SECRET"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
59
extensions/zalo/package.json
Normal file
59
extensions/zalo/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "@openclaw/zalo",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Zalo channel plugin for bot and webhook chats.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"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": "zalo",
|
||||
"label": "Zalo",
|
||||
"selectionLabel": "Zalo (Bot API)",
|
||||
"docsPath": "/channels/zalo",
|
||||
"docsLabel": "zalo",
|
||||
"blurb": "Vietnam-focused messaging platform with Bot API.",
|
||||
"aliases": [
|
||||
"zl"
|
||||
],
|
||||
"order": 80,
|
||||
"quickstartAllowFrom": true
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/zalo",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
}
|
||||
11
extensions/zalo/runtime-api.test.ts
Normal file
11
extensions/zalo/runtime-api.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Zalo tests cover runtime api plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as runtime from "./runtime-api.js";
|
||||
|
||||
describe("zalo runtime api", () => {
|
||||
it("loads the narrow runtime api without reentering setup surfaces", () => {
|
||||
expect(Object.hasOwn(runtime, "zaloPlugin")).toBe(false);
|
||||
expect(Object.hasOwn(runtime, "zaloSetupWizard")).toBe(false);
|
||||
expect(typeof runtime.setZaloRuntime).toBe("function");
|
||||
});
|
||||
});
|
||||
72
extensions/zalo/runtime-api.ts
Normal file
72
extensions/zalo/runtime-api.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
// Zalo API module exposes the plugin public contract.
|
||||
export {
|
||||
addWildcardAllowFrom,
|
||||
applyAccountNameToChannelSection,
|
||||
applyBasicWebhookRequestGuards,
|
||||
applySetupAccountConfigPatch,
|
||||
type BaseProbeResult,
|
||||
type BaseTokenResolution,
|
||||
buildBaseAccountStatusSnapshot,
|
||||
buildChannelConfigSchema,
|
||||
buildSecretInputSchema,
|
||||
buildSingleChannelSecretPromptState,
|
||||
buildTokenChannelStatusSummary,
|
||||
type ChannelAccountSnapshot,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelPlugin,
|
||||
type ChannelStatusIssue,
|
||||
chunkTextForOutbound,
|
||||
createChannelPairingController,
|
||||
createChannelMessageReplyPipeline,
|
||||
createDedupeCache,
|
||||
createFixedWindowRateLimiter,
|
||||
createWebhookAnomalyTracker,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deliverTextOrMediaReply,
|
||||
formatAllowFromLowercase,
|
||||
formatPairingApproveHint,
|
||||
type GroupPolicy,
|
||||
hasConfiguredSecretInput,
|
||||
isNormalizedSenderAllowed,
|
||||
isNumericTargetId,
|
||||
jsonResult,
|
||||
logTypingFailure,
|
||||
type MarkdownTableMode,
|
||||
mergeAllowFromEntries,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
type OpenClawConfig,
|
||||
type OutboundReplyPayload,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
type PluginRuntime,
|
||||
promptSingleChannelSecretInput,
|
||||
readJsonWebhookBodyOrReject,
|
||||
readStringParam,
|
||||
registerPluginHttpRoute,
|
||||
type RegisterWebhookPluginRouteOptions,
|
||||
registerWebhookTarget,
|
||||
type RegisterWebhookTargetOptions,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
type ReplyPayload,
|
||||
resolveClientIp,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveInboundRouteEnvelopeBuilderWithRuntime,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
resolveWebhookPath,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
runSingleChannelSecretStep,
|
||||
type RuntimeEnv,
|
||||
type SecretInput,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
setTopLevelChannelDmPolicyWithAllowFrom,
|
||||
setZaloRuntime,
|
||||
waitForAbortSignal,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
type WizardPrompter,
|
||||
} from "./src/runtime-api.js";
|
||||
6
extensions/zalo/secret-contract-api.ts
Normal file
6
extensions/zalo/secret-contract-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Zalo API module exposes the plugin public contract.
|
||||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
35
extensions/zalo/setup-api.ts
Normal file
35
extensions/zalo/setup-api.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Zalo API module exposes the plugin public contract.
|
||||
import { loadBundledEntryExportSync } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
type SetupSurfaceModule = typeof import("./src/setup-surface.js");
|
||||
|
||||
function createLazyObjectValue<T extends object>(load: () => T): T {
|
||||
return new Proxy({} as T, {
|
||||
get(_target, property, receiver) {
|
||||
return Reflect.get(load(), property, receiver);
|
||||
},
|
||||
has(_target, property) {
|
||||
return property in load();
|
||||
},
|
||||
ownKeys() {
|
||||
return Reflect.ownKeys(load());
|
||||
},
|
||||
getOwnPropertyDescriptor(_target, property) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(load(), property);
|
||||
return descriptor ? { ...descriptor, configurable: true } : undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function loadSetupSurfaceModule(): SetupSurfaceModule {
|
||||
return loadBundledEntryExportSync<SetupSurfaceModule>(import.meta.url, {
|
||||
specifier: "./src/setup-surface.js",
|
||||
});
|
||||
}
|
||||
|
||||
export { zaloDmPolicy, zaloSetupAdapter, createZaloSetupWizardProxy } from "./src/setup-core.js";
|
||||
export { resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";
|
||||
|
||||
export const zaloSetupWizard: SetupSurfaceModule["zaloSetupWizard"] = createLazyObjectValue(
|
||||
() => loadSetupSurfaceModule().zaloSetupWizard as object,
|
||||
) as SetupSurfaceModule["zaloSetupWizard"];
|
||||
14
extensions/zalo/setup-entry.ts
Normal file
14
extensions/zalo/setup-entry.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Zalo plugin module implements setup entry behavior.
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./api.js",
|
||||
exportName: "zaloPlugin",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
});
|
||||
96
extensions/zalo/src/accounts.test.ts
Normal file
96
extensions/zalo/src/accounts.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// Zalo tests cover accounts plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
listEnabledZaloAccounts,
|
||||
listZaloAccountIds,
|
||||
resolveDefaultZaloAccountId,
|
||||
resolveZaloAccount,
|
||||
} from "./accounts.js";
|
||||
|
||||
describe("resolveZaloAccount", () => {
|
||||
it("resolves account config when account key casing differs from normalized id", () => {
|
||||
const resolved = resolveZaloAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalo: {
|
||||
webhookUrl: "https://top.example.com",
|
||||
accounts: {
|
||||
Work: {
|
||||
name: "Work",
|
||||
webhookUrl: "https://work.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.name).toBe("Work");
|
||||
expect(resolved.config.webhookUrl).toBe("https://work.example.com");
|
||||
});
|
||||
|
||||
it("falls back to top-level config for named accounts without overrides", () => {
|
||||
const resolved = resolveZaloAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
webhookUrl: "https://top.example.com",
|
||||
accounts: {
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.enabled).toBe(true);
|
||||
expect(resolved.config.webhookUrl).toBe("https://top.example.com");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const resolved = resolveZaloAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalo: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
name: "Work",
|
||||
botToken: "work-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.name).toBe("Work");
|
||||
expect(resolved.token).toBe("work-token");
|
||||
});
|
||||
|
||||
it("keeps the implicit default account when named accounts are added to top-level credentials", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
zalo: {
|
||||
botToken: "default-token",
|
||||
accounts: {
|
||||
work: {
|
||||
enabled: false,
|
||||
botToken: "work-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(listZaloAccountIds(cfg)).toEqual(["default", "work"]);
|
||||
expect(resolveDefaultZaloAccountId(cfg)).toBe("default");
|
||||
expect(listEnabledZaloAccounts(cfg).map((account) => account.accountId)).toEqual(["default"]);
|
||||
});
|
||||
});
|
||||
66
extensions/zalo/src/accounts.ts
Normal file
66
extensions/zalo/src/accounts.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// Zalo plugin module implements accounts behavior.
|
||||
import {
|
||||
createAccountListHelpers,
|
||||
resolveMergedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-helpers";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveZaloToken } from "./token.js";
|
||||
import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
|
||||
|
||||
export type { ResolvedZaloAccount };
|
||||
|
||||
const { listAccountIds: listZaloAccountIds, resolveDefaultAccountId: resolveDefaultZaloAccountId } =
|
||||
createAccountListHelpers("zalo", {
|
||||
implicitDefaultAccount: {
|
||||
channelKeys: ["botToken", "tokenFile"],
|
||||
envVars: ["ZALO_BOT_TOKEN"],
|
||||
},
|
||||
});
|
||||
export { listZaloAccountIds, resolveDefaultZaloAccountId };
|
||||
|
||||
function mergeZaloAccountConfig(cfg: OpenClawConfig, accountId: string): ZaloAccountConfig {
|
||||
return resolveMergedAccountConfig<ZaloAccountConfig>({
|
||||
channelConfig: cfg.channels?.zalo as ZaloAccountConfig | undefined,
|
||||
accounts: (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts as
|
||||
| Record<string, Partial<ZaloAccountConfig>>
|
||||
| undefined,
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveZaloAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
allowUnresolvedSecretRef?: boolean;
|
||||
}): ResolvedZaloAccount {
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? (params.cfg.channels?.zalo as ZaloConfig | undefined)?.defaultAccount,
|
||||
);
|
||||
const baseEnabled = (params.cfg.channels?.zalo as ZaloConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeZaloAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const tokenResolution = resolveZaloToken(
|
||||
params.cfg.channels?.zalo as ZaloConfig | undefined,
|
||||
accountId,
|
||||
{ allowUnresolvedSecretRef: params.allowUnresolvedSecretRef },
|
||||
);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
enabled,
|
||||
token: tokenResolution.token,
|
||||
tokenSource: tokenResolution.source,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledZaloAccounts(cfg: OpenClawConfig): ResolvedZaloAccount[] {
|
||||
return listZaloAccountIds(cfg)
|
||||
.map((accountId) => resolveZaloAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
6
extensions/zalo/src/actions.runtime.ts
Normal file
6
extensions/zalo/src/actions.runtime.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Zalo plugin module implements actions behavior.
|
||||
import { sendMessageZalo as sendMessageZaloImpl } from "./send.js";
|
||||
|
||||
export const zaloActionsRuntime = {
|
||||
sendMessageZalo: sendMessageZaloImpl,
|
||||
};
|
||||
33
extensions/zalo/src/actions.test.ts
Normal file
33
extensions/zalo/src/actions.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Zalo tests cover actions plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { zaloMessageActions } from "./actions.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
describe("zaloMessageActions.describeMessageTool", () => {
|
||||
it("honors the selected Zalo account during discovery", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
botToken: "root-token",
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: false,
|
||||
botToken: "default-token",
|
||||
},
|
||||
work: {
|
||||
enabled: true,
|
||||
botToken: "work-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(zaloMessageActions.describeMessageTool?.({ cfg, accountId: "default" })).toBeNull();
|
||||
expect(zaloMessageActions.describeMessageTool?.({ cfg, accountId: "work" })).toEqual({
|
||||
actions: ["send"],
|
||||
capabilities: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
63
extensions/zalo/src/actions.ts
Normal file
63
extensions/zalo/src/actions.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// Zalo plugin module implements actions behavior.
|
||||
import { jsonResult, readStringParam } from "openclaw/plugin-sdk/channel-actions";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
import { listEnabledZaloAccounts, resolveZaloAccount } from "./accounts.js";
|
||||
|
||||
const loadZaloActionsRuntime = createLazyRuntimeNamedExport(
|
||||
() => import("./actions.runtime.js"),
|
||||
"zaloActionsRuntime",
|
||||
);
|
||||
|
||||
const providerId = "zalo";
|
||||
|
||||
function listEnabledAccounts(cfg: OpenClawConfig, accountId?: string | null) {
|
||||
return (
|
||||
accountId ? [resolveZaloAccount({ cfg, accountId })] : listEnabledZaloAccounts(cfg)
|
||||
).filter((account) => account.enabled && account.tokenSource !== "none");
|
||||
}
|
||||
|
||||
export const zaloMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: ({ cfg, accountId }) => {
|
||||
const accounts = listEnabledAccounts(cfg, accountId);
|
||||
if (accounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||
return { actions: Array.from(actions), capabilities: [] };
|
||||
},
|
||||
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||
if (action === "send") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "message", {
|
||||
required: true,
|
||||
allowEmpty: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
|
||||
const { sendMessageZalo } = await loadZaloActionsRuntime();
|
||||
const result = await sendMessageZalo(to ?? "", content ?? "", {
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
cfg,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
error: result.error ?? "Failed to send Zalo message",
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResult({ ok: true, to, messageId: result.messageId });
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||
},
|
||||
};
|
||||
335
extensions/zalo/src/api.test.ts
Normal file
335
extensions/zalo/src/api.test.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
// Zalo tests cover api plugin behavior.
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { resolvePinnedHostnameWithPolicyMock } = vi.hoisted(() => ({
|
||||
resolvePinnedHostnameWithPolicyMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
resolvePinnedHostnameWithPolicy: (...args: unknown[]) =>
|
||||
resolvePinnedHostnameWithPolicyMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
callZaloApi,
|
||||
deleteWebhook,
|
||||
getMe,
|
||||
getWebhookInfo,
|
||||
sendChatAction,
|
||||
sendPhoto,
|
||||
type ZaloFetch,
|
||||
} from "./api.js";
|
||||
|
||||
const ZALO_JSON_CAP_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function oversizedZaloJsonResponse(onCancel: () => void): Response {
|
||||
const response = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(ZALO_JSON_CAP_BYTES + 1));
|
||||
},
|
||||
cancel() {
|
||||
onCancel();
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" }, status: 200 },
|
||||
);
|
||||
Object.defineProperty(response, "json", {
|
||||
value: async () => {
|
||||
throw new Error("unbounded json reader was used");
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
function createOkFetcher() {
|
||||
return vi.fn<ZaloFetch>(async () => new Response(JSON.stringify({ ok: true, result: {} })));
|
||||
}
|
||||
|
||||
function requireFirstFetchCall(fetcher: ReturnType<typeof createOkFetcher>, label: string) {
|
||||
const [call] = fetcher.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
async function expectPostJsonRequest(run: (token: string, fetcher: ZaloFetch) => Promise<unknown>) {
|
||||
const fetcher = createOkFetcher();
|
||||
await run("test-token", fetcher);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const [, init] = requireFirstFetchCall(fetcher, "Zalo request");
|
||||
if (!init) {
|
||||
throw new Error("expected Zalo request init");
|
||||
}
|
||||
expect(init.method).toBe("POST");
|
||||
expect(init.headers).toEqual({ "Content-Type": "application/json" });
|
||||
}
|
||||
|
||||
describe("Zalo API request methods", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
resolvePinnedHostnameWithPolicyMock.mockReset();
|
||||
resolvePinnedHostnameWithPolicyMock.mockResolvedValue({
|
||||
hostname: "example.com",
|
||||
addresses: ["93.184.216.34"],
|
||||
lookup: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the native Zalo getMe identity fields", async () => {
|
||||
const fetcher: ZaloFetch = vi.fn(async () =>
|
||||
Response.json({
|
||||
ok: true,
|
||||
result: {
|
||||
account_name: "bot.example",
|
||||
account_type: "BASIC",
|
||||
can_join_groups: false,
|
||||
id: "1459232241454765289",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(getMe("test-token", undefined, fetcher)).resolves.toMatchObject({
|
||||
result: {
|
||||
account_name: "bot.example",
|
||||
account_type: "BASIC",
|
||||
can_join_groups: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the production API root by default", async () => {
|
||||
const fetcher = createOkFetcher();
|
||||
|
||||
await callZaloApi("getMe", "test-token", undefined, { fetch: fetcher });
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
"https://bot-api.zaloplatforms.com/bottest-token/getMe",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses ZALO_API_URL for provider-compatible alternate endpoints", async () => {
|
||||
vi.stubEnv("ZALO_API_URL", " http://127.0.0.1:49152/zalo/ ");
|
||||
const fetcher = createOkFetcher();
|
||||
|
||||
await callZaloApi("getMe", "test-token", undefined, { fetch: fetcher });
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:49152/zalo/bottest-token/getMe",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers an explicit API URL over ZALO_API_URL", async () => {
|
||||
vi.stubEnv("ZALO_API_URL", "http://127.0.0.1:49152/env");
|
||||
const fetcher = createOkFetcher();
|
||||
|
||||
await callZaloApi("getMe", "test-token", undefined, {
|
||||
apiUrl: "http://127.0.0.1:49153/explicit/",
|
||||
fetch: fetcher,
|
||||
});
|
||||
|
||||
expect(fetcher).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:49153/explicit/bottest-token/getMe",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an explicitly empty API URL instead of falling back to ZALO_API_URL", async () => {
|
||||
vi.stubEnv("ZALO_API_URL", "http://127.0.0.1:49152/env");
|
||||
|
||||
await expect(
|
||||
callZaloApi("getMe", "test-token", undefined, {
|
||||
apiUrl: " ",
|
||||
fetch: createOkFetcher(),
|
||||
}),
|
||||
).rejects.toThrow("ZALO_API_URL must not be empty.");
|
||||
});
|
||||
|
||||
it("rejects invalid alternate API URLs", async () => {
|
||||
vi.stubEnv("ZALO_API_URL", "file:///tmp/zalo");
|
||||
|
||||
await expect(
|
||||
callZaloApi("getMe", "test-token", undefined, { fetch: createOkFetcher() }),
|
||||
).rejects.toThrow("ZALO_API_URL must use http:// or https://.");
|
||||
});
|
||||
|
||||
it.each(["https://proxy.example/zalo?tenant=1", "https://proxy.example/zalo#provider"])(
|
||||
"rejects an API root with URL suffix components: %s",
|
||||
async (apiUrl) => {
|
||||
await expect(
|
||||
callZaloApi("getMe", "test-token", undefined, {
|
||||
apiUrl,
|
||||
fetch: createOkFetcher(),
|
||||
}),
|
||||
).rejects.toThrow("ZALO_API_URL must not include a query string or fragment.");
|
||||
},
|
||||
);
|
||||
|
||||
it("uses POST for getWebhookInfo", async () => {
|
||||
await expectPostJsonRequest(getWebhookInfo);
|
||||
});
|
||||
|
||||
it("keeps POST for deleteWebhook", async () => {
|
||||
await expectPostJsonRequest(deleteWebhook);
|
||||
});
|
||||
|
||||
it("aborts sendChatAction when the typing timeout elapses", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const fetcher = vi.fn<ZaloFetch>(
|
||||
(_, init) =>
|
||||
new Promise<Response>((_Local, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const promise = sendChatAction(
|
||||
"test-token",
|
||||
{
|
||||
chat_id: "chat-123",
|
||||
action: "typing",
|
||||
},
|
||||
fetcher,
|
||||
25,
|
||||
);
|
||||
const rejected = expect(promise).rejects.toThrow("aborted");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
|
||||
await rejected;
|
||||
const [, init] = requireFirstFetchCall(fetcher, "Zalo chat action request");
|
||||
if (!init) {
|
||||
throw new Error("expected Zalo chat action request init");
|
||||
}
|
||||
if (!init.signal) {
|
||||
throw new Error("expected Zalo chat action abort signal");
|
||||
}
|
||||
expect(init.signal.aborted).toBe(true);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("caps oversized sendChatAction timeouts before scheduling the timer", async () => {
|
||||
const setTimeoutMock = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
|
||||
const clearTimeoutMock = vi
|
||||
.spyOn(globalThis, "clearTimeout")
|
||||
.mockImplementation(() => undefined);
|
||||
try {
|
||||
const fetcher = vi.fn<ZaloFetch>(
|
||||
async () => new Response(JSON.stringify({ ok: true, result: {} })),
|
||||
);
|
||||
|
||||
await sendChatAction(
|
||||
"test-token",
|
||||
{
|
||||
chat_id: "chat-123",
|
||||
action: "typing",
|
||||
},
|
||||
fetcher,
|
||||
MAX_TIMER_TIMEOUT_MS + 1_000_000,
|
||||
);
|
||||
|
||||
expect(setTimeoutMock).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||
} finally {
|
||||
setTimeoutMock.mockRestore();
|
||||
clearTimeoutMock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("validates outbound photo URLs against the SSRF guard before posting", async () => {
|
||||
const fetcher = createOkFetcher();
|
||||
|
||||
await sendPhoto(
|
||||
"test-token",
|
||||
{
|
||||
chat_id: "chat-123",
|
||||
photo: "https://example.com/image.png",
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
|
||||
expect(resolvePinnedHostnameWithPolicyMock).toHaveBeenCalledWith("example.com", {
|
||||
policy: {},
|
||||
});
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("blocks private-network photo URLs before they reach the Zalo API", async () => {
|
||||
const fetcher = createOkFetcher();
|
||||
resolvePinnedHostnameWithPolicyMock.mockRejectedValueOnce(
|
||||
new Error("Blocked hostname or private/internal/special-use IP address"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
sendPhoto(
|
||||
"test-token",
|
||||
{
|
||||
chat_id: "chat-123",
|
||||
photo: "http://169.254.169.254/latest/meta-data/iam/security-credentials/",
|
||||
},
|
||||
fetcher,
|
||||
),
|
||||
).rejects.toThrow("Blocked hostname or private/internal/special-use IP address");
|
||||
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-http photo URLs", async () => {
|
||||
const fetcher = createOkFetcher();
|
||||
|
||||
await expect(
|
||||
sendPhoto(
|
||||
"test-token",
|
||||
{
|
||||
chat_id: "chat-123",
|
||||
photo: "file:///etc/passwd",
|
||||
},
|
||||
fetcher,
|
||||
),
|
||||
).rejects.toThrow("Zalo photo URL must use HTTP or HTTPS");
|
||||
|
||||
expect(resolvePinnedHostnameWithPolicyMock).not.toHaveBeenCalled();
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-URL strings", async () => {
|
||||
const fetcher = createOkFetcher();
|
||||
|
||||
await expect(
|
||||
sendPhoto(
|
||||
"test-token",
|
||||
{
|
||||
chat_id: "chat-123",
|
||||
photo: "not a url",
|
||||
},
|
||||
fetcher,
|
||||
),
|
||||
).rejects.toThrow("Zalo photo URL must be an absolute HTTP or HTTPS URL");
|
||||
|
||||
expect(resolvePinnedHostnameWithPolicyMock).not.toHaveBeenCalled();
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bounds oversized getMe JSON responses and cancels the stream", async () => {
|
||||
let cancelCount = 0;
|
||||
const fetcher = vi.fn<ZaloFetch>(async () =>
|
||||
oversizedZaloJsonResponse(() => {
|
||||
cancelCount += 1;
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(getMe("test-token", undefined, fetcher)).rejects.toThrow(
|
||||
"zalo.getMe: JSON response exceeds 16777216 bytes",
|
||||
);
|
||||
expect(cancelCount).toBe(1);
|
||||
});
|
||||
});
|
||||
293
extensions/zalo/src/api.ts
Normal file
293
extensions/zalo/src/api.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* Zalo Bot API client
|
||||
* @see https://bot.zaloplatforms.com/docs
|
||||
*/
|
||||
|
||||
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { resolvePinnedHostnameWithPolicy, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
const ZALO_API_BASE = "https://bot-api.zaloplatforms.com";
|
||||
const ZALO_API_URL_ENV = "ZALO_API_URL";
|
||||
const ZALO_MEDIA_SSRF_POLICY: SsrFPolicy = {};
|
||||
|
||||
export type ZaloFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export type ZaloApiResponse<T = unknown> = {
|
||||
ok: boolean;
|
||||
result?: T;
|
||||
error_code?: number;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type ZaloBotInfo = {
|
||||
id: string;
|
||||
account_name: string;
|
||||
account_type: string;
|
||||
can_join_groups: boolean;
|
||||
};
|
||||
|
||||
export type ZaloMessage = {
|
||||
message_id: string;
|
||||
from: {
|
||||
id: string;
|
||||
name?: string;
|
||||
display_name?: string;
|
||||
avatar?: string;
|
||||
is_bot?: boolean;
|
||||
};
|
||||
chat: {
|
||||
id: string;
|
||||
chat_type: "PRIVATE" | "GROUP";
|
||||
};
|
||||
date: number;
|
||||
text?: string;
|
||||
photo_url?: string;
|
||||
caption?: string;
|
||||
sticker?: string;
|
||||
message_type?: string;
|
||||
};
|
||||
|
||||
export type ZaloUpdate = {
|
||||
event_name:
|
||||
| "message.text.received"
|
||||
| "message.image.received"
|
||||
| "message.sticker.received"
|
||||
| "message.unsupported.received";
|
||||
message?: ZaloMessage;
|
||||
};
|
||||
|
||||
export type ZaloSendMessageParams = {
|
||||
chat_id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type ZaloSendPhotoParams = {
|
||||
chat_id: string;
|
||||
photo: string;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export type ZaloSendChatActionParams = {
|
||||
chat_id: string;
|
||||
action: "typing" | "upload_photo";
|
||||
};
|
||||
|
||||
export type ZaloSetWebhookParams = {
|
||||
url: string;
|
||||
secret_token: string;
|
||||
};
|
||||
|
||||
export type ZaloWebhookInfo = {
|
||||
url?: string;
|
||||
updated_at?: number;
|
||||
has_custom_certificate?: boolean;
|
||||
};
|
||||
|
||||
export type ZaloGetUpdatesParams = {
|
||||
/** Timeout in seconds (passed as string to API) */
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export class ZaloApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly errorCode?: number,
|
||||
public readonly description?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ZaloApiError";
|
||||
}
|
||||
|
||||
/** True if this is a long-polling timeout (no updates available) */
|
||||
get isPollingTimeout(): boolean {
|
||||
return this.errorCode === 408;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveZaloApiUrl(apiUrl?: string): string {
|
||||
const value =
|
||||
apiUrl === undefined ? (process.env[ZALO_API_URL_ENV]?.trim() ?? ZALO_API_BASE) : apiUrl.trim();
|
||||
if (!value) {
|
||||
throw new Error(`${ZALO_API_URL_ENV} must not be empty.`);
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`${ZALO_API_URL_ENV} must be a valid URL.`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`${ZALO_API_URL_ENV} must use http:// or https://.`);
|
||||
}
|
||||
if (parsed.search || parsed.hash) {
|
||||
throw new Error(`${ZALO_API_URL_ENV} must not include a query string or fragment.`);
|
||||
}
|
||||
return parsed.href.replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the Zalo Bot API
|
||||
*/
|
||||
export async function callZaloApi<T = unknown>(
|
||||
method: string,
|
||||
token: string,
|
||||
body?: Record<string, unknown>,
|
||||
options?: { apiUrl?: string; timeoutMs?: number; fetch?: ZaloFetch },
|
||||
): Promise<ZaloApiResponse<T>> {
|
||||
const url = `${resolveZaloApiUrl(options?.apiUrl)}/bot${token}/${method}`;
|
||||
const controller = new AbortController();
|
||||
const requestTimeoutMs =
|
||||
options?.timeoutMs === undefined ? undefined : resolveTimerTimeoutMs(options.timeoutMs, 1);
|
||||
const timeoutId =
|
||||
requestTimeoutMs === undefined
|
||||
? undefined
|
||||
: setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
const fetcher = options?.fetch ?? fetch;
|
||||
|
||||
try {
|
||||
const response = await fetcher(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const data = await readProviderJsonResponse<ZaloApiResponse<T>>(response, `zalo.${method}`);
|
||||
|
||||
if (!data.ok) {
|
||||
throw new ZaloApiError(
|
||||
data.description ?? `Zalo API error: ${method}`,
|
||||
data.error_code,
|
||||
data.description,
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate bot token and get bot info
|
||||
*/
|
||||
export async function getMe(
|
||||
token: string,
|
||||
timeoutMs?: number,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloBotInfo>> {
|
||||
return callZaloApi<ZaloBotInfo>("getMe", token, undefined, { timeoutMs, fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text message
|
||||
*/
|
||||
export async function sendMessage(
|
||||
token: string,
|
||||
params: ZaloSendMessageParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloMessage>> {
|
||||
return callZaloApi<ZaloMessage>("sendMessage", token, params, { fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a photo message
|
||||
*/
|
||||
export async function sendPhoto(
|
||||
token: string,
|
||||
params: ZaloSendPhotoParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloMessage>> {
|
||||
const photoUrl = params.photo.trim();
|
||||
let parsedPhotoUrl: URL;
|
||||
try {
|
||||
parsedPhotoUrl = new URL(photoUrl);
|
||||
} catch {
|
||||
throw new Error("Zalo photo URL must be an absolute HTTP or HTTPS URL");
|
||||
}
|
||||
|
||||
if (parsedPhotoUrl.protocol !== "http:" && parsedPhotoUrl.protocol !== "https:") {
|
||||
throw new Error("Zalo photo URL must use HTTP or HTTPS");
|
||||
}
|
||||
|
||||
await resolvePinnedHostnameWithPolicy(parsedPhotoUrl.hostname, {
|
||||
policy: ZALO_MEDIA_SSRF_POLICY,
|
||||
});
|
||||
|
||||
return callZaloApi<ZaloMessage>(
|
||||
"sendPhoto",
|
||||
token,
|
||||
{ ...params, photo: parsedPhotoUrl.href },
|
||||
{ fetch: fetcher },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a temporary chat action such as typing.
|
||||
*/
|
||||
export async function sendChatAction(
|
||||
token: string,
|
||||
params: ZaloSendChatActionParams,
|
||||
fetcher?: ZaloFetch,
|
||||
timeoutMs?: number,
|
||||
): Promise<ZaloApiResponse<boolean>> {
|
||||
return callZaloApi<boolean>("sendChatAction", token, params, {
|
||||
timeoutMs,
|
||||
fetch: fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get updates using long polling (dev/testing only)
|
||||
* Note: Zalo returns a single update per call, not an array like Telegram
|
||||
*/
|
||||
export async function getUpdates(
|
||||
token: string,
|
||||
params?: ZaloGetUpdatesParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloUpdate>> {
|
||||
const pollTimeoutSec = params?.timeout ?? 30;
|
||||
const timeoutMs = (pollTimeoutSec + 5) * 1000;
|
||||
const body = { timeout: String(pollTimeoutSec) };
|
||||
return callZaloApi<ZaloUpdate>("getUpdates", token, body, { timeoutMs, fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set webhook URL for receiving updates
|
||||
*/
|
||||
export async function setWebhook(
|
||||
token: string,
|
||||
params: ZaloSetWebhookParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
|
||||
return callZaloApi<ZaloWebhookInfo>("setWebhook", token, params, { fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete webhook configuration
|
||||
*/
|
||||
export async function deleteWebhook(
|
||||
token: string,
|
||||
fetcher?: ZaloFetch,
|
||||
timeoutMs?: number,
|
||||
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
|
||||
return callZaloApi<ZaloWebhookInfo>("deleteWebhook", token, undefined, {
|
||||
timeoutMs,
|
||||
fetch: fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current webhook info
|
||||
*/
|
||||
export async function getWebhookInfo(
|
||||
token: string,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloWebhookInfo>> {
|
||||
return callZaloApi<ZaloWebhookInfo>("getWebhookInfo", token, undefined, { fetch: fetcher });
|
||||
}
|
||||
18
extensions/zalo/src/approval-auth.test.ts
Normal file
18
extensions/zalo/src/approval-auth.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Zalo tests cover approval auth plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { zaloApprovalAuth } from "./approval-auth.js";
|
||||
|
||||
describe("zaloApprovalAuth", () => {
|
||||
it("authorizes numeric Zalo user ids", () => {
|
||||
const cfg = { channels: { zalo: { allowFrom: ["zl:123"] } } };
|
||||
|
||||
expect(
|
||||
zaloApprovalAuth.authorizeActorAction({
|
||||
cfg,
|
||||
senderId: "123",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
});
|
||||
});
|
||||
26
extensions/zalo/src/approval-auth.ts
Normal file
26
extensions/zalo/src/approval-auth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Zalo plugin module implements approval auth behavior.
|
||||
import {
|
||||
createResolvedApproverActionAuthAdapter,
|
||||
resolveApprovalApprovers,
|
||||
} from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import { resolveZaloAccount } from "./accounts.js";
|
||||
|
||||
function normalizeZaloApproverId(value: string | number): string | undefined {
|
||||
const normalized = String(value)
|
||||
.trim()
|
||||
.replace(/^(zalo|zl):/i, "")
|
||||
.trim();
|
||||
return /^\d+$/.test(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
export const zaloApprovalAuth = createResolvedApproverActionAuthAdapter({
|
||||
channelLabel: "Zalo",
|
||||
resolveApprovers: ({ cfg, accountId }) => {
|
||||
const account = resolveZaloAccount({ cfg, accountId }).config;
|
||||
return resolveApprovalApprovers({
|
||||
allowFrom: account.allowFrom,
|
||||
normalizeApprover: normalizeZaloApproverId,
|
||||
});
|
||||
},
|
||||
normalizeSenderId: (value) => normalizeZaloApproverId(value),
|
||||
});
|
||||
57
extensions/zalo/src/channel.directory.test.ts
Normal file
57
extensions/zalo/src/channel.directory.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
// Zalo tests cover channelirectory plugin behavior.
|
||||
import {
|
||||
createDirectoryTestRuntime,
|
||||
expectDirectorySurface,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import { zaloPlugin } from "./channel.js";
|
||||
|
||||
describe("zalo directory", () => {
|
||||
const runtimeEnv = createDirectoryTestRuntime() as RuntimeEnv;
|
||||
const directory = expectDirectorySurface(zaloPlugin.directory);
|
||||
|
||||
async function expectPeersFromAllowFrom(allowFrom: string[]) {
|
||||
const cfg = {
|
||||
channels: {
|
||||
zalo: {
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const peers = await directory.listPeers({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
});
|
||||
expect(peers).toStrictEqual([
|
||||
{ kind: "user", id: "123" },
|
||||
{ kind: "user", id: "234" },
|
||||
{ kind: "user", id: "345" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
directory.listGroups({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toStrictEqual([]);
|
||||
}
|
||||
|
||||
it("lists peers from allowFrom", async () => {
|
||||
await expectPeersFromAllowFrom(["zalo:123", "zl:234", "345"]);
|
||||
});
|
||||
|
||||
it("normalizes spaced zalo prefixes in allowFrom and pairing entries", async () => {
|
||||
await expectPeersFromAllowFrom([" zalo:123 ", " zl:234 ", " 345 "]);
|
||||
|
||||
expect(zaloPlugin.pairing?.normalizeAllowEntry?.(" zalo:123 ")).toBe("123");
|
||||
expect(zaloPlugin.messaging?.normalizeTarget?.(" zl:234 ")).toBe("234");
|
||||
});
|
||||
});
|
||||
94
extensions/zalo/src/channel.runtime.ts
Normal file
94
extensions/zalo/src/channel.runtime.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
// Zalo plugin module implements channel behavior.
|
||||
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { probeZalo } from "./probe.js";
|
||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||
import {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
type ChannelPlugin,
|
||||
type OpenClawConfig,
|
||||
} from "./runtime-api.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { sendMessageZalo } from "./send.js";
|
||||
import type { ResolvedZaloAccount } from "./types.js";
|
||||
|
||||
export async function notifyZaloPairingApproval(params: { cfg: OpenClawConfig; id: string }) {
|
||||
const { resolveZaloAccount } = await import("./accounts.js");
|
||||
const account = resolveZaloAccount({ cfg: params.cfg });
|
||||
if (!account.token) {
|
||||
throw new Error("Zalo token not configured");
|
||||
}
|
||||
await sendMessageZalo(params.id, PAIRING_APPROVED_MESSAGE, {
|
||||
token: account.token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendZaloText(
|
||||
params: Parameters<typeof sendMessageZalo>[2] & {
|
||||
to: string;
|
||||
text: string;
|
||||
},
|
||||
) {
|
||||
return await sendMessageZalo(params.to, params.text, params);
|
||||
}
|
||||
|
||||
export async function probeZaloAccount(params: {
|
||||
account: import("./accounts.js").ResolvedZaloAccount;
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
return await probeZalo(
|
||||
params.account.token,
|
||||
params.timeoutMs,
|
||||
resolveZaloProxyFetch(params.account.config.proxy),
|
||||
);
|
||||
}
|
||||
|
||||
export async function startZaloGatewayAccount(
|
||||
ctx: Parameters<
|
||||
NonNullable<NonNullable<ChannelPlugin<ResolvedZaloAccount>["gateway"]>["startAccount"]>
|
||||
>[0],
|
||||
) {
|
||||
const account = ctx.account;
|
||||
const token = account.token.trim();
|
||||
const mode = account.config.webhookUrl ? "webhook" : "polling";
|
||||
let zaloBotLabel = "";
|
||||
const fetcher = resolveZaloProxyFetch(account.config.proxy);
|
||||
try {
|
||||
const probe = await probeZalo(token, 2500, fetcher);
|
||||
const name = probe.ok ? probe.bot?.account_name?.trim() : null;
|
||||
if (name) {
|
||||
zaloBotLabel = ` (${name})`;
|
||||
}
|
||||
if (!probe.ok) {
|
||||
ctx.log?.warn?.(
|
||||
`[${account.accountId}] Zalo probe failed before provider start (${String(probe.elapsedMs)}ms): ${probe.error}`,
|
||||
);
|
||||
}
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
bot: probe.bot,
|
||||
});
|
||||
} catch (err) {
|
||||
ctx.log?.warn?.(
|
||||
`[${account.accountId}] Zalo probe threw before provider start: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`,
|
||||
);
|
||||
}
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel} mode=${mode}`);
|
||||
const { monitorZaloProvider } = await import("./monitor.js");
|
||||
return monitorZaloProvider({
|
||||
token,
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
useWebhook: Boolean(account.config.webhookUrl),
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
webhookSecret: normalizeSecretInputString(account.config.webhookSecret),
|
||||
webhookPath: account.config.webhookPath,
|
||||
fetcher,
|
||||
statusSink,
|
||||
});
|
||||
}
|
||||
122
extensions/zalo/src/channel.startup.test.ts
Normal file
122
extensions/zalo/src/channel.startup.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Zalo tests cover channel.startup plugin behavior.
|
||||
import {
|
||||
expectLifecyclePatch,
|
||||
expectPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
monitorZaloProvider: vi.fn(),
|
||||
probeZalo: vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
error: "probe failed",
|
||||
elapsedMs: 1,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("./monitor.js", () => {
|
||||
return {
|
||||
monitorZaloProvider: hoisted.monitorZaloProvider,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./probe.js", () => {
|
||||
return {
|
||||
probeZalo: hoisted.probeZalo,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./channel.runtime.js", () => ({
|
||||
probeZaloAccount: hoisted.probeZalo,
|
||||
startZaloGatewayAccount: async (ctx: {
|
||||
account: ResolvedZaloAccount;
|
||||
abortSignal: AbortSignal;
|
||||
setStatus: (patch: Partial<ResolvedZaloAccount>) => void;
|
||||
}) => {
|
||||
await hoisted.probeZalo();
|
||||
ctx.setStatus({ accountId: ctx.account.accountId });
|
||||
return await hoisted.monitorZaloProvider({
|
||||
token: ctx.account.token,
|
||||
account: ctx.account,
|
||||
abortSignal: ctx.abortSignal,
|
||||
useWebhook: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
import { zaloPlugin } from "./channel.js";
|
||||
|
||||
type ZaloGateway = NonNullable<typeof zaloPlugin.gateway>;
|
||||
type ZaloStartAccount = NonNullable<ZaloGateway["startAccount"]>;
|
||||
|
||||
function requireStartAccount(): ZaloStartAccount {
|
||||
const startAccount = zaloPlugin.gateway?.startAccount;
|
||||
if (!startAccount) {
|
||||
throw new Error("Expected Zalo gateway startAccount");
|
||||
}
|
||||
return startAccount;
|
||||
}
|
||||
|
||||
function buildAccount(): ResolvedZaloAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
token: "test-token",
|
||||
tokenSource: "config",
|
||||
config: {},
|
||||
};
|
||||
}
|
||||
|
||||
function requireMonitorArgs() {
|
||||
const [call] = hoisted.monitorZaloProvider.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected Zalo monitor call");
|
||||
}
|
||||
const [monitorArgs] = call;
|
||||
return monitorArgs;
|
||||
}
|
||||
|
||||
describe("zaloPlugin gateway.startAccount", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort", async () => {
|
||||
hoisted.monitorZaloProvider.mockImplementationOnce(
|
||||
async ({ abortSignal }: { abortSignal: AbortSignal }) =>
|
||||
await new Promise<void>((resolve) => {
|
||||
if (abortSignal.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
abortSignal.addEventListener("abort", () => resolve(), { once: true });
|
||||
}),
|
||||
);
|
||||
|
||||
const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: requireStartAccount(),
|
||||
account: buildAccount(),
|
||||
});
|
||||
|
||||
await expectPendingUntilAbort({
|
||||
waitForStarted: waitForStartedMocks(hoisted.probeZalo, hoisted.monitorZaloProvider),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
});
|
||||
|
||||
expectLifecyclePatch(patches, { accountId: "default" });
|
||||
expect(isSettled()).toBe(true);
|
||||
expect(hoisted.monitorZaloProvider).toHaveBeenCalledTimes(1);
|
||||
const monitorArgs = requireMonitorArgs();
|
||||
expect(monitorArgs).toStrictEqual({
|
||||
token: "test-token",
|
||||
account: buildAccount(),
|
||||
abortSignal: abort.signal,
|
||||
useWebhook: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
311
extensions/zalo/src/channel.ts
Normal file
311
extensions/zalo/src/channel.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
// Zalo plugin module implements channel behavior.
|
||||
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
createScopedDmSecurityResolver,
|
||||
mapAllowFromEntries,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
createChatChannelPlugin,
|
||||
type ChannelPlugin,
|
||||
} from "openclaw/plugin-sdk/channel-core";
|
||||
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
buildOpenGroupPolicyRestrictSendersWarning,
|
||||
buildOpenGroupPolicyWarning,
|
||||
createOpenProviderGroupPolicyWarningCollector,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import {
|
||||
createEmptyChannelResult,
|
||||
createRawChannelSendResultAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { buildTokenChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
isNumericTargetId,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
import {
|
||||
listZaloAccountIds,
|
||||
resolveDefaultZaloAccountId,
|
||||
resolveZaloAccount,
|
||||
type ResolvedZaloAccount,
|
||||
} from "./accounts.js";
|
||||
import { zaloMessageActions } from "./actions.js";
|
||||
import { zaloApprovalAuth } from "./approval-auth.js";
|
||||
import { ZaloConfigSchema } from "./config-schema.js";
|
||||
import type { ZaloProbeResult } from "./probe.js";
|
||||
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
|
||||
import { resolveZaloOutboundSessionRoute } from "./session-route.js";
|
||||
import { createZaloSetupWizardProxy, zaloSetupAdapter } from "./setup-core.js";
|
||||
import { collectZaloStatusIssues } from "./status-issues.js";
|
||||
|
||||
const meta = {
|
||||
id: "zalo",
|
||||
label: "Zalo",
|
||||
selectionLabel: "Zalo (Bot API)",
|
||||
docsPath: "/channels/zalo",
|
||||
docsLabel: "zalo",
|
||||
blurb: "Vietnam-focused messaging platform with Bot API.",
|
||||
aliases: ["zl"],
|
||||
order: 80,
|
||||
quickstartAllowFrom: true,
|
||||
};
|
||||
|
||||
function normalizeZaloMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed.replace(/^(zalo|zl):/i, "").trim();
|
||||
}
|
||||
|
||||
const loadZaloChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
|
||||
const zaloSetupWizard = createZaloSetupWizardProxy(
|
||||
async () => (await import("./setup-surface.js")).zaloSetupWizard,
|
||||
);
|
||||
const zaloTextChunkLimit = 2000;
|
||||
|
||||
const zaloRawSendResultAdapter = createRawChannelSendResultAdapter({
|
||||
channel: "zalo",
|
||||
sendText: async ({ to, text, accountId, cfg }) =>
|
||||
await (
|
||||
await loadZaloChannelRuntime()
|
||||
).sendZaloText({
|
||||
to,
|
||||
text,
|
||||
accountId: accountId ?? undefined,
|
||||
cfg,
|
||||
}),
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) =>
|
||||
await (
|
||||
await loadZaloChannelRuntime()
|
||||
).sendZaloText({
|
||||
to,
|
||||
text,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl,
|
||||
cfg,
|
||||
}),
|
||||
});
|
||||
|
||||
export const zaloMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "zalo",
|
||||
durableFinal: {
|
||||
capabilities: {
|
||||
text: true,
|
||||
media: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
},
|
||||
send: {
|
||||
text: async ({ to, text, accountId, cfg }) =>
|
||||
await (
|
||||
await loadZaloChannelRuntime()
|
||||
).sendZaloText({
|
||||
to,
|
||||
text,
|
||||
accountId: accountId ?? undefined,
|
||||
cfg,
|
||||
}),
|
||||
media: async ({ to, text, mediaUrl, accountId, cfg }) =>
|
||||
await (
|
||||
await loadZaloChannelRuntime()
|
||||
).sendZaloText({
|
||||
to,
|
||||
text,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl,
|
||||
cfg,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const zaloConfigAdapter = createScopedChannelConfigAdapter<ResolvedZaloAccount>({
|
||||
sectionKey: "zalo",
|
||||
listAccountIds: listZaloAccountIds,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveZaloAccount),
|
||||
defaultAccountId: resolveDefaultZaloAccountId,
|
||||
clearBaseFields: ["botToken", "tokenFile", "name"],
|
||||
resolveAllowFrom: (account: ResolvedZaloAccount) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalo|zl):/i }),
|
||||
});
|
||||
|
||||
const resolveZaloDmPolicy = createScopedDmSecurityResolver<ResolvedZaloAccount>({
|
||||
channelKey: "zalo",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw) => raw.trim().replace(/^(zalo|zl):/i, ""),
|
||||
});
|
||||
|
||||
const collectZaloSecurityWarnings = createOpenProviderGroupPolicyWarningCollector<{
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedZaloAccount;
|
||||
}>({
|
||||
providerConfigPresent: (cfg) => cfg.channels?.zalo !== undefined,
|
||||
resolveGroupPolicy: ({ account }) => account.config.groupPolicy,
|
||||
collect: ({ account, groupPolicy }) => {
|
||||
if (groupPolicy !== "open") {
|
||||
return [];
|
||||
}
|
||||
const explicitGroupAllowFrom = mapAllowFromEntries(account.config.groupAllowFrom);
|
||||
const dmAllowFrom = mapAllowFromEntries(account.config.allowFrom);
|
||||
const effectiveAllowFrom =
|
||||
explicitGroupAllowFrom.length > 0 ? explicitGroupAllowFrom : dmAllowFrom;
|
||||
if (effectiveAllowFrom.length > 0) {
|
||||
return [
|
||||
buildOpenGroupPolicyRestrictSendersWarning({
|
||||
surface: "Zalo groups",
|
||||
openScope: "any member",
|
||||
groupPolicyPath: "channels.zalo.groupPolicy",
|
||||
groupAllowFromPath: "channels.zalo.groupAllowFrom",
|
||||
}),
|
||||
];
|
||||
}
|
||||
return [
|
||||
buildOpenGroupPolicyWarning({
|
||||
surface: "Zalo groups",
|
||||
openBehavior:
|
||||
"with no groupAllowFrom/allowFrom allowlist; any member can trigger (mention-gated)",
|
||||
remediation: 'Set channels.zalo.groupPolicy="allowlist" + channels.zalo.groupAllowFrom',
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount, ZaloProbeResult> =
|
||||
createChatChannelPlugin({
|
||||
base: {
|
||||
id: "zalo",
|
||||
meta,
|
||||
setup: zaloSetupAdapter,
|
||||
setupWizard: zaloSetupWizard,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
reactions: false,
|
||||
threads: false,
|
||||
polls: false,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.zalo"] },
|
||||
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
|
||||
config: {
|
||||
...zaloConfigAdapter,
|
||||
isConfigured: (account) => Boolean(account.token?.trim()),
|
||||
describeAccount: (account): ChannelAccountSnapshot =>
|
||||
describeWebhookAccountSnapshot({
|
||||
account,
|
||||
configured: Boolean(account.token?.trim()),
|
||||
mode: account.config.webhookUrl ? "webhook" : "polling",
|
||||
extra: {
|
||||
tokenSource: account.tokenSource,
|
||||
},
|
||||
}),
|
||||
},
|
||||
approvalCapability: zaloApprovalAuth,
|
||||
secrets: {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
},
|
||||
actions: zaloMessageActions,
|
||||
messaging: {
|
||||
targetPrefixes: ["zalo", "zl"],
|
||||
normalizeTarget: normalizeZaloMessagingTarget,
|
||||
resolveOutboundSessionRoute: (params) => resolveZaloOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
looksLikeId: isNumericTargetId,
|
||||
hint: "<chatId>",
|
||||
},
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
listPeers: async (params) =>
|
||||
listResolvedDirectoryUserEntriesFromAllowFrom<ResolvedZaloAccount>({
|
||||
...params,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveZaloAccount),
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
normalizeId: (entry) => entry.trim().replace(/^(zalo|zl):/i, ""),
|
||||
}),
|
||||
listGroups: async () => [],
|
||||
}),
|
||||
status: createComputedAccountStatusAdapter<ResolvedZaloAccount, ZaloProbeResult>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: collectZaloStatusIssues,
|
||||
buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
await (await loadZaloChannelRuntime()).probeZaloAccount({ account, timeoutMs }),
|
||||
resolveAccountSnapshot: ({ account }) => {
|
||||
const configured = Boolean(account.token?.trim());
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
extra: {
|
||||
tokenSource: account.tokenSource,
|
||||
mode: account.config.webhookUrl ? "webhook" : "polling",
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
gateway: {
|
||||
startAccount: async (ctx) =>
|
||||
await (await loadZaloChannelRuntime()).startZaloGatewayAccount(ctx),
|
||||
},
|
||||
message: zaloMessageAdapter,
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveZaloDmPolicy,
|
||||
collectWarnings: collectZaloSecurityWarnings,
|
||||
},
|
||||
pairing: {
|
||||
text: {
|
||||
idLabel: "zaloUserId",
|
||||
message: "Your pairing request has been approved.",
|
||||
normalizeAllowEntry: (entry) => entry.trim().replace(/^(zalo|zl):/i, ""),
|
||||
notify: async (params) =>
|
||||
await (await loadZaloChannelRuntime()).notifyZaloPairingApproval(params),
|
||||
},
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: createStaticReplyToModeResolver("off"),
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: zaloTextChunkLimit,
|
||||
sendPayload: async (ctx) =>
|
||||
await sendPayloadWithChunkedTextAndMedia({
|
||||
ctx,
|
||||
textChunkLimit: zaloTextChunkLimit,
|
||||
chunker: chunkTextForOutbound,
|
||||
sendText: (nextCtx) => zaloRawSendResultAdapter.sendText!(nextCtx),
|
||||
sendMedia: (nextCtx) => zaloRawSendResultAdapter.sendMedia!(nextCtx),
|
||||
emptyResult: createEmptyChannelResult("zalo"),
|
||||
onResult: ctx.onDeliveryResult,
|
||||
}),
|
||||
...zaloRawSendResultAdapter,
|
||||
},
|
||||
});
|
||||
31
extensions/zalo/src/config-schema.test.ts
Normal file
31
extensions/zalo/src/config-schema.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Zalo tests cover config schema plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ZaloConfigSchema } from "./config-schema.js";
|
||||
|
||||
describe("ZaloConfigSchema SecretInput", () => {
|
||||
it("accepts SecretRef botToken and webhookSecret at top-level", () => {
|
||||
const result = ZaloConfigSchema.safeParse({
|
||||
botToken: { source: "env", provider: "default", id: "ZALO_BOT_TOKEN" },
|
||||
webhookUrl: "https://example.com/zalo",
|
||||
webhookSecret: { source: "env", provider: "default", id: "ZALO_WEBHOOK_SECRET" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts SecretRef botToken and webhookSecret on account", () => {
|
||||
const result = ZaloConfigSchema.safeParse({
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: { source: "env", provider: "default", id: "ZALO_WORK_BOT_TOKEN" },
|
||||
webhookUrl: "https://example.com/zalo/work",
|
||||
webhookSecret: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "ZALO_WORK_WEBHOOK_SECRET",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
30
extensions/zalo/src/config-schema.ts
Normal file
30
extensions/zalo/src/config-schema.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Zalo helper module supports config schema behavior.
|
||||
import {
|
||||
AllowFromListSchema,
|
||||
buildCatchallMultiAccountChannelSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { z } from "zod";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
const zaloAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
botToken: buildSecretInputSchema().optional(),
|
||||
tokenFile: z.string().optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
webhookSecret: buildSecretInputSchema().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
dmPolicy: DmPolicySchema.optional(),
|
||||
allowFrom: AllowFromListSchema,
|
||||
groupPolicy: GroupPolicySchema.optional(),
|
||||
groupAllowFrom: AllowFromListSchema,
|
||||
mediaMaxMb: z.number().optional(),
|
||||
proxy: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZaloConfigSchema = buildCatchallMultiAccountChannelSchema(zaloAccountSchema);
|
||||
24
extensions/zalo/src/group-access.ts
Normal file
24
extensions/zalo/src/group-access.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Zalo plugin module implements group access behavior.
|
||||
import type { GroupPolicy } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
|
||||
const ZALO_ALLOW_FROM_PREFIX_RE = /^(zalo|zl):/i;
|
||||
|
||||
export function normalizeZaloAllowEntry(value: string): string {
|
||||
return value.trim().replace(ZALO_ALLOW_FROM_PREFIX_RE, "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function resolveZaloRuntimeGroupPolicy(params: {
|
||||
providerConfigPresent: boolean;
|
||||
groupPolicy?: GroupPolicy;
|
||||
defaultGroupPolicy?: GroupPolicy;
|
||||
}): {
|
||||
groupPolicy: GroupPolicy;
|
||||
providerMissingFallbackApplied: boolean;
|
||||
} {
|
||||
return resolveOpenProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: params.providerConfigPresent,
|
||||
groupPolicy: params.groupPolicy,
|
||||
defaultGroupPolicy: params.defaultGroupPolicy,
|
||||
});
|
||||
}
|
||||
50
extensions/zalo/src/monitor-durable.test.ts
Normal file
50
extensions/zalo/src/monitor-durable.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// Zalo tests cover monitor durable plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
prepareZaloDurableReplyPayload,
|
||||
resolveZaloDurableReplyOptions,
|
||||
} from "./monitor-durable.js";
|
||||
|
||||
describe("Zalo durable reply helpers", () => {
|
||||
it("normalizes markdown tables before durable or legacy delivery", () => {
|
||||
const convertMarkdownTables = vi.fn(() => "converted table");
|
||||
|
||||
expect(
|
||||
prepareZaloDurableReplyPayload({
|
||||
payload: { text: "| a |\n| - |" },
|
||||
tableMode: "code",
|
||||
convertMarkdownTables,
|
||||
}),
|
||||
).toEqual({ text: "converted table" });
|
||||
expect(convertMarkdownTables).toHaveBeenCalledWith("| a |\n| - |", "code");
|
||||
});
|
||||
|
||||
it("uses durable final delivery for text-only final replies", () => {
|
||||
expect(
|
||||
resolveZaloDurableReplyOptions({
|
||||
payload: { text: "hello" },
|
||||
infoKind: "final",
|
||||
chatId: "123456789",
|
||||
}),
|
||||
).toEqual({
|
||||
to: "123456789",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps media and non-final replies on the legacy path", () => {
|
||||
expect(
|
||||
resolveZaloDurableReplyOptions({
|
||||
payload: { text: "photo", mediaUrl: "https://example.com/photo.jpg" },
|
||||
infoKind: "final",
|
||||
chatId: "123456789",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveZaloDurableReplyOptions({
|
||||
payload: { text: "hello" },
|
||||
infoKind: "block",
|
||||
chatId: "123456789",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
39
extensions/zalo/src/monitor-durable.ts
Normal file
39
extensions/zalo/src/monitor-durable.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
// Zalo plugin module implements monitor durable behavior.
|
||||
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
|
||||
export type ZaloDurableReplyOptions = {
|
||||
to: string;
|
||||
};
|
||||
|
||||
export function prepareZaloDurableReplyPayload(params: {
|
||||
payload: OutboundReplyPayload;
|
||||
tableMode: MarkdownTableMode;
|
||||
convertMarkdownTables: (text: string, tableMode: MarkdownTableMode) => string;
|
||||
}): OutboundReplyPayload {
|
||||
if (!params.payload.text) {
|
||||
return params.payload;
|
||||
}
|
||||
return {
|
||||
...params.payload,
|
||||
text: params.convertMarkdownTables(params.payload.text, params.tableMode),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveZaloDurableReplyOptions(params: {
|
||||
payload: OutboundReplyPayload;
|
||||
infoKind: string;
|
||||
chatId: string;
|
||||
}): ZaloDurableReplyOptions | false {
|
||||
if (params.infoKind !== "final") {
|
||||
return false;
|
||||
}
|
||||
const reply = resolveSendableOutboundReplyParts(params.payload);
|
||||
if (reply.hasMedia || !reply.hasText) {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
to: params.chatId,
|
||||
};
|
||||
}
|
||||
214
extensions/zalo/src/monitor.group-policy.test.ts
Normal file
214
extensions/zalo/src/monitor.group-policy.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
// Zalo tests cover monitor.group policy plugin behavior.
|
||||
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import type { GroupPolicy, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { normalizeZaloAllowEntry, resolveZaloRuntimeGroupPolicy } from "./group-access.js";
|
||||
import type { ZaloAccountConfig } from "./types.js";
|
||||
|
||||
function stringEntries(entries: Array<string | number> | undefined): string[] {
|
||||
return (entries ?? []).map((entry) => String(entry));
|
||||
}
|
||||
|
||||
const groupPolicyCases: Array<[string, ZaloAccountConfig, string, boolean, string]> = [
|
||||
[
|
||||
"disabled policy",
|
||||
{ groupPolicy: "disabled", groupAllowFrom: ["zalo:123"] },
|
||||
"123",
|
||||
false,
|
||||
"group_policy_disabled",
|
||||
],
|
||||
[
|
||||
"empty allowlist",
|
||||
{ groupPolicy: "allowlist", groupAllowFrom: [] },
|
||||
"attacker",
|
||||
false,
|
||||
"group_policy_empty_allowlist",
|
||||
],
|
||||
[
|
||||
"allowlist mismatch",
|
||||
{ groupPolicy: "allowlist", groupAllowFrom: ["zalo:victim-user-001"] },
|
||||
"attacker-user-999",
|
||||
false,
|
||||
"group_policy_not_allowlisted",
|
||||
],
|
||||
[
|
||||
"Zalo prefix match",
|
||||
{ groupPolicy: "allowlist", groupAllowFrom: ["zl:12345"] },
|
||||
"12345",
|
||||
true,
|
||||
"group_policy_allowed",
|
||||
],
|
||||
[
|
||||
"allowFrom fallback",
|
||||
{ groupPolicy: "allowlist", allowFrom: ["zl:12345"], groupAllowFrom: [] },
|
||||
"12345",
|
||||
true,
|
||||
"group_policy_allowed",
|
||||
],
|
||||
[
|
||||
"open policy",
|
||||
{ groupPolicy: "open", groupAllowFrom: [] },
|
||||
"attacker-user-999",
|
||||
true,
|
||||
"group_policy_open",
|
||||
],
|
||||
];
|
||||
|
||||
async function resolveAccess(
|
||||
params: {
|
||||
cfg?: OpenClawConfig;
|
||||
accountConfig?: ZaloAccountConfig;
|
||||
providerConfigPresent?: boolean;
|
||||
defaultGroupPolicy?: GroupPolicy;
|
||||
isGroup?: boolean;
|
||||
senderId?: string;
|
||||
rawBody?: string;
|
||||
storeAllowFrom?: string[];
|
||||
shouldComputeCommandAuthorized?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const readAllowFromStore = vi.fn(async () => params.storeAllowFrom ?? []);
|
||||
const accountConfig = {
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
...params.accountConfig,
|
||||
} satisfies ZaloAccountConfig;
|
||||
const { groupPolicy, providerMissingFallbackApplied } = resolveZaloRuntimeGroupPolicy({
|
||||
providerConfigPresent: params.providerConfigPresent ?? true,
|
||||
groupPolicy: accountConfig.groupPolicy,
|
||||
defaultGroupPolicy: params.defaultGroupPolicy ?? "open",
|
||||
});
|
||||
const shouldComputeAuth = params.shouldComputeCommandAuthorized ?? false;
|
||||
const isGroup = params.isGroup ?? true;
|
||||
const result = await resolveStableChannelMessageIngress({
|
||||
channelId: "zalo",
|
||||
accountId: "default",
|
||||
identity: {
|
||||
key: "zalo-user-id",
|
||||
normalize: normalizeZaloAllowEntry,
|
||||
sensitivity: "pii",
|
||||
entryIdPrefix: "zalo-entry",
|
||||
},
|
||||
accessGroups: params.cfg?.accessGroups,
|
||||
readStoreAllowFrom: async () => await readAllowFromStore(),
|
||||
useAccessGroups: params.cfg?.commands?.useAccessGroups !== false,
|
||||
subject: { stableId: params.senderId ?? "123" },
|
||||
conversation: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: "chat-1",
|
||||
},
|
||||
providerMissingFallbackApplied,
|
||||
dmPolicy: accountConfig.dmPolicy ?? "pairing",
|
||||
groupPolicy,
|
||||
policy: { groupAllowFromFallbackToAllowFrom: true },
|
||||
allowFrom: stringEntries(accountConfig.allowFrom),
|
||||
groupAllowFrom: stringEntries(accountConfig.groupAllowFrom),
|
||||
command: shouldComputeAuth ? {} : undefined,
|
||||
});
|
||||
return { result, readAllowFromStore };
|
||||
}
|
||||
|
||||
function stableSenderAccess(access: { allowed: boolean; decision: string; reasonCode: string }) {
|
||||
return {
|
||||
allowed: access.allowed,
|
||||
decision: access.decision,
|
||||
reasonCode: access.reasonCode,
|
||||
};
|
||||
}
|
||||
|
||||
describe("zalo shared ingress access policy", () => {
|
||||
it.each(groupPolicyCases)(
|
||||
"maps %s through shared ingress",
|
||||
async (_name, accountConfig, senderId, allowed, reasonCode) => {
|
||||
const { result } = await resolveAccess({ accountConfig, senderId });
|
||||
expect(stableSenderAccess(result.senderAccess)).toEqual({
|
||||
allowed,
|
||||
decision: allowed ? "allow" : "block",
|
||||
reasonCode,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps group control-command authorization separate from group sender access", async () => {
|
||||
const { result } = await resolveAccess({
|
||||
accountConfig: {
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
rawBody: "/reset",
|
||||
shouldComputeCommandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.senderAccess.decision).toBe("allow");
|
||||
expect(result.commandAccess.authorized).toBe(false);
|
||||
});
|
||||
|
||||
it("authorizes direct commands from the pairing store", async () => {
|
||||
const { result, readAllowFromStore } = await resolveAccess({
|
||||
isGroup: false,
|
||||
accountConfig: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
},
|
||||
senderId: "12345",
|
||||
storeAllowFrom: ["zl:12345"],
|
||||
rawBody: "/status",
|
||||
shouldComputeCommandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledTimes(1);
|
||||
expect(stableSenderAccess(result.senderAccess)).toEqual({
|
||||
allowed: true,
|
||||
decision: "allow",
|
||||
reasonCode: "dm_policy_allowlisted",
|
||||
});
|
||||
expect(result.commandAccess.authorized).toBe(true);
|
||||
});
|
||||
|
||||
it("requires an explicit wildcard or allowlist match for open DMs", async () => {
|
||||
const { result, readAllowFromStore } = await resolveAccess({
|
||||
isGroup: false,
|
||||
accountConfig: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
},
|
||||
senderId: "12345",
|
||||
});
|
||||
|
||||
expect(readAllowFromStore).not.toHaveBeenCalled();
|
||||
expect(stableSenderAccess(result.senderAccess)).toEqual({
|
||||
allowed: false,
|
||||
decision: "block",
|
||||
reasonCode: "dm_policy_not_allowlisted",
|
||||
});
|
||||
});
|
||||
|
||||
it("matches static access-group entries through the shared ingress resolver", async () => {
|
||||
const { result } = await resolveAccess({
|
||||
cfg: {
|
||||
accessGroups: {
|
||||
operators: {
|
||||
type: "message.senders",
|
||||
members: {
|
||||
zalo: ["zl:12345"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountConfig: {
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["accessGroup:operators"],
|
||||
},
|
||||
senderId: "12345",
|
||||
});
|
||||
|
||||
expect(stableSenderAccess(result.senderAccess)).toEqual({
|
||||
allowed: true,
|
||||
decision: "allow",
|
||||
reasonCode: "group_policy_allowed",
|
||||
});
|
||||
});
|
||||
});
|
||||
155
extensions/zalo/src/monitor.image.polling.test.ts
Normal file
155
extensions/zalo/src/monitor.image.polling.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
// Zalo tests cover monitor.image.polling plugin behavior.
|
||||
import { createRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createImageLifecycleCore,
|
||||
createImageUpdate,
|
||||
createLifecycleMonitorSetup,
|
||||
expectImageLifecycleDelivery,
|
||||
settleAsyncWork,
|
||||
} from "./test-support/lifecycle-test-support.js";
|
||||
import {
|
||||
getUpdatesMock,
|
||||
getZaloRuntimeMock,
|
||||
loadCachedLifecycleMonitorModule,
|
||||
resetLifecycleTestState,
|
||||
sendMessageMock,
|
||||
} from "./test-support/monitor-mocks-test-support.js";
|
||||
|
||||
describe("Zalo polling image handling", () => {
|
||||
const {
|
||||
core,
|
||||
finalizeInboundContextMock,
|
||||
recordInboundSessionMock,
|
||||
readRemoteMediaBufferMock,
|
||||
saveRemoteMediaMock,
|
||||
saveMediaBufferMock,
|
||||
} = createImageLifecycleCore();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetLifecycleTestState();
|
||||
getZaloRuntimeMock.mockReturnValue(core);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await resetLifecycleTestState();
|
||||
});
|
||||
|
||||
it("downloads inbound image media from photo_url and preserves display_name", async () => {
|
||||
getUpdatesMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: createImageUpdate({ date: 1774084566880 }),
|
||||
})
|
||||
.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule("zalo-image-polling");
|
||||
const abort = new AbortController();
|
||||
const runtime = createRuntimeEnv();
|
||||
const { account, config } = createLifecycleMonitorSetup({
|
||||
accountId: "default",
|
||||
dmPolicy: "open",
|
||||
});
|
||||
const run = monitorZaloProvider({
|
||||
token: "zalo-token", // pragma: allowlist secret
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
await settleAsyncWork();
|
||||
expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1);
|
||||
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
|
||||
expectImageLifecycleDelivery({
|
||||
readRemoteMediaBufferMock,
|
||||
saveRemoteMediaMock,
|
||||
saveMediaBufferMock,
|
||||
finalizeInboundContextMock,
|
||||
recordInboundSessionMock,
|
||||
});
|
||||
expect(finalizeInboundContextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ Timestamp: 1774084566880 }),
|
||||
);
|
||||
|
||||
abort.abort();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("rejects unauthorized DM images before downloading media", async () => {
|
||||
getUpdatesMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: createImageUpdate({
|
||||
messageId: "msg-unauthorized-1",
|
||||
userId: "user-unauthorized-1",
|
||||
chatId: "chat-unauthorized-1",
|
||||
}),
|
||||
})
|
||||
.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule("zalo-image-polling");
|
||||
const abort = new AbortController();
|
||||
const runtime = createRuntimeEnv();
|
||||
const { account, config } = createLifecycleMonitorSetup({
|
||||
accountId: "default",
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["allowed-user"],
|
||||
});
|
||||
const run = monitorZaloProvider({
|
||||
token: "zalo-token", // pragma: allowlist secret
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
await settleAsyncWork();
|
||||
expect(sendMessageMock).toHaveBeenCalledTimes(1);
|
||||
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
|
||||
expect(saveMediaBufferMock).not.toHaveBeenCalled();
|
||||
expect(finalizeInboundContextMock).not.toHaveBeenCalled();
|
||||
expect(recordInboundSessionMock).not.toHaveBeenCalled();
|
||||
|
||||
abort.abort();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("dispatches an unavailable notice when the inbound image download fails", async () => {
|
||||
saveRemoteMediaMock.mockRejectedValueOnce(new Error("expired image URL"));
|
||||
getUpdatesMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: createImageUpdate({ caption: "/reset" }),
|
||||
})
|
||||
.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule("zalo-image-polling");
|
||||
const abort = new AbortController();
|
||||
const runtime = createRuntimeEnv();
|
||||
const { account, config } = createLifecycleMonitorSetup({
|
||||
accountId: "default",
|
||||
dmPolicy: "open",
|
||||
});
|
||||
const run = monitorZaloProvider({
|
||||
token: "zalo-token", // pragma: allowlist secret
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(finalizeInboundContextMock).toHaveBeenCalledTimes(1));
|
||||
expect(finalizeInboundContextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
RawBody: "/reset",
|
||||
CommandBody: "/reset",
|
||||
BodyForAgent: "/reset\n\n[zalo image attachment unavailable]",
|
||||
MediaPath: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
abort.abort();
|
||||
await run;
|
||||
});
|
||||
});
|
||||
197
extensions/zalo/src/monitor.lifecycle.test.ts
Normal file
197
extensions/zalo/src/monitor.lifecycle.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
// Zalo tests cover monitor.lifecycle plugin behavior.
|
||||
import {
|
||||
createEmptyPluginRegistry,
|
||||
createRuntimeEnv,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
|
||||
const getWebhookInfoMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
||||
const deleteWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
||||
const getUpdatesMock = vi.fn(() => new Promise(() => {}));
|
||||
const setWebhookMock = vi.fn(async () => ({ ok: true, result: { url: "" } }));
|
||||
|
||||
vi.mock("./api.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./api.js")>("./api.js");
|
||||
return {
|
||||
...actual,
|
||||
deleteWebhook: deleteWebhookMock,
|
||||
getWebhookInfo: getWebhookInfoMock,
|
||||
getUpdates: getUpdatesMock,
|
||||
setWebhook: setWebhookMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./runtime.js", () => ({
|
||||
getZaloRuntime: () => ({
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const TEST_ACCOUNT = {
|
||||
accountId: "default",
|
||||
config: {},
|
||||
} as unknown as ResolvedZaloAccount;
|
||||
|
||||
const TEST_CONFIG = {} as OpenClawConfig;
|
||||
|
||||
async function settleLifecycleWork(): Promise<void> {
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function startLifecycleMonitor(
|
||||
options: {
|
||||
useWebhook?: boolean;
|
||||
webhookSecret?: string;
|
||||
webhookUrl?: string;
|
||||
} = {},
|
||||
) {
|
||||
const { monitorZaloProvider } = await import("./monitor.js");
|
||||
const abort = new AbortController();
|
||||
const runtime = createRuntimeEnv();
|
||||
const run = monitorZaloProvider({
|
||||
token: "test-token",
|
||||
account: TEST_ACCOUNT,
|
||||
config: TEST_CONFIG,
|
||||
runtime,
|
||||
abortSignal: abort.signal,
|
||||
...options,
|
||||
});
|
||||
return { abort, runtime, run };
|
||||
}
|
||||
|
||||
describe("monitorZaloProvider lifecycle", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
it("stays alive in polling mode until abort", async () => {
|
||||
let settled = false;
|
||||
const { abort, runtime, run } = await startLifecycleMonitor();
|
||||
const monitoredRun = run.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await settleLifecycleWork();
|
||||
expect(getUpdatesMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
|
||||
expect(deleteWebhookMock).not.toHaveBeenCalled();
|
||||
expect(getUpdatesMock).toHaveBeenCalledTimes(1);
|
||||
expect(settled).toBe(false);
|
||||
|
||||
abort.abort();
|
||||
await monitoredRun;
|
||||
|
||||
expect(settled).toBe(true);
|
||||
expect(runtime.log).toHaveBeenCalledWith("[default] Zalo provider stopped mode=polling");
|
||||
});
|
||||
|
||||
it("deletes an existing webhook before polling", async () => {
|
||||
getWebhookInfoMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { url: "https://example.com/hooks/zalo" },
|
||||
});
|
||||
|
||||
const { abort, runtime, run } = await startLifecycleMonitor();
|
||||
|
||||
await settleLifecycleWork();
|
||||
expect(getUpdatesMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
|
||||
expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[default] Zalo polling mode ready (webhook disabled)",
|
||||
);
|
||||
|
||||
abort.abort();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("continues polling when webhook inspection returns 404", async () => {
|
||||
const { ZaloApiError } = await import("./api.js");
|
||||
getWebhookInfoMock.mockRejectedValueOnce(new ZaloApiError("Not Found", 404, "Not Found"));
|
||||
|
||||
const { abort, runtime, run } = await startLifecycleMonitor();
|
||||
|
||||
await settleLifecycleWork();
|
||||
expect(getUpdatesMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(getWebhookInfoMock).toHaveBeenCalledTimes(1);
|
||||
expect(deleteWebhookMock).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"[default] Zalo polling mode webhook inspection unavailable; continuing without webhook cleanup",
|
||||
);
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
|
||||
abort.abort();
|
||||
await run;
|
||||
});
|
||||
|
||||
it("waits for webhook deletion before finishing webhook shutdown", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
let resolveSetWebhookCalled: (() => void) | undefined;
|
||||
const setWebhookCalled = new Promise<void>((resolve) => {
|
||||
resolveSetWebhookCalled = resolve;
|
||||
});
|
||||
setWebhookMock.mockImplementationOnce(async () => {
|
||||
resolveSetWebhookCalled?.();
|
||||
return { ok: true, result: { url: "" } };
|
||||
});
|
||||
|
||||
let resolveDeleteWebhookCalled: (() => void) | undefined;
|
||||
const deleteWebhookCalled = new Promise<void>((resolve) => {
|
||||
resolveDeleteWebhookCalled = resolve;
|
||||
});
|
||||
let resolveDeleteWebhook: (() => void) | undefined;
|
||||
deleteWebhookMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveDeleteWebhookCalled?.();
|
||||
resolveDeleteWebhook = () => resolve({ ok: true, result: { url: "" } });
|
||||
}),
|
||||
);
|
||||
|
||||
let settled = false;
|
||||
const { abort, runtime, run } = await startLifecycleMonitor({
|
||||
useWebhook: true,
|
||||
webhookUrl: "https://example.com/hooks/zalo",
|
||||
webhookSecret: "supersecret", // pragma: allowlist secret
|
||||
});
|
||||
const monitoredRun = run.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
await setWebhookCalled;
|
||||
await settleLifecycleWork();
|
||||
expect(setWebhookMock).toHaveBeenCalledTimes(1);
|
||||
expect(registry.httpRoutes).toHaveLength(2);
|
||||
|
||||
abort.abort();
|
||||
|
||||
await deleteWebhookCalled;
|
||||
expect(deleteWebhookMock).toHaveBeenCalledTimes(1);
|
||||
expect(deleteWebhookMock).toHaveBeenCalledWith("test-token", undefined, 5000);
|
||||
expect(settled).toBe(false);
|
||||
expect(registry.httpRoutes).toHaveLength(2);
|
||||
|
||||
resolveDeleteWebhook?.();
|
||||
await monitoredRun;
|
||||
|
||||
expect(settled).toBe(true);
|
||||
expect(registry.httpRoutes).toHaveLength(0);
|
||||
expect(runtime.log).toHaveBeenCalledWith("[default] Zalo provider stopped mode=webhook");
|
||||
});
|
||||
});
|
||||
144
extensions/zalo/src/monitor.pairing.lifecycle.test.ts
Normal file
144
extensions/zalo/src/monitor.pairing.lifecycle.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
// Zalo tests cover monitor.pairing.lifecycle plugin behavior.
|
||||
import { withServer } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createLifecycleMonitorSetup,
|
||||
createTextUpdate,
|
||||
postWebhookReplay,
|
||||
settleAsyncWork,
|
||||
} from "./test-support/lifecycle-test-support.js";
|
||||
import {
|
||||
resetLifecycleTestState,
|
||||
sendMessageMock,
|
||||
setLifecycleRuntimeCore,
|
||||
startWebhookLifecycleMonitor,
|
||||
} from "./test-support/monitor-mocks-test-support.js";
|
||||
|
||||
describe("Zalo pairing lifecycle", () => {
|
||||
const readAllowFromStoreMock = vi.fn(async () => [] as string[]);
|
||||
const upsertPairingRequestMock = vi.fn(async () => ({ code: "PAIRCODE", created: true }));
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetLifecycleTestState();
|
||||
setLifecycleRuntimeCore({
|
||||
pairing: {
|
||||
readAllowFromStore: readAllowFromStoreMock,
|
||||
upsertPairingRequest: upsertPairingRequestMock,
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn(() => false),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await resetLifecycleTestState();
|
||||
});
|
||||
|
||||
function createPairingMonitorSetup() {
|
||||
return createLifecycleMonitorSetup({
|
||||
accountId: "acct-zalo-pairing",
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
});
|
||||
}
|
||||
|
||||
it("emits one pairing reply across duplicate webhook replay and scopes reads and writes to accountId", async () => {
|
||||
const monitor = await startWebhookLifecycleMonitor({
|
||||
...createPairingMonitorSetup(),
|
||||
cacheKey: "zalo-pairing-lifecycle",
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(
|
||||
(req, res) => {
|
||||
void monitor.route.handler(req, res);
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const { first, replay } = await postWebhookReplay({
|
||||
baseUrl,
|
||||
path: "/hooks/zalo",
|
||||
secret: "supersecret",
|
||||
payload: createTextUpdate({
|
||||
messageId: `zalo-pairing-${Date.now()}`,
|
||||
userId: "user-unauthorized",
|
||||
userName: "Unauthorized User",
|
||||
chatId: "dm-pairing-1",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(replay.status).toBe(200);
|
||||
await settleAsyncWork();
|
||||
},
|
||||
);
|
||||
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledTimes(1);
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledWith({
|
||||
channel: "zalo",
|
||||
accountId: "acct-zalo-pairing",
|
||||
});
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalledWith({
|
||||
channel: "zalo",
|
||||
accountId: "acct-zalo-pairing",
|
||||
id: "user-unauthorized",
|
||||
meta: { name: "Unauthorized User" },
|
||||
});
|
||||
expect(sendMessageMock).toHaveBeenCalledTimes(1);
|
||||
const [sendToken, sendPayload, sendOptions] = sendMessageMock.mock.calls[0] as [
|
||||
string,
|
||||
{ chat_id?: string; text?: string },
|
||||
unknown,
|
||||
];
|
||||
expect(sendToken).toBe("zalo-token");
|
||||
expect(sendPayload.chat_id).toBe("dm-pairing-1");
|
||||
expect(sendPayload.text).toContain("PAIRCODE");
|
||||
expect(sendOptions).toBeUndefined();
|
||||
} finally {
|
||||
await monitor.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not emit a second pairing reply when replay arrives after the first send fails", async () => {
|
||||
sendMessageMock.mockRejectedValueOnce(new Error("pairing send failed"));
|
||||
|
||||
const monitor = await startWebhookLifecycleMonitor({
|
||||
...createPairingMonitorSetup(),
|
||||
cacheKey: "zalo-pairing-lifecycle",
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(
|
||||
(req, res) => {
|
||||
void monitor.route.handler(req, res);
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const { first, replay } = await postWebhookReplay({
|
||||
baseUrl,
|
||||
path: "/hooks/zalo",
|
||||
secret: "supersecret",
|
||||
payload: createTextUpdate({
|
||||
messageId: `zalo-pairing-retry-${Date.now()}`,
|
||||
userId: "user-unauthorized",
|
||||
userName: "Unauthorized User",
|
||||
chatId: "dm-pairing-1",
|
||||
}),
|
||||
settleBeforeReplay: true,
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(replay.status).toBe(200);
|
||||
await settleAsyncWork();
|
||||
},
|
||||
);
|
||||
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageMock).toHaveBeenCalledTimes(1);
|
||||
expect(monitor.runtime.error).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await monitor.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
453
extensions/zalo/src/monitor.polling.media-reply.test.ts
Normal file
453
extensions/zalo/src/monitor.polling.media-reply.test.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
// Zalo tests cover monitor.polling.media reply plugin behavior.
|
||||
import type { ServerResponse } from "node:http";
|
||||
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import {
|
||||
createEmptyPluginRegistry,
|
||||
createRuntimeEnv,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import { setZaloRuntime } from "./runtime.js";
|
||||
import {
|
||||
createLifecycleMonitorSetup,
|
||||
createTextUpdate,
|
||||
settleAsyncWork,
|
||||
} from "./test-support/lifecycle-test-support.js";
|
||||
import {
|
||||
getUpdatesMock,
|
||||
loadCachedLifecycleMonitorModule,
|
||||
resetLifecycleTestState,
|
||||
sendPhotoMock,
|
||||
setLifecycleRuntimeCore,
|
||||
} from "./test-support/monitor-mocks-test-support.js";
|
||||
|
||||
const prepareHostedZaloMediaUrlMock = vi.fn();
|
||||
|
||||
vi.mock("./outbound-media.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./outbound-media.js")>("./outbound-media.js");
|
||||
return {
|
||||
...actual,
|
||||
prepareHostedZaloMediaUrl: (...args: unknown[]) => prepareHostedZaloMediaUrlMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { clearHostedZaloMediaForTest } from "./outbound-media.js";
|
||||
|
||||
function installZaloRuntimeForTest(): void {
|
||||
setZaloRuntime({
|
||||
state: {
|
||||
openKeyedStore: <T>(options: OpenKeyedStoreOptions) =>
|
||||
createPluginStateKeyedStoreForTests<T>("zalo", options),
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
}
|
||||
|
||||
async function writeHostedZaloMediaFixture(params: {
|
||||
id: string;
|
||||
routePath: string;
|
||||
token: string;
|
||||
buffer: Buffer;
|
||||
contentType?: string;
|
||||
}): Promise<void> {
|
||||
const metaStore = createPluginStateKeyedStoreForTests("zalo", {
|
||||
namespace: "hosted-outbound-media",
|
||||
maxEntries: 80,
|
||||
});
|
||||
const chunkStore = createPluginStateKeyedStoreForTests("zalo", {
|
||||
namespace: "hosted-outbound-media-chunks",
|
||||
maxEntries: 16_384,
|
||||
});
|
||||
await chunkStore.register(`media:${params.id}:chunk:0000`, {
|
||||
id: params.id,
|
||||
index: 0,
|
||||
dataBase64: params.buffer.toString("base64"),
|
||||
});
|
||||
await metaStore.register(`media:${params.id}:meta`, {
|
||||
id: params.id,
|
||||
routePath: params.routePath,
|
||||
token: params.token,
|
||||
...(params.contentType ? { contentType: params.contentType } : {}),
|
||||
expiresAt: Date.now() + 60_000,
|
||||
chunkCount: 1,
|
||||
byteLength: params.buffer.byteLength,
|
||||
});
|
||||
}
|
||||
|
||||
function createHostedMediaResponse() {
|
||||
const headers = new Map<string, string>();
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headersSent: false,
|
||||
setHeader(name: string, value: string) {
|
||||
headers.set(name, value);
|
||||
},
|
||||
end: vi.fn((body?: unknown) => {
|
||||
res.headersSent = true;
|
||||
return body;
|
||||
}),
|
||||
};
|
||||
return { headers, res: res as unknown as ServerResponse & { end: ReturnType<typeof vi.fn> } };
|
||||
}
|
||||
|
||||
function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
|
||||
let count = 0;
|
||||
for (const item of items) {
|
||||
if (predicate(item)) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
describe("Zalo polling media replies", () => {
|
||||
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
||||
const recordInboundSessionMock = vi.fn(async () => undefined);
|
||||
const resolveAgentRouteMock = vi.fn(() => ({
|
||||
agentId: "main",
|
||||
channel: "zalo",
|
||||
accountId: "acct-zalo-polling-media",
|
||||
sessionKey: "agent:main:zalo:direct:dm-chat-1",
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "default",
|
||||
}));
|
||||
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetLifecycleTestState();
|
||||
await clearHostedZaloMediaForTest();
|
||||
resetPluginStateStoreForTests();
|
||||
installZaloRuntimeForTest();
|
||||
prepareHostedZaloMediaUrlMock.mockReset();
|
||||
prepareHostedZaloMediaUrlMock.mockResolvedValue(
|
||||
"https://example.com/hooks/zalo/media/abc123abc123abc123abc123?token=secret",
|
||||
);
|
||||
dispatchReplyWithBufferedBlockDispatcherMock.mockReset();
|
||||
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementation(
|
||||
async (params: {
|
||||
dispatcherOptions: {
|
||||
deliver: (payload: { text: string; mediaUrl: string }) => Promise<void>;
|
||||
};
|
||||
}) => {
|
||||
await params.dispatcherOptions.deliver({
|
||||
text: "caption text",
|
||||
mediaUrl: "https://example.com/reply-image.png",
|
||||
});
|
||||
},
|
||||
);
|
||||
setLifecycleRuntimeCore({
|
||||
routing: {
|
||||
resolveAgentRoute:
|
||||
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
reply: {
|
||||
finalizeInboundContext:
|
||||
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
dispatchReplyWithBufferedBlockDispatcherMock as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
},
|
||||
session: {
|
||||
recordInboundSession:
|
||||
recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await clearHostedZaloMediaForTest();
|
||||
await resetLifecycleTestState();
|
||||
});
|
||||
|
||||
it("hosts and sends media replies while polling when a webhook URL is configured", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(registry);
|
||||
getUpdatesMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: createTextUpdate({
|
||||
messageId: "polling-media-1",
|
||||
userId: "user-1",
|
||||
userName: "User One",
|
||||
chatId: "dm-chat-1",
|
||||
text: "send media",
|
||||
}),
|
||||
})
|
||||
.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule(
|
||||
"zalo-polling-media-reply",
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const runtime = createRuntimeEnv();
|
||||
const { account, config } = createLifecycleMonitorSetup({
|
||||
accountId: "acct-zalo-polling-media",
|
||||
dmPolicy: "open",
|
||||
webhookUrl: "https://example.com/hooks/zalo",
|
||||
});
|
||||
const run = monitorZaloProvider({
|
||||
token: "zalo-token",
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
try {
|
||||
await settleAsyncWork();
|
||||
expect(sendPhotoMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(registry.httpRoutes).toHaveLength(1);
|
||||
expect(prepareHostedZaloMediaUrlMock).toHaveBeenCalledWith({
|
||||
mediaUrl: "https://example.com/reply-image.png",
|
||||
webhookUrl: "https://example.com/hooks/zalo",
|
||||
webhookPath: "/hooks/zalo",
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
proxyUrl: undefined,
|
||||
});
|
||||
expect(sendPhotoMock).toHaveBeenCalledWith(
|
||||
"zalo-token",
|
||||
{
|
||||
chat_id: "dm-chat-1",
|
||||
photo: "https://example.com/hooks/zalo/media/abc123abc123abc123abc123?token=secret",
|
||||
caption: "caption text",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
abort.abort();
|
||||
await run;
|
||||
}
|
||||
|
||||
expect(registry.httpRoutes).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("sends media replies directly when webhook hosting is not configured", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(registry);
|
||||
getUpdatesMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: createTextUpdate({
|
||||
messageId: "polling-media-2",
|
||||
userId: "user-2",
|
||||
userName: "User Two",
|
||||
chatId: "dm-chat-2",
|
||||
text: "send media directly",
|
||||
}),
|
||||
})
|
||||
.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule(
|
||||
"zalo-polling-media-reply",
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const runtime = createRuntimeEnv();
|
||||
const { account, config } = createLifecycleMonitorSetup({
|
||||
accountId: "acct-zalo-polling-direct-media",
|
||||
dmPolicy: "open",
|
||||
webhookUrl: "",
|
||||
});
|
||||
const run = monitorZaloProvider({
|
||||
token: "zalo-token",
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal: abort.signal,
|
||||
});
|
||||
|
||||
try {
|
||||
await settleAsyncWork();
|
||||
expect(sendPhotoMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(prepareHostedZaloMediaUrlMock).not.toHaveBeenCalled();
|
||||
expect(sendPhotoMock).toHaveBeenCalledWith(
|
||||
"zalo-token",
|
||||
{
|
||||
chat_id: "dm-chat-2",
|
||||
photo: "https://example.com/reply-image.png",
|
||||
caption: "caption text",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
} finally {
|
||||
abort.abort();
|
||||
await run;
|
||||
}
|
||||
});
|
||||
|
||||
it("shares one hosted media route across accounts on the same path", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(registry);
|
||||
getUpdatesMock.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule(
|
||||
"zalo-polling-media-reply",
|
||||
);
|
||||
const firstAbort = new AbortController();
|
||||
const firstRuntime = createRuntimeEnv();
|
||||
const firstSetup = createLifecycleMonitorSetup({
|
||||
accountId: "acct-zalo-polling-media-one",
|
||||
dmPolicy: "open",
|
||||
webhookUrl: "https://example.com/hooks/zalo",
|
||||
});
|
||||
const firstRun = monitorZaloProvider({
|
||||
token: "zalo-token-one",
|
||||
account: firstSetup.account,
|
||||
config: firstSetup.config,
|
||||
runtime: firstRuntime,
|
||||
abortSignal: firstAbort.signal,
|
||||
});
|
||||
|
||||
const secondAbort = new AbortController();
|
||||
let secondRun: Promise<void> | undefined;
|
||||
|
||||
try {
|
||||
await settleAsyncWork();
|
||||
const firstHostedMediaRoutes = registry.httpRoutes.filter(
|
||||
(route) => route.source === "zalo-hosted-media",
|
||||
);
|
||||
expect(firstHostedMediaRoutes).toHaveLength(1);
|
||||
const hostedMediaRoute = firstHostedMediaRoutes[0];
|
||||
expect(hostedMediaRoute?.path).toBe("/hooks/zalo/media");
|
||||
expect(hostedMediaRoute?.pluginId).toBe("zalo");
|
||||
expect(hostedMediaRoute?.source).toBe("zalo-hosted-media");
|
||||
expect(hostedMediaRoute?.handler).toBeTypeOf("function");
|
||||
|
||||
const secondRuntime = createRuntimeEnv();
|
||||
const secondSetup = createLifecycleMonitorSetup({
|
||||
accountId: "acct-zalo-polling-media-two",
|
||||
dmPolicy: "open",
|
||||
webhookUrl: "https://example.com/hooks/zalo",
|
||||
});
|
||||
secondRun = monitorZaloProvider({
|
||||
token: "zalo-token-two",
|
||||
account: secondSetup.account,
|
||||
config: secondSetup.config,
|
||||
runtime: secondRuntime,
|
||||
abortSignal: secondAbort.signal,
|
||||
});
|
||||
|
||||
await settleAsyncWork();
|
||||
const hostedMediaRoutes = registry.httpRoutes.filter(
|
||||
(route) => route.source === "zalo-hosted-media",
|
||||
);
|
||||
expect(hostedMediaRoutes).toHaveLength(1);
|
||||
expect(hostedMediaRoutes[0]).toBe(hostedMediaRoute);
|
||||
|
||||
await writeHostedZaloMediaFixture({
|
||||
id: "abc123abc123abc123abc123",
|
||||
routePath: "/hooks/zalo/media/",
|
||||
token: "route-token-one",
|
||||
buffer: Buffer.from("first-image-bytes"),
|
||||
contentType: "image/png",
|
||||
});
|
||||
const firstFetch = createHostedMediaResponse();
|
||||
await hostedMediaRoute.handler(
|
||||
{
|
||||
method: "GET",
|
||||
url: "/hooks/zalo/media/abc123abc123abc123abc123?token=route-token-one",
|
||||
} as never,
|
||||
firstFetch.res as never,
|
||||
);
|
||||
expect(firstFetch.res.statusCode).toBe(200);
|
||||
expect(firstFetch.headers.get("Content-Type")).toBe("image/png");
|
||||
expect(firstFetch.headers.get("Cache-Control")).toBe("no-store");
|
||||
expect(firstFetch.res.end).toHaveBeenCalledWith(Buffer.from("first-image-bytes"));
|
||||
|
||||
firstAbort.abort();
|
||||
await firstRun;
|
||||
expect(registry.httpRoutes.find((route) => route.source === "zalo-hosted-media")).toEqual(
|
||||
hostedMediaRoute,
|
||||
);
|
||||
expect(
|
||||
countMatching(registry.httpRoutes, (route) => route.source === "zalo-hosted-media"),
|
||||
).toBe(1);
|
||||
|
||||
await writeHostedZaloMediaFixture({
|
||||
id: "def456def456def456def456",
|
||||
routePath: "/hooks/zalo/media/",
|
||||
token: "route-token-two",
|
||||
buffer: Buffer.from("second-image-bytes"),
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
const secondFetch = createHostedMediaResponse();
|
||||
await hostedMediaRoute.handler(
|
||||
{
|
||||
method: "GET",
|
||||
url: "/hooks/zalo/media/def456def456def456def456?token=route-token-two",
|
||||
} as never,
|
||||
secondFetch.res as never,
|
||||
);
|
||||
expect(secondFetch.res.statusCode).toBe(200);
|
||||
expect(secondFetch.headers.get("Content-Type")).toBe("image/jpeg");
|
||||
expect(secondFetch.res.end).toHaveBeenCalledWith(Buffer.from("second-image-bytes"));
|
||||
} finally {
|
||||
firstAbort.abort();
|
||||
secondAbort.abort();
|
||||
await firstRun;
|
||||
await secondRun;
|
||||
}
|
||||
|
||||
expect(
|
||||
registry.httpRoutes.filter((route) => route.source === "zalo-hosted-media"),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("re-registers the hosted media route after the active registry swaps", async () => {
|
||||
const firstRegistry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(firstRegistry);
|
||||
getUpdatesMock.mockImplementation(() => new Promise(() => {}));
|
||||
|
||||
const { monitorZaloProvider } = await loadCachedLifecycleMonitorModule(
|
||||
"zalo-polling-media-reply",
|
||||
);
|
||||
const firstAbort = new AbortController();
|
||||
const firstRuntime = createRuntimeEnv();
|
||||
const { account, config } = createLifecycleMonitorSetup({
|
||||
accountId: "acct-zalo-polling-media",
|
||||
dmPolicy: "open",
|
||||
webhookUrl: "https://example.com/hooks/zalo",
|
||||
});
|
||||
const firstRun = monitorZaloProvider({
|
||||
token: "zalo-token",
|
||||
account,
|
||||
config,
|
||||
runtime: firstRuntime,
|
||||
abortSignal: firstAbort.signal,
|
||||
});
|
||||
|
||||
const secondRegistry = createEmptyPluginRegistry();
|
||||
const secondAbort = new AbortController();
|
||||
const secondRuntime = createRuntimeEnv();
|
||||
let secondRun: Promise<void> | undefined;
|
||||
|
||||
try {
|
||||
await settleAsyncWork();
|
||||
expect(firstRegistry.httpRoutes).toHaveLength(1);
|
||||
|
||||
setActivePluginRegistry(secondRegistry);
|
||||
secondRun = monitorZaloProvider({
|
||||
token: "zalo-token",
|
||||
account,
|
||||
config,
|
||||
runtime: secondRuntime,
|
||||
abortSignal: secondAbort.signal,
|
||||
});
|
||||
|
||||
await settleAsyncWork();
|
||||
expect(secondRegistry.httpRoutes).toHaveLength(1);
|
||||
} finally {
|
||||
firstAbort.abort();
|
||||
secondAbort.abort();
|
||||
await firstRun;
|
||||
await secondRun;
|
||||
}
|
||||
|
||||
expect(firstRegistry.httpRoutes).toHaveLength(0);
|
||||
expect(secondRegistry.httpRoutes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
183
extensions/zalo/src/monitor.reply-once.lifecycle.test.ts
Normal file
183
extensions/zalo/src/monitor.reply-once.lifecycle.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
// Zalo tests cover monitor.reply once.lifecycle plugin behavior.
|
||||
import { withServer } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import {
|
||||
createLifecycleMonitorSetup,
|
||||
createTextUpdate,
|
||||
postWebhookReplay,
|
||||
settleAsyncWork,
|
||||
} from "./test-support/lifecycle-test-support.js";
|
||||
import {
|
||||
resetLifecycleTestState,
|
||||
sendMessageMock,
|
||||
setLifecycleRuntimeCore,
|
||||
startWebhookLifecycleMonitor,
|
||||
} from "./test-support/monitor-mocks-test-support.js";
|
||||
|
||||
describe("Zalo reply-once lifecycle", () => {
|
||||
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
||||
const recordInboundSessionMock = vi.fn(
|
||||
async (_input: { sessionKey?: string; ctx?: Record<string, unknown> }) => undefined,
|
||||
);
|
||||
const resolveAgentRouteMock = vi.fn(() => ({
|
||||
agentId: "main",
|
||||
channel: "zalo",
|
||||
accountId: "acct-zalo-lifecycle",
|
||||
sessionKey: "agent:main:zalo:direct:dm-chat-1",
|
||||
mainSessionKey: "agent:main:main",
|
||||
matchedBy: "default",
|
||||
}));
|
||||
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetLifecycleTestState();
|
||||
setLifecycleRuntimeCore({
|
||||
routing: {
|
||||
resolveAgentRoute:
|
||||
resolveAgentRouteMock as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
reply: {
|
||||
finalizeInboundContext:
|
||||
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
dispatchReplyWithBufferedBlockDispatcherMock as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
},
|
||||
session: {
|
||||
recordInboundSession:
|
||||
recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await resetLifecycleTestState();
|
||||
});
|
||||
|
||||
function createReplyOnceMonitorSetup() {
|
||||
return createLifecycleMonitorSetup({
|
||||
accountId: "acct-zalo-lifecycle",
|
||||
dmPolicy: "open",
|
||||
});
|
||||
}
|
||||
|
||||
function requireRecordInboundSessionArgs() {
|
||||
const [call] = recordInboundSessionMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected inbound session record call");
|
||||
}
|
||||
const [recordArgs] = call;
|
||||
return recordArgs;
|
||||
}
|
||||
|
||||
it("routes one accepted webhook event to one visible reply across duplicate replay", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementation(
|
||||
async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "zalo reply once" });
|
||||
},
|
||||
);
|
||||
|
||||
const monitor = await startWebhookLifecycleMonitor({
|
||||
...createReplyOnceMonitorSetup(),
|
||||
cacheKey: "zalo-reply-once-lifecycle",
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(
|
||||
(req, res) => {
|
||||
void monitor.route.handler(req, res);
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const { first, replay } = await postWebhookReplay({
|
||||
baseUrl,
|
||||
path: "/hooks/zalo",
|
||||
secret: "supersecret",
|
||||
payload: createTextUpdate({
|
||||
messageId: `zalo-replay-${Date.now()}`,
|
||||
userId: "user-1",
|
||||
userName: "User One",
|
||||
chatId: "dm-chat-1",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(replay.status).toBe(200);
|
||||
await settleAsyncWork();
|
||||
},
|
||||
);
|
||||
|
||||
expect(recordInboundSessionMock).toHaveBeenCalledTimes(1);
|
||||
const recordArgs = requireRecordInboundSessionArgs();
|
||||
expect(recordArgs?.sessionKey).toBe("agent:main:zalo:direct:dm-chat-1");
|
||||
expect(recordArgs?.ctx?.AccountId).toBe("acct-zalo-lifecycle");
|
||||
expect(recordArgs?.ctx?.SessionKey).toBe("agent:main:zalo:direct:dm-chat-1");
|
||||
expect(recordArgs?.ctx?.From).toBe("zalo:user-1");
|
||||
expect(recordArgs?.ctx?.To).toBe("zalo:dm-chat-1");
|
||||
expect(recordArgs?.ctx?.MessageSid).toContain("zalo-replay-");
|
||||
expect(sendMessageMock).toHaveBeenCalledTimes(1);
|
||||
const [sendToken, sendPayload, sendOptions] = sendMessageMock.mock.calls[0] as [
|
||||
string,
|
||||
{ chat_id?: string; text?: string },
|
||||
unknown,
|
||||
];
|
||||
expect(sendToken).toBe("zalo-token");
|
||||
expect(sendPayload.chat_id).toBe("dm-chat-1");
|
||||
expect(sendPayload.text).toBe("zalo reply once");
|
||||
expect(sendOptions).toBeUndefined();
|
||||
} finally {
|
||||
await monitor.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not emit a second visible reply when replay arrives after a post-send failure", async () => {
|
||||
let dispatchAttempts = 0;
|
||||
dispatchReplyWithBufferedBlockDispatcherMock.mockImplementation(
|
||||
async ({ dispatcherOptions }) => {
|
||||
dispatchAttempts += 1;
|
||||
await dispatcherOptions.deliver({ text: "zalo reply after failure" });
|
||||
if (dispatchAttempts === 1) {
|
||||
throw new Error("post-send failure");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const monitor = await startWebhookLifecycleMonitor({
|
||||
...createReplyOnceMonitorSetup(),
|
||||
cacheKey: "zalo-reply-once-lifecycle",
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(
|
||||
(req, res) => {
|
||||
void monitor.route.handler(req, res);
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const { first, replay } = await postWebhookReplay({
|
||||
baseUrl,
|
||||
path: "/hooks/zalo",
|
||||
secret: "supersecret",
|
||||
payload: createTextUpdate({
|
||||
messageId: `zalo-retry-${Date.now()}`,
|
||||
userId: "user-1",
|
||||
userName: "User One",
|
||||
chatId: "dm-chat-1",
|
||||
}),
|
||||
settleBeforeReplay: true,
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(replay.status).toBe(200);
|
||||
await settleAsyncWork();
|
||||
},
|
||||
);
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcherMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageMock).toHaveBeenCalledTimes(1);
|
||||
expect(monitor.runtime.error).toHaveBeenCalledWith(
|
||||
"[acct-zalo-lifecycle] Zalo webhook failed: Error: post-send failure",
|
||||
);
|
||||
} finally {
|
||||
await monitor.stop();
|
||||
}
|
||||
});
|
||||
});
|
||||
1018
extensions/zalo/src/monitor.ts
Normal file
1018
extensions/zalo/src/monitor.ts
Normal file
File diff suppressed because it is too large
Load Diff
5
extensions/zalo/src/monitor.types.ts
Normal file
5
extensions/zalo/src/monitor.types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Zalo type declarations define plugin contracts.
|
||||
export type ZaloRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
811
extensions/zalo/src/monitor.webhook.test.ts
Normal file
811
extensions/zalo/src/monitor.webhook.test.ts
Normal file
@@ -0,0 +1,811 @@
|
||||
// Zalo tests cover monitor.webhook plugin behavior.
|
||||
import type { RequestListener } from "node:http";
|
||||
import {
|
||||
createEmptyPluginRegistry,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { withServer } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import { handleZaloWebhookRequest } from "./monitor.js";
|
||||
import type { ZaloRuntimeEnv } from "./monitor.types.js";
|
||||
import {
|
||||
clearZaloWebhookSecurityStateForTest,
|
||||
getZaloWebhookRateLimitStateSizeForTest,
|
||||
getZaloWebhookStatusCounterSizeForTest,
|
||||
handleZaloWebhookRequest as handleZaloWebhookRequestInternal,
|
||||
registerZaloWebhookTarget,
|
||||
type ZaloWebhookProcessUpdate,
|
||||
ZaloRetryableWebhookError,
|
||||
} from "./monitor.webhook.js";
|
||||
import {
|
||||
createImageLifecycleCore,
|
||||
createImageUpdate,
|
||||
createTextUpdate,
|
||||
expectImageLifecycleDelivery,
|
||||
postWebhookReplay,
|
||||
} from "./test-support/lifecycle-test-support.js";
|
||||
import type { ResolvedZaloAccount } from "./types.js";
|
||||
const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
token: "tok",
|
||||
tokenSource: "config",
|
||||
config: {},
|
||||
};
|
||||
|
||||
function createWebhookRequestHandler(processUpdate?: ZaloWebhookProcessUpdate): RequestListener {
|
||||
return (req, res) => {
|
||||
void (async () => {
|
||||
const handled = processUpdate
|
||||
? await handleZaloWebhookRequestInternal(req, res, processUpdate)
|
||||
: await handleZaloWebhookRequest(req, res);
|
||||
if (!handled) {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
}
|
||||
})();
|
||||
};
|
||||
}
|
||||
|
||||
const webhookRequestHandler = createWebhookRequestHandler();
|
||||
|
||||
function registerTarget(params: {
|
||||
path: string;
|
||||
secret?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
account?: ResolvedZaloAccount;
|
||||
config?: OpenClawConfig;
|
||||
core?: PluginRuntime;
|
||||
runtime?: Partial<ZaloRuntimeEnv>;
|
||||
}): () => void {
|
||||
return registerZaloWebhookTarget({
|
||||
token: "tok",
|
||||
account: params.account ?? DEFAULT_ACCOUNT,
|
||||
config: params.config ?? ({} as OpenClawConfig),
|
||||
runtime: (params.runtime ?? {}) as ZaloRuntimeEnv,
|
||||
core: params.core ?? ({} as PluginRuntime),
|
||||
secret: params.secret ?? "secret",
|
||||
path: params.path,
|
||||
webhookUrl: `https://example.com${params.path}`,
|
||||
webhookPath: params.path,
|
||||
mediaMaxMb: 5,
|
||||
canHostMedia: true,
|
||||
statusSink: params.statusSink,
|
||||
});
|
||||
}
|
||||
|
||||
function createPairingAuthCore(params?: { storeAllowFrom?: string[]; pairingCreated?: boolean }): {
|
||||
core: PluginRuntime;
|
||||
readAllowFromStore: ReturnType<typeof vi.fn>;
|
||||
upsertPairingRequest: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const readAllowFromStore = vi.fn().mockResolvedValue(params?.storeAllowFrom ?? []);
|
||||
const upsertPairingRequest = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ code: "PAIRCODE", created: params?.pairingCreated ?? false });
|
||||
const core = {
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore,
|
||||
upsertPairingRequest,
|
||||
buildPairingReply: vi.fn(() => "Pairing code: PAIRCODE"),
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn(() => false),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
return { core, readAllowFromStore, upsertPairingRequest };
|
||||
}
|
||||
|
||||
async function postUntilRateLimited(params: {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
secret: string;
|
||||
withNonceQuery?: boolean;
|
||||
attempts?: number;
|
||||
}): Promise<boolean> {
|
||||
const attempts = params.attempts ?? 130;
|
||||
for (let i = 0; i < attempts; i += 1) {
|
||||
const url = params.withNonceQuery
|
||||
? `${params.baseUrl}${params.path}?nonce=${i}`
|
||||
: `${params.baseUrl}${params.path}`;
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": params.secret,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
if (response.status === 429) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function postWebhookJson(params: {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
secret: string;
|
||||
payload: unknown;
|
||||
}) {
|
||||
return fetch(`${params.baseUrl}${params.path}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": params.secret,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(params.payload),
|
||||
});
|
||||
}
|
||||
|
||||
async function expectTwoWebhookPostsOk(params: {
|
||||
baseUrl: string;
|
||||
first: { path: string; secret: string; payload: unknown };
|
||||
second: { path: string; secret: string; payload: unknown };
|
||||
}) {
|
||||
const first = await postWebhookJson({
|
||||
baseUrl: params.baseUrl,
|
||||
path: params.first.path,
|
||||
secret: params.first.secret,
|
||||
payload: params.first.payload,
|
||||
});
|
||||
const second = await postWebhookJson({
|
||||
baseUrl: params.baseUrl,
|
||||
path: params.second.path,
|
||||
secret: params.second.secret,
|
||||
payload: params.second.payload,
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(200);
|
||||
}
|
||||
|
||||
describe("handleZaloWebhookRequest", () => {
|
||||
afterEach(() => {
|
||||
clearZaloWebhookSecurityStateForTest();
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
it("returns 400 for non-object payloads", async () => {
|
||||
const unregister = registerTarget({ path: "/hook" });
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/hook`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": "secret",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "null",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.text()).toBe("Bad Request");
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects ambiguous routing when multiple targets match the same secret", async () => {
|
||||
const sinkA = vi.fn();
|
||||
const sinkB = vi.fn();
|
||||
const unregisterA = registerTarget({ path: "/hook", statusSink: sinkA });
|
||||
const unregisterB = registerTarget({ path: "/hook", statusSink: sinkB });
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/hook`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": "secret",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(sinkA).not.toHaveBeenCalled();
|
||||
expect(sinkB).not.toHaveBeenCalled();
|
||||
});
|
||||
} finally {
|
||||
unregisterA();
|
||||
unregisterB();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 415 for non-json content-type", async () => {
|
||||
const unregister = registerTarget({ path: "/hook-content-type" });
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/hook-content-type`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": "secret",
|
||||
"content-type": "text/plain",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(415);
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("deduplicates webhook replay for the same event origin", async () => {
|
||||
const sink = vi.fn();
|
||||
const unregister = registerTarget({ path: "/hook-replay", statusSink: sink });
|
||||
const payload = createTextUpdate({
|
||||
messageId: "msg-replay-1",
|
||||
userId: "123",
|
||||
userName: "",
|
||||
chatId: "123",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const { first, replay } = await postWebhookReplay({
|
||||
baseUrl,
|
||||
path: "/hook-replay",
|
||||
secret: "secret",
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(replay.status).toBe(200);
|
||||
expect(sink).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows a retry after processUpdate throws a retryable replay error", async () => {
|
||||
const error = vi.fn();
|
||||
const unregister = registerTarget({
|
||||
path: "/hook-retry-after-failure",
|
||||
runtime: { error },
|
||||
});
|
||||
const payload = createTextUpdate({
|
||||
messageId: "msg-retry-after-failure-1",
|
||||
userId: "123",
|
||||
userName: "",
|
||||
chatId: "123",
|
||||
text: "hello",
|
||||
});
|
||||
let attempts = 0;
|
||||
const processUpdate = vi.fn<ZaloWebhookProcessUpdate>(async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new ZaloRetryableWebhookError("boom");
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(createWebhookRequestHandler(processUpdate), async (baseUrl) => {
|
||||
const first = await postWebhookJson({
|
||||
baseUrl,
|
||||
path: "/hook-retry-after-failure",
|
||||
secret: "secret",
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
await vi.waitFor(() => expect(error).toHaveBeenCalledTimes(1));
|
||||
|
||||
const second = await postWebhookJson({
|
||||
baseUrl,
|
||||
path: "/hook-retry-after-failure",
|
||||
secret: "secret",
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(second.status).toBe(200);
|
||||
await vi.waitFor(() => expect(processUpdate).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps replay dedupe isolated per authenticated target", async () => {
|
||||
const sinkA = vi.fn();
|
||||
const sinkB = vi.fn();
|
||||
const unregisterA = registerTarget({
|
||||
path: "/hook-replay-scope",
|
||||
secret: "secret-a",
|
||||
statusSink: sinkA,
|
||||
});
|
||||
const unregisterB = registerTarget({
|
||||
path: "/hook-replay-scope",
|
||||
secret: "secret-b",
|
||||
statusSink: sinkB,
|
||||
account: {
|
||||
...DEFAULT_ACCOUNT,
|
||||
accountId: "work",
|
||||
},
|
||||
});
|
||||
const payload = createTextUpdate({
|
||||
messageId: "msg-replay-scope-1",
|
||||
userId: "123",
|
||||
userName: "",
|
||||
chatId: "123",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
await expectTwoWebhookPostsOk({
|
||||
baseUrl,
|
||||
first: { path: "/hook-replay-scope", secret: "secret-a", payload },
|
||||
second: { path: "/hook-replay-scope", secret: "secret-b", payload },
|
||||
});
|
||||
});
|
||||
|
||||
expect(sinkA).toHaveBeenCalledTimes(1);
|
||||
expect(sinkB).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
unregisterA();
|
||||
unregisterB();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not collide replay dedupe across different chats", async () => {
|
||||
const sink = vi.fn();
|
||||
const unregister = registerTarget({ path: "/hook-replay-chat-scope", statusSink: sink });
|
||||
const firstPayload = createTextUpdate({
|
||||
messageId: "msg-replay-chat-1",
|
||||
userId: "123",
|
||||
userName: "",
|
||||
chatId: "chat-a",
|
||||
text: "hello from a",
|
||||
});
|
||||
const secondPayload = createTextUpdate({
|
||||
messageId: "msg-replay-chat-1",
|
||||
userId: "123",
|
||||
userName: "",
|
||||
chatId: "chat-b",
|
||||
text: "hello from b",
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
await expectTwoWebhookPostsOk({
|
||||
baseUrl,
|
||||
first: { path: "/hook-replay-chat-scope", secret: "secret", payload: firstPayload },
|
||||
second: { path: "/hook-replay-chat-scope", secret: "secret", payload: secondPayload },
|
||||
});
|
||||
});
|
||||
|
||||
expect(sink).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not collide replay dedupe across different senders in the same chat", async () => {
|
||||
const sink = vi.fn();
|
||||
const unregister = registerTarget({ path: "/hook-replay-sender-scope", statusSink: sink });
|
||||
const firstPayload = createTextUpdate({
|
||||
messageId: "msg-replay-sender-1",
|
||||
userId: "user-a",
|
||||
userName: "",
|
||||
chatId: "chat-shared",
|
||||
text: "hello from user a",
|
||||
});
|
||||
const secondPayload = createTextUpdate({
|
||||
messageId: "msg-replay-sender-1",
|
||||
userId: "user-b",
|
||||
userName: "",
|
||||
chatId: "chat-shared",
|
||||
text: "hello from user b",
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
await expectTwoWebhookPostsOk({
|
||||
baseUrl,
|
||||
first: { path: "/hook-replay-sender-scope", secret: "secret", payload: firstPayload },
|
||||
second: { path: "/hook-replay-sender-scope", secret: "secret", payload: secondPayload },
|
||||
});
|
||||
});
|
||||
|
||||
expect(sink).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts replay metadata when optional fields are missing", async () => {
|
||||
const sink = vi.fn();
|
||||
const unregister = registerTarget({ path: "/hook-replay-partial", statusSink: sink });
|
||||
const payload = {
|
||||
event_name: "message.text.received",
|
||||
message: {
|
||||
message_id: "msg-replay-partial-1",
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
text: "hello",
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/hook-replay-partial`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": "secret",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
expect(sink).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps replay dedupe isolated when path/account values collide under colon-joined keys", async () => {
|
||||
const sinkA = vi.fn();
|
||||
const sinkB = vi.fn();
|
||||
// Old key format `${path}:${accountId}:${event_name}:${messageId}` would collide for these two targets.
|
||||
const unregisterA = registerTarget({
|
||||
path: "/hook-replay-collision:a",
|
||||
secret: "secret-a",
|
||||
statusSink: sinkA,
|
||||
account: {
|
||||
...DEFAULT_ACCOUNT,
|
||||
accountId: "team",
|
||||
},
|
||||
});
|
||||
const unregisterB = registerTarget({
|
||||
path: "/hook-replay-collision",
|
||||
secret: "secret-b",
|
||||
statusSink: sinkB,
|
||||
account: {
|
||||
...DEFAULT_ACCOUNT,
|
||||
accountId: "a:team",
|
||||
},
|
||||
});
|
||||
const payload = createTextUpdate({
|
||||
messageId: "msg-replay-collision-1",
|
||||
userId: "123",
|
||||
userName: "",
|
||||
chatId: "123",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
await expectTwoWebhookPostsOk({
|
||||
baseUrl,
|
||||
first: { path: "/hook-replay-collision:a", secret: "secret-a", payload },
|
||||
second: { path: "/hook-replay-collision", secret: "secret-b", payload },
|
||||
});
|
||||
});
|
||||
|
||||
expect(sinkA).toHaveBeenCalledTimes(1);
|
||||
expect(sinkB).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
unregisterA();
|
||||
unregisterB();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps replay dedupe isolated across different webhook paths", async () => {
|
||||
const sinkA = vi.fn();
|
||||
const sinkB = vi.fn();
|
||||
const sharedSecret = "secret";
|
||||
const unregisterA = registerTarget({
|
||||
path: "/hook-replay-scope-a",
|
||||
secret: sharedSecret,
|
||||
statusSink: sinkA,
|
||||
});
|
||||
const unregisterB = registerTarget({
|
||||
path: "/hook-replay-scope-b",
|
||||
secret: sharedSecret,
|
||||
statusSink: sinkB,
|
||||
});
|
||||
const payload = createTextUpdate({
|
||||
messageId: "msg-replay-cross-path-1",
|
||||
userId: "123",
|
||||
userName: "",
|
||||
chatId: "123",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
await expectTwoWebhookPostsOk({
|
||||
baseUrl,
|
||||
first: { path: "/hook-replay-scope-a", secret: sharedSecret, payload },
|
||||
second: { path: "/hook-replay-scope-b", secret: sharedSecret, payload },
|
||||
});
|
||||
});
|
||||
|
||||
expect(sinkA).toHaveBeenCalledTimes(1);
|
||||
expect(sinkB).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
unregisterA();
|
||||
unregisterB();
|
||||
}
|
||||
});
|
||||
|
||||
it("downloads inbound image media from webhook photo_url and preserves display_name", async () => {
|
||||
const {
|
||||
core,
|
||||
finalizeInboundContextMock,
|
||||
recordInboundSessionMock,
|
||||
readRemoteMediaBufferMock,
|
||||
saveRemoteMediaMock,
|
||||
saveMediaBufferMock,
|
||||
} = createImageLifecycleCore();
|
||||
const unregister = registerTarget({
|
||||
path: "/hook-image",
|
||||
core,
|
||||
account: {
|
||||
...DEFAULT_ACCOUNT,
|
||||
config: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
});
|
||||
const payload = createImageUpdate();
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/hook-image`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": "secret",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
|
||||
await vi.waitFor(() => expect(saveRemoteMediaMock).toHaveBeenCalledTimes(1));
|
||||
expect(readRemoteMediaBufferMock).not.toHaveBeenCalled();
|
||||
expectImageLifecycleDelivery({
|
||||
readRemoteMediaBufferMock,
|
||||
saveRemoteMediaMock,
|
||||
saveMediaBufferMock,
|
||||
finalizeInboundContextMock,
|
||||
recordInboundSessionMock,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 429 when per-path request rate exceeds threshold", async () => {
|
||||
const unregister = registerTarget({ path: "/hook-rate" });
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const saw429 = await postUntilRateLimited({
|
||||
baseUrl,
|
||||
path: "/hook-rate",
|
||||
secret: "secret", // pragma: allowlist secret
|
||||
});
|
||||
|
||||
expect(saw429).toBe(true);
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
it("does not grow status counters when query strings churn on unauthorized requests", async () => {
|
||||
const unregister = registerTarget({ path: "/hook-query-status" });
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
let saw429 = false;
|
||||
for (let i = 0; i < 200; i += 1) {
|
||||
const response = await fetch(`${baseUrl}/hook-query-status?nonce=${i}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
expect([401, 429]).toContain(response.status);
|
||||
if (response.status === 429) {
|
||||
saw429 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(saw429).toBe(true);
|
||||
expect(getZaloWebhookStatusCounterSizeForTest()).toBe(2);
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("rate limits authenticated requests even when query strings churn", async () => {
|
||||
const unregister = registerTarget({ path: "/hook-query-rate" });
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const saw429 = await postUntilRateLimited({
|
||||
baseUrl,
|
||||
path: "/hook-query-rate",
|
||||
secret: "secret", // pragma: allowlist secret
|
||||
withNonceQuery: true,
|
||||
});
|
||||
|
||||
expect(saw429).toBe(true);
|
||||
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("rate limits unauthorized secret guesses before authentication succeeds", async () => {
|
||||
const unregister = registerTarget({ path: "/hook-preauth-rate" });
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const saw429 = await postUntilRateLimited({
|
||||
baseUrl,
|
||||
path: "/hook-preauth-rate",
|
||||
secret: "invalid-token", // pragma: allowlist secret
|
||||
withNonceQuery: true,
|
||||
});
|
||||
|
||||
expect(saw429).toBe(true);
|
||||
expect(getZaloWebhookRateLimitStateSizeForTest()).toBe(1);
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let unauthorized floods rate-limit authenticated traffic from a different trusted forwarded client IP", async () => {
|
||||
const unregister = registerTarget({
|
||||
path: "/hook-preauth-split",
|
||||
config: {
|
||||
gateway: {
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
for (let i = 0; i < 130; i += 1) {
|
||||
const response = await fetch(`${baseUrl}/hook-preauth-split?nonce=${i}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
|
||||
"content-type": "application/json",
|
||||
"x-forwarded-for": "203.0.113.10",
|
||||
},
|
||||
body: "{}",
|
||||
});
|
||||
if (response.status === 429) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const validResponse = await fetch(`${baseUrl}/hook-preauth-split`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": "secret",
|
||||
"content-type": "application/json",
|
||||
"x-forwarded-for": "198.51.100.20",
|
||||
},
|
||||
body: JSON.stringify({ event_name: "message.unsupported.received" }),
|
||||
});
|
||||
|
||||
expect(validResponse.status).toBe(200);
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("still returns 401 before 415 when both secret and content-type are invalid", async () => {
|
||||
const unregister = registerTarget({ path: "/hook-auth-before-type" });
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/hook-auth-before-type`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": "invalid-token", // pragma: allowlist secret
|
||||
"content-type": "text/plain",
|
||||
},
|
||||
body: "not-json",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes DM pairing store reads and writes to accountId", async () => {
|
||||
const { core, readAllowFromStore, upsertPairingRequest } = createPairingAuthCore({
|
||||
pairingCreated: false,
|
||||
});
|
||||
const account: ResolvedZaloAccount = {
|
||||
...DEFAULT_ACCOUNT,
|
||||
accountId: "work",
|
||||
config: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
},
|
||||
};
|
||||
const unregister = registerTarget({
|
||||
path: "/hook-account-scope",
|
||||
account,
|
||||
core,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
event_name: "message.text.received",
|
||||
message: {
|
||||
from: { id: "123", name: "Attacker" },
|
||||
chat: { id: "dm-work", chat_type: "PRIVATE" },
|
||||
message_id: "msg-work-1",
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
text: "hello",
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
await withServer(webhookRequestHandler, async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/hook-account-scope`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": "secret",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledTimes(1);
|
||||
expect(readAllowFromStore).toHaveBeenCalledWith({
|
||||
channel: "zalo",
|
||||
accountId: "work",
|
||||
});
|
||||
expect(upsertPairingRequest).toHaveBeenCalledTimes(1);
|
||||
expect(upsertPairingRequest).toHaveBeenCalledWith({
|
||||
channel: "zalo",
|
||||
accountId: "work",
|
||||
id: "123",
|
||||
meta: { name: "Attacker" },
|
||||
});
|
||||
});
|
||||
});
|
||||
275
extensions/zalo/src/monitor.webhook.ts
Normal file
275
extensions/zalo/src/monitor.webhook.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
// Zalo plugin module implements monitor.webhook behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
import type { ZaloFetch, ZaloUpdate } from "./api.js";
|
||||
import type { ZaloRuntimeEnv } from "./monitor.types.js";
|
||||
import {
|
||||
createFixedWindowRateLimiter,
|
||||
createWebhookAnomalyTracker,
|
||||
readJsonWebhookBodyOrReject,
|
||||
applyBasicWebhookRequestGuards,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
type RegisterWebhookTargetOptions,
|
||||
type RegisterWebhookPluginRouteOptions,
|
||||
registerWebhookTarget,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
resolveClientIp,
|
||||
type OpenClawConfig,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
const ZALO_WEBHOOK_REPLAY_WINDOW_MS = 5 * 60_000;
|
||||
|
||||
export type ZaloWebhookTarget = {
|
||||
token: string;
|
||||
account: ResolvedZaloAccount;
|
||||
config: OpenClawConfig;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
core: unknown;
|
||||
secret: string;
|
||||
path: string;
|
||||
webhookUrl: string;
|
||||
webhookPath: string;
|
||||
mediaMaxMb: number;
|
||||
canHostMedia: boolean;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
};
|
||||
|
||||
export type ZaloWebhookProcessUpdate = (params: {
|
||||
update: ZaloUpdate;
|
||||
target: ZaloWebhookTarget;
|
||||
}) => Promise<void>;
|
||||
|
||||
const webhookTargets = new Map<string, ZaloWebhookTarget[]>();
|
||||
const webhookRateLimiter = createFixedWindowRateLimiter({
|
||||
windowMs: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs,
|
||||
maxRequests: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests,
|
||||
maxTrackedKeys: WEBHOOK_RATE_LIMIT_DEFAULTS.maxTrackedKeys,
|
||||
});
|
||||
const recentWebhookEvents = createClaimableDedupe({
|
||||
ttlMs: ZALO_WEBHOOK_REPLAY_WINDOW_MS,
|
||||
memoryMaxSize: 5000,
|
||||
});
|
||||
const webhookAnomalyTracker = createWebhookAnomalyTracker({
|
||||
maxTrackedKeys: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.maxTrackedKeys,
|
||||
ttlMs: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.ttlMs,
|
||||
logEvery: WEBHOOK_ANOMALY_COUNTER_DEFAULTS.logEvery,
|
||||
});
|
||||
|
||||
export function clearZaloWebhookSecurityStateForTest(): void {
|
||||
webhookRateLimiter.clear();
|
||||
recentWebhookEvents.clearMemory();
|
||||
webhookAnomalyTracker.clear();
|
||||
}
|
||||
|
||||
export function getZaloWebhookRateLimitStateSizeForTest(): number {
|
||||
return webhookRateLimiter.size();
|
||||
}
|
||||
|
||||
export function getZaloWebhookStatusCounterSizeForTest(): number {
|
||||
return webhookAnomalyTracker.size();
|
||||
}
|
||||
|
||||
function buildReplayEventCacheKey(target: ZaloWebhookTarget, update: ZaloUpdate): string | null {
|
||||
const messageId = update.message?.message_id;
|
||||
if (!messageId) {
|
||||
return null;
|
||||
}
|
||||
const chatId = update.message?.chat?.id ?? "";
|
||||
const senderId = update.message?.from?.id ?? "";
|
||||
return JSON.stringify([
|
||||
target.path,
|
||||
target.account.accountId,
|
||||
update.event_name,
|
||||
chatId,
|
||||
senderId,
|
||||
messageId,
|
||||
]);
|
||||
}
|
||||
|
||||
export class ZaloRetryableWebhookError extends Error {
|
||||
constructor(message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = "ZaloRetryableWebhookError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function processZaloReplayGuardedUpdate(params: {
|
||||
target: ZaloWebhookTarget;
|
||||
update: ZaloUpdate;
|
||||
processUpdate: ZaloWebhookProcessUpdate;
|
||||
nowMs?: number;
|
||||
}): Promise<"processed" | "duplicate"> {
|
||||
const replayEventKey = buildReplayEventCacheKey(params.target, params.update);
|
||||
if (replayEventKey) {
|
||||
const replayClaim = await recentWebhookEvents.claim(replayEventKey, { now: params.nowMs });
|
||||
if (replayClaim.kind !== "claimed") {
|
||||
return "duplicate";
|
||||
}
|
||||
}
|
||||
|
||||
params.target.statusSink?.({ lastInboundAt: Date.now() });
|
||||
try {
|
||||
await params.processUpdate({ update: params.update, target: params.target });
|
||||
if (replayEventKey) {
|
||||
await recentWebhookEvents.commit(replayEventKey);
|
||||
}
|
||||
return "processed";
|
||||
} catch (error) {
|
||||
if (replayEventKey) {
|
||||
if (error instanceof ZaloRetryableWebhookError) {
|
||||
recentWebhookEvents.release(replayEventKey, { error });
|
||||
} else {
|
||||
await recentWebhookEvents.commit(replayEventKey);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function recordWebhookStatus(
|
||||
runtime: ZaloRuntimeEnv | undefined,
|
||||
path: string,
|
||||
statusCode: number,
|
||||
): void {
|
||||
webhookAnomalyTracker.record({
|
||||
key: `${path}:${statusCode}`,
|
||||
statusCode,
|
||||
log: runtime?.log,
|
||||
message: (count) =>
|
||||
`[zalo] webhook anomaly path=${path} status=${statusCode} count=${String(count)}`,
|
||||
});
|
||||
}
|
||||
|
||||
function headerValue(value: string | string[] | undefined): string | undefined {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
export function registerZaloWebhookTarget(
|
||||
target: ZaloWebhookTarget,
|
||||
opts?: {
|
||||
route?: RegisterWebhookPluginRouteOptions;
|
||||
} & Pick<
|
||||
RegisterWebhookTargetOptions<ZaloWebhookTarget>,
|
||||
"onFirstPathTarget" | "onLastPathTargetRemoved"
|
||||
>,
|
||||
): () => void {
|
||||
if (opts?.route) {
|
||||
return registerWebhookTargetWithPluginRoute({
|
||||
targetsByPath: webhookTargets,
|
||||
target,
|
||||
route: opts.route,
|
||||
onLastPathTargetRemoved: opts.onLastPathTargetRemoved,
|
||||
}).unregister;
|
||||
}
|
||||
return registerWebhookTarget(webhookTargets, target, opts).unregister;
|
||||
}
|
||||
|
||||
export async function handleZaloWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
processUpdate: ZaloWebhookProcessUpdate,
|
||||
): Promise<boolean> {
|
||||
return await withResolvedWebhookRequestPipeline({
|
||||
req,
|
||||
res,
|
||||
targetsByPath: webhookTargets,
|
||||
allowMethods: ["POST"],
|
||||
handle: async ({ targets, path }) => {
|
||||
const trustedProxies = targets[0]?.config.gateway?.trustedProxies;
|
||||
const allowRealIpFallback = targets[0]?.config.gateway?.allowRealIpFallback === true;
|
||||
const clientIp =
|
||||
resolveClientIp({
|
||||
remoteAddr: req.socket.remoteAddress,
|
||||
forwardedFor: headerValue(req.headers["x-forwarded-for"]),
|
||||
realIp: headerValue(req.headers["x-real-ip"]),
|
||||
trustedProxies,
|
||||
allowRealIpFallback,
|
||||
}) ??
|
||||
req.socket.remoteAddress ??
|
||||
"unknown";
|
||||
const rateLimitKey = `${path}:${clientIp}`;
|
||||
const nowMs = Date.now();
|
||||
if (
|
||||
!applyBasicWebhookRequestGuards({
|
||||
req,
|
||||
res,
|
||||
rateLimiter: webhookRateLimiter,
|
||||
rateLimitKey,
|
||||
nowMs,
|
||||
})
|
||||
) {
|
||||
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
||||
const target = resolveWebhookTargetWithAuthOrRejectSync({
|
||||
targets,
|
||||
res,
|
||||
isMatch: (entry) => safeEqualSecret(entry.secret, headerToken),
|
||||
});
|
||||
if (!target) {
|
||||
recordWebhookStatus(targets[0]?.runtime, path, res.statusCode);
|
||||
return true;
|
||||
}
|
||||
// Preserve the historical 401-before-415 ordering for invalid secrets while still
|
||||
// consuming rate-limit budget on unauthenticated guesses.
|
||||
if (
|
||||
!applyBasicWebhookRequestGuards({
|
||||
req,
|
||||
res,
|
||||
requireJsonContentType: true,
|
||||
})
|
||||
) {
|
||||
recordWebhookStatus(target.runtime, path, res.statusCode);
|
||||
return true;
|
||||
}
|
||||
const body = await readJsonWebhookBodyOrReject({
|
||||
req,
|
||||
res,
|
||||
maxBytes: 1024 * 1024,
|
||||
timeoutMs: 30_000,
|
||||
emptyObjectOnEmpty: false,
|
||||
invalidJsonMessage: "Bad Request",
|
||||
});
|
||||
if (!body.ok) {
|
||||
recordWebhookStatus(target.runtime, path, res.statusCode);
|
||||
return true;
|
||||
}
|
||||
const raw = body.value;
|
||||
|
||||
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }.
|
||||
const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
||||
const update: ZaloUpdate | undefined =
|
||||
record && record.ok === true && record.result
|
||||
? (record.result as ZaloUpdate)
|
||||
: ((record as ZaloUpdate | null) ?? undefined);
|
||||
|
||||
if (!update?.event_name) {
|
||||
res.statusCode = 400;
|
||||
res.end("Bad Request");
|
||||
recordWebhookStatus(target.runtime, path, res.statusCode);
|
||||
return true;
|
||||
}
|
||||
|
||||
void processZaloReplayGuardedUpdate({
|
||||
target,
|
||||
update,
|
||||
processUpdate,
|
||||
nowMs,
|
||||
}).catch((err: unknown) => {
|
||||
target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
253
extensions/zalo/src/outbound-media.test.ts
Normal file
253
extensions/zalo/src/outbound-media.test.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
|
||||
// Zalo tests cover outbound media plugin behavior.
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
|
||||
const loadWebMediaMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/web-media", () => {
|
||||
return {
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
clearHostedZaloMediaForTest,
|
||||
prepareHostedZaloMediaUrl,
|
||||
resolveHostedZaloMediaRoutePrefix,
|
||||
tryHandleHostedZaloMediaRequest,
|
||||
} from "./outbound-media.js";
|
||||
import { setZaloRuntime } from "./runtime.js";
|
||||
|
||||
function createMockResponse() {
|
||||
const headers = new Map<string, string>();
|
||||
return {
|
||||
headers,
|
||||
res: {
|
||||
statusCode: 200,
|
||||
setHeader(name: string, value: string) {
|
||||
headers.set(name, value);
|
||||
},
|
||||
end: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installZaloRuntimeForTest(): void {
|
||||
setZaloRuntime({
|
||||
state: {
|
||||
openKeyedStore: <T>(options: OpenKeyedStoreOptions) =>
|
||||
createPluginStateKeyedStoreForTests<T>("zalo", options),
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
}
|
||||
|
||||
describe("zalo outbound hosted media", () => {
|
||||
beforeEach(async () => {
|
||||
resetPluginStateStoreForTests();
|
||||
installZaloRuntimeForTest();
|
||||
await clearHostedZaloMediaForTest();
|
||||
loadWebMediaMock.mockReset();
|
||||
loadWebMediaMock.mockResolvedValue({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
kind: "image",
|
||||
contentType: "image/png",
|
||||
fileName: "photo.png",
|
||||
});
|
||||
});
|
||||
|
||||
it("loads outbound media under OpenClaw control and returns a hosted URL", async () => {
|
||||
const hostedUrl = await prepareHostedZaloMediaUrl({
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
webhookUrl: "https://gateway.example.com/zalo-webhook",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
||||
"https://example.com/photo.png",
|
||||
expect.objectContaining({ maxBytes: 1024 }),
|
||||
);
|
||||
expect(hostedUrl).toMatch(
|
||||
/^https:\/\/gateway\.example\.com\/zalo-webhook\/media\/[a-f0-9]+\?token=[a-f0-9]+$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes proxy-aware fetch options into hosted media downloads", async () => {
|
||||
await prepareHostedZaloMediaUrl({
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
webhookUrl: "https://gateway.example.com/zalo-webhook",
|
||||
maxBytes: 1024,
|
||||
proxyUrl: "http://proxy.example:8080",
|
||||
});
|
||||
|
||||
expect(loadWebMediaMock).toHaveBeenCalledWith(
|
||||
"https://example.com/photo.png",
|
||||
expect.objectContaining({ maxBytes: 1024, proxyUrl: "http://proxy.example:8080" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("persists hosted media in SQLite plugin state instead of temp sidecars", async () => {
|
||||
const hostedUrl = await prepareHostedZaloMediaUrl({
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
webhookUrl: "https://gateway.example.com/zalo-webhook",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
|
||||
const { pathname } = new URL(hostedUrl);
|
||||
const id = pathname.split("/").pop();
|
||||
if (!id) {
|
||||
throw new Error("expected hosted Zalo media id");
|
||||
}
|
||||
expect(id).toHaveLength(24);
|
||||
expect(/^[0-9a-f]+$/.test(id)).toBe(true);
|
||||
|
||||
const metaStore = createPluginStateKeyedStoreForTests("zalo", {
|
||||
namespace: "hosted-outbound-media",
|
||||
maxEntries: 80,
|
||||
});
|
||||
const chunkStore = createPluginStateKeyedStoreForTests("zalo", {
|
||||
namespace: "hosted-outbound-media-chunks",
|
||||
maxEntries: 16_384,
|
||||
});
|
||||
|
||||
const metaEntries = await metaStore.entries();
|
||||
expect(metaEntries).toHaveLength(1);
|
||||
expect(metaEntries[0]?.value).toMatchObject({
|
||||
id,
|
||||
routePath: "/zalo-webhook/media/",
|
||||
contentType: "image/png",
|
||||
byteLength: Buffer.byteLength("image-bytes"),
|
||||
});
|
||||
expect(await chunkStore.entries()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves the root webhook path when deriving the hosted media route", () => {
|
||||
expect(
|
||||
resolveHostedZaloMediaRoutePrefix({
|
||||
webhookUrl: "https://gateway.example.com/",
|
||||
}),
|
||||
).toBe("/media");
|
||||
});
|
||||
|
||||
it("serves hosted media once when the route token matches", async () => {
|
||||
const hostedUrl = await prepareHostedZaloMediaUrl({
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
webhookUrl: "https://gateway.example.com/zalo-webhook",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
const { pathname, search } = new URL(hostedUrl);
|
||||
const response = createMockResponse();
|
||||
|
||||
const handled = await tryHandleHostedZaloMediaRequest(
|
||||
{
|
||||
method: "GET",
|
||||
url: `${pathname}${search}`,
|
||||
} as never,
|
||||
response.res as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("image/png");
|
||||
expect(response.res.end).toHaveBeenCalledWith(Buffer.from("image-bytes"));
|
||||
|
||||
const secondResponse = createMockResponse();
|
||||
const handledAgain = await tryHandleHostedZaloMediaRequest(
|
||||
{
|
||||
method: "GET",
|
||||
url: `${pathname}${search}`,
|
||||
} as never,
|
||||
secondResponse.res as never,
|
||||
);
|
||||
|
||||
expect(handledAgain).toBe(true);
|
||||
expect(secondResponse.res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects hosted media preparation when the expiry would exceed a valid Date", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(8_640_000_000_000_000));
|
||||
try {
|
||||
await expect(
|
||||
prepareHostedZaloMediaUrl({
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
webhookUrl: "https://gateway.example.com/zalo-webhook",
|
||||
maxBytes: 1024,
|
||||
}),
|
||||
).rejects.toThrow(/expiry/);
|
||||
|
||||
expect(loadWebMediaMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not serve hosted media when the current clock is invalid", async () => {
|
||||
const hostedUrl = await prepareHostedZaloMediaUrl({
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
webhookUrl: "https://gateway.example.com/zalo-webhook",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
const { pathname, search } = new URL(hostedUrl);
|
||||
const response = createMockResponse();
|
||||
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
|
||||
try {
|
||||
const handled = await tryHandleHostedZaloMediaRequest(
|
||||
{
|
||||
method: "GET",
|
||||
url: `${pathname}${search}`,
|
||||
} as never,
|
||||
response.res as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.res.statusCode).toBe(410);
|
||||
expect(response.res.end).toHaveBeenCalledWith("Expired");
|
||||
} finally {
|
||||
dateNow.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects hosted media requests with the wrong token", async () => {
|
||||
const hostedUrl = await prepareHostedZaloMediaUrl({
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
webhookUrl: "https://gateway.example.com/custom/zalo",
|
||||
webhookPath: "/custom/zalo-hook",
|
||||
maxBytes: 1024,
|
||||
});
|
||||
const pathname = new URL(hostedUrl).pathname;
|
||||
const response = createMockResponse();
|
||||
|
||||
const handled = await tryHandleHostedZaloMediaRequest(
|
||||
{
|
||||
method: "GET",
|
||||
url: `${pathname}?token=wrong`,
|
||||
} as never,
|
||||
response.res as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
expect(response.res.end).toHaveBeenCalledWith("Unauthorized");
|
||||
});
|
||||
|
||||
it("rejects malformed hosted media ids before touching disk", async () => {
|
||||
const response = createMockResponse();
|
||||
|
||||
const handled = await tryHandleHostedZaloMediaRequest(
|
||||
{
|
||||
method: "GET",
|
||||
url: "/zalo-webhook/media/not-a-valid-hex-id?token=wrong",
|
||||
} as never,
|
||||
response.res as never,
|
||||
);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(response.res.statusCode).toBe(404);
|
||||
expect(response.res.end).toHaveBeenCalledWith("Not Found");
|
||||
});
|
||||
});
|
||||
194
extensions/zalo/src/outbound-media.ts
Normal file
194
extensions/zalo/src/outbound-media.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// Zalo plugin module implements outbound media behavior.
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
asDateTimestampMs,
|
||||
resolveExpiresAtMsFromDurationMs,
|
||||
} from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
createHostedOutboundMediaStore,
|
||||
type HostedOutboundMediaChunkRecord,
|
||||
type HostedOutboundMediaMetaRecord,
|
||||
type HostedOutboundMediaStore,
|
||||
} from "openclaw/plugin-sdk/outbound-media";
|
||||
import { resolveWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
||||
import { getZaloRuntime } from "./runtime.js";
|
||||
|
||||
const ZALO_OUTBOUND_MEDIA_TTL_MS = 2 * 60_000;
|
||||
const ZALO_OUTBOUND_MEDIA_SEGMENT = "media";
|
||||
const ZALO_OUTBOUND_MEDIA_PREFIX = `/${ZALO_OUTBOUND_MEDIA_SEGMENT}/`;
|
||||
const ZALO_OUTBOUND_MEDIA_ID_RE = /^[a-f0-9]{24}$/;
|
||||
const ZALO_OUTBOUND_MEDIA_NAMESPACE = "hosted-outbound-media";
|
||||
const ZALO_OUTBOUND_MEDIA_CHUNKS_NAMESPACE = "hosted-outbound-media-chunks";
|
||||
const ZALO_OUTBOUND_MEDIA_MAX_ENTRIES = 64;
|
||||
const ZALO_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET = 256;
|
||||
const ZALO_OUTBOUND_MEDIA_MAX_CHUNK_ROWS =
|
||||
ZALO_OUTBOUND_MEDIA_MAX_ENTRIES * ZALO_OUTBOUND_MEDIA_CHUNK_ROWS_PER_ENTRY_BUDGET;
|
||||
|
||||
let hostedZaloMediaStore: HostedOutboundMediaStore | undefined;
|
||||
|
||||
function createHostedZaloMediaStore(): HostedOutboundMediaStore {
|
||||
const runtime = getZaloRuntime();
|
||||
return createHostedOutboundMediaStore({
|
||||
metadataStore: runtime.state.openKeyedStore<HostedOutboundMediaMetaRecord>({
|
||||
namespace: ZALO_OUTBOUND_MEDIA_NAMESPACE,
|
||||
maxEntries: ZALO_OUTBOUND_MEDIA_MAX_ENTRIES + 16,
|
||||
}),
|
||||
chunkStore: runtime.state.openKeyedStore<HostedOutboundMediaChunkRecord>({
|
||||
namespace: ZALO_OUTBOUND_MEDIA_CHUNKS_NAMESPACE,
|
||||
maxEntries: ZALO_OUTBOUND_MEDIA_MAX_CHUNK_ROWS,
|
||||
}),
|
||||
ttlMs: ZALO_OUTBOUND_MEDIA_TTL_MS,
|
||||
maxEntries: ZALO_OUTBOUND_MEDIA_MAX_ENTRIES,
|
||||
maxChunkRows: ZALO_OUTBOUND_MEDIA_MAX_CHUNK_ROWS,
|
||||
resolveExpiresAtMs: (ttlMs) => resolveExpiresAtMsFromDurationMs(ttlMs),
|
||||
});
|
||||
}
|
||||
|
||||
function getHostedZaloMediaStore(): HostedOutboundMediaStore {
|
||||
hostedZaloMediaStore ??= createHostedZaloMediaStore();
|
||||
return hostedZaloMediaStore;
|
||||
}
|
||||
|
||||
export function resolveHostedZaloMediaRoutePrefix(params: {
|
||||
webhookUrl: string;
|
||||
webhookPath?: string;
|
||||
}): string {
|
||||
const webhookRoutePath = resolveWebhookPath({
|
||||
webhookPath: params.webhookPath,
|
||||
webhookUrl: params.webhookUrl,
|
||||
defaultPath: null,
|
||||
});
|
||||
if (!webhookRoutePath) {
|
||||
throw new Error("Zalo webhookPath could not be derived for outbound media hosting");
|
||||
}
|
||||
return webhookRoutePath === "/"
|
||||
? `/${ZALO_OUTBOUND_MEDIA_SEGMENT}`
|
||||
: `${webhookRoutePath}/${ZALO_OUTBOUND_MEDIA_SEGMENT}`;
|
||||
}
|
||||
|
||||
function resolveHostedZaloMediaRoutePath(params: {
|
||||
webhookUrl: string;
|
||||
webhookPath?: string;
|
||||
}): string {
|
||||
return `${resolveHostedZaloMediaRoutePrefix(params)}/`;
|
||||
}
|
||||
|
||||
export async function prepareHostedZaloMediaUrl(params: {
|
||||
mediaUrl: string;
|
||||
webhookUrl: string;
|
||||
webhookPath?: string;
|
||||
maxBytes: number;
|
||||
proxyUrl?: string;
|
||||
}): Promise<string> {
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
const expiresAt =
|
||||
now === undefined
|
||||
? undefined
|
||||
: resolveExpiresAtMsFromDurationMs(ZALO_OUTBOUND_MEDIA_TTL_MS, { nowMs: now });
|
||||
if (expiresAt === undefined) {
|
||||
throw new Error("Zalo outbound media expiry could not be resolved");
|
||||
}
|
||||
|
||||
const routePath = resolveHostedZaloMediaRoutePath({
|
||||
webhookUrl: params.webhookUrl,
|
||||
webhookPath: params.webhookPath,
|
||||
});
|
||||
const publicBaseUrl = new URL(params.webhookUrl).origin;
|
||||
|
||||
return await getHostedZaloMediaStore().prepareUrl({
|
||||
mediaUrl: params.mediaUrl,
|
||||
routePath,
|
||||
publicBaseUrl,
|
||||
maxBytes: params.maxBytes,
|
||||
...(params.proxyUrl ? { proxyUrl: params.proxyUrl } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function tryHandleHostedZaloMediaRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
const store = getHostedZaloMediaStore();
|
||||
await store.cleanupExpired();
|
||||
|
||||
const method = req.method ?? "GET";
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(req.url ?? "/", "http://localhost");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mediaPath = url.pathname;
|
||||
const prefixIndex = mediaPath.lastIndexOf(ZALO_OUTBOUND_MEDIA_PREFIX);
|
||||
if (prefixIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const routePath = mediaPath.slice(0, prefixIndex + ZALO_OUTBOUND_MEDIA_PREFIX.length);
|
||||
const id = mediaPath.slice(prefixIndex + ZALO_OUTBOUND_MEDIA_PREFIX.length);
|
||||
if (!id || !ZALO_OUTBOUND_MEDIA_ID_RE.test(id)) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = asDateTimestampMs(Date.now());
|
||||
if (now === undefined) {
|
||||
await store.delete(id);
|
||||
res.statusCode = 410;
|
||||
res.end("Expired");
|
||||
return true;
|
||||
}
|
||||
|
||||
const entry = await store.read(id, now);
|
||||
if (!entry || entry.metadata.routePath !== routePath) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
return true;
|
||||
}
|
||||
|
||||
const expiresAt = asDateTimestampMs(entry.metadata.expiresAt);
|
||||
if (expiresAt === undefined || expiresAt <= now) {
|
||||
await store.delete(id);
|
||||
res.statusCode = 410;
|
||||
res.end("Expired");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.searchParams.get("token") !== entry.metadata.token) {
|
||||
res.statusCode = 401;
|
||||
res.end("Unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (entry.metadata.contentType) {
|
||||
res.setHeader("Content-Type", entry.metadata.contentType);
|
||||
}
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("Content-Length", String(entry.metadata.byteLength));
|
||||
|
||||
if (method === "HEAD") {
|
||||
res.statusCode = 200;
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end(entry.buffer);
|
||||
await store.delete(id);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function clearHostedZaloMediaForTest(): Promise<void> {
|
||||
if (!hostedZaloMediaStore) {
|
||||
return;
|
||||
}
|
||||
await hostedZaloMediaStore.clear();
|
||||
hostedZaloMediaStore = undefined;
|
||||
}
|
||||
145
extensions/zalo/src/outbound-payload.contract.test.ts
Normal file
145
extensions/zalo/src/outbound-payload.contract.test.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
// Zalo tests cover outbound payload.contract plugin behavior.
|
||||
import {
|
||||
installChannelOutboundPayloadContractSuite,
|
||||
primeChannelOutboundSendMock,
|
||||
type OutboundPayloadHarnessParams,
|
||||
} from "openclaw/plugin-sdk/channel-contract-testing";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { zaloMessageAdapter, zaloPlugin } from "./channel.js";
|
||||
|
||||
const { sendZaloTextMock } = vi.hoisted(() => ({
|
||||
sendZaloTextMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./channel.runtime.js", () => ({
|
||||
sendZaloText: sendZaloTextMock,
|
||||
}));
|
||||
|
||||
type ZaloOutbound = NonNullable<typeof zaloPlugin.outbound>;
|
||||
type ZaloSendPayload = NonNullable<ZaloOutbound["sendPayload"]>;
|
||||
type ZaloMessageSender = NonNullable<typeof zaloMessageAdapter.send>;
|
||||
|
||||
function requireZaloSendPayload(): ZaloSendPayload {
|
||||
const sendPayload = zaloPlugin.outbound?.sendPayload;
|
||||
if (!sendPayload) {
|
||||
throw new Error("Expected Zalo outbound sendPayload");
|
||||
}
|
||||
return sendPayload;
|
||||
}
|
||||
|
||||
function requireZaloTextSender(): NonNullable<ZaloMessageSender["text"]> {
|
||||
const text = zaloMessageAdapter.send?.text;
|
||||
if (!text) {
|
||||
throw new Error("Expected Zalo message adapter text sender");
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function requireZaloMediaSender(): NonNullable<ZaloMessageSender["media"]> {
|
||||
const media = zaloMessageAdapter.send?.media;
|
||||
if (!media) {
|
||||
throw new Error("Expected Zalo message adapter media sender");
|
||||
}
|
||||
return media;
|
||||
}
|
||||
|
||||
function createZaloHarness(params: OutboundPayloadHarnessParams) {
|
||||
const sendZalo = vi.fn();
|
||||
primeChannelOutboundSendMock(sendZalo, { ok: true, messageId: "zl-1" }, params.sendResults);
|
||||
sendZaloTextMock.mockReset().mockImplementation(
|
||||
async (nextCtx: { to: string; text: string; mediaUrl?: string }) =>
|
||||
await sendZalo(nextCtx.to, nextCtx.text, {
|
||||
mediaUrl: nextCtx.mediaUrl,
|
||||
}),
|
||||
);
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
to: "123456789",
|
||||
text: "",
|
||||
payload: params.payload,
|
||||
};
|
||||
const sendPayload = requireZaloSendPayload();
|
||||
return {
|
||||
run: async () => await sendPayload(ctx),
|
||||
sendMock: sendZalo,
|
||||
to: ctx.to,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Zalo outbound payload contract", () => {
|
||||
installChannelOutboundPayloadContractSuite({
|
||||
channel: "zalo",
|
||||
chunking: { mode: "split", longTextLength: 3000, maxChunkLength: 2000 },
|
||||
createHarness: createZaloHarness,
|
||||
});
|
||||
|
||||
it("declares message adapter durable text and media with receipt proofs", async () => {
|
||||
sendZaloTextMock.mockReset().mockImplementation(async (ctx: { mediaUrl?: string }) =>
|
||||
ctx.mediaUrl
|
||||
? {
|
||||
ok: true,
|
||||
messageId: "zl-media-1",
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "zalo", messageId: "zl-media-1" }],
|
||||
kind: "media",
|
||||
}),
|
||||
}
|
||||
: {
|
||||
ok: true,
|
||||
messageId: "zl-text-1",
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "zalo", messageId: "zl-text-1" }],
|
||||
kind: "text",
|
||||
}),
|
||||
},
|
||||
);
|
||||
const sendText = requireZaloTextSender();
|
||||
const sendMedia = requireZaloMediaSender();
|
||||
|
||||
const proofs = await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "zalo",
|
||||
adapter: zaloMessageAdapter,
|
||||
proofs: {
|
||||
text: async () => {
|
||||
const result = await sendText({
|
||||
cfg: {},
|
||||
to: "123456789",
|
||||
text: "hello",
|
||||
});
|
||||
expect(result.receipt.platformMessageIds).toEqual(["zl-text-1"]);
|
||||
},
|
||||
media: async () => {
|
||||
const result = await sendMedia({
|
||||
cfg: {},
|
||||
to: "123456789",
|
||||
text: "image",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
});
|
||||
expect(result.receipt.platformMessageIds).toEqual(["zl-media-1"]);
|
||||
},
|
||||
messageSendingHooks: () => {
|
||||
expect(sendText).toBeTypeOf("function");
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(proofs).toStrictEqual([
|
||||
{ capability: "text", status: "verified" },
|
||||
{ capability: "media", status: "verified" },
|
||||
{ capability: "poll", status: "not_declared" },
|
||||
{ capability: "payload", status: "not_declared" },
|
||||
{ capability: "silent", status: "not_declared" },
|
||||
{ capability: "replyTo", status: "not_declared" },
|
||||
{ capability: "thread", status: "not_declared" },
|
||||
{ capability: "nativeQuote", status: "not_declared" },
|
||||
{ capability: "messageSendingHooks", status: "verified" },
|
||||
{ capability: "batch", status: "not_declared" },
|
||||
{ capability: "reconcileUnknownSend", status: "not_declared" },
|
||||
{ capability: "afterSendSuccess", status: "not_declared" },
|
||||
{ capability: "afterCommit", status: "not_declared" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
46
extensions/zalo/src/probe.ts
Normal file
46
extensions/zalo/src/probe.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// Zalo plugin module implements probe behavior.
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js";
|
||||
|
||||
export type ZaloProbeResult = BaseProbeResult<string> & {
|
||||
bot?: ZaloBotInfo;
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
export async function probeZalo(
|
||||
token: string,
|
||||
timeoutMs = 5000,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloProbeResult> {
|
||||
if (!token?.trim()) {
|
||||
return { ok: false, error: "No token provided", elapsedMs: 0 };
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await getMe(token.trim(), timeoutMs, fetcher);
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
|
||||
if (response.ok && response.result) {
|
||||
return { ok: true, bot: response.result, elapsedMs };
|
||||
}
|
||||
|
||||
return { ok: false, error: "Invalid response from Zalo API", elapsedMs };
|
||||
} catch (err) {
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
|
||||
if (err instanceof ZaloApiError) {
|
||||
return { ok: false, error: err.description ?? err.message, elapsedMs };
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (err.name === "AbortError") {
|
||||
return { ok: false, error: `Request timed out after ${timeoutMs}ms`, elapsedMs };
|
||||
}
|
||||
return { ok: false, error: err.message, elapsedMs };
|
||||
}
|
||||
|
||||
return { ok: false, error: String(err), elapsedMs };
|
||||
}
|
||||
}
|
||||
19
extensions/zalo/src/proxy.ts
Normal file
19
extensions/zalo/src/proxy.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Zalo plugin module implements proxy behavior.
|
||||
import { makeProxyFetch } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
import type { ZaloFetch } from "./api.js";
|
||||
|
||||
const proxyCache = new Map<string, ZaloFetch>();
|
||||
|
||||
export function resolveZaloProxyFetch(proxyUrl?: string | null): ZaloFetch | undefined {
|
||||
const trimmed = proxyUrl?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const cached = proxyCache.get(trimmed);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const fetcher = makeProxyFetch(trimmed) as ZaloFetch;
|
||||
proxyCache.set(trimmed, fetcher);
|
||||
return fetcher;
|
||||
}
|
||||
82
extensions/zalo/src/runtime-api.ts
Normal file
82
extensions/zalo/src/runtime-api.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
// Zalo API module exposes the plugin public contract.
|
||||
export {
|
||||
type BaseProbeResult,
|
||||
type BaseTokenResolution,
|
||||
type ChannelAccountSnapshot,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelPlugin,
|
||||
type ChannelStatusIssue,
|
||||
type GroupPolicy,
|
||||
type MarkdownTableMode,
|
||||
type OpenClawConfig,
|
||||
type OutboundReplyPayload,
|
||||
type PluginRuntime,
|
||||
type RegisterWebhookPluginRouteOptions,
|
||||
type RegisterWebhookTargetOptions,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
type SecretInput,
|
||||
type WizardPrompter,
|
||||
} from "./runtime-support.js";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
buildChannelConfigSchema,
|
||||
createDedupeCache,
|
||||
formatPairingApproveHint,
|
||||
jsonResult,
|
||||
normalizeAccountId,
|
||||
readStringParam,
|
||||
resolveClientIp,
|
||||
} from "./runtime-support.js";
|
||||
export {
|
||||
addWildcardAllowFrom,
|
||||
applyAccountNameToChannelSection,
|
||||
applySetupAccountConfigPatch,
|
||||
buildSingleChannelSecretPromptState,
|
||||
mergeAllowFromEntries,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
promptSingleChannelSecretInput,
|
||||
runSingleChannelSecretStep,
|
||||
setTopLevelChannelDmPolicyWithAllowFrom,
|
||||
} from "./runtime-support.js";
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "./runtime-support.js";
|
||||
export { buildTokenChannelStatusSummary, PAIRING_APPROVED_MESSAGE } from "./runtime-support.js";
|
||||
export { buildBaseAccountStatusSnapshot } from "./runtime-support.js";
|
||||
export { chunkTextForOutbound } from "./runtime-support.js";
|
||||
export { formatAllowFromLowercase, isNormalizedSenderAllowed } from "./runtime-support.js";
|
||||
export {
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "./runtime-support.js";
|
||||
export { createChannelPairingController } from "./runtime-support.js";
|
||||
export { createChannelMessageReplyPipeline } from "./runtime-support.js";
|
||||
export { logTypingFailure } from "./runtime-support.js";
|
||||
export {
|
||||
deliverTextOrMediaReply,
|
||||
isNumericTargetId,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
} from "./runtime-support.js";
|
||||
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "./runtime-support.js";
|
||||
export { waitForAbortSignal } from "./runtime-support.js";
|
||||
export {
|
||||
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
applyBasicWebhookRequestGuards,
|
||||
createFixedWindowRateLimiter,
|
||||
createWebhookAnomalyTracker,
|
||||
readJsonWebhookBodyOrReject,
|
||||
registerPluginHttpRoute,
|
||||
registerWebhookTarget,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
resolveWebhookPath,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
} from "./runtime-support.js";
|
||||
export { setZaloRuntime } from "./runtime.js";
|
||||
86
extensions/zalo/src/runtime-support.ts
Normal file
86
extensions/zalo/src/runtime-support.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// Zalo plugin module implements runtime support behavior.
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type { OpenClawConfig, GroupPolicy } from "openclaw/plugin-sdk/config-contracts";
|
||||
export type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
export type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
ChannelAccountSnapshot,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
ChannelStatusIssue,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export type { SecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
export type { ChannelPlugin, PluginRuntime, WizardPrompter } from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
buildChannelConfigSchema,
|
||||
createDedupeCache,
|
||||
formatPairingApproveHint,
|
||||
jsonResult,
|
||||
normalizeAccountId,
|
||||
readStringParam,
|
||||
resolveClientIp,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
applyAccountNameToChannelSection,
|
||||
applySetupAccountConfigPatch,
|
||||
buildSingleChannelSecretPromptState,
|
||||
mergeAllowFromEntries,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
promptSingleChannelSecretInput,
|
||||
runSingleChannelSecretStep,
|
||||
setTopLevelChannelDmPolicyWithAllowFrom,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
export {
|
||||
buildTokenChannelStatusSummary,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
} from "openclaw/plugin-sdk/channel-status";
|
||||
export { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export {
|
||||
formatAllowFromLowercase,
|
||||
isNormalizedSenderAllowed,
|
||||
} from "openclaw/plugin-sdk/allow-from";
|
||||
export { addWildcardAllowFrom } from "openclaw/plugin-sdk/setup";
|
||||
export { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
export {
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
resolveDefaultGroupPolicy,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
||||
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
|
||||
export {
|
||||
deliverTextOrMediaReply,
|
||||
isNumericTargetId,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
export { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
||||
export { waitForAbortSignal } from "openclaw/plugin-sdk/runtime";
|
||||
export {
|
||||
applyBasicWebhookRequestGuards,
|
||||
createFixedWindowRateLimiter,
|
||||
createWebhookAnomalyTracker,
|
||||
readJsonWebhookBodyOrReject,
|
||||
registerPluginHttpRoute,
|
||||
registerWebhookTarget,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
resolveWebhookPath,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
|
||||
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
export type {
|
||||
RegisterWebhookPluginRouteOptions,
|
||||
RegisterWebhookTargetOptions,
|
||||
} from "openclaw/plugin-sdk/webhook-ingress";
|
||||
10
extensions/zalo/src/runtime.ts
Normal file
10
extensions/zalo/src/runtime.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Zalo plugin module implements runtime behavior.
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "./runtime-support.js";
|
||||
|
||||
const { setRuntime: setZaloRuntime, getRuntime: getZaloRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "zalo",
|
||||
errorMessage: "Zalo runtime not initialized",
|
||||
});
|
||||
export { getZaloRuntime, setZaloRuntime };
|
||||
110
extensions/zalo/src/secret-contract.ts
Normal file
110
extensions/zalo/src/secret-contract.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// Zalo plugin module implements secret contract behavior.
|
||||
import {
|
||||
collectConditionalChannelFieldAssignments,
|
||||
getChannelSurface,
|
||||
hasOwnProperty,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
||||
|
||||
export const secretTargetRegistryEntries: SecretTargetRegistryEntry[] = [
|
||||
{
|
||||
id: "channels.zalo.accounts.*.botToken",
|
||||
targetType: "channels.zalo.accounts.*.botToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.zalo.accounts.*.botToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.zalo.accounts.*.webhookSecret",
|
||||
targetType: "channels.zalo.accounts.*.webhookSecret",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.zalo.accounts.*.webhookSecret",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.zalo.botToken",
|
||||
targetType: "channels.zalo.botToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.zalo.botToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.zalo.webhookSecret",
|
||||
targetType: "channels.zalo.webhookSecret",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.zalo.webhookSecret",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults?: SecretDefaults;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "zalo");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const { channel: zalo, surface } = resolved;
|
||||
collectConditionalChannelFieldAssignments({
|
||||
channelKey: "zalo",
|
||||
field: "botToken",
|
||||
channel: zalo,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topLevelActiveWithoutAccounts: true,
|
||||
topLevelInheritedAccountActive: ({ account, enabled }) =>
|
||||
enabled && !hasOwnProperty(account, "botToken"),
|
||||
accountActive: ({ enabled }) => enabled,
|
||||
topInactiveReason: "no enabled Zalo surface inherits this top-level botToken.",
|
||||
accountInactiveReason: "Zalo account is disabled.",
|
||||
});
|
||||
const baseWebhookUrl = typeof zalo.webhookUrl === "string" ? zalo.webhookUrl.trim() : "";
|
||||
const accountWebhookUrl = (account: Record<string, unknown>) =>
|
||||
hasOwnProperty(account, "webhookUrl")
|
||||
? typeof account.webhookUrl === "string"
|
||||
? account.webhookUrl.trim()
|
||||
: ""
|
||||
: baseWebhookUrl;
|
||||
collectConditionalChannelFieldAssignments({
|
||||
channelKey: "zalo",
|
||||
field: "webhookSecret",
|
||||
channel: zalo,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topLevelActiveWithoutAccounts: baseWebhookUrl.length > 0,
|
||||
topLevelInheritedAccountActive: ({ account, enabled }) =>
|
||||
enabled && !hasOwnProperty(account, "webhookSecret") && accountWebhookUrl(account).length > 0,
|
||||
accountActive: ({ account, enabled }) => enabled && accountWebhookUrl(account).length > 0,
|
||||
topInactiveReason:
|
||||
"no enabled Zalo webhook surface inherits this top-level webhookSecret (webhook mode is not active).",
|
||||
accountInactiveReason:
|
||||
"Zalo account is disabled or webhook mode is not active for this account.",
|
||||
});
|
||||
}
|
||||
|
||||
export const channelSecrets = {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
};
|
||||
6
extensions/zalo/src/secret-input.ts
Normal file
6
extensions/zalo/src/secret-input.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Zalo plugin module implements secret input behavior.
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
151
extensions/zalo/src/send.test.ts
Normal file
151
extensions/zalo/src/send.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
// Zalo tests cover send plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const sendMessageMock = vi.fn();
|
||||
const sendPhotoMock = vi.fn();
|
||||
const resolveZaloProxyFetchMock = vi.fn();
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
sendMessage: (...args: unknown[]) => sendMessageMock(...args),
|
||||
sendPhoto: (...args: unknown[]) => sendPhotoMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./proxy.js", () => ({
|
||||
resolveZaloProxyFetch: (...args: unknown[]) => resolveZaloProxyFetchMock(...args),
|
||||
}));
|
||||
|
||||
import { sendMessageZalo, sendPhotoZalo } from "./send.js";
|
||||
|
||||
type ZaloSendResult = Awaited<ReturnType<typeof sendMessageZalo>>;
|
||||
|
||||
function requireSuccessfulSend(result: ZaloSendResult, expectedMessageId: string) {
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) {
|
||||
throw new Error(`expected successful Zalo send: ${result.error}`);
|
||||
}
|
||||
expect(result.messageId).toBe(expectedMessageId);
|
||||
return result;
|
||||
}
|
||||
|
||||
function expectFailedSend(result: ZaloSendResult, expectedError: string) {
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("expected failed Zalo send");
|
||||
}
|
||||
expect(result.error).toBe(expectedError);
|
||||
expect(result.receipt.platformMessageIds).toStrictEqual([]);
|
||||
}
|
||||
|
||||
describe("zalo send", () => {
|
||||
beforeEach(() => {
|
||||
sendMessageMock.mockReset();
|
||||
sendPhotoMock.mockReset();
|
||||
resolveZaloProxyFetchMock.mockReset();
|
||||
resolveZaloProxyFetchMock.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
it("sends text messages through the message API", async () => {
|
||||
sendMessageMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { message_id: "z-msg-1" },
|
||||
});
|
||||
|
||||
const result = await sendMessageZalo("dm-chat-1", "hello there", {
|
||||
token: "zalo-token",
|
||||
});
|
||||
|
||||
expect(sendMessageMock).toHaveBeenCalledWith(
|
||||
"zalo-token",
|
||||
{
|
||||
chat_id: "dm-chat-1",
|
||||
text: "hello there",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(sendPhotoMock).not.toHaveBeenCalled();
|
||||
const successful = requireSuccessfulSend(result, "z-msg-1");
|
||||
expect(successful.receipt.primaryPlatformMessageId).toBe("z-msg-1");
|
||||
expect(successful.receipt.platformMessageIds).toEqual(["z-msg-1"]);
|
||||
expect(successful.receipt.parts).toHaveLength(1);
|
||||
expect(successful.receipt.parts[0]?.platformMessageId).toBe("z-msg-1");
|
||||
expect(successful.receipt.parts[0]?.kind).toBe("text");
|
||||
expect(successful.receipt.parts[0]?.raw).toEqual({
|
||||
channel: "zalo",
|
||||
chatId: "dm-chat-1",
|
||||
messageId: "z-msg-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("routes media-bearing sends through the photo API and uses text as caption", async () => {
|
||||
sendPhotoMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { message_id: "z-photo-1" },
|
||||
});
|
||||
|
||||
const result = await sendMessageZalo("dm-chat-2", "caption text", {
|
||||
token: "zalo-token",
|
||||
mediaUrl: "https://example.com/photo.jpg",
|
||||
caption: "ignored fallback caption",
|
||||
});
|
||||
|
||||
expect(sendPhotoMock).toHaveBeenCalledWith(
|
||||
"zalo-token",
|
||||
{
|
||||
chat_id: "dm-chat-2",
|
||||
photo: "https://example.com/photo.jpg",
|
||||
caption: "caption text",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
const successful = requireSuccessfulSend(result, "z-photo-1");
|
||||
expect(successful.receipt.primaryPlatformMessageId).toBe("z-photo-1");
|
||||
expect(successful.receipt.platformMessageIds).toEqual(["z-photo-1"]);
|
||||
expect(successful.receipt.parts).toHaveLength(1);
|
||||
expect(successful.receipt.parts[0]?.platformMessageId).toBe("z-photo-1");
|
||||
expect(successful.receipt.parts[0]?.kind).toBe("media");
|
||||
});
|
||||
|
||||
it("fails fast for missing token or blank photo URLs", async () => {
|
||||
const missingToken = await sendMessageZalo("dm-chat-3", "hello", {});
|
||||
expectFailedSend(missingToken, "No Zalo bot token configured");
|
||||
|
||||
const blankPhoto = await sendPhotoZalo("dm-chat-4", " ", {
|
||||
token: "zalo-token",
|
||||
});
|
||||
expectFailedSend(blankPhoto, "No photo URL provided");
|
||||
|
||||
expect(sendMessageMock).not.toHaveBeenCalled();
|
||||
expect(sendPhotoMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends cfg-backed media directly without hosted-media rewrites", async () => {
|
||||
sendPhotoMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
result: { message_id: "z-photo-2" },
|
||||
});
|
||||
|
||||
const result = await sendPhotoZalo("dm-chat-5", "https://example.com/photo.jpg", {
|
||||
cfg: {
|
||||
channels: {
|
||||
zalo: {
|
||||
botToken: "zalo-token",
|
||||
webhookUrl: "https://gateway.example.com/zalo-webhook",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(sendPhotoMock).toHaveBeenCalledWith(
|
||||
"zalo-token",
|
||||
{
|
||||
chat_id: "dm-chat-5",
|
||||
photo: "https://example.com/photo.jpg",
|
||||
caption: undefined,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
const successful = requireSuccessfulSend(result, "z-photo-2");
|
||||
expect(successful.receipt.platformMessageIds).toEqual(["z-photo-2"]);
|
||||
});
|
||||
});
|
||||
208
extensions/zalo/src/send.ts
Normal file
208
extensions/zalo/src/send.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// Zalo plugin module implements send behavior.
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type MessageReceipt,
|
||||
type MessageReceiptPartKind,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { resolveZaloAccount } from "./accounts.js";
|
||||
import type { ZaloFetch } from "./api.js";
|
||||
import { sendMessage, sendPhoto } from "./api.js";
|
||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||
import { resolveZaloToken } from "./token.js";
|
||||
|
||||
type ZaloSendOptions = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
cfg?: OpenClawConfig;
|
||||
mediaUrl?: string;
|
||||
caption?: string;
|
||||
verbose?: boolean;
|
||||
proxy?: string;
|
||||
};
|
||||
|
||||
type ZaloSendResult = {
|
||||
ok: boolean;
|
||||
messageId?: string;
|
||||
receipt: MessageReceipt;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function createZaloSendReceipt(params: {
|
||||
messageId?: string;
|
||||
chatId: string;
|
||||
kind: MessageReceiptPartKind;
|
||||
}): MessageReceipt {
|
||||
const messageId = params.messageId?.trim();
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results: messageId
|
||||
? [
|
||||
{
|
||||
channel: "zalo",
|
||||
messageId,
|
||||
chatId: params.chatId,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
kind: params.kind,
|
||||
});
|
||||
}
|
||||
|
||||
function toZaloSendResult(
|
||||
response: {
|
||||
ok?: boolean;
|
||||
result?: { message_id?: string };
|
||||
},
|
||||
params: { chatId: string; kind: MessageReceiptPartKind },
|
||||
): ZaloSendResult {
|
||||
if (response.ok && response.result) {
|
||||
return {
|
||||
ok: true,
|
||||
messageId: response.result.message_id,
|
||||
receipt: createZaloSendReceipt({
|
||||
messageId: response.result.message_id,
|
||||
chatId: params.chatId,
|
||||
kind: params.kind,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: "Failed to send message",
|
||||
receipt: createZaloSendReceipt({ chatId: params.chatId, kind: params.kind }),
|
||||
};
|
||||
}
|
||||
|
||||
async function runZaloSend(
|
||||
failureMessage: string,
|
||||
params: { chatId: string; kind: MessageReceiptPartKind },
|
||||
send: () => Promise<{ ok?: boolean; result?: { message_id?: string } }>,
|
||||
): Promise<ZaloSendResult> {
|
||||
try {
|
||||
const result = toZaloSendResult(await send(), params);
|
||||
return result.ok ? result : { ok: false, error: failureMessage, receipt: result.receipt };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: formatErrorMessage(err),
|
||||
receipt: createZaloSendReceipt({ chatId: params.chatId, kind: params.kind }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSendContext(options: ZaloSendOptions): {
|
||||
token: string;
|
||||
fetcher?: ZaloFetch;
|
||||
} {
|
||||
if (options.cfg) {
|
||||
const account = resolveZaloAccount({
|
||||
cfg: options.cfg,
|
||||
accountId: options.accountId,
|
||||
});
|
||||
const token = options.token || account.token;
|
||||
const proxy = options.proxy ?? account.config.proxy;
|
||||
return { token, fetcher: resolveZaloProxyFetch(proxy) };
|
||||
}
|
||||
|
||||
const token = options.token ?? resolveZaloToken(undefined, options.accountId).token;
|
||||
const proxy = options.proxy;
|
||||
return { token, fetcher: resolveZaloProxyFetch(proxy) };
|
||||
}
|
||||
|
||||
function resolveValidatedSendContext(
|
||||
chatId: string,
|
||||
options: ZaloSendOptions,
|
||||
): { ok: true; chatId: string; token: string; fetcher?: ZaloFetch } | { ok: false; error: string } {
|
||||
const { token, fetcher } = resolveSendContext(options);
|
||||
if (!token) {
|
||||
return { ok: false, error: "No Zalo bot token configured" };
|
||||
}
|
||||
const trimmedChatId = chatId?.trim();
|
||||
if (!trimmedChatId) {
|
||||
return { ok: false, error: "No chat_id provided" };
|
||||
}
|
||||
return { ok: true, chatId: trimmedChatId, token, fetcher };
|
||||
}
|
||||
|
||||
function resolveSendContextOrFailure(
|
||||
chatId: string,
|
||||
options: ZaloSendOptions,
|
||||
):
|
||||
| { context: { chatId: string; token: string; fetcher?: ZaloFetch } }
|
||||
| { failure: ZaloSendResult } {
|
||||
const context = resolveValidatedSendContext(chatId, options);
|
||||
return context.ok
|
||||
? { context }
|
||||
: {
|
||||
failure: {
|
||||
ok: false,
|
||||
error: context.error,
|
||||
receipt: createZaloSendReceipt({ chatId, kind: "unknown" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendMessageZalo(
|
||||
chatId: string,
|
||||
text: string,
|
||||
options: ZaloSendOptions = {},
|
||||
): Promise<ZaloSendResult> {
|
||||
const resolved = resolveSendContextOrFailure(chatId, options);
|
||||
if ("failure" in resolved) {
|
||||
return resolved.failure;
|
||||
}
|
||||
const { context } = resolved;
|
||||
|
||||
if (options.mediaUrl) {
|
||||
return sendPhotoZalo(context.chatId, options.mediaUrl, {
|
||||
...options,
|
||||
token: context.token,
|
||||
caption: text || options.caption,
|
||||
});
|
||||
}
|
||||
|
||||
return await runZaloSend("Failed to send message", { chatId: context.chatId, kind: "text" }, () =>
|
||||
sendMessage(
|
||||
context.token,
|
||||
{
|
||||
chat_id: context.chatId,
|
||||
text: text.slice(0, 2000),
|
||||
},
|
||||
context.fetcher,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendPhotoZalo(
|
||||
chatId: string,
|
||||
photoUrl: string,
|
||||
options: ZaloSendOptions = {},
|
||||
): Promise<ZaloSendResult> {
|
||||
const resolved = resolveSendContextOrFailure(chatId, options);
|
||||
if ("failure" in resolved) {
|
||||
return resolved.failure;
|
||||
}
|
||||
const { context } = resolved;
|
||||
|
||||
if (!photoUrl?.trim()) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "No photo URL provided",
|
||||
receipt: createZaloSendReceipt({ chatId: context.chatId, kind: "media" }),
|
||||
};
|
||||
}
|
||||
|
||||
return await runZaloSend("Failed to send photo", { chatId: context.chatId, kind: "media" }, () =>
|
||||
(async () =>
|
||||
sendPhoto(
|
||||
context.token,
|
||||
{
|
||||
chat_id: context.chatId,
|
||||
photo: photoUrl.trim(),
|
||||
caption: options.caption?.slice(0, 2000),
|
||||
},
|
||||
context.fetcher,
|
||||
))(),
|
||||
);
|
||||
}
|
||||
33
extensions/zalo/src/session-route.ts
Normal file
33
extensions/zalo/src/session-route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Zalo plugin module implements session route behavior.
|
||||
import {
|
||||
buildChannelOutboundSessionRoute,
|
||||
stripChannelTargetPrefix,
|
||||
stripTargetKindPrefix,
|
||||
type ChannelOutboundSessionRouteParams,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export function resolveZaloOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
|
||||
const trimmed = stripChannelTargetPrefix(params.target, "zalo", "zl");
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const isGroup = normalizeLowercaseStringOrEmpty(trimmed).startsWith("group:");
|
||||
const peerId = stripTargetKindPrefix(trimmed);
|
||||
if (!peerId) {
|
||||
return null;
|
||||
}
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "zalo",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `zalo:group:${peerId}` : `zalo:${peerId}`,
|
||||
to: `zalo:${peerId}`,
|
||||
});
|
||||
}
|
||||
98
extensions/zalo/src/setup-allow-from.ts
Normal file
98
extensions/zalo/src/setup-allow-from.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
// Zalo plugin module implements setup allow from behavior.
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
createSetupTranslator,
|
||||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
type ZaloAccountSetupConfig = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export async function noteZaloTokenHelp(
|
||||
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"],
|
||||
): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
t("wizard.zalo.helpOpenPlatform"),
|
||||
t("wizard.zalo.helpCreateBot"),
|
||||
t("wizard.zalo.helpTokenFormat"),
|
||||
t("wizard.zalo.helpEnvTip"),
|
||||
`Docs: ${formatDocsLink("/channels/zalo", "zalo")}`,
|
||||
].join("\n"),
|
||||
t("wizard.zalo.botTokenTitle"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function promptZaloAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
|
||||
accountId?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const { cfg, prompter } = params;
|
||||
const accountId = params.accountId ?? resolveDefaultZaloAccountId(cfg);
|
||||
const resolved = resolveZaloAccount({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
const entry = await prompter.text({
|
||||
message: t("wizard.zalo.allowFromPrompt"),
|
||||
placeholder: "123456789",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = (value ?? "").trim();
|
||||
if (!raw) {
|
||||
return t("common.required");
|
||||
}
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
return t("wizard.zalo.allowFromNumeric");
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = entry.trim();
|
||||
const unique = mergeAllowFromEntries(existingAllowFrom, [normalized]);
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
const currentAccount = cfg.channels?.zalo?.accounts?.[accountId] as
|
||||
| ZaloAccountSetupConfig
|
||||
| undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...cfg.channels?.zalo?.accounts,
|
||||
[accountId]: {
|
||||
...currentAccount,
|
||||
enabled: currentAccount?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
153
extensions/zalo/src/setup-core.ts
Normal file
153
extensions/zalo/src/setup-core.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// Zalo plugin module implements setup core behavior.
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
createDelegatedSetupWizardProxy,
|
||||
createPatchedAccountSetupAdapter,
|
||||
createSetupInputPresenceValidator,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
createSetupTranslator,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
|
||||
import { promptZaloAllowFrom } from "./setup-allow-from.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "zalo" as const;
|
||||
|
||||
type ZaloAccountSetupConfig = {
|
||||
enabled?: boolean;
|
||||
dmPolicy?: string;
|
||||
allowFrom?: Array<string | number> | ReadonlyArray<string | number>;
|
||||
};
|
||||
|
||||
export const zaloSetupAdapter = createPatchedAccountSetupAdapter({
|
||||
channelKey: channel,
|
||||
validateInput: createSetupInputPresenceValidator({
|
||||
defaultAccountOnlyEnvError: "ZALO_BOT_TOKEN can only be used for the default account.",
|
||||
whenNotUseEnv: [
|
||||
{
|
||||
someOf: ["token", "tokenFile"],
|
||||
message: "Zalo requires token or --token-file (or --use-env).",
|
||||
},
|
||||
],
|
||||
}),
|
||||
buildPatch: (input) =>
|
||||
input.useEnv
|
||||
? {}
|
||||
: input.tokenFile
|
||||
? { tokenFile: input.tokenFile }
|
||||
: input.token
|
||||
? { botToken: input.token }
|
||||
: {},
|
||||
});
|
||||
|
||||
export const zaloDmPolicy: ChannelSetupDmPolicy = {
|
||||
label: "Zalo",
|
||||
channel,
|
||||
policyKey: "channels.zalo.dmPolicy",
|
||||
allowFromKey: "channels.zalo.allowFrom",
|
||||
resolveConfigKeys: (cfg, accountId) =>
|
||||
(accountId ?? resolveDefaultZaloAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
|
||||
? {
|
||||
policyKey: `channels.zalo.accounts.${accountId ?? resolveDefaultZaloAccountId(cfg)}.dmPolicy`,
|
||||
allowFromKey: `channels.zalo.accounts.${accountId ?? resolveDefaultZaloAccountId(cfg)}.allowFrom`,
|
||||
}
|
||||
: {
|
||||
policyKey: "channels.zalo.dmPolicy",
|
||||
allowFromKey: "channels.zalo.allowFrom",
|
||||
},
|
||||
getCurrent: (cfg, accountId) =>
|
||||
resolveZaloAccount({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultZaloAccountId(cfg),
|
||||
}).config.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy, accountId) => {
|
||||
const resolvedAccountId =
|
||||
accountId && normalizeAccountId(accountId)
|
||||
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
|
||||
: resolveDefaultZaloAccountId(cfg);
|
||||
const resolved = resolveZaloAccount({
|
||||
cfg,
|
||||
accountId: resolvedAccountId,
|
||||
});
|
||||
if (resolvedAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
enabled: true,
|
||||
dmPolicy: policy,
|
||||
...(policy === "open"
|
||||
? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
const currentAccount = cfg.channels?.zalo?.accounts?.[resolvedAccountId] as
|
||||
| ZaloAccountSetupConfig
|
||||
| undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...cfg.channels?.zalo?.accounts,
|
||||
[resolvedAccountId]: {
|
||||
...currentAccount,
|
||||
enabled: currentAccount?.enabled ?? true,
|
||||
dmPolicy: policy,
|
||||
...(policy === "open"
|
||||
? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) =>
|
||||
promptZaloAllowFrom({
|
||||
cfg,
|
||||
prompter,
|
||||
accountId: accountId ?? resolveDefaultZaloAccountId(cfg),
|
||||
}),
|
||||
};
|
||||
|
||||
export function createZaloSetupWizardProxy(
|
||||
loadWizard: () => Promise<ChannelSetupWizard>,
|
||||
): ChannelSetupWizard {
|
||||
return createDelegatedSetupWizardProxy({
|
||||
channel,
|
||||
loadWizard,
|
||||
status: {
|
||||
configuredLabel: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsToken"),
|
||||
configuredHint: t("wizard.channels.statusRecommendedConfigured"),
|
||||
unconfiguredHint: t("wizard.channels.statusRecommendedNewcomerFriendly"),
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 10,
|
||||
},
|
||||
credentials: [],
|
||||
delegateFinalize: true,
|
||||
dmPolicy: zaloDmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
34
extensions/zalo/src/setup-status.test.ts
Normal file
34
extensions/zalo/src/setup-status.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
// Zalo tests cover setup status plugin behavior.
|
||||
import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { zaloSetupWizard } from "./setup-surface.js";
|
||||
|
||||
const zaloGetStatus = createPluginSetupWizardStatus({
|
||||
id: "zalo",
|
||||
meta: {
|
||||
label: "Zalo",
|
||||
},
|
||||
setupWizard: zaloSetupWizard,
|
||||
} as never);
|
||||
|
||||
describe("zalo setup wizard status", () => {
|
||||
it("treats SecretRef botToken as configured", async () => {
|
||||
const status = await zaloGetStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalo: {
|
||||
botToken: {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "ZALO_BOT_TOKEN",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountOverrides: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(true);
|
||||
});
|
||||
});
|
||||
194
extensions/zalo/src/setup-surface.test.ts
Normal file
194
extensions/zalo/src/setup-surface.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// Zalo tests cover setup surface plugin behavior.
|
||||
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount } from "./accounts.js";
|
||||
import { zaloDmPolicy } from "./setup-core.js";
|
||||
import { zaloSetupAdapter, zaloSetupWizard } from "./setup-surface.js";
|
||||
|
||||
const zaloSetupPlugin = {
|
||||
id: "zalo",
|
||||
meta: {
|
||||
id: "zalo",
|
||||
label: "Zalo",
|
||||
selectionLabel: "Zalo (Bot API)",
|
||||
docsPath: "/channels/zalo",
|
||||
blurb: "Vietnam-focused messaging platform with Bot API.",
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
|
||||
},
|
||||
config: {
|
||||
listAccountIds: (cfg: unknown) => listZaloAccountIds(cfg as never),
|
||||
defaultAccountId: (cfg: unknown) => resolveDefaultZaloAccountId(cfg as never),
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveZaloAccount),
|
||||
},
|
||||
setup: zaloSetupAdapter,
|
||||
setupWizard: zaloSetupWizard,
|
||||
} as const;
|
||||
|
||||
const zaloConfigure = createPluginSetupWizardConfigure(zaloSetupPlugin);
|
||||
|
||||
describe("zalo setup wizard", () => {
|
||||
it("configures a polling token flow", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
select: vi.fn(async () => "plaintext") as WizardPrompter["select"],
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Enter Zalo bot token") {
|
||||
return "12345689:abc-xyz";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Use webhook mode for Zalo?") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: zaloConfigure,
|
||||
cfg: {} as OpenClawConfig,
|
||||
prompter,
|
||||
options: { secretInputMode: "plaintext" as const },
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
const zaloConfig = result.cfg.channels?.zalo;
|
||||
if (!zaloConfig) {
|
||||
throw new Error("expected Zalo config");
|
||||
}
|
||||
expect(zaloConfig.enabled).toBe(true);
|
||||
expect(zaloConfig.botToken).toBe("12345689:abc-xyz");
|
||||
expect(zaloConfig.webhookUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reads the named-account DM policy instead of the channel root", () => {
|
||||
expect(
|
||||
zaloDmPolicy.getCurrent(
|
||||
{
|
||||
channels: {
|
||||
zalo: {
|
||||
dmPolicy: "disabled",
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
"work",
|
||||
),
|
||||
).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("reports account-scoped config keys for named accounts", () => {
|
||||
expect(zaloDmPolicy.resolveConfigKeys?.({} as OpenClawConfig, "work")).toEqual({
|
||||
policyKey: "channels.zalo.accounts.work.dmPolicy",
|
||||
allowFromKey: "channels.zalo.accounts.work.allowFrom",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted DM policy account context", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
zalo: {
|
||||
defaultAccount: "work",
|
||||
dmPolicy: "disabled",
|
||||
allowFrom: ["123456789"],
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(zaloDmPolicy.getCurrent(cfg)).toBe("allowlist");
|
||||
expect(zaloDmPolicy.resolveConfigKeys?.(cfg)).toEqual({
|
||||
policyKey: "channels.zalo.accounts.work.dmPolicy",
|
||||
allowFromKey: "channels.zalo.accounts.work.allowFrom",
|
||||
});
|
||||
|
||||
const next = zaloDmPolicy.setPolicy(cfg, "open");
|
||||
const zaloConfig = next.channels?.zalo;
|
||||
if (!zaloConfig) {
|
||||
throw new Error("expected Zalo config");
|
||||
}
|
||||
expect(zaloConfig.dmPolicy).toBe("disabled");
|
||||
const workAccount = next.channels?.zalo?.accounts?.work as
|
||||
| { dmPolicy?: string; allowFrom?: Array<string | number> }
|
||||
| undefined;
|
||||
if (!workAccount) {
|
||||
throw new Error("expected Zalo work account");
|
||||
}
|
||||
expect(workAccount.dmPolicy).toBe("open");
|
||||
});
|
||||
|
||||
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', () => {
|
||||
const next = zaloDmPolicy.setPolicy(
|
||||
{
|
||||
channels: {
|
||||
zalo: {
|
||||
allowFrom: ["123456789"],
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "12345689:abc-xyz",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
"open",
|
||||
"work",
|
||||
);
|
||||
|
||||
const zaloConfig = next.channels?.zalo;
|
||||
if (!zaloConfig) {
|
||||
throw new Error("expected Zalo config");
|
||||
}
|
||||
expect(zaloConfig.dmPolicy).toBeUndefined();
|
||||
const workAccount = next.channels?.zalo?.accounts?.work as
|
||||
| { dmPolicy?: string; allowFrom?: Array<string | number> }
|
||||
| undefined;
|
||||
if (!workAccount) {
|
||||
throw new Error("expected Zalo work account");
|
||||
}
|
||||
expect(workAccount.dmPolicy).toBe("open");
|
||||
expect(workAccount.allowFrom).toEqual(["123456789", "*"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted setup configured state", async () => {
|
||||
const configured = await zaloSetupWizard.status.resolveConfigured({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalo: {
|
||||
defaultAccount: "work",
|
||||
botToken: "root-token",
|
||||
accounts: {
|
||||
alerts: {
|
||||
botToken: "alerts-token",
|
||||
},
|
||||
work: {
|
||||
botToken: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(configured).toBe(false);
|
||||
});
|
||||
});
|
||||
295
extensions/zalo/src/setup-surface.ts
Normal file
295
extensions/zalo/src/setup-surface.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
// Zalo plugin module implements setup surface behavior.
|
||||
import {
|
||||
buildSingleChannelSecretPromptState,
|
||||
createStandardChannelSetupStatus,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
hasConfiguredSecretInput,
|
||||
promptSingleChannelSecretInput,
|
||||
runSingleChannelSecretStep,
|
||||
type ChannelSetupWizard,
|
||||
type OpenClawConfig,
|
||||
type SecretInput,
|
||||
createSetupTranslator,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { resolveZaloAccount } from "./accounts.js";
|
||||
import { noteZaloTokenHelp, promptZaloAllowFrom } from "./setup-allow-from.js";
|
||||
import { zaloDmPolicy } from "./setup-core.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "zalo" as const;
|
||||
|
||||
type UpdateMode = "polling" | "webhook";
|
||||
|
||||
function setZaloUpdateMode(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
mode: UpdateMode,
|
||||
webhookUrl?: string,
|
||||
webhookSecret?: SecretInput,
|
||||
webhookPath?: string,
|
||||
): OpenClawConfig {
|
||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
if (mode === "polling") {
|
||||
if (isDefault) {
|
||||
const {
|
||||
webhookUrl: _url,
|
||||
webhookSecret: _secret,
|
||||
webhookPath: _path,
|
||||
...rest
|
||||
} = cfg.channels?.zalo ?? {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: rest,
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
const accounts = { ...cfg.channels?.zalo?.accounts } as Record<string, Record<string, unknown>>;
|
||||
const existing = accounts[accountId] ?? {};
|
||||
const { webhookUrl: _url, webhookSecret: _secret, webhookPath: _path, ...rest } = existing;
|
||||
accounts[accountId] = rest;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
accounts,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
if (isDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
webhookPath,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
const accounts = { ...cfg.channels?.zalo?.accounts } as Record<string, Record<string, unknown>>;
|
||||
accounts[accountId] = {
|
||||
...accounts[accountId],
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
webhookPath,
|
||||
};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
accounts,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
export { zaloSetupAdapter } from "./setup-core.js";
|
||||
|
||||
export const zaloSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: createStandardChannelSetupStatus({
|
||||
channelLabel: "Zalo",
|
||||
configuredLabel: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsToken"),
|
||||
configuredHint: t("wizard.channels.statusRecommendedConfigured"),
|
||||
unconfiguredHint: t("wizard.channels.statusRecommendedNewcomerFriendly"),
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 10,
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) => {
|
||||
const account = resolveZaloAccount({
|
||||
cfg,
|
||||
accountId,
|
||||
allowUnresolvedSecretRef: true,
|
||||
});
|
||||
return (
|
||||
Boolean(account.token) ||
|
||||
hasConfiguredSecretInput(account.config.botToken) ||
|
||||
Boolean(account.config.tokenFile?.trim())
|
||||
);
|
||||
},
|
||||
}),
|
||||
credentials: [],
|
||||
finalize: async ({ cfg, accountId, forceAllowFrom, options, prompter }) => {
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveZaloAccount({
|
||||
cfg: next,
|
||||
accountId,
|
||||
allowUnresolvedSecretRef: true,
|
||||
});
|
||||
const accountConfigured = Boolean(resolvedAccount.token);
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const hasConfigToken = Boolean(
|
||||
hasConfiguredSecretInput(resolvedAccount.config.botToken) || resolvedAccount.config.tokenFile,
|
||||
);
|
||||
const tokenStep = await runSingleChannelSecretStep({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "zalo",
|
||||
credentialLabel: t("wizard.zalo.botToken"),
|
||||
secretInputMode: options?.secretInputMode,
|
||||
accountConfigured,
|
||||
hasConfigToken,
|
||||
allowEnv,
|
||||
envValue: process.env.ZALO_BOT_TOKEN,
|
||||
envPrompt: t("wizard.zalo.tokenEnvPrompt"),
|
||||
keepPrompt: t("wizard.zalo.tokenKeep"),
|
||||
inputPrompt: t("wizard.zalo.tokenInput"),
|
||||
preferredEnvVar: "ZALO_BOT_TOKEN",
|
||||
onMissingConfigured: async () => await noteZaloTokenHelp(prompter),
|
||||
applyUseEnv: async (currentCfg) =>
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? ({
|
||||
...currentCfg,
|
||||
channels: {
|
||||
...currentCfg.channels,
|
||||
zalo: {
|
||||
...currentCfg.channels?.zalo,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig)
|
||||
: currentCfg,
|
||||
applySet: async (currentCfg, value) =>
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? ({
|
||||
...currentCfg,
|
||||
channels: {
|
||||
...currentCfg.channels,
|
||||
zalo: {
|
||||
...currentCfg.channels?.zalo,
|
||||
enabled: true,
|
||||
botToken: value,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig)
|
||||
: ({
|
||||
...currentCfg,
|
||||
channels: {
|
||||
...currentCfg.channels,
|
||||
zalo: {
|
||||
...currentCfg.channels?.zalo,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...currentCfg.channels?.zalo?.accounts,
|
||||
[accountId]: {
|
||||
...(currentCfg.channels?.zalo?.accounts?.[accountId] as
|
||||
| Record<string, unknown>
|
||||
| undefined),
|
||||
enabled: true,
|
||||
botToken: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig),
|
||||
});
|
||||
next = tokenStep.cfg;
|
||||
|
||||
const wantsWebhook = await prompter.confirm({
|
||||
message: t("wizard.zalo.webhookModePrompt"),
|
||||
initialValue: Boolean(resolvedAccount.config.webhookUrl),
|
||||
});
|
||||
if (wantsWebhook) {
|
||||
const webhookUrl = (
|
||||
await prompter.text({
|
||||
message: t("wizard.zalo.webhookUrlPrompt"),
|
||||
initialValue: resolvedAccount.config.webhookUrl,
|
||||
validate: (value) =>
|
||||
value?.trim()?.startsWith("https://") ? undefined : "HTTPS URL required",
|
||||
})
|
||||
).trim();
|
||||
const defaultPath = (() => {
|
||||
try {
|
||||
return new URL(webhookUrl).pathname || "/zalo-webhook";
|
||||
} catch {
|
||||
return "/zalo-webhook";
|
||||
}
|
||||
})();
|
||||
|
||||
let webhookSecretResult = await promptSingleChannelSecretInput({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "zalo-webhook",
|
||||
credentialLabel: t("wizard.zalo.webhookSecret"),
|
||||
secretInputMode: options?.secretInputMode,
|
||||
...buildSingleChannelSecretPromptState({
|
||||
accountConfigured: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
|
||||
hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.webhookSecret),
|
||||
allowEnv: false,
|
||||
}),
|
||||
envPrompt: "",
|
||||
keepPrompt: t("wizard.zalo.webhookSecretKeep"),
|
||||
inputPrompt: t("wizard.zalo.webhookSecretInput"),
|
||||
preferredEnvVar: "ZALO_WEBHOOK_SECRET",
|
||||
});
|
||||
while (
|
||||
webhookSecretResult.action === "set" &&
|
||||
typeof webhookSecretResult.value === "string" &&
|
||||
(webhookSecretResult.value.length < 8 || webhookSecretResult.value.length > 256)
|
||||
) {
|
||||
await prompter.note(t("wizard.zalo.webhookSecretLength"), t("wizard.zalo.webhookTitle"));
|
||||
webhookSecretResult = await promptSingleChannelSecretInput({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "zalo-webhook",
|
||||
credentialLabel: t("wizard.zalo.webhookSecret"),
|
||||
secretInputMode: options?.secretInputMode,
|
||||
...buildSingleChannelSecretPromptState({
|
||||
accountConfigured: false,
|
||||
hasConfigToken: false,
|
||||
allowEnv: false,
|
||||
}),
|
||||
envPrompt: "",
|
||||
keepPrompt: t("wizard.zalo.webhookSecretKeep"),
|
||||
inputPrompt: t("wizard.zalo.webhookSecretInput"),
|
||||
preferredEnvVar: "ZALO_WEBHOOK_SECRET",
|
||||
});
|
||||
}
|
||||
const webhookSecret =
|
||||
webhookSecretResult.action === "set"
|
||||
? webhookSecretResult.value
|
||||
: resolvedAccount.config.webhookSecret;
|
||||
const webhookPath = (
|
||||
await prompter.text({
|
||||
message: t("wizard.zalo.webhookPathPrompt"),
|
||||
initialValue: resolvedAccount.config.webhookPath ?? defaultPath,
|
||||
})
|
||||
).trim();
|
||||
next = setZaloUpdateMode(
|
||||
next,
|
||||
accountId,
|
||||
"webhook",
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
webhookPath || undefined,
|
||||
);
|
||||
} else {
|
||||
next = setZaloUpdateMode(next, accountId, "polling");
|
||||
}
|
||||
|
||||
if (forceAllowFrom) {
|
||||
next = await promptZaloAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
return { cfg: next };
|
||||
},
|
||||
dmPolicy: zaloDmPolicy,
|
||||
};
|
||||
18
extensions/zalo/src/status-issues.test.ts
Normal file
18
extensions/zalo/src/status-issues.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Zalo tests cover status issues plugin behavior.
|
||||
import { expectOpenDmPolicyConfigIssue } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { describe, it } from "vitest";
|
||||
import { collectZaloStatusIssues } from "./status-issues.js";
|
||||
|
||||
describe("collectZaloStatusIssues", () => {
|
||||
it("warns when dmPolicy is open", () => {
|
||||
expectOpenDmPolicyConfigIssue({
|
||||
collectIssues: collectZaloStatusIssues,
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
38
extensions/zalo/src/status-issues.ts
Normal file
38
extensions/zalo/src/status-issues.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Zalo 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 ZALO_STATUS_FIELDS = ["accountId", "enabled", "configured", "dmPolicy"] as const;
|
||||
|
||||
export function collectZaloStatusIssues(accounts: ChannelAccountSnapshot[]): ChannelStatusIssue[] {
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
for (const entry of accounts) {
|
||||
const account = readStatusIssueFields(entry, ZALO_STATUS_FIELDS);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const accountId = coerceStatusIssueAccountId(account.accountId) ?? "default";
|
||||
const enabled = account.enabled !== false;
|
||||
const configured = account.configured === true;
|
||||
if (!enabled || !configured) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (account.dmPolicy === "open") {
|
||||
issues.push({
|
||||
channel: "zalo",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: 'Zalo dmPolicy is "open", allowing any user to message the bot without pairing.',
|
||||
fix: 'Set channels.zalo.dmPolicy to "pairing" or "allowlist" to restrict access.',
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
460
extensions/zalo/src/test-support/lifecycle-test-support.ts
Normal file
460
extensions/zalo/src/test-support/lifecycle-test-support.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
// Zalo plugin module implements lifecycle test support behavior.
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { createPluginRuntimeMediaMock } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { expect, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import type { ResolvedZaloAccount } from "../types.js";
|
||||
|
||||
function resolveLifecycleAllowFrom(params: {
|
||||
dmPolicy: "open" | "pairing";
|
||||
allowFrom?: string[];
|
||||
}): string[] | undefined {
|
||||
return params.allowFrom ?? (params.dmPolicy === "open" ? ["*"] : undefined);
|
||||
}
|
||||
|
||||
function createLifecycleConfig(params: {
|
||||
accountId: string;
|
||||
dmPolicy: "open" | "pairing";
|
||||
allowFrom?: string[];
|
||||
webhookUrl?: string;
|
||||
webhookSecret?: string;
|
||||
}): OpenClawConfig {
|
||||
const webhookUrl = params.webhookUrl ?? "https://example.com/hooks/zalo";
|
||||
const webhookSecret = params.webhookSecret ?? "supersecret";
|
||||
const allowFrom = resolveLifecycleAllowFrom(params);
|
||||
return {
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
accounts: {
|
||||
[params.accountId]: {
|
||||
enabled: true,
|
||||
webhookUrl,
|
||||
webhookSecret, // pragma: allowlist secret
|
||||
dmPolicy: params.dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createLifecycleAccount(params: {
|
||||
accountId: string;
|
||||
dmPolicy: "open" | "pairing";
|
||||
allowFrom?: string[];
|
||||
webhookUrl?: string;
|
||||
webhookSecret?: string;
|
||||
}): ResolvedZaloAccount {
|
||||
const webhookUrl = params.webhookUrl ?? "https://example.com/hooks/zalo";
|
||||
const webhookSecret = params.webhookSecret ?? "supersecret";
|
||||
const allowFrom = resolveLifecycleAllowFrom(params);
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
enabled: true,
|
||||
token: "zalo-token",
|
||||
tokenSource: "config",
|
||||
config: {
|
||||
webhookUrl,
|
||||
webhookSecret, // pragma: allowlist secret
|
||||
dmPolicy: params.dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
} as ResolvedZaloAccount;
|
||||
}
|
||||
|
||||
export function createLifecycleMonitorSetup(params: {
|
||||
accountId: string;
|
||||
dmPolicy: "open" | "pairing";
|
||||
allowFrom?: string[];
|
||||
webhookUrl?: string;
|
||||
webhookSecret?: string;
|
||||
}) {
|
||||
return {
|
||||
account: createLifecycleAccount(params),
|
||||
config: createLifecycleConfig(params),
|
||||
};
|
||||
}
|
||||
|
||||
export function createTextUpdate(params: {
|
||||
messageId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
chatId: string;
|
||||
text?: string;
|
||||
}) {
|
||||
return {
|
||||
event_name: "message.text.received",
|
||||
message: {
|
||||
from: { id: params.userId, name: params.userName },
|
||||
chat: { id: params.chatId, chat_type: "PRIVATE" as const },
|
||||
message_id: params.messageId,
|
||||
date: Math.floor(Date.now() / 1000),
|
||||
text: params.text ?? "hello from zalo",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageUpdate(params?: {
|
||||
messageId?: string;
|
||||
userId?: string;
|
||||
displayName?: string;
|
||||
chatId?: string;
|
||||
photoUrl?: string;
|
||||
caption?: string;
|
||||
date?: number;
|
||||
}) {
|
||||
return {
|
||||
event_name: "message.image.received",
|
||||
message: {
|
||||
date: params?.date ?? 1774086023728,
|
||||
chat: { chat_type: "PRIVATE" as const, id: params?.chatId ?? "chat-123" },
|
||||
caption: params?.caption ?? "",
|
||||
message_id: params?.messageId ?? "msg-123",
|
||||
message_type: "CHAT_PHOTO",
|
||||
from: {
|
||||
id: params?.userId ?? "user-123",
|
||||
is_bot: false,
|
||||
display_name: params?.displayName ?? "Test User",
|
||||
},
|
||||
photo_url: params?.photoUrl ?? "https://example.com/test-image.jpg",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createImageLifecycleCore() {
|
||||
const finalizeInboundContextMock = vi.fn((ctx: Record<string, unknown>) => ctx);
|
||||
const buildChannelInboundEventContextMock = vi.fn(
|
||||
(params: {
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
messageId?: string;
|
||||
timestamp?: number;
|
||||
from: string;
|
||||
sender: { id: string; name?: string };
|
||||
conversation: { kind: string; label?: string };
|
||||
route: {
|
||||
accountId?: string;
|
||||
routeSessionKey: string;
|
||||
dispatchSessionKey?: string;
|
||||
};
|
||||
reply: { to: string; originatingTo: string };
|
||||
message: { body?: string; rawBody: string; bodyForAgent?: string; commandBody?: string };
|
||||
media?: Array<{ path?: string; url?: string; contentType?: string }>;
|
||||
extra?: Record<string, unknown>;
|
||||
}) =>
|
||||
finalizeInboundContextMock({
|
||||
Body: params.message.body ?? params.message.rawBody,
|
||||
BodyForAgent: params.message.bodyForAgent ?? params.message.rawBody,
|
||||
RawBody: params.message.rawBody,
|
||||
CommandBody: params.message.commandBody ?? params.message.rawBody,
|
||||
From: params.from,
|
||||
To: params.reply.to,
|
||||
SessionKey: params.route.dispatchSessionKey ?? params.route.routeSessionKey,
|
||||
AccountId: params.route.accountId ?? params.accountId,
|
||||
ChatType: params.conversation.kind,
|
||||
ConversationLabel: params.conversation.label,
|
||||
SenderName: params.sender.name,
|
||||
SenderId: params.sender.id,
|
||||
Provider: params.channel,
|
||||
Surface: params.channel,
|
||||
MessageSid: params.messageId,
|
||||
Timestamp: params.timestamp,
|
||||
MediaPath: params.media?.[0]?.path,
|
||||
MediaType: params.media?.[0]?.contentType,
|
||||
MediaUrl: params.media?.[0]?.url ?? params.media?.[0]?.path,
|
||||
OriginatingChannel: params.channel,
|
||||
OriginatingTo: params.reply.originatingTo,
|
||||
...params.extra,
|
||||
}),
|
||||
);
|
||||
const recordInboundSessionMock = vi.fn(async () => undefined);
|
||||
const readRemoteMediaBufferMock = vi.fn(async () => ({
|
||||
buffer: Buffer.from("image-bytes"),
|
||||
contentType: "image/jpeg",
|
||||
}));
|
||||
const saveRemoteMediaMock = vi.fn(async () => ({
|
||||
path: "/tmp/zalo-photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
}));
|
||||
const saveMediaBufferMock = vi.fn(async () => ({
|
||||
path: "/tmp/zalo-photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
}));
|
||||
const readAllowFromStoreMock = vi.fn(async () => [] as string[]);
|
||||
const upsertPairingRequestMock = vi.fn(async () => ({ code: "PAIRCODE", created: true }));
|
||||
const core = {
|
||||
logging: {
|
||||
shouldLogVerbose: vi.fn(
|
||||
() => false,
|
||||
) as unknown as PluginRuntime["logging"]["shouldLogVerbose"],
|
||||
},
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore:
|
||||
readAllowFromStoreMock as unknown as PluginRuntime["channel"]["pairing"]["readAllowFromStore"],
|
||||
upsertPairingRequest:
|
||||
upsertPairingRequestMock as unknown as PluginRuntime["channel"]["pairing"]["upsertPairingRequest"],
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute: vi.fn(() => ({
|
||||
agentId: "main",
|
||||
accountId: "default",
|
||||
sessionKey: "agent:main:zalo:direct:chat-123",
|
||||
})) as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: vi.fn(
|
||||
() => "/tmp/zalo-sessions.json",
|
||||
) as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
|
||||
readSessionUpdatedAt: vi.fn(
|
||||
() => undefined,
|
||||
) as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
|
||||
recordInboundSession:
|
||||
recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"],
|
||||
},
|
||||
text: {
|
||||
resolveMarkdownTableMode: vi.fn(
|
||||
() => "code",
|
||||
) as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
|
||||
},
|
||||
media: createPluginRuntimeMediaMock({
|
||||
readRemoteMediaBuffer:
|
||||
readRemoteMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["readRemoteMediaBuffer"],
|
||||
saveRemoteMedia:
|
||||
saveRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["saveRemoteMedia"],
|
||||
saveMediaBuffer:
|
||||
saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
||||
}) as unknown as PluginRuntime["channel"]["media"],
|
||||
reply: {
|
||||
finalizeInboundContext:
|
||||
finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"],
|
||||
resolveEnvelopeFormatOptions: vi.fn(() => ({
|
||||
template: "channel+name+time",
|
||||
})) as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
||||
formatAgentEnvelope: vi.fn(
|
||||
(opts: { body: string }) => opts.body,
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["formatAgentEnvelope"],
|
||||
dispatchReplyWithBufferedBlockDispatcher: vi.fn(
|
||||
async () => undefined,
|
||||
) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
},
|
||||
inbound: {
|
||||
run: vi.fn(async (params: Parameters<PluginRuntime["channel"]["inbound"]["run"]>[0]) => {
|
||||
const input = await params.adapter.ingest(params.raw);
|
||||
if (!input) {
|
||||
return {
|
||||
admission: { kind: "drop" as const, reason: "ingest-null" },
|
||||
dispatched: false,
|
||||
};
|
||||
}
|
||||
const resolved = await params.adapter.resolveTurn(
|
||||
input,
|
||||
{
|
||||
kind: "message",
|
||||
canStartAgentTurn: true,
|
||||
},
|
||||
{},
|
||||
);
|
||||
await resolved.recordInboundSession({
|
||||
storePath: resolved.storePath,
|
||||
sessionKey: resolved.ctxPayload.SessionKey ?? resolved.routeSessionKey,
|
||||
ctx: resolved.ctxPayload,
|
||||
groupResolution: resolved.record?.groupResolution,
|
||||
createIfMissing: resolved.record?.createIfMissing,
|
||||
updateLastRoute: resolved.record?.updateLastRoute,
|
||||
onRecordError: resolved.record?.onRecordError ?? (() => undefined),
|
||||
});
|
||||
if ("runDispatch" in resolved) {
|
||||
const dispatchResult = await resolved.runDispatch();
|
||||
return {
|
||||
admission: { kind: "dispatch" as const },
|
||||
dispatched: true,
|
||||
ctxPayload: resolved.ctxPayload,
|
||||
routeSessionKey: resolved.routeSessionKey,
|
||||
dispatchResult,
|
||||
};
|
||||
}
|
||||
const dispatchResult = await resolved.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: resolved.ctxPayload,
|
||||
cfg: resolved.cfg,
|
||||
dispatcherOptions: {
|
||||
...resolved.dispatcherOptions,
|
||||
deliver: async (...args: Parameters<typeof resolved.delivery.deliver>) => {
|
||||
await resolved.delivery.deliver(...args);
|
||||
},
|
||||
onError: resolved.delivery.onError,
|
||||
},
|
||||
replyOptions: resolved.replyOptions,
|
||||
replyResolver: resolved.replyResolver,
|
||||
});
|
||||
return {
|
||||
admission: { kind: "dispatch" as const },
|
||||
dispatched: true,
|
||||
ctxPayload: resolved.ctxPayload,
|
||||
routeSessionKey: resolved.routeSessionKey,
|
||||
dispatchResult,
|
||||
};
|
||||
}) as unknown as PluginRuntime["channel"]["inbound"]["run"],
|
||||
dispatchReply: vi.fn(
|
||||
async (params: Parameters<PluginRuntime["channel"]["inbound"]["dispatchReply"]>[0]) => {
|
||||
await params.recordInboundSession({
|
||||
storePath: params.storePath,
|
||||
sessionKey: params.ctxPayload.SessionKey ?? params.routeSessionKey,
|
||||
ctx: params.ctxPayload,
|
||||
groupResolution: params.record?.groupResolution,
|
||||
createIfMissing: params.record?.createIfMissing,
|
||||
updateLastRoute: params.record?.updateLastRoute,
|
||||
onRecordError: params.record?.onRecordError ?? (() => undefined),
|
||||
});
|
||||
const dispatchResult = await params.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: params.ctxPayload,
|
||||
cfg: params.cfg,
|
||||
dispatcherOptions: {
|
||||
...params.dispatcherOptions,
|
||||
deliver: async (...args: Parameters<typeof params.delivery.deliver>) => {
|
||||
await params.delivery.deliver(...args);
|
||||
},
|
||||
onError: params.delivery.onError,
|
||||
},
|
||||
replyOptions: params.replyOptions,
|
||||
replyResolver: params.replyResolver,
|
||||
});
|
||||
return {
|
||||
admission: params.admission ?? { kind: "dispatch" as const },
|
||||
dispatched: true,
|
||||
ctxPayload: params.ctxPayload,
|
||||
routeSessionKey: params.routeSessionKey,
|
||||
dispatchResult,
|
||||
};
|
||||
},
|
||||
) as unknown as PluginRuntime["channel"]["inbound"]["dispatchReply"],
|
||||
buildContext:
|
||||
buildChannelInboundEventContextMock as unknown as PluginRuntime["channel"]["inbound"]["buildContext"],
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn(
|
||||
() => false,
|
||||
) as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"],
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(
|
||||
() => false,
|
||||
) as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
|
||||
isControlCommandMessage: vi.fn(
|
||||
() => false,
|
||||
) as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"],
|
||||
},
|
||||
},
|
||||
} as PluginRuntime;
|
||||
return {
|
||||
core,
|
||||
finalizeInboundContextMock,
|
||||
recordInboundSessionMock,
|
||||
readRemoteMediaBufferMock,
|
||||
saveRemoteMediaMock,
|
||||
saveMediaBufferMock,
|
||||
readAllowFromStoreMock,
|
||||
upsertPairingRequestMock,
|
||||
};
|
||||
}
|
||||
|
||||
export function expectImageLifecycleDelivery(params: {
|
||||
readRemoteMediaBufferMock: ReturnType<typeof vi.fn>;
|
||||
saveRemoteMediaMock?: ReturnType<typeof vi.fn>;
|
||||
saveMediaBufferMock: ReturnType<typeof vi.fn>;
|
||||
finalizeInboundContextMock: ReturnType<typeof vi.fn>;
|
||||
recordInboundSessionMock: ReturnType<typeof vi.fn>;
|
||||
photoUrl?: string;
|
||||
senderName?: string;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
}) {
|
||||
const photoUrl = params.photoUrl ?? "https://example.com/test-image.jpg";
|
||||
const senderName = params.senderName ?? "Test User";
|
||||
const mediaPath = params.mediaPath ?? "/tmp/zalo-photo.jpg";
|
||||
const mediaType = params.mediaType ?? "image/jpeg";
|
||||
const saveRemoteMediaMock = params.saveRemoteMediaMock ?? params.readRemoteMediaBufferMock;
|
||||
expect(saveRemoteMediaMock).toHaveBeenCalledWith({
|
||||
url: photoUrl,
|
||||
maxBytes: 5 * 1024 * 1024,
|
||||
});
|
||||
expect(params.saveMediaBufferMock).not.toHaveBeenCalled();
|
||||
expect(params.finalizeInboundContextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
SenderName: senderName,
|
||||
MediaPath: mediaPath,
|
||||
MediaType: mediaType,
|
||||
}),
|
||||
);
|
||||
expect(params.recordInboundSessionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
SenderName: senderName,
|
||||
MediaPath: mediaPath,
|
||||
MediaType: mediaType,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function settleAsyncWork(): Promise<void> {
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
await Promise.resolve();
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function postWebhookUpdate(params: {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
secret: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) {
|
||||
const url = new URL(params.path, params.baseUrl);
|
||||
const body = JSON.stringify(params.payload);
|
||||
return await new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||
const req = httpRequest(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
"x-bot-api-secret-token": params.secret,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function postWebhookReplay(params: {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
secret: string;
|
||||
payload: Record<string, unknown>;
|
||||
settleBeforeReplay?: boolean;
|
||||
}) {
|
||||
const first = await postWebhookUpdate(params);
|
||||
if (params.settleBeforeReplay) {
|
||||
await settleAsyncWork();
|
||||
}
|
||||
const replay = await postWebhookUpdate(params);
|
||||
return { first, replay };
|
||||
}
|
||||
209
extensions/zalo/src/test-support/monitor-mocks-test-support.ts
Normal file
209
extensions/zalo/src/test-support/monitor-mocks-test-support.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
// Zalo plugin module implements monitor mocks test support behavior.
|
||||
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
createEmptyPluginRegistry,
|
||||
createRuntimeEnv,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { vi, type Mock } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import type { ResolvedZaloAccount } from "../types.js";
|
||||
|
||||
type MonitorModule = typeof import("../monitor.js");
|
||||
type SecretInputModule = typeof import("../secret-input.js");
|
||||
type WebhookModule = typeof import("../monitor.webhook.js");
|
||||
|
||||
const monitorModuleUrl = new URL("../monitor.ts", import.meta.url).href;
|
||||
const secretInputModuleUrl = new URL("../secret-input.ts", import.meta.url).href;
|
||||
const webhookModuleUrl = new URL("../monitor.webhook.ts", import.meta.url).href;
|
||||
const apiModuleId = new URL("../api.js", import.meta.url).pathname;
|
||||
const runtimeModuleId = new URL("../runtime.js", import.meta.url).pathname;
|
||||
|
||||
type UnknownMock = Mock<(...args: unknown[]) => unknown>;
|
||||
type AsyncUnknownMock = Mock<(...args: unknown[]) => Promise<unknown>>;
|
||||
const loadedMonitorModules = new Set<MonitorModule>();
|
||||
const cachedMonitorModules = new Map<string, Promise<MonitorModule>>();
|
||||
|
||||
type ZaloLifecycleMocks = {
|
||||
setWebhookMock: AsyncUnknownMock;
|
||||
deleteWebhookMock: AsyncUnknownMock;
|
||||
getWebhookInfoMock: AsyncUnknownMock;
|
||||
getUpdatesMock: UnknownMock;
|
||||
sendChatActionMock: AsyncUnknownMock;
|
||||
sendMessageMock: AsyncUnknownMock;
|
||||
sendPhotoMock: AsyncUnknownMock;
|
||||
getZaloRuntimeMock: UnknownMock;
|
||||
};
|
||||
|
||||
const lifecycleMocks = vi.hoisted(
|
||||
(): ZaloLifecycleMocks => ({
|
||||
setWebhookMock: vi.fn(async () => ({ ok: true, result: { url: "" } })),
|
||||
deleteWebhookMock: vi.fn(async () => ({ ok: true, result: { url: "" } })),
|
||||
getWebhookInfoMock: vi.fn(async () => ({ ok: true, result: { url: "" } })),
|
||||
getUpdatesMock: vi.fn(() => new Promise(() => {})),
|
||||
sendChatActionMock: vi.fn(async () => ({ ok: true })),
|
||||
sendMessageMock: vi.fn(async () => ({
|
||||
ok: true,
|
||||
result: { message_id: "zalo-test-reply-1" },
|
||||
})),
|
||||
sendPhotoMock: vi.fn(async () => ({ ok: true })),
|
||||
getZaloRuntimeMock: vi.fn(),
|
||||
}),
|
||||
);
|
||||
|
||||
const setWebhookMock = lifecycleMocks.setWebhookMock;
|
||||
export const getUpdatesMock = lifecycleMocks.getUpdatesMock;
|
||||
export const sendMessageMock = lifecycleMocks.sendMessageMock;
|
||||
export const sendPhotoMock = lifecycleMocks.sendPhotoMock;
|
||||
export const getZaloRuntimeMock: UnknownMock = lifecycleMocks.getZaloRuntimeMock;
|
||||
|
||||
function installLifecycleModuleMocks() {
|
||||
vi.doMock(apiModuleId, async () => {
|
||||
const actual = await vi.importActual<object>(apiModuleId);
|
||||
return {
|
||||
...actual,
|
||||
deleteWebhook: lifecycleMocks.deleteWebhookMock,
|
||||
getUpdates: lifecycleMocks.getUpdatesMock,
|
||||
getWebhookInfo: lifecycleMocks.getWebhookInfoMock,
|
||||
sendChatAction: lifecycleMocks.sendChatActionMock,
|
||||
sendMessage: lifecycleMocks.sendMessageMock,
|
||||
sendPhoto: lifecycleMocks.sendPhotoMock,
|
||||
setWebhook: lifecycleMocks.setWebhookMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.doMock(runtimeModuleId, () => ({
|
||||
getZaloRuntime: lifecycleMocks.getZaloRuntimeMock,
|
||||
}));
|
||||
}
|
||||
|
||||
async function importMonitorModule(params: {
|
||||
cacheBust: string;
|
||||
mocked: boolean;
|
||||
}): Promise<MonitorModule> {
|
||||
vi.resetModules();
|
||||
if (params.mocked) {
|
||||
installLifecycleModuleMocks();
|
||||
} else {
|
||||
vi.doUnmock(apiModuleId);
|
||||
vi.doUnmock(runtimeModuleId);
|
||||
}
|
||||
const module = (await import(
|
||||
`${monitorModuleUrl}?t=${params.cacheBust}-${Date.now()}`
|
||||
)) as MonitorModule;
|
||||
loadedMonitorModules.add(module);
|
||||
return module;
|
||||
}
|
||||
|
||||
async function importSecretInputModule(cacheBust: string): Promise<SecretInputModule> {
|
||||
return (await import(
|
||||
`${secretInputModuleUrl}?t=${cacheBust}-${Date.now()}`
|
||||
)) as SecretInputModule;
|
||||
}
|
||||
|
||||
const importCachedWebhookModule = createLazyRuntimeModule(
|
||||
() => import(webhookModuleUrl) as Promise<WebhookModule>,
|
||||
);
|
||||
|
||||
export async function resetLifecycleTestState() {
|
||||
vi.clearAllMocks();
|
||||
(await importCachedWebhookModule()).clearZaloWebhookSecurityStateForTest();
|
||||
for (const module of loadedMonitorModules) {
|
||||
module.testing.clearHostedMediaRouteRefsForTest();
|
||||
}
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
}
|
||||
|
||||
export function setLifecycleRuntimeCore(
|
||||
channel: NonNullable<NonNullable<Parameters<typeof createPluginRuntimeMock>[0]>["channel"]>,
|
||||
) {
|
||||
getZaloRuntimeMock.mockReturnValue(
|
||||
createPluginRuntimeMock({
|
||||
channel,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadLifecycleMonitorModule(): Promise<MonitorModule> {
|
||||
return await importMonitorModule({ cacheBust: "monitor", mocked: true });
|
||||
}
|
||||
|
||||
export async function loadCachedLifecycleMonitorModule(cacheKey: string): Promise<MonitorModule> {
|
||||
const key = cacheKey.trim();
|
||||
if (!key) {
|
||||
throw new Error("cacheKey is required");
|
||||
}
|
||||
const cached =
|
||||
cachedMonitorModules.get(key) ??
|
||||
(async () => {
|
||||
installLifecycleModuleMocks();
|
||||
const module = (await import(`${monitorModuleUrl}?t=${key}`)) as MonitorModule;
|
||||
loadedMonitorModules.add(module);
|
||||
return module;
|
||||
})();
|
||||
cachedMonitorModules.set(key, cached);
|
||||
return await cached;
|
||||
}
|
||||
|
||||
export async function startWebhookLifecycleMonitor(params: {
|
||||
account: ResolvedZaloAccount;
|
||||
config: OpenClawConfig;
|
||||
token?: string;
|
||||
webhookUrl?: string;
|
||||
webhookSecret?: string;
|
||||
cacheKey?: string;
|
||||
}) {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(registry);
|
||||
const abort = new AbortController();
|
||||
const runtime = createRuntimeEnv();
|
||||
const accountWebhookUrl =
|
||||
typeof params.account.config?.webhookUrl === "string"
|
||||
? params.account.config.webhookUrl
|
||||
: undefined;
|
||||
const webhookUrl = params.webhookUrl ?? accountWebhookUrl;
|
||||
const { normalizeSecretInputString } = await importSecretInputModule("secret-input");
|
||||
const webhookSecret =
|
||||
params.webhookSecret ?? normalizeSecretInputString(params.account.config?.webhookSecret);
|
||||
const { monitorZaloProvider } = params.cacheKey
|
||||
? await loadCachedLifecycleMonitorModule(params.cacheKey)
|
||||
: await loadLifecycleMonitorModule();
|
||||
const run = monitorZaloProvider({
|
||||
token: params.token ?? "zalo-token",
|
||||
account: params.account,
|
||||
config: params.config,
|
||||
runtime,
|
||||
abortSignal: abort.signal,
|
||||
useWebhook: true,
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const webhookRoute = registry.httpRoutes.find((route) => route.source === "zalo-webhook");
|
||||
const hostedMediaRoute = registry.httpRoutes.find(
|
||||
(route) => route.source === "zalo-hosted-media",
|
||||
);
|
||||
if (setWebhookMock.mock.calls.length !== 1 || !webhookRoute || !hostedMediaRoute) {
|
||||
throw new Error("waiting for webhook registration");
|
||||
}
|
||||
});
|
||||
|
||||
const route = registry.httpRoutes.find((entry) => entry.source === "zalo-webhook");
|
||||
if (!route) {
|
||||
throw new Error("missing plugin HTTP route");
|
||||
}
|
||||
|
||||
return {
|
||||
abort,
|
||||
registry,
|
||||
route,
|
||||
run,
|
||||
runtime,
|
||||
stop: async () => {
|
||||
abort.abort();
|
||||
await run;
|
||||
},
|
||||
};
|
||||
}
|
||||
107
extensions/zalo/src/token.test.ts
Normal file
107
extensions/zalo/src/token.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// Zalo tests cover token plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveZaloToken } from "./token.js";
|
||||
import type { ZaloConfig } from "./types.js";
|
||||
|
||||
function createSymlinkedFile(targetPath: string, linkPath: string): boolean {
|
||||
try {
|
||||
fs.writeFileSync(targetPath, "file-token\n", "utf8");
|
||||
fs.symlinkSync(targetPath, linkPath, "file");
|
||||
return true;
|
||||
} catch {
|
||||
fs.rmSync(linkPath, { force: true });
|
||||
fs.rmSync(targetPath, { force: true });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("resolveZaloToken", () => {
|
||||
it("falls back to top-level token for non-default accounts without overrides", () => {
|
||||
const cfg = {
|
||||
botToken: "top-level-token",
|
||||
accounts: {
|
||||
work: {},
|
||||
},
|
||||
} as ZaloConfig;
|
||||
const res = resolveZaloToken(cfg, "work");
|
||||
expect(res.token).toBe("top-level-token");
|
||||
expect(res.source).toBe("config");
|
||||
});
|
||||
|
||||
it("uses accounts.default botToken for default account when configured", () => {
|
||||
const cfg = {
|
||||
botToken: "top-level-token",
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: "default-account-token",
|
||||
},
|
||||
},
|
||||
} as ZaloConfig;
|
||||
const res = resolveZaloToken(cfg, "default");
|
||||
expect(res.token).toBe("default-account-token");
|
||||
expect(res.source).toBe("config");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount token when accountId is omitted", () => {
|
||||
const cfg = {
|
||||
defaultAccount: "work",
|
||||
botToken: "top-level-token",
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "work-token",
|
||||
},
|
||||
},
|
||||
} as ZaloConfig;
|
||||
const res = resolveZaloToken(cfg);
|
||||
expect(res.token).toBe("work-token");
|
||||
expect(res.source).toBe("config");
|
||||
});
|
||||
|
||||
it("does not inherit top-level token when account token is explicitly blank", () => {
|
||||
const cfg = {
|
||||
botToken: "top-level-token",
|
||||
accounts: {
|
||||
work: {
|
||||
botToken: "",
|
||||
},
|
||||
},
|
||||
} as ZaloConfig;
|
||||
const res = resolveZaloToken(cfg, "work");
|
||||
expect(res.token).toBe("");
|
||||
expect(res.source).toBe("none");
|
||||
});
|
||||
|
||||
it("resolves account token when account key casing differs from normalized id", () => {
|
||||
const cfg = {
|
||||
accounts: {
|
||||
Work: {
|
||||
botToken: "work-token",
|
||||
},
|
||||
},
|
||||
} as ZaloConfig;
|
||||
const res = resolveZaloToken(cfg, "work");
|
||||
expect(res.token).toBe("work-token");
|
||||
expect(res.source).toBe("config");
|
||||
});
|
||||
|
||||
it("rejects symlinked token files", ({ skip }) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-zalo-token-"));
|
||||
try {
|
||||
const tokenFile = path.join(dir, "token.txt");
|
||||
const tokenLink = path.join(dir, "token-link.txt");
|
||||
if (!createSymlinkedFile(tokenFile, tokenLink)) {
|
||||
skip("file symlinks are unavailable on this host");
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
tokenFile: tokenLink,
|
||||
} as ZaloConfig;
|
||||
expect(() => resolveZaloToken(cfg)).toThrow(/Zalo token file.*must not be a symlink/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
78
extensions/zalo/src/token.ts
Normal file
78
extensions/zalo/src/token.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// Zalo plugin module implements token behavior.
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/core";
|
||||
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "./secret-input.js";
|
||||
import type { ZaloConfig } from "./types.js";
|
||||
|
||||
type ZaloTokenResolution = BaseTokenResolution & {
|
||||
source: "env" | "config" | "configFile" | "none";
|
||||
};
|
||||
|
||||
function readTokenFromFile(tokenFile: string | undefined): string {
|
||||
return tryReadSecretFileSync(tokenFile, "Zalo token file", { rejectSymlink: true }) ?? "";
|
||||
}
|
||||
|
||||
export function resolveZaloToken(
|
||||
config: ZaloConfig | undefined,
|
||||
accountId?: string | null,
|
||||
options?: { allowUnresolvedSecretRef?: boolean },
|
||||
): ZaloTokenResolution {
|
||||
const resolvedAccountId = normalizeAccountId(accountId ?? config?.defaultAccount);
|
||||
const isDefaultAccount = resolvedAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const baseConfig = config;
|
||||
const accountConfig = resolveAccountEntry(
|
||||
baseConfig?.accounts as Record<string, ZaloConfig> | undefined,
|
||||
normalizeAccountId(resolvedAccountId),
|
||||
);
|
||||
const accountHasBotToken = Boolean(accountConfig && Object.hasOwn(accountConfig, "botToken"));
|
||||
|
||||
if (accountConfig && accountHasBotToken) {
|
||||
const token = options?.allowUnresolvedSecretRef
|
||||
? normalizeSecretInputString(accountConfig.botToken)
|
||||
: normalizeResolvedSecretInputString({
|
||||
value: accountConfig.botToken,
|
||||
path: `channels.zalo.accounts.${resolvedAccountId}.botToken`,
|
||||
});
|
||||
if (token) {
|
||||
return { token, source: "config" };
|
||||
}
|
||||
const fileToken = readTokenFromFile(accountConfig.tokenFile);
|
||||
if (fileToken) {
|
||||
return { token: fileToken, source: "configFile" };
|
||||
}
|
||||
}
|
||||
|
||||
if (!accountHasBotToken) {
|
||||
const fileToken = readTokenFromFile(accountConfig?.tokenFile);
|
||||
if (fileToken) {
|
||||
return { token: fileToken, source: "configFile" };
|
||||
}
|
||||
}
|
||||
|
||||
if (!accountHasBotToken) {
|
||||
const token = options?.allowUnresolvedSecretRef
|
||||
? normalizeSecretInputString(baseConfig?.botToken)
|
||||
: normalizeResolvedSecretInputString({
|
||||
value: baseConfig?.botToken,
|
||||
path: "channels.zalo.botToken",
|
||||
});
|
||||
if (token) {
|
||||
return { token, source: "config" };
|
||||
}
|
||||
const fileToken = readTokenFromFile(baseConfig?.tokenFile);
|
||||
if (fileToken) {
|
||||
return { token: fileToken, source: "configFile" };
|
||||
}
|
||||
}
|
||||
|
||||
if (isDefaultAccount) {
|
||||
const envToken = process.env.ZALO_BOT_TOKEN?.trim();
|
||||
if (envToken) {
|
||||
return { token: envToken, source: "env" };
|
||||
}
|
||||
}
|
||||
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
51
extensions/zalo/src/types.ts
Normal file
51
extensions/zalo/src/types.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Zalo type declarations define plugin contracts.
|
||||
import type { SecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
|
||||
export type ZaloAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** If false, do not start this Zalo account. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Bot token from Zalo Bot Creator. */
|
||||
botToken?: SecretInput;
|
||||
/** Path to file containing the bot token. */
|
||||
tokenFile?: string;
|
||||
/** Webhook URL for receiving updates (HTTPS required). */
|
||||
webhookUrl?: string;
|
||||
/** Webhook secret token (8-256 chars) for request verification. */
|
||||
webhookSecret?: SecretInput;
|
||||
/** Webhook path for the gateway HTTP server (defaults to webhook URL path). */
|
||||
webhookPath?: string;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
/** Allowlist for DM senders (Zalo user IDs). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Group-message access policy. */
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
/** Allowlist for group senders (falls back to allowFrom when unset). */
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
/** Max inbound media size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Proxy URL for API requests. */
|
||||
proxy?: string;
|
||||
/** Outbound response prefix override for this channel/account. */
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type ZaloConfig = {
|
||||
/** Optional per-account Zalo configuration (multi-account). */
|
||||
accounts?: Record<string, ZaloAccountConfig>;
|
||||
/** Default account ID when multiple accounts are configured. */
|
||||
defaultAccount?: string;
|
||||
} & ZaloAccountConfig;
|
||||
|
||||
type ZaloTokenSource = "env" | "config" | "configFile" | "none";
|
||||
|
||||
export type ResolvedZaloAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
tokenSource: ZaloTokenSource;
|
||||
config: ZaloAccountConfig;
|
||||
};
|
||||
2
extensions/zalo/test-api.ts
Normal file
2
extensions/zalo/test-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Zalo API module exposes the plugin public contract.
|
||||
export { resolveZaloRuntimeGroupPolicy } from "./src/group-access.js";
|
||||
16
extensions/zalo/tsconfig.json
Normal file
16
extensions/zalo/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