mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-18 03:30:48 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
120
openclaw/extensions/zalouser/README.md
Normal file
120
openclaw/extensions/zalouser/README.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# @openclaw/zalouser
|
||||
|
||||
OpenClaw extension for Zalo Personal Account messaging via native `zca-js` integration.
|
||||
|
||||
> **Warning:** Using Zalo automation may result in account suspension or ban. Use at your own risk. This is an unofficial integration.
|
||||
|
||||
## Features
|
||||
|
||||
- Channel plugin integration with setup wizard + QR login
|
||||
- In-process listener/sender via `zca-js` (no external CLI)
|
||||
- Multi-account support
|
||||
- Agent tool integration (`zalouser`)
|
||||
- DM/group policy support
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OpenClaw Gateway
|
||||
- Zalo mobile app (for QR login)
|
||||
|
||||
No external `zca`, `openzca`, or `zca-cli` binary is required.
|
||||
|
||||
## Install
|
||||
|
||||
### Option A: npm
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/zalouser
|
||||
```
|
||||
|
||||
### Option B: local source checkout
|
||||
|
||||
```bash
|
||||
PLUGIN_SRC=./path/to/local/zalouser-plugin
|
||||
openclaw plugins install "$PLUGIN_SRC"
|
||||
cd "$PLUGIN_SRC" && pnpm install
|
||||
```
|
||||
|
||||
Restart the Gateway after install.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Login (QR)
|
||||
|
||||
```bash
|
||||
openclaw channels login --channel zalouser
|
||||
```
|
||||
|
||||
Scan the QR code with the Zalo app on your phone.
|
||||
|
||||
### Enable channel
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
zalouser:
|
||||
enabled: true
|
||||
dmPolicy: pairing # pairing | allowlist | open | disabled
|
||||
```
|
||||
|
||||
### Send a message
|
||||
|
||||
```bash
|
||||
openclaw message send --channel zalouser --target <threadId> --message "Hello from OpenClaw"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Basic:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
zalouser:
|
||||
enabled: true
|
||||
dmPolicy: pairing
|
||||
```
|
||||
|
||||
Multi-account:
|
||||
|
||||
```yaml
|
||||
channels:
|
||||
zalouser:
|
||||
enabled: true
|
||||
defaultAccount: default
|
||||
accounts:
|
||||
default:
|
||||
enabled: true
|
||||
profile: default
|
||||
work:
|
||||
enabled: true
|
||||
profile: work
|
||||
```
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
openclaw channels login --channel zalouser
|
||||
openclaw channels login --channel zalouser --account work
|
||||
openclaw channels status --probe
|
||||
openclaw channels logout --channel zalouser
|
||||
|
||||
openclaw directory self --channel zalouser
|
||||
openclaw directory peers list --channel zalouser --query "name"
|
||||
openclaw directory groups list --channel zalouser --query "work"
|
||||
openclaw directory groups members --channel zalouser --group-id <id>
|
||||
```
|
||||
|
||||
## Agent tool
|
||||
|
||||
The extension registers a `zalouser` tool for AI agents.
|
||||
|
||||
Available actions: `send`, `image`, `link`, `friends`, `groups`, `me`, `status`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Login not persisted: `openclaw channels logout --channel zalouser && openclaw channels login --channel zalouser`
|
||||
- Probe status: `openclaw channels status --probe`
|
||||
- Name resolution issues (allowlist/groups): use numeric IDs or exact Zalo names
|
||||
|
||||
## Credits
|
||||
|
||||
Built on [zca-js](https://github.com/RFS-ADRENO/zca-js).
|
||||
6
openclaw/extensions/zalouser/api.ts
Normal file
6
openclaw/extensions/zalouser/api.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { zalouserPlugin } from "./src/channel.js";
|
||||
export { zalouserSetupPlugin } from "./src/channel.setup.js";
|
||||
export { createZalouserTool } from "./src/tool.js";
|
||||
export * from "./src/setup-core.js";
|
||||
export * from "./src/setup-surface.js";
|
||||
export * from "./src/security-audit.js";
|
||||
3
openclaw/extensions/zalouser/channel-plugin-api.ts
Normal file
3
openclaw/extensions/zalouser/channel-plugin-api.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
|
||||
// not drag setup-only or tool runtime surfaces into lightweight plugin loads.
|
||||
export { zalouserPlugin } from "./src/channel.js";
|
||||
2
openclaw/extensions/zalouser/contract-api.ts
Normal file
2
openclaw/extensions/zalouser/contract-api.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { collectZalouserSecurityAuditFindings } from "./src/security-audit.js";
|
||||
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js";
|
||||
1
openclaw/extensions/zalouser/doctor-contract-api.ts
Normal file
1
openclaw/extensions/zalouser/doctor-contract-api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";
|
||||
34
openclaw/extensions/zalouser/index.ts
Normal file
34
openclaw/extensions/zalouser/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import {
|
||||
type AnyAgentTool,
|
||||
defineBundledChannelEntry,
|
||||
loadBundledEntryExportSync,
|
||||
} from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
function createZalouserTool(context?: unknown): AnyAgentTool {
|
||||
const createTool = loadBundledEntryExportSync<(context?: unknown) => AnyAgentTool>(
|
||||
import.meta.url,
|
||||
{
|
||||
specifier: "./api.js",
|
||||
exportName: "createZalouserTool",
|
||||
},
|
||||
);
|
||||
return createTool(context);
|
||||
}
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
id: "zalouser",
|
||||
name: "Zalo Personal",
|
||||
description: "Zalo personal account messaging via native zca-js integration",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "zalouserPlugin",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setZalouserRuntime",
|
||||
},
|
||||
registerFull(api) {
|
||||
api.registerTool((ctx) => createZalouserTool(ctx), { name: "zalouser" });
|
||||
},
|
||||
});
|
||||
12
openclaw/extensions/zalouser/openclaw.plugin.json
Normal file
12
openclaw/extensions/zalouser/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"id": "zalouser",
|
||||
"channels": ["zalouser"],
|
||||
"channelEnvVars": {
|
||||
"zalouser": ["ZALOUSER_PROFILE", "ZCA_PROFILE"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
56
openclaw/extensions/zalouser/package.json
Normal file
56
openclaw/extensions/zalouser/package.json
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"name": "@openclaw/zalouser",
|
||||
"version": "2026.4.20",
|
||||
"description": "OpenClaw Zalo Personal Account plugin via native zca-js integration",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@sinclair/typebox": "0.34.49",
|
||||
"zca-js": "2.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*",
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.4.20"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "zalouser",
|
||||
"label": "Zalo Personal",
|
||||
"selectionLabel": "Zalo (Personal Account)",
|
||||
"docsPath": "/channels/zalouser",
|
||||
"docsLabel": "zalouser",
|
||||
"blurb": "Zalo personal account via QR code login.",
|
||||
"aliases": [
|
||||
"zlu"
|
||||
],
|
||||
"order": 85,
|
||||
"quickstartAllowFrom": false
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/zalouser",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.4.20"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.4.20"
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
61
openclaw/extensions/zalouser/runtime-api.ts
Normal file
61
openclaw/extensions/zalouser/runtime-api.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Private runtime barrel for the bundled Zalo Personal extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "./api.js";
|
||||
export { setZalouserRuntime } from "./src/runtime.js";
|
||||
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
export type {
|
||||
BaseProbeResult,
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelStatusIssue,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export type {
|
||||
OpenClawConfig,
|
||||
GroupToolPolicyConfig,
|
||||
MarkdownTableMode,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export type {
|
||||
PluginRuntime,
|
||||
AnyAgentTool,
|
||||
ChannelPlugin,
|
||||
OpenClawPluginToolContext,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
buildChannelConfigSchema,
|
||||
normalizeAccountId,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export {
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveOpenProviderRuntimeGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export {
|
||||
mergeAllowlist,
|
||||
summarizeMapping,
|
||||
formatAllowFromLowercase,
|
||||
} from "openclaw/plugin-sdk/allow-from";
|
||||
export { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export { buildBaseAccountStatusSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
export { resolveSenderCommandAuthorization } from "openclaw/plugin-sdk/command-auth";
|
||||
export {
|
||||
evaluateGroupRouteAccessForPolicy,
|
||||
resolveSenderScopedGroupPolicy,
|
||||
} from "openclaw/plugin-sdk/group-access";
|
||||
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
|
||||
export {
|
||||
deliverTextOrMediaReply,
|
||||
isNumericTargetId,
|
||||
resolveSendableOutboundReplyParts,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
type OutboundReplyPayload,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
export { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/browser-security-runtime";
|
||||
4
openclaw/extensions/zalouser/secret-contract-api.ts
Normal file
4
openclaw/extensions/zalouser/secret-contract-api.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Zalo User does not expose secret-contract surfaces.
|
||||
export const secretTargetRegistryEntries: readonly [] = [];
|
||||
|
||||
export function collectRuntimeConfigAssignments(): void {}
|
||||
9
openclaw/extensions/zalouser/setup-entry.ts
Normal file
9
openclaw/extensions/zalouser/setup-entry.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./setup-plugin-api.js",
|
||||
exportName: "zalouserSetupPlugin",
|
||||
},
|
||||
});
|
||||
2
openclaw/extensions/zalouser/setup-plugin-api.ts
Normal file
2
openclaw/extensions/zalouser/setup-plugin-api.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Keep setup-entry imports narrow so setup loads do not pull tool surfaces.
|
||||
export { zalouserSetupPlugin } from "./src/channel.setup.js";
|
||||
1
openclaw/extensions/zalouser/src/accounts.runtime.ts
Normal file
1
openclaw/extensions/zalouser/src/accounts.runtime.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js";
|
||||
14
openclaw/extensions/zalouser/src/accounts.test-mocks.ts
Normal file
14
openclaw/extensions/zalouser/src/accounts.test-mocks.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { vi } from "vitest";
|
||||
import { createDefaultResolvedZalouserAccount } from "./test-helpers.js";
|
||||
|
||||
vi.mock("./accounts.js", () => {
|
||||
return {
|
||||
listZalouserAccountIds: () => ["default"],
|
||||
resolveDefaultZalouserAccountId: () => "default",
|
||||
resolveZalouserAccountSync: () => createDefaultResolvedZalouserAccount(),
|
||||
resolveZalouserAccount: async () => createDefaultResolvedZalouserAccount(),
|
||||
listEnabledZalouserAccounts: async () => [createDefaultResolvedZalouserAccount()],
|
||||
getZcaUserInfo: async () => null,
|
||||
checkZcaAuthenticated: async () => false,
|
||||
};
|
||||
});
|
||||
266
openclaw/extensions/zalouser/src/accounts.test.ts
Normal file
266
openclaw/extensions/zalouser/src/accounts.test.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
getZcaUserInfo,
|
||||
listEnabledZalouserAccounts,
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccount,
|
||||
resolveZalouserAccountSync,
|
||||
} from "./accounts.js";
|
||||
import { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js";
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
checkZaloAuthenticated: vi.fn(),
|
||||
getZaloUserInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockCheckAuthenticated = vi.mocked(checkZaloAuthenticated);
|
||||
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
|
||||
|
||||
function asConfig(value: unknown): OpenClawConfig {
|
||||
return value as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("zalouser account resolution", () => {
|
||||
beforeEach(() => {
|
||||
mockCheckAuthenticated.mockReset();
|
||||
mockGetUserInfo.mockReset();
|
||||
delete process.env.ZALOUSER_PROFILE;
|
||||
delete process.env.ZCA_PROFILE;
|
||||
});
|
||||
|
||||
it("returns default account id when no accounts are configured", () => {
|
||||
expect(listZalouserAccountIds(asConfig({}))).toEqual([DEFAULT_ACCOUNT_ID]);
|
||||
});
|
||||
|
||||
it("returns sorted configured account ids", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
work: {},
|
||||
personal: {},
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(listZalouserAccountIds(cfg)).toEqual(["default", "personal", "work"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when present", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
default: {},
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveDefaultZalouserAccountId(cfg)).toBe("work");
|
||||
});
|
||||
|
||||
it("falls back to default account when configured defaultAccount is missing", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
defaultAccount: "missing",
|
||||
accounts: {
|
||||
default: {},
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveDefaultZalouserAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("falls back to first sorted configured account when default is absent", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
zzz: {},
|
||||
aaa: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveDefaultZalouserAccountId(cfg)).toBe("aaa");
|
||||
});
|
||||
|
||||
it("resolves sync account by merging base + account config", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
dmPolicy: "pairing",
|
||||
accounts: {
|
||||
work: {
|
||||
enabled: false,
|
||||
name: "Work",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["123"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId: "work" });
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.enabled).toBe(false);
|
||||
expect(resolved.name).toBe("Work");
|
||||
expect(resolved.config.dmPolicy).toBe("allowlist");
|
||||
expect(resolved.config.allowFrom).toEqual(["123"]);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
name: "Work",
|
||||
profile: "work-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = resolveZalouserAccountSync({ cfg });
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.name).toBe("Work");
|
||||
expect(resolved.profile).toBe("work-profile");
|
||||
});
|
||||
|
||||
it("resolves account config when account key casing differs from normalized id", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
Work: {
|
||||
name: "Work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId: "work" });
|
||||
expect(resolved.accountId).toBe("work");
|
||||
expect(resolved.name).toBe("Work");
|
||||
});
|
||||
|
||||
it("defaults group policy to allowlist when unset", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId: "default" });
|
||||
expect(resolved.config.groupPolicy).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("resolves profile precedence correctly", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
process.env.ZALOUSER_PROFILE = "zalo-env";
|
||||
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("zalo-env");
|
||||
|
||||
delete process.env.ZALOUSER_PROFILE;
|
||||
process.env.ZCA_PROFILE = "zca-env";
|
||||
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("zca-env");
|
||||
|
||||
delete process.env.ZCA_PROFILE;
|
||||
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("work");
|
||||
});
|
||||
|
||||
it("uses explicit profile from config over env fallback", () => {
|
||||
process.env.ZALOUSER_PROFILE = "env-profile";
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "explicit-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("explicit-profile");
|
||||
});
|
||||
|
||||
it("checks authentication during async account resolution", async () => {
|
||||
mockCheckAuthenticated.mockResolvedValueOnce(true);
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
default: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const resolved = await resolveZalouserAccount({ cfg, accountId: "default" });
|
||||
expect(mockCheckAuthenticated).toHaveBeenCalledWith("default");
|
||||
expect(resolved.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it("filters disabled accounts when listing enabled accounts", async () => {
|
||||
mockCheckAuthenticated.mockResolvedValue(true);
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
default: { enabled: true },
|
||||
work: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const accounts = await listEnabledZalouserAccounts(cfg);
|
||||
expect(accounts.map((account) => account.accountId)).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("maps account info helper from zalo-js", async () => {
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
userId: "123",
|
||||
displayName: "Alice",
|
||||
avatar: "https://example.com/avatar.png",
|
||||
});
|
||||
expect(await getZcaUserInfo("default")).toEqual({
|
||||
userId: "123",
|
||||
displayName: "Alice",
|
||||
});
|
||||
|
||||
mockGetUserInfo.mockResolvedValueOnce(null);
|
||||
expect(await getZcaUserInfo("default")).toBeNull();
|
||||
});
|
||||
});
|
||||
131
openclaw/extensions/zalouser/src/accounts.ts
Normal file
131
openclaw/extensions/zalouser/src/accounts.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import {
|
||||
createAccountListHelpers,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
resolveMergedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js";
|
||||
|
||||
let zalouserAccountsRuntimePromise: Promise<typeof import("./accounts.runtime.js")> | undefined;
|
||||
|
||||
async function loadZalouserAccountsRuntime() {
|
||||
zalouserAccountsRuntimePromise ??= import("./accounts.runtime.js");
|
||||
return await zalouserAccountsRuntimePromise;
|
||||
}
|
||||
|
||||
const {
|
||||
listAccountIds: listZalouserAccountIds,
|
||||
resolveDefaultAccountId: resolveDefaultZalouserAccountId,
|
||||
} = createAccountListHelpers("zalouser");
|
||||
export { listZalouserAccountIds, resolveDefaultZalouserAccountId };
|
||||
|
||||
function mergeZalouserAccountConfig(cfg: OpenClawConfig, accountId: string): ZalouserAccountConfig {
|
||||
const merged = resolveMergedAccountConfig<ZalouserAccountConfig>({
|
||||
channelConfig: cfg.channels?.zalouser as ZalouserAccountConfig | undefined,
|
||||
accounts: (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts as
|
||||
| Record<string, Partial<ZalouserAccountConfig>>
|
||||
| undefined,
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
});
|
||||
return {
|
||||
...merged,
|
||||
// Match Telegram's safe default: groups stay allowlisted unless explicitly opened.
|
||||
groupPolicy: merged.groupPolicy ?? "allowlist",
|
||||
};
|
||||
}
|
||||
|
||||
function resolveProfile(config: ZalouserAccountConfig, accountId: string): string {
|
||||
if (config.profile?.trim()) {
|
||||
return config.profile.trim();
|
||||
}
|
||||
if (process.env.ZALOUSER_PROFILE?.trim()) {
|
||||
return process.env.ZALOUSER_PROFILE.trim();
|
||||
}
|
||||
if (process.env.ZCA_PROFILE?.trim()) {
|
||||
return process.env.ZCA_PROFILE.trim();
|
||||
}
|
||||
if (accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return accountId;
|
||||
}
|
||||
return "default";
|
||||
}
|
||||
|
||||
function resolveZalouserAccountBase(params: { cfg: OpenClawConfig; accountId?: string | null }) {
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? resolveDefaultZalouserAccountId(params.cfg),
|
||||
);
|
||||
const baseEnabled =
|
||||
(params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
|
||||
return {
|
||||
accountId,
|
||||
enabled: baseEnabled && merged.enabled !== false,
|
||||
merged,
|
||||
profile: resolveProfile(merged, accountId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveZalouserAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): Promise<ResolvedZalouserAccount> {
|
||||
const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params);
|
||||
const authenticated = await (await loadZalouserAccountsRuntime()).checkZaloAuthenticated(profile);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
enabled,
|
||||
profile,
|
||||
authenticated,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveZalouserAccountSync(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedZalouserAccount {
|
||||
const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
enabled,
|
||||
profile,
|
||||
authenticated: false,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEnabledZalouserAccounts(
|
||||
cfg: OpenClawConfig,
|
||||
): Promise<ResolvedZalouserAccount[]> {
|
||||
const ids = listZalouserAccountIds(cfg);
|
||||
const accounts = await Promise.all(
|
||||
ids.map((accountId) => resolveZalouserAccount({ cfg, accountId })),
|
||||
);
|
||||
return accounts.filter((account) => account.enabled);
|
||||
}
|
||||
|
||||
export async function getZcaUserInfo(
|
||||
profile: string,
|
||||
): Promise<{ userId?: string; displayName?: string } | null> {
|
||||
const info = await (await loadZalouserAccountsRuntime()).getZaloUserInfo(profile);
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
userId: info.userId,
|
||||
displayName: info.displayName,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkZcaAuthenticated(profile: string): Promise<boolean> {
|
||||
return await (await loadZalouserAccountsRuntime()).checkZaloAuthenticated(profile);
|
||||
}
|
||||
|
||||
export type { ResolvedZalouserAccount } from "./types.js";
|
||||
23
openclaw/extensions/zalouser/src/channel-api.ts
Normal file
23
openclaw/extensions/zalouser/src/channel-api.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
isDangerousNameMatchingEnabled,
|
||||
type GroupToolPolicyConfig,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export {
|
||||
isNumericTargetId,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
390
openclaw/extensions/zalouser/src/channel.adapters.ts
Normal file
390
openclaw/extensions/zalouser/src/channel.adapters.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import {
|
||||
createEmptyChannelResult,
|
||||
createRawChannelSendResultAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
checkZcaAuthenticated,
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
type ResolvedZalouserAccount,
|
||||
} from "./accounts.js";
|
||||
import type {
|
||||
ChannelGroupContext,
|
||||
ChannelMessageActionAdapter,
|
||||
GroupToolPolicyConfig,
|
||||
OpenClawConfig,
|
||||
} from "./channel-api.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
chunkTextForOutbound,
|
||||
isDangerousNameMatchingEnabled,
|
||||
isNumericTargetId,
|
||||
normalizeAccountId,
|
||||
sendPayloadWithChunkedTextAndMedia,
|
||||
} from "./channel-api.js";
|
||||
import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js";
|
||||
import { resolveZalouserReactionMessageIds } from "./message-sid.js";
|
||||
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
|
||||
import { getZalouserRuntime } from "./runtime.js";
|
||||
import {
|
||||
normalizeZalouserTarget,
|
||||
parseZalouserOutboundTarget,
|
||||
resolveZalouserOutboundSessionRoute,
|
||||
} from "./session-route.js";
|
||||
|
||||
const loadZalouserChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
|
||||
|
||||
const ZALOUSER_TEXT_CHUNK_LIMIT = 2000;
|
||||
|
||||
export function resolveZalouserQrProfile(accountId?: string | null): string {
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
|
||||
return process.env.ZALOUSER_PROFILE?.trim() || process.env.ZCA_PROFILE?.trim() || "default";
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveZalouserOutboundChunkMode(cfg: OpenClawConfig, accountId?: string) {
|
||||
return getZalouserRuntime().channel.text.resolveChunkMode(cfg, "zalouser", accountId);
|
||||
}
|
||||
|
||||
function resolveZalouserOutboundTextChunkLimit(cfg: OpenClawConfig, accountId?: string) {
|
||||
return getZalouserRuntime().channel.text.resolveTextChunkLimit(cfg, "zalouser", accountId, {
|
||||
fallbackLimit: ZALOUSER_TEXT_CHUNK_LIMIT,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveZalouserGroupPolicyEntry(params: ChannelGroupContext) {
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId ?? undefined,
|
||||
});
|
||||
const groups = account.config.groups ?? {};
|
||||
return findZalouserGroupEntry(
|
||||
groups,
|
||||
buildZalouserGroupCandidates({
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
includeWildcard: true,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(account.config),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveZalouserGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
return resolveZalouserGroupPolicyEntry(params)?.tools;
|
||||
}
|
||||
|
||||
function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
|
||||
const entry = resolveZalouserGroupPolicyEntry(params);
|
||||
if (typeof entry?.requireMention === "boolean") {
|
||||
return entry.requireMention;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const zalouserRawSendResultAdapter = createRawChannelSendResultAdapter({
|
||||
channel: "zalouser",
|
||||
sendText: async ({ to, text, accountId, cfg }) => {
|
||||
const { sendMessageZalouser } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
|
||||
const target = parseZalouserOutboundTarget(to);
|
||||
return await sendMessageZalouser(target.threadId, text, {
|
||||
profile: account.profile,
|
||||
isGroup: target.isGroup,
|
||||
textMode: "markdown",
|
||||
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
|
||||
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
|
||||
});
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots, mediaReadFile }) => {
|
||||
const { sendMessageZalouser } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
|
||||
const target = parseZalouserOutboundTarget(to);
|
||||
return await sendMessageZalouser(target.threadId, text, {
|
||||
profile: account.profile,
|
||||
isGroup: target.isGroup,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
mediaReadFile,
|
||||
textMode: "markdown",
|
||||
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
|
||||
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const resolveZalouserDmPolicy = createScopedDmSecurityResolver<ResolvedZalouserAccount>({
|
||||
channelKey: "zalouser",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw) => raw.trim().replace(/^(zalouser|zlu):/i, ""),
|
||||
});
|
||||
|
||||
export const zalouserGroupsAdapter = {
|
||||
resolveRequireMention: resolveZalouserRequireMention,
|
||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
||||
};
|
||||
|
||||
export const zalouserMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: ({ cfg, accountId }) => {
|
||||
const accounts = accountId
|
||||
? [resolveZalouserAccountSync({ cfg, accountId })].filter((account) => account.enabled)
|
||||
: listZalouserAccountIds(cfg)
|
||||
.map((resolvedAccountId) =>
|
||||
resolveZalouserAccountSync({ cfg, accountId: resolvedAccountId }),
|
||||
)
|
||||
.filter((account) => account.enabled);
|
||||
if (accounts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { actions: ["react"] };
|
||||
},
|
||||
supportsAction: ({ action }) => action === "react",
|
||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||
if (action !== "react") {
|
||||
throw new Error(`Zalouser action ${action} not supported`);
|
||||
}
|
||||
const { sendReactionZalouser } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const threadId =
|
||||
(typeof params.threadId === "string" ? params.threadId.trim() : "") ||
|
||||
(typeof params.to === "string" ? params.to.trim() : "") ||
|
||||
(typeof params.chatId === "string" ? params.chatId.trim() : "") ||
|
||||
(toolContext?.currentChannelId?.trim() ?? "");
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser react requires threadId (or to/chatId).");
|
||||
}
|
||||
const emoji = typeof params.emoji === "string" ? params.emoji.trim() : "";
|
||||
if (!emoji) {
|
||||
throw new Error("Zalouser react requires emoji.");
|
||||
}
|
||||
const ids = resolveZalouserReactionMessageIds({
|
||||
messageId: typeof params.messageId === "string" ? params.messageId : undefined,
|
||||
cliMsgId: typeof params.cliMsgId === "string" ? params.cliMsgId : undefined,
|
||||
currentMessageId: toolContext?.currentMessageId,
|
||||
});
|
||||
if (!ids) {
|
||||
throw new Error(
|
||||
"Zalouser react requires messageId + cliMsgId (or a current message context id).",
|
||||
);
|
||||
}
|
||||
const result = await sendReactionZalouser({
|
||||
profile: account.profile,
|
||||
threadId,
|
||||
isGroup: params.isGroup === true,
|
||||
msgId: ids.msgId,
|
||||
cliMsgId: ids.cliMsgId,
|
||||
emoji,
|
||||
remove: params.remove === true,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to react on Zalo message");
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text:
|
||||
params.remove === true
|
||||
? `Removed reaction ${emoji} from ${ids.msgId}`
|
||||
: `Reacted ${emoji} on ${ids.msgId}`,
|
||||
},
|
||||
],
|
||||
details: {
|
||||
messageId: ids.msgId,
|
||||
cliMsgId: ids.cliMsgId,
|
||||
threadId,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserResolverAdapter = {
|
||||
resolveTargets: async ({
|
||||
cfg,
|
||||
accountId,
|
||||
inputs,
|
||||
kind,
|
||||
runtime,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
inputs: string[];
|
||||
kind: "user" | "group";
|
||||
runtime: RuntimeEnv;
|
||||
}) => {
|
||||
const results = [];
|
||||
for (const input of inputs) {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
results.push({ input, resolved: false, note: "empty input" });
|
||||
continue;
|
||||
}
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
results.push({ input, resolved: true, id: trimmed });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const runtimeModule = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg: cfg,
|
||||
accountId: accountId ?? resolveDefaultZalouserAccountId(cfg),
|
||||
});
|
||||
if (kind === "user") {
|
||||
const friends = await runtimeModule.listZaloFriendsMatching(account.profile, trimmed);
|
||||
const best = friends[0];
|
||||
results.push({
|
||||
input,
|
||||
resolved: Boolean(best?.userId),
|
||||
id: best?.userId,
|
||||
name: best?.displayName,
|
||||
note: friends.length > 1 ? "multiple matches; chose first" : undefined,
|
||||
});
|
||||
} else {
|
||||
const groups = await runtimeModule.listZaloGroupsMatching(account.profile, trimmed);
|
||||
const best =
|
||||
groups.find(
|
||||
(group) =>
|
||||
normalizeLowercaseStringOrEmpty(group.name) ===
|
||||
normalizeLowercaseStringOrEmpty(trimmed),
|
||||
) ?? groups[0];
|
||||
results.push({
|
||||
input,
|
||||
resolved: Boolean(best?.groupId),
|
||||
id: best?.groupId,
|
||||
name: best?.name,
|
||||
note: groups.length > 1 ? "multiple matches; chose first" : undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
runtime.error?.(`zalouser resolve failed: ${String(err)}`);
|
||||
results.push({ input, resolved: false, note: "lookup failed" });
|
||||
}
|
||||
}
|
||||
return results;
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserAuthAdapter = {
|
||||
login: async ({
|
||||
cfg,
|
||||
accountId,
|
||||
runtime,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
runtime: RuntimeEnv;
|
||||
}) => {
|
||||
const { startZaloQrLogin, waitForZaloQrLogin } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg: cfg,
|
||||
accountId: accountId ?? resolveDefaultZalouserAccountId(cfg),
|
||||
});
|
||||
|
||||
runtime.log(
|
||||
`Generating QR login for Zalo Personal (account: ${account.accountId}, profile: ${account.profile})...`,
|
||||
);
|
||||
|
||||
const started = await startZaloQrLogin({
|
||||
profile: account.profile,
|
||||
timeoutMs: 35_000,
|
||||
});
|
||||
if (!started.qrDataUrl) {
|
||||
throw new Error(started.message || "Failed to start QR login");
|
||||
}
|
||||
|
||||
const qrPath = await writeQrDataUrlToTempFile(started.qrDataUrl, account.profile);
|
||||
if (qrPath) {
|
||||
runtime.log(`Scan QR image: ${qrPath}`);
|
||||
} else {
|
||||
runtime.log("QR generated but could not be written to a temp file.");
|
||||
}
|
||||
|
||||
const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 180_000 });
|
||||
if (!waited.connected) {
|
||||
throw new Error(waited.message || "Zalouser login failed");
|
||||
}
|
||||
|
||||
runtime.log(waited.message);
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserSecurityAdapter = {
|
||||
resolveDmPolicy: resolveZalouserDmPolicy,
|
||||
collectAuditFindings: async (params: {
|
||||
accountId?: string | null;
|
||||
account: ResolvedZalouserAccount;
|
||||
orderedAccountIds: string[];
|
||||
hasExplicitAccountPath: boolean;
|
||||
}) => (await loadZalouserChannelRuntime()).collectZalouserSecurityAuditFindings(params),
|
||||
};
|
||||
|
||||
export const zalouserThreadingAdapter = {
|
||||
resolveReplyToMode: createStaticReplyToModeResolver("off"),
|
||||
};
|
||||
|
||||
export const zalouserPairingTextAdapter = {
|
||||
idLabel: "zalouserUserId",
|
||||
message: "Your pairing request has been approved.",
|
||||
normalizeAllowEntry: createPairingPrefixStripper(/^(zalouser|zlu):/i),
|
||||
notify: async ({ cfg, id, message }: { cfg: OpenClawConfig; id: string; message: string }) => {
|
||||
const { sendMessageZalouser } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg });
|
||||
const authenticated = await checkZcaAuthenticated(account.profile);
|
||||
if (!authenticated) {
|
||||
throw new Error("Zalouser not authenticated");
|
||||
}
|
||||
await sendMessageZalouser(id, message, {
|
||||
profile: account.profile,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const zalouserOutboundAdapter = {
|
||||
deliveryMode: "direct" as const,
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "markdown" as const,
|
||||
sendPayload: async (
|
||||
ctx: { payload: object } & Parameters<
|
||||
NonNullable<typeof zalouserRawSendResultAdapter.sendText>
|
||||
>[0],
|
||||
) =>
|
||||
await sendPayloadWithChunkedTextAndMedia({
|
||||
ctx,
|
||||
sendText: (nextCtx) => zalouserRawSendResultAdapter.sendText!(nextCtx),
|
||||
sendMedia: (nextCtx) => zalouserRawSendResultAdapter.sendMedia!(nextCtx),
|
||||
emptyResult: createEmptyChannelResult("zalouser"),
|
||||
}),
|
||||
...zalouserRawSendResultAdapter,
|
||||
};
|
||||
|
||||
export const zalouserMessagingAdapter = {
|
||||
normalizeTarget: (raw: string) => normalizeZalouserTarget(raw),
|
||||
resolveOutboundSessionRoute: (
|
||||
params: Parameters<typeof resolveZalouserOutboundSessionRoute>[0],
|
||||
) => resolveZalouserOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
looksLikeId: (raw: string) => {
|
||||
const normalized = normalizeZalouserTarget(raw);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (/^group:[^\s]+$/i.test(normalized) || /^user:[^\s]+$/i.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return isNumericTargetId(normalized);
|
||||
},
|
||||
hint: "<user:id|group:id>",
|
||||
},
|
||||
};
|
||||
59
openclaw/extensions/zalouser/src/channel.directory.test.ts
Normal file
59
openclaw/extensions/zalouser/src/channel.directory.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import "./accounts.test-mocks.js";
|
||||
import { listZalouserDirectoryGroupMembers } from "./directory.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import { listZaloGroupMembersMock } from "./zalo-js.test-mocks.js";
|
||||
|
||||
describe("zalouser directory group members", () => {
|
||||
beforeEach(() => {
|
||||
listZaloGroupMembersMock.mockClear();
|
||||
});
|
||||
|
||||
it("accepts prefixed group ids from directory groups list output", async () => {
|
||||
await listZalouserDirectoryGroupMembers(
|
||||
{
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
groupId: "group:1471383327500481391",
|
||||
},
|
||||
{
|
||||
listZaloGroupMembers: async (profile, groupId) =>
|
||||
await listZaloGroupMembersMock(profile, groupId),
|
||||
},
|
||||
);
|
||||
|
||||
expect(listZaloGroupMembersMock).toHaveBeenLastCalledWith("default", "1471383327500481391");
|
||||
});
|
||||
|
||||
it("keeps backward compatibility for raw group ids", async () => {
|
||||
await listZalouserDirectoryGroupMembers(
|
||||
{
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
groupId: "1471383327500481391",
|
||||
},
|
||||
{
|
||||
listZaloGroupMembers: async (profile, groupId) =>
|
||||
await listZaloGroupMembersMock(profile, groupId),
|
||||
},
|
||||
);
|
||||
|
||||
expect(listZaloGroupMembersMock).toHaveBeenLastCalledWith("default", "1471383327500481391");
|
||||
});
|
||||
|
||||
it("accepts provider-native g- group ids without stripping the prefix", async () => {
|
||||
await listZalouserDirectoryGroupMembers(
|
||||
{
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
groupId: "g-1471383327500481391",
|
||||
},
|
||||
{
|
||||
listZaloGroupMembers: async (profile, groupId) =>
|
||||
await listZaloGroupMembersMock(profile, groupId),
|
||||
},
|
||||
);
|
||||
|
||||
expect(listZaloGroupMembersMock).toHaveBeenLastCalledWith("default", "g-1471383327500481391");
|
||||
});
|
||||
});
|
||||
12
openclaw/extensions/zalouser/src/channel.runtime.ts
Normal file
12
openclaw/extensions/zalouser/src/channel.runtime.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export { probeZalouser } from "./probe.js";
|
||||
export { collectZalouserSecurityAuditFindings } from "./security-audit.js";
|
||||
export { sendMessageZalouser, sendReactionZalouser } from "./send.js";
|
||||
export {
|
||||
listZaloFriendsMatching,
|
||||
listZaloGroupMembers,
|
||||
listZaloGroupsMatching,
|
||||
logoutZaloProfile,
|
||||
startZaloQrLogin,
|
||||
waitForZaloQrLogin,
|
||||
getZaloUserInfo,
|
||||
} from "./zalo-js.js";
|
||||
136
openclaw/extensions/zalouser/src/channel.sendpayload.test.ts
Normal file
136
openclaw/extensions/zalouser/src/channel.sendpayload.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { primeChannelOutboundSendMock } from "../../../src/channels/plugins/contracts/test-helpers.js";
|
||||
import "./accounts.test-mocks.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import type { ReplyPayload } from "../runtime-api.js";
|
||||
import { zalouserPlugin } from "./channel.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import * as sendModule from "./send.js";
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" }),
|
||||
sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
|
||||
function baseCtx(payload: ReplyPayload) {
|
||||
return {
|
||||
cfg: {},
|
||||
to: "user:987654321",
|
||||
text: "",
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
describe("zalouserPlugin outbound sendPayload", () => {
|
||||
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>;
|
||||
|
||||
beforeEach(() => {
|
||||
setZalouserRuntime({
|
||||
channel: {
|
||||
text: {
|
||||
resolveChunkMode: vi.fn(() => "length"),
|
||||
resolveTextChunkLimit: vi.fn(() => 1200),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
mockedSend = vi.mocked(sendModule.sendMessageZalouser);
|
||||
primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" });
|
||||
});
|
||||
|
||||
it("group target delegates with isGroup=true and stripped threadId", async () => {
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g1" });
|
||||
|
||||
const result = await zalouserPlugin.outbound!.sendPayload!({
|
||||
...baseCtx({ text: "hello group" }),
|
||||
to: "group:1471383327500481391",
|
||||
});
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledWith(
|
||||
"1471383327500481391",
|
||||
"hello group",
|
||||
expect.objectContaining({ isGroup: true, textMode: "markdown" }),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g1" });
|
||||
});
|
||||
|
||||
it("treats bare numeric targets as direct chats for backward compatibility", async () => {
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-d1" });
|
||||
|
||||
const result = await zalouserPlugin.outbound!.sendPayload!({
|
||||
...baseCtx({ text: "hello" }),
|
||||
to: "987654321",
|
||||
});
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledWith(
|
||||
"987654321",
|
||||
"hello",
|
||||
expect.objectContaining({ isGroup: false, textMode: "markdown" }),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-d1" });
|
||||
});
|
||||
|
||||
it("preserves provider-native group ids when sending to raw g- targets", async () => {
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g-native" });
|
||||
|
||||
const result = await zalouserPlugin.outbound!.sendPayload!({
|
||||
...baseCtx({ text: "hello native group" }),
|
||||
to: "g-1471383327500481391",
|
||||
});
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledWith(
|
||||
"g-1471383327500481391",
|
||||
"hello native group",
|
||||
expect.objectContaining({ isGroup: true, textMode: "markdown" }),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-g-native" });
|
||||
});
|
||||
|
||||
it("passes long markdown through once so formatting happens before chunking", async () => {
|
||||
const text = `**${"a".repeat(2501)}**`;
|
||||
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" });
|
||||
|
||||
const result = await zalouserPlugin.outbound!.sendPayload!({
|
||||
...baseCtx({ text }),
|
||||
to: "987654321",
|
||||
});
|
||||
|
||||
expect(mockedSend).toHaveBeenCalledTimes(1);
|
||||
expect(mockedSend).toHaveBeenCalledWith(
|
||||
"987654321",
|
||||
text,
|
||||
expect.objectContaining({
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "length",
|
||||
textChunkLimit: 1200,
|
||||
}),
|
||||
);
|
||||
expect(result).toMatchObject({ channel: "zalouser", messageId: "zlu-code" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("zalouserPlugin messaging target normalization", () => {
|
||||
it("normalizes user/group aliases to canonical targets", () => {
|
||||
const normalize = zalouserPlugin.messaging?.normalizeTarget;
|
||||
if (!normalize) {
|
||||
throw new Error("normalizeTarget unavailable");
|
||||
}
|
||||
expect(normalize("zlu:g:30003")).toBe("group:30003");
|
||||
expect(normalize("zalouser:u:20002")).toBe("user:20002");
|
||||
expect(normalize("zlu:g-30003")).toBe("group:g-30003");
|
||||
expect(normalize("zalouser:u-20002")).toBe("user:u-20002");
|
||||
expect(normalize("20002")).toBe("20002");
|
||||
});
|
||||
|
||||
it("treats canonical and provider-native user/group targets as ids", () => {
|
||||
const looksLikeId = zalouserPlugin.messaging?.targetResolver?.looksLikeId;
|
||||
if (!looksLikeId) {
|
||||
throw new Error("looksLikeId unavailable");
|
||||
}
|
||||
expect(looksLikeId("user:20002")).toBe(true);
|
||||
expect(looksLikeId("group:30003")).toBe(true);
|
||||
expect(looksLikeId("g-30003")).toBe(true);
|
||||
expect(looksLikeId("u-20002")).toBe(true);
|
||||
expect(looksLikeId("Alice Nguyen")).toBe(false);
|
||||
});
|
||||
});
|
||||
33
openclaw/extensions/zalouser/src/channel.setup.test.ts
Normal file
33
openclaw/extensions/zalouser/src/channel.setup.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { withEnvAsync } from "openclaw/plugin-sdk/testing";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createPluginSetupWizardStatus } from "../../../test/helpers/plugins/setup-wizard.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import { zalouserSetupPlugin } from "./setup-test-helpers.js";
|
||||
|
||||
const zalouserSetupGetStatus = createPluginSetupWizardStatus(zalouserSetupPlugin);
|
||||
|
||||
describe("zalouser setup plugin", () => {
|
||||
it("builds setup status without an initialized runtime", async () => {
|
||||
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-setup-"));
|
||||
|
||||
try {
|
||||
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
await expect(
|
||||
zalouserSetupGetStatus({
|
||||
cfg: {},
|
||||
accountOverrides: {},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
channel: "zalouser",
|
||||
configured: false,
|
||||
statusLines: ["Zalo Personal: needs QR login"],
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
await rm(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
12
openclaw/extensions/zalouser/src/channel.setup.ts
Normal file
12
openclaw/extensions/zalouser/src/channel.setup.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { ResolvedZalouserAccount } from "./accounts.js";
|
||||
import type { ChannelPlugin } from "./channel-api.js";
|
||||
import { zalouserSetupAdapter } from "./setup-core.js";
|
||||
import { zalouserSetupWizard } from "./setup-surface.js";
|
||||
import { createZalouserPluginBase } from "./shared.js";
|
||||
|
||||
export const zalouserSetupPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
...createZalouserPluginBase({
|
||||
setupWizard: zalouserSetupWizard,
|
||||
setup: zalouserSetupAdapter,
|
||||
}),
|
||||
};
|
||||
377
openclaw/extensions/zalouser/src/channel.test.ts
Normal file
377
openclaw/extensions/zalouser/src/channel.test.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createNonExitingRuntimeEnv } from "../../../test/helpers/plugins/runtime-env.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import {
|
||||
zalouserAuthAdapter,
|
||||
zalouserGroupsAdapter,
|
||||
zalouserMessageActions,
|
||||
zalouserOutboundAdapter,
|
||||
zalouserPairingTextAdapter,
|
||||
zalouserResolverAdapter,
|
||||
zalouserSecurityAdapter,
|
||||
} from "./channel.adapters.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
|
||||
import {
|
||||
listZaloFriendsMatchingMock,
|
||||
startZaloQrLoginMock,
|
||||
waitForZaloQrLoginMock,
|
||||
} from "./zalo-js.test-mocks.js";
|
||||
|
||||
vi.mock("./qr-temp-file.js", () => ({
|
||||
writeQrDataUrlToTempFile: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", async () => {
|
||||
const actual = (await vi.importActual("./send.js")) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
sendMessageZalouser: vi.fn(async () => ({ ok: true, messageId: "mid-1" })),
|
||||
sendReactionZalouser: vi.fn(async () => ({ ok: true })),
|
||||
};
|
||||
});
|
||||
|
||||
const mockSendMessage = vi.mocked(sendMessageZalouser);
|
||||
const mockSendReaction = vi.mocked(sendReactionZalouser);
|
||||
|
||||
function requireZalouserSendText() {
|
||||
const sendText = zalouserOutboundAdapter.sendText;
|
||||
if (!sendText) {
|
||||
throw new Error("zalouser outbound.sendText unavailable");
|
||||
}
|
||||
return sendText;
|
||||
}
|
||||
|
||||
function getResolveToolPolicy() {
|
||||
const resolveToolPolicy = zalouserGroupsAdapter.resolveToolPolicy;
|
||||
if (!resolveToolPolicy) {
|
||||
throw new Error("resolveToolPolicy unavailable");
|
||||
}
|
||||
return resolveToolPolicy;
|
||||
}
|
||||
|
||||
function requireZalouserResolveRequireMention() {
|
||||
const resolveRequireMention = zalouserGroupsAdapter.resolveRequireMention;
|
||||
if (!resolveRequireMention) {
|
||||
throw new Error("resolveRequireMention unavailable");
|
||||
}
|
||||
return resolveRequireMention;
|
||||
}
|
||||
|
||||
function requireZalouserPairingNormalizer() {
|
||||
const normalizeAllowEntry = zalouserPairingTextAdapter.normalizeAllowEntry;
|
||||
if (!normalizeAllowEntry) {
|
||||
throw new Error("pairing.normalizeAllowEntry unavailable");
|
||||
}
|
||||
return normalizeAllowEntry;
|
||||
}
|
||||
|
||||
function resolveGroupToolPolicy(
|
||||
groups: Record<string, { tools: { allow?: string[]; deny?: string[] } }>,
|
||||
groupId: string,
|
||||
) {
|
||||
return getResolveToolPolicy()({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups,
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
groupId,
|
||||
groupChannel: groupId,
|
||||
});
|
||||
}
|
||||
|
||||
describe("zalouser outbound", () => {
|
||||
beforeEach(() => {
|
||||
mockSendMessage.mockClear();
|
||||
setZalouserRuntime({
|
||||
channel: {
|
||||
text: {
|
||||
resolveChunkMode: vi.fn(() => "newline"),
|
||||
resolveTextChunkLimit: vi.fn(() => 10),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("passes markdown chunk settings through sendText", async () => {
|
||||
const sendText = requireZalouserSendText();
|
||||
|
||||
const result = await sendText({
|
||||
cfg: { channels: { zalouser: { enabled: true } } } as never,
|
||||
to: "group:123456",
|
||||
text: "hello world\nthis is a test",
|
||||
accountId: "default",
|
||||
} as never);
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith(
|
||||
"123456",
|
||||
"hello world\nthis is a test",
|
||||
expect.objectContaining({
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "newline",
|
||||
textChunkLimit: 10,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
channel: "zalouser",
|
||||
messageId: "mid-1",
|
||||
ok: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zalouser outbound chunking", () => {
|
||||
it("chunks outbound text without requiring Zalouser runtime initialization", () => {
|
||||
const chunker = zalouserOutboundAdapter.chunker;
|
||||
if (!chunker) {
|
||||
throw new Error("zalouser outbound.chunker unavailable");
|
||||
}
|
||||
|
||||
expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zalouser channel policies", () => {
|
||||
beforeEach(() => {
|
||||
mockSendReaction.mockClear();
|
||||
mockSendReaction.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("normalizes dm allowlist entries after trimming channel prefixes", () => {
|
||||
const resolveDmPolicy = zalouserSecurityAdapter.resolveDmPolicy;
|
||||
if (!resolveDmPolicy) {
|
||||
throw new Error("resolveDmPolicy unavailable");
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
zalouser: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [" zlu:123456 "],
|
||||
},
|
||||
},
|
||||
} as never;
|
||||
const account = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
authenticated: false,
|
||||
profile: "default",
|
||||
config: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [" zlu:123456 "],
|
||||
},
|
||||
} as never;
|
||||
|
||||
const result = resolveDmPolicy({ cfg, account });
|
||||
if (!result) {
|
||||
throw new Error("zalouser resolveDmPolicy returned null");
|
||||
}
|
||||
|
||||
expect(result.policy).toBe("allowlist");
|
||||
expect(result.allowFrom).toEqual([" zlu:123456 "]);
|
||||
expect(result.normalizeEntry?.(" zlu:123456 ")).toBe("123456");
|
||||
});
|
||||
|
||||
it("normalizes pairing allowlist entries after trimming channel prefixes", () => {
|
||||
const normalizeAllowEntry = requireZalouserPairingNormalizer();
|
||||
|
||||
expect(normalizeAllowEntry(" zlu:123456 ")).toBe("123456");
|
||||
expect(normalizeAllowEntry(" zalouser:654321 ")).toBe("654321");
|
||||
});
|
||||
|
||||
it("resolves requireMention from group config", () => {
|
||||
const resolveRequireMention = requireZalouserResolveRequireMention();
|
||||
const requireMention = resolveRequireMention({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups: {
|
||||
"123": { requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
groupId: "123",
|
||||
groupChannel: "123",
|
||||
});
|
||||
expect(requireMention).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves group tool policy by explicit group id", () => {
|
||||
const policy = resolveGroupToolPolicy({ "123": { tools: { allow: ["search"] } } }, "123");
|
||||
expect(policy).toEqual({ allow: ["search"] });
|
||||
});
|
||||
|
||||
it("falls back to wildcard group policy", () => {
|
||||
const policy = resolveGroupToolPolicy({ "*": { tools: { deny: ["system.run"] } } }, "missing");
|
||||
expect(policy).toEqual({ deny: ["system.run"] });
|
||||
});
|
||||
|
||||
it("handles react action", async () => {
|
||||
const actions = zalouserMessageActions;
|
||||
expect(
|
||||
actions?.describeMessageTool?.({ cfg: { channels: { zalouser: { enabled: true } } } })
|
||||
?.actions,
|
||||
).toEqual(["react"]);
|
||||
const result = await actions?.handleAction?.({
|
||||
channel: "zalouser",
|
||||
action: "react",
|
||||
params: {
|
||||
threadId: "123456",
|
||||
messageId: "111",
|
||||
cliMsgId: "222",
|
||||
emoji: "👍",
|
||||
},
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
profile: "default",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mockSendReaction).toHaveBeenCalledWith({
|
||||
profile: "default",
|
||||
threadId: "123456",
|
||||
isGroup: false,
|
||||
msgId: "111",
|
||||
cliMsgId: "222",
|
||||
emoji: "👍",
|
||||
remove: false,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
content: [{ type: "text", text: "Reacted 👍 on 111" }],
|
||||
details: {
|
||||
messageId: "111",
|
||||
cliMsgId: "222",
|
||||
threadId: "123456",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("honors the selected Zalouser account during discovery", () => {
|
||||
const actions = zalouserMessageActions;
|
||||
const cfg = {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
profile: "default",
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: false,
|
||||
profile: "default",
|
||||
},
|
||||
work: {
|
||||
enabled: true,
|
||||
profile: "work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(actions?.describeMessageTool?.({ cfg, accountId: "default" })).toBeNull();
|
||||
expect(actions?.describeMessageTool?.({ cfg, accountId: "work" })?.actions).toEqual(["react"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zalouser account resolution", () => {
|
||||
beforeEach(() => {
|
||||
listZaloFriendsMatchingMock.mockReset();
|
||||
startZaloQrLoginMock.mockReset();
|
||||
waitForZaloQrLoginMock.mockReset();
|
||||
});
|
||||
|
||||
it("uses the configured default account for omitted target lookup", async () => {
|
||||
const resolveTargets = zalouserResolverAdapter.resolveTargets;
|
||||
if (!resolveTargets) {
|
||||
throw new Error("zalouser resolver.resolveTargets unavailable");
|
||||
}
|
||||
|
||||
listZaloFriendsMatchingMock.mockResolvedValue([
|
||||
{ userId: "42", displayName: "Work User" } as never,
|
||||
]);
|
||||
|
||||
const result = await resolveTargets({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "work-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
inputs: ["Work User"],
|
||||
kind: "user",
|
||||
runtime: createNonExitingRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(listZaloFriendsMatchingMock).toHaveBeenCalledWith("work-profile", "Work User");
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
input: "Work User",
|
||||
resolved: true,
|
||||
id: "42",
|
||||
name: "Work User",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the configured default account for omitted qr login", async () => {
|
||||
const login = zalouserAuthAdapter.login;
|
||||
if (!login) {
|
||||
throw new Error("zalouser auth.login unavailable");
|
||||
}
|
||||
|
||||
startZaloQrLoginMock.mockResolvedValue({
|
||||
message: "qr ready",
|
||||
qrDataUrl: "data:image/png;base64,abc",
|
||||
} as never);
|
||||
waitForZaloQrLoginMock.mockResolvedValue({
|
||||
connected: true,
|
||||
userId: "u-1",
|
||||
displayName: "Work User",
|
||||
} as never);
|
||||
|
||||
const runtime = createNonExitingRuntimeEnv();
|
||||
|
||||
await login({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "work-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(startZaloQrLoginMock).toHaveBeenCalledWith({
|
||||
profile: "work-profile",
|
||||
timeoutMs: 35_000,
|
||||
});
|
||||
expect(waitForZaloQrLoginMock).toHaveBeenCalledWith({
|
||||
profile: "work-profile",
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
221
openclaw/extensions/zalouser/src/channel.ts
Normal file
221
openclaw/extensions/zalouser/src/channel.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
createAsyncComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import {
|
||||
checkZcaAuthenticated,
|
||||
resolveZalouserAccountSync,
|
||||
type ResolvedZalouserAccount,
|
||||
} from "./accounts.js";
|
||||
import type { ChannelDirectoryEntry, ChannelPlugin } from "./channel-api.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "./channel-api.js";
|
||||
import {
|
||||
zalouserAuthAdapter,
|
||||
zalouserGroupsAdapter,
|
||||
zalouserMessageActions,
|
||||
zalouserMessagingAdapter,
|
||||
zalouserOutboundAdapter,
|
||||
zalouserPairingTextAdapter,
|
||||
resolveZalouserQrProfile,
|
||||
zalouserResolverAdapter,
|
||||
zalouserSecurityAdapter,
|
||||
zalouserThreadingAdapter,
|
||||
} from "./channel.adapters.js";
|
||||
import { listZalouserDirectoryGroupMembers } from "./directory.js";
|
||||
import type { ZalouserProbeResult } from "./probe.js";
|
||||
import { createZalouserSetupWizardProxy, zalouserSetupAdapter } from "./setup-core.js";
|
||||
import { createZalouserPluginBase } from "./shared.js";
|
||||
import { collectZalouserStatusIssues } from "./status-issues.js";
|
||||
|
||||
const loadZalouserChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
|
||||
const zalouserSetupWizardProxy = createZalouserSetupWizardProxy(
|
||||
async () => (await import("./setup-surface.js")).zalouserSetupWizard,
|
||||
);
|
||||
|
||||
function mapUser(params: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
raw?: unknown;
|
||||
}): ChannelDirectoryEntry {
|
||||
return {
|
||||
kind: "user",
|
||||
id: params.id,
|
||||
name: params.name ?? undefined,
|
||||
avatarUrl: params.avatarUrl ?? undefined,
|
||||
raw: params.raw,
|
||||
};
|
||||
}
|
||||
|
||||
function mapGroup(params: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
raw?: unknown;
|
||||
}): ChannelDirectoryEntry {
|
||||
return {
|
||||
kind: "group",
|
||||
id: params.id,
|
||||
name: params.name ?? undefined,
|
||||
raw: params.raw,
|
||||
};
|
||||
}
|
||||
|
||||
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount, ZalouserProbeResult> =
|
||||
createChatChannelPlugin({
|
||||
base: {
|
||||
...createZalouserPluginBase({
|
||||
setupWizard: zalouserSetupWizardProxy,
|
||||
setup: zalouserSetupAdapter,
|
||||
}),
|
||||
groups: zalouserGroupsAdapter,
|
||||
actions: zalouserMessageActions,
|
||||
messaging: zalouserMessagingAdapter,
|
||||
directory: {
|
||||
self: async ({ cfg, accountId }) => {
|
||||
const { getZaloUserInfo } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
|
||||
const parsed = await getZaloUserInfo(account.profile);
|
||||
if (!parsed?.userId) {
|
||||
return null;
|
||||
}
|
||||
return mapUser({
|
||||
id: parsed.userId,
|
||||
name: parsed.displayName ?? null,
|
||||
avatarUrl: parsed.avatar ?? null,
|
||||
raw: parsed,
|
||||
});
|
||||
},
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const { listZaloFriendsMatching } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
|
||||
const friends = await listZaloFriendsMatching(account.profile, query);
|
||||
const rows = friends.map((friend) =>
|
||||
mapUser({
|
||||
id: friend.userId,
|
||||
name: friend.displayName ?? null,
|
||||
avatarUrl: friend.avatar ?? null,
|
||||
raw: friend,
|
||||
}),
|
||||
);
|
||||
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const { listZaloGroupsMatching } = await loadZalouserChannelRuntime();
|
||||
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
|
||||
const groups = await listZaloGroupsMatching(account.profile, query);
|
||||
const rows = groups.map((group) =>
|
||||
mapGroup({
|
||||
id: `group:${group.groupId}`,
|
||||
name: group.name ?? null,
|
||||
raw: group,
|
||||
}),
|
||||
);
|
||||
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
|
||||
},
|
||||
listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
|
||||
const { listZaloGroupMembers } = await loadZalouserChannelRuntime();
|
||||
return await listZalouserDirectoryGroupMembers(
|
||||
{
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
groupId,
|
||||
limit: limit ?? undefined,
|
||||
},
|
||||
{ listZaloGroupMembers },
|
||||
);
|
||||
},
|
||||
},
|
||||
resolver: zalouserResolverAdapter,
|
||||
auth: zalouserAuthAdapter,
|
||||
status: createAsyncComputedAccountStatusAdapter<ResolvedZalouserAccount, ZalouserProbeResult>(
|
||||
{
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
collectStatusIssues: collectZalouserStatusIssues,
|
||||
buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
(await loadZalouserChannelRuntime()).probeZalouser(account.profile, timeoutMs),
|
||||
resolveAccountSnapshot: async ({ account, runtime }) => {
|
||||
const configured = await checkZcaAuthenticated(account.profile);
|
||||
const configError = "not authenticated";
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
extra: {
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
lastError: configured
|
||||
? (runtime?.lastError ?? null)
|
||||
: (runtime?.lastError ?? configError),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const { getZaloUserInfo } = await loadZalouserChannelRuntime();
|
||||
const account = ctx.account;
|
||||
let userLabel = "";
|
||||
try {
|
||||
const userInfo = await getZaloUserInfo(account.profile);
|
||||
if (userInfo?.displayName) {
|
||||
userLabel = ` (${userInfo.displayName})`;
|
||||
}
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
profile: userInfo,
|
||||
});
|
||||
} catch {
|
||||
// ignore probe errors
|
||||
}
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
|
||||
const { monitorZalouserProvider } = await import("./monitor.js");
|
||||
return monitorZalouserProvider({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
loginWithQrStart: async (params) => {
|
||||
const { startZaloQrLogin } = await loadZalouserChannelRuntime();
|
||||
const profile = resolveZalouserQrProfile(params.accountId);
|
||||
return await startZaloQrLogin({
|
||||
profile,
|
||||
force: params.force,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
},
|
||||
loginWithQrWait: async (params) => {
|
||||
const { waitForZaloQrLogin } = await loadZalouserChannelRuntime();
|
||||
const profile = resolveZalouserQrProfile(params.accountId);
|
||||
return await waitForZaloQrLogin({
|
||||
profile,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
},
|
||||
logoutAccount: async (ctx) =>
|
||||
await (
|
||||
await loadZalouserChannelRuntime()
|
||||
).logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)),
|
||||
},
|
||||
},
|
||||
security: zalouserSecurityAdapter,
|
||||
threading: zalouserThreadingAdapter,
|
||||
pairing: {
|
||||
text: zalouserPairingTextAdapter,
|
||||
},
|
||||
outbound: zalouserOutboundAdapter,
|
||||
});
|
||||
|
||||
export type { ResolvedZalouserAccount };
|
||||
33
openclaw/extensions/zalouser/src/config-schema.ts
Normal file
33
openclaw/extensions/zalouser/src/config-schema.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
AllowFromListSchema,
|
||||
buildCatchallMultiAccountChannelSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
ToolPolicySchema,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
|
||||
const groupConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
});
|
||||
|
||||
const zalouserAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
profile: z.string().optional(),
|
||||
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional(),
|
||||
allowFrom: AllowFromListSchema,
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
groupAllowFrom: AllowFromListSchema,
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
groups: z.object({}).catchall(groupConfigSchema).optional(),
|
||||
messagePrefix: z.string().optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZalouserConfigSchema = buildCatchallMultiAccountChannelSchema(zalouserAccountSchema);
|
||||
54
openclaw/extensions/zalouser/src/directory.ts
Normal file
54
openclaw/extensions/zalouser/src/directory.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { resolveZalouserAccountSync } from "./accounts.js";
|
||||
import type { ChannelDirectoryEntry, OpenClawConfig } from "./channel-api.js";
|
||||
import { parseZalouserDirectoryGroupId } from "./session-route.js";
|
||||
|
||||
type ZalouserDirectoryDeps = {
|
||||
listZaloGroupMembers: (
|
||||
profile: string,
|
||||
groupId: string,
|
||||
) => Promise<
|
||||
Array<{
|
||||
userId: string;
|
||||
displayName?: string | null;
|
||||
avatar?: string | null;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
|
||||
function mapUser(params: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
raw?: unknown;
|
||||
}): ChannelDirectoryEntry {
|
||||
return {
|
||||
kind: "user",
|
||||
id: params.id,
|
||||
name: params.name ?? undefined,
|
||||
avatarUrl: params.avatarUrl ?? undefined,
|
||||
raw: params.raw,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listZalouserDirectoryGroupMembers(
|
||||
params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
groupId: string;
|
||||
limit?: number;
|
||||
},
|
||||
deps: ZalouserDirectoryDeps,
|
||||
) {
|
||||
const account = resolveZalouserAccountSync({ cfg: params.cfg, accountId: params.accountId });
|
||||
const normalizedGroupId = parseZalouserDirectoryGroupId(params.groupId);
|
||||
const members = await deps.listZaloGroupMembers(account.profile, normalizedGroupId);
|
||||
const rows = members.map((member) =>
|
||||
mapUser({
|
||||
id: member.userId,
|
||||
name: member.displayName,
|
||||
avatarUrl: member.avatar ?? null,
|
||||
raw: member,
|
||||
}),
|
||||
);
|
||||
return typeof params.limit === "number" && params.limit > 0 ? rows.slice(0, params.limit) : rows;
|
||||
}
|
||||
156
openclaw/extensions/zalouser/src/doctor-contract.ts
Normal file
156
openclaw/extensions/zalouser/src/doctor-contract.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import type {
|
||||
ChannelDoctorConfigMutation,
|
||||
ChannelDoctorLegacyConfigRule,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
|
||||
type ZalouserChannelsConfig = NonNullable<OpenClawConfig["channels"]>;
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function hasLegacyZalouserGroupAllowAlias(value: unknown): boolean {
|
||||
const group = asObjectRecord(value);
|
||||
return Boolean(group && typeof group.allow === "boolean");
|
||||
}
|
||||
|
||||
function hasLegacyZalouserGroupAllowAliases(value: unknown): boolean {
|
||||
const groups = asObjectRecord(value);
|
||||
return Boolean(
|
||||
groups && Object.values(groups).some((group) => hasLegacyZalouserGroupAllowAlias(group)),
|
||||
);
|
||||
}
|
||||
|
||||
function hasLegacyZalouserAccountGroupAllowAliases(value: unknown): boolean {
|
||||
const accounts = asObjectRecord(value);
|
||||
if (!accounts) {
|
||||
return false;
|
||||
}
|
||||
return Object.values(accounts).some((account) => {
|
||||
const accountRecord = asObjectRecord(account);
|
||||
return Boolean(accountRecord && hasLegacyZalouserGroupAllowAliases(accountRecord.groups));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeZalouserGroupAllowAliases(params: {
|
||||
groups: Record<string, unknown>;
|
||||
pathPrefix: string;
|
||||
changes: string[];
|
||||
}): { groups: Record<string, unknown>; changed: boolean } {
|
||||
let changed = false;
|
||||
const nextGroups: Record<string, unknown> = { ...params.groups };
|
||||
for (const [groupId, groupValue] of Object.entries(params.groups)) {
|
||||
const group = asObjectRecord(groupValue);
|
||||
if (!group || typeof group.allow !== "boolean") {
|
||||
continue;
|
||||
}
|
||||
const nextGroup = { ...group };
|
||||
if (typeof nextGroup.enabled !== "boolean") {
|
||||
nextGroup.enabled = group.allow;
|
||||
}
|
||||
delete nextGroup.allow;
|
||||
nextGroups[groupId] = nextGroup;
|
||||
changed = true;
|
||||
params.changes.push(
|
||||
`Moved ${params.pathPrefix}.${groupId}.allow → ${params.pathPrefix}.${groupId}.enabled (${String(nextGroup.enabled)}).`,
|
||||
);
|
||||
}
|
||||
return { groups: nextGroups, changed };
|
||||
}
|
||||
|
||||
function normalizeZalouserCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
|
||||
const channels = asObjectRecord(cfg.channels);
|
||||
const zalouser = asObjectRecord(channels?.zalouser);
|
||||
if (!zalouser) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
let updatedZalouser: Record<string, unknown> = zalouser;
|
||||
let changed = false;
|
||||
|
||||
const groups = asObjectRecord(updatedZalouser.groups);
|
||||
if (groups) {
|
||||
const normalized = normalizeZalouserGroupAllowAliases({
|
||||
groups,
|
||||
pathPrefix: "channels.zalouser.groups",
|
||||
changes,
|
||||
});
|
||||
if (normalized.changed) {
|
||||
updatedZalouser = { ...updatedZalouser, groups: normalized.groups };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = asObjectRecord(updatedZalouser.accounts);
|
||||
if (accounts) {
|
||||
let accountsChanged = false;
|
||||
const nextAccounts: Record<string, unknown> = { ...accounts };
|
||||
for (const [accountId, accountValue] of Object.entries(accounts)) {
|
||||
const account = asObjectRecord(accountValue);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const accountGroups = asObjectRecord(account.groups);
|
||||
if (!accountGroups) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeZalouserGroupAllowAliases({
|
||||
groups: accountGroups,
|
||||
pathPrefix: `channels.zalouser.accounts.${accountId}.groups`,
|
||||
changes,
|
||||
});
|
||||
if (!normalized.changed) {
|
||||
continue;
|
||||
}
|
||||
nextAccounts[accountId] = {
|
||||
...account,
|
||||
groups: normalized.groups,
|
||||
};
|
||||
accountsChanged = true;
|
||||
}
|
||||
if (accountsChanged) {
|
||||
updatedZalouser = { ...updatedZalouser, accounts: nextAccounts };
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
config: {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalouser: updatedZalouser as ZalouserChannelsConfig["zalouser"],
|
||||
},
|
||||
},
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "zalouser", "groups"],
|
||||
message:
|
||||
'channels.zalouser.groups.<id>.allow is legacy; use channels.zalouser.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
|
||||
match: hasLegacyZalouserGroupAllowAliases,
|
||||
},
|
||||
{
|
||||
path: ["channels", "zalouser", "accounts"],
|
||||
message:
|
||||
'channels.zalouser.accounts.<id>.groups.<id>.allow is legacy; use channels.zalouser.accounts.<id>.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
|
||||
match: hasLegacyZalouserAccountGroupAllowAliases,
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig(params: {
|
||||
cfg: OpenClawConfig;
|
||||
}): ChannelDoctorConfigMutation {
|
||||
return normalizeZalouserCompatibilityConfig(params.cfg);
|
||||
}
|
||||
77
openclaw/extensions/zalouser/src/doctor.test.ts
Normal file
77
openclaw/extensions/zalouser/src/doctor.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { zalouserDoctor } from "./doctor.js";
|
||||
|
||||
describe("zalouser doctor", () => {
|
||||
it("warns when mutable group names rely on disabled name matching", () => {
|
||||
expect(
|
||||
zalouserDoctor.collectMutableAllowlistWarnings?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups: {
|
||||
"group:trusted": {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
}),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining("mutable allowlist entry across zalouser"),
|
||||
expect.stringContaining("channels.zalouser.groups: group:trusted"),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes legacy group allow aliases to enabled", () => {
|
||||
const normalize = zalouserDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
groups: {
|
||||
"group:trusted": {
|
||||
allow: true,
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
groups: {
|
||||
"group:legacy": {
|
||||
allow: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.zalouser?.groups?.["group:trusted"]).toEqual({
|
||||
enabled: true,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
result.config.channels?.zalouser?.accounts?.work as
|
||||
| { groups?: Record<string, unknown> }
|
||||
| undefined
|
||||
)?.groups?.["group:legacy"],
|
||||
).toEqual({
|
||||
enabled: false,
|
||||
});
|
||||
expect(result.changes).toEqual(
|
||||
expect.arrayContaining([
|
||||
"Moved channels.zalouser.groups.group:trusted.allow → channels.zalouser.groups.group:trusted.enabled (true).",
|
||||
"Moved channels.zalouser.accounts.work.groups.group:legacy.allow → channels.zalouser.accounts.work.groups.group:legacy.enabled (false).",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
37
openclaw/extensions/zalouser/src/doctor.ts
Normal file
37
openclaw/extensions/zalouser/src/doctor.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
|
||||
import { isZalouserMutableGroupEntry } from "./security-audit.js";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export const collectZalouserMutableAllowlistWarnings =
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "zalouser",
|
||||
detector: isZalouserMutableGroupEntry,
|
||||
collectLists: (scope) => {
|
||||
const groups = asObjectRecord(scope.account.groups);
|
||||
return groups
|
||||
? [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.groups`,
|
||||
list: Object.keys(groups),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
},
|
||||
});
|
||||
|
||||
export const zalouserDoctor: ChannelDoctorAdapter = {
|
||||
dmAllowFromMode: "topOnly",
|
||||
groupModel: "hybrid",
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
warnOnEmptyGroupSenderAllowlist: false,
|
||||
legacyConfigRules,
|
||||
normalizeCompatibilityConfig,
|
||||
collectMutableAllowlistWarnings: collectZalouserMutableAllowlistWarnings,
|
||||
};
|
||||
61
openclaw/extensions/zalouser/src/group-policy.test.ts
Normal file
61
openclaw/extensions/zalouser/src/group-policy.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildZalouserGroupCandidates,
|
||||
findZalouserGroupEntry,
|
||||
isZalouserGroupEntryAllowed,
|
||||
normalizeZalouserGroupSlug,
|
||||
} from "./group-policy.js";
|
||||
|
||||
describe("zalouser group policy helpers", () => {
|
||||
it("normalizes group slug names", () => {
|
||||
expect(normalizeZalouserGroupSlug(" Team Alpha ")).toBe("team-alpha");
|
||||
expect(normalizeZalouserGroupSlug("#Roadmap Updates")).toBe("roadmap-updates");
|
||||
});
|
||||
|
||||
it("builds ordered candidates with optional aliases", () => {
|
||||
expect(
|
||||
buildZalouserGroupCandidates({
|
||||
groupId: "123",
|
||||
groupChannel: "chan-1",
|
||||
groupName: "Team Alpha",
|
||||
includeGroupIdAlias: true,
|
||||
}),
|
||||
).toEqual(["123", "group:123", "chan-1", "Team Alpha", "team-alpha", "*"]);
|
||||
});
|
||||
|
||||
it("builds id-only candidates when name matching is disabled", () => {
|
||||
expect(
|
||||
buildZalouserGroupCandidates({
|
||||
groupId: "123",
|
||||
groupChannel: "chan-1",
|
||||
groupName: "Team Alpha",
|
||||
includeGroupIdAlias: true,
|
||||
allowNameMatching: false,
|
||||
}),
|
||||
).toEqual(["123", "group:123", "*"]);
|
||||
});
|
||||
|
||||
it("finds the first matching group entry", () => {
|
||||
const groups = {
|
||||
"group:123": { enabled: true },
|
||||
"team-alpha": { requireMention: false },
|
||||
"*": { requireMention: true },
|
||||
};
|
||||
const entry = findZalouserGroupEntry(
|
||||
groups,
|
||||
buildZalouserGroupCandidates({
|
||||
groupId: "123",
|
||||
groupName: "Team Alpha",
|
||||
includeGroupIdAlias: true,
|
||||
}),
|
||||
);
|
||||
expect(entry).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("evaluates allow/enable flags", () => {
|
||||
expect(isZalouserGroupEntryAllowed({ enabled: true })).toBe(true);
|
||||
expect(isZalouserGroupEntryAllowed({ allow: false } as never)).toBe(false);
|
||||
expect(isZalouserGroupEntryAllowed({ enabled: false })).toBe(false);
|
||||
expect(isZalouserGroupEntryAllowed(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
83
openclaw/extensions/zalouser/src/group-policy.ts
Normal file
83
openclaw/extensions/zalouser/src/group-policy.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ZalouserGroupConfig } from "./types.js";
|
||||
|
||||
type ZalouserGroups = Record<string, ZalouserGroupConfig>;
|
||||
|
||||
function toGroupCandidate(value?: string | null): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function normalizeZalouserGroupSlug(raw?: string | null): string {
|
||||
const trimmed = normalizeOptionalLowercaseString(raw) ?? "";
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return trimmed
|
||||
.replace(/^#/, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
export function buildZalouserGroupCandidates(params: {
|
||||
groupId?: string | null;
|
||||
groupChannel?: string | null;
|
||||
groupName?: string | null;
|
||||
includeGroupIdAlias?: boolean;
|
||||
includeWildcard?: boolean;
|
||||
allowNameMatching?: boolean;
|
||||
}): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
const push = (value?: string | null) => {
|
||||
const normalized = toGroupCandidate(value);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
out.push(normalized);
|
||||
};
|
||||
|
||||
const groupId = toGroupCandidate(params.groupId);
|
||||
const groupChannel = toGroupCandidate(params.groupChannel);
|
||||
const groupName = toGroupCandidate(params.groupName);
|
||||
|
||||
push(groupId);
|
||||
if (params.includeGroupIdAlias === true && groupId) {
|
||||
push(`group:${groupId}`);
|
||||
}
|
||||
if (params.allowNameMatching !== false) {
|
||||
push(groupChannel);
|
||||
push(groupName);
|
||||
if (groupName) {
|
||||
push(normalizeZalouserGroupSlug(groupName));
|
||||
}
|
||||
}
|
||||
if (params.includeWildcard !== false) {
|
||||
push("*");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function findZalouserGroupEntry(
|
||||
groups: ZalouserGroups | undefined,
|
||||
candidates: string[],
|
||||
): ZalouserGroupConfig | undefined {
|
||||
if (!groups) {
|
||||
return undefined;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const entry = groups[candidate];
|
||||
if (entry) {
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isZalouserGroupEntryAllowed(entry: ZalouserGroupConfig | undefined): boolean {
|
||||
if (!entry) {
|
||||
return false;
|
||||
}
|
||||
const legacyAllow = (entry as ZalouserGroupConfig & { allow?: unknown }).allow;
|
||||
return legacyAllow !== false && entry.enabled !== false;
|
||||
}
|
||||
66
openclaw/extensions/zalouser/src/message-sid.test.ts
Normal file
66
openclaw/extensions/zalouser/src/message-sid.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatZalouserMessageSidFull,
|
||||
parseZalouserMessageSidFull,
|
||||
resolveZalouserMessageSid,
|
||||
resolveZalouserReactionMessageIds,
|
||||
} from "./message-sid.js";
|
||||
|
||||
describe("zalouser message sid helpers", () => {
|
||||
it("parses MessageSidFull pairs", () => {
|
||||
expect(parseZalouserMessageSidFull("111:222")).toEqual({
|
||||
msgId: "111",
|
||||
cliMsgId: "222",
|
||||
});
|
||||
expect(parseZalouserMessageSidFull("111")).toBeNull();
|
||||
expect(parseZalouserMessageSidFull(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves reaction ids from explicit params first", () => {
|
||||
expect(
|
||||
resolveZalouserReactionMessageIds({
|
||||
messageId: "m-1",
|
||||
cliMsgId: "c-1",
|
||||
currentMessageId: "x:y",
|
||||
}),
|
||||
).toEqual({
|
||||
msgId: "m-1",
|
||||
cliMsgId: "c-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves reaction ids from current message sid full", () => {
|
||||
expect(
|
||||
resolveZalouserReactionMessageIds({
|
||||
currentMessageId: "m-2:c-2",
|
||||
}),
|
||||
).toEqual({
|
||||
msgId: "m-2",
|
||||
cliMsgId: "c-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to duplicated current id when no pair is available", () => {
|
||||
expect(
|
||||
resolveZalouserReactionMessageIds({
|
||||
currentMessageId: "solo",
|
||||
}),
|
||||
).toEqual({
|
||||
msgId: "solo",
|
||||
cliMsgId: "solo",
|
||||
});
|
||||
});
|
||||
|
||||
it("formats message sid fields for context payload", () => {
|
||||
expect(formatZalouserMessageSidFull({ msgId: "1", cliMsgId: "2" })).toBe("1:2");
|
||||
expect(formatZalouserMessageSidFull({ msgId: "1" })).toBe("1");
|
||||
expect(formatZalouserMessageSidFull({ cliMsgId: "2" })).toBe("2");
|
||||
expect(formatZalouserMessageSidFull({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves primary message sid with fallback timestamp", () => {
|
||||
expect(resolveZalouserMessageSid({ msgId: "1", cliMsgId: "2", fallback: "t" })).toBe("1");
|
||||
expect(resolveZalouserMessageSid({ cliMsgId: "2", fallback: "t" })).toBe("2");
|
||||
expect(resolveZalouserMessageSid({ fallback: "t" })).toBe("t");
|
||||
});
|
||||
});
|
||||
80
openclaw/extensions/zalouser/src/message-sid.ts
Normal file
80
openclaw/extensions/zalouser/src/message-sid.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
function toMessageSidPart(value?: string | number | null): string {
|
||||
if (typeof value === "string") {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(Math.trunc(value));
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function parseZalouserMessageSidFull(
|
||||
value?: string | number | null,
|
||||
): { msgId: string; cliMsgId: string } | null {
|
||||
const raw = toMessageSidPart(value);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const [msgIdPart, cliMsgIdPart] = raw.split(":").map((entry) => entry.trim());
|
||||
if (!msgIdPart || !cliMsgIdPart) {
|
||||
return null;
|
||||
}
|
||||
return { msgId: msgIdPart, cliMsgId: cliMsgIdPart };
|
||||
}
|
||||
|
||||
export function resolveZalouserReactionMessageIds(params: {
|
||||
messageId?: string;
|
||||
cliMsgId?: string;
|
||||
currentMessageId?: string | number;
|
||||
}): { msgId: string; cliMsgId: string } | null {
|
||||
const explicitMessageId = toMessageSidPart(params.messageId);
|
||||
const explicitCliMsgId = toMessageSidPart(params.cliMsgId);
|
||||
if (explicitMessageId && explicitCliMsgId) {
|
||||
return { msgId: explicitMessageId, cliMsgId: explicitCliMsgId };
|
||||
}
|
||||
|
||||
const parsedFromCurrent = parseZalouserMessageSidFull(params.currentMessageId);
|
||||
if (parsedFromCurrent) {
|
||||
return parsedFromCurrent;
|
||||
}
|
||||
|
||||
const currentRaw = toMessageSidPart(params.currentMessageId);
|
||||
if (!currentRaw) {
|
||||
return null;
|
||||
}
|
||||
if (explicitMessageId && !explicitCliMsgId) {
|
||||
return { msgId: explicitMessageId, cliMsgId: currentRaw };
|
||||
}
|
||||
if (!explicitMessageId && explicitCliMsgId) {
|
||||
return { msgId: currentRaw, cliMsgId: explicitCliMsgId };
|
||||
}
|
||||
return { msgId: currentRaw, cliMsgId: currentRaw };
|
||||
}
|
||||
|
||||
export function formatZalouserMessageSidFull(params: {
|
||||
msgId?: string | null;
|
||||
cliMsgId?: string | null;
|
||||
}): string | undefined {
|
||||
const msgId = toMessageSidPart(params.msgId);
|
||||
const cliMsgId = toMessageSidPart(params.cliMsgId);
|
||||
if (!msgId && !cliMsgId) {
|
||||
return undefined;
|
||||
}
|
||||
if (msgId && cliMsgId) {
|
||||
return `${msgId}:${cliMsgId}`;
|
||||
}
|
||||
return msgId || cliMsgId || undefined;
|
||||
}
|
||||
|
||||
export function resolveZalouserMessageSid(params: {
|
||||
msgId?: string | null;
|
||||
cliMsgId?: string | null;
|
||||
fallback?: string | null;
|
||||
}): string | undefined {
|
||||
const msgId = toMessageSidPart(params.msgId);
|
||||
const cliMsgId = toMessageSidPart(params.cliMsgId);
|
||||
if (msgId || cliMsgId) {
|
||||
return msgId || cliMsgId;
|
||||
}
|
||||
return toMessageSidPart(params.fallback) || undefined;
|
||||
}
|
||||
107
openclaw/extensions/zalouser/src/monitor.account-scope.test.ts
Normal file
107
openclaw/extensions/zalouser/src/monitor.account-scope.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import "./monitor.send-mocks.js";
|
||||
import { __testing } from "./monitor.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import { sendMessageZalouserMock } from "./monitor.send-mocks.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import { createZalouserRuntimeEnv } from "./test-helpers.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
describe("zalouser monitor pairing account scoping", () => {
|
||||
it("scopes DM pairing-store reads and pairing requests to accountId", async () => {
|
||||
const readAllowFromStore = vi.fn(
|
||||
async (
|
||||
channelOrParams:
|
||||
| string
|
||||
| {
|
||||
channel?: string;
|
||||
accountId?: string;
|
||||
},
|
||||
_env?: NodeJS.ProcessEnv,
|
||||
accountId?: string,
|
||||
) => {
|
||||
const scopedAccountId =
|
||||
typeof channelOrParams === "object" && channelOrParams !== null
|
||||
? channelOrParams.accountId
|
||||
: accountId;
|
||||
return scopedAccountId === "beta" ? [] : ["attacker"];
|
||||
},
|
||||
);
|
||||
const upsertPairingRequest = vi.fn(async () => ({ code: "PAIRME88", created: true }));
|
||||
|
||||
setZalouserRuntime({
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore,
|
||||
upsertPairingRequest,
|
||||
buildPairingReply: vi.fn(() => "pairing reply"),
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn(() => false),
|
||||
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
|
||||
isControlCommandMessage: vi.fn(() => false),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
const account: ResolvedZalouserAccount = {
|
||||
accountId: "beta",
|
||||
enabled: true,
|
||||
profile: "beta",
|
||||
authenticated: true,
|
||||
config: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
},
|
||||
};
|
||||
|
||||
const config: OpenClawConfig = {
|
||||
channels: {
|
||||
zalouser: {
|
||||
accounts: {
|
||||
alpha: { dmPolicy: "pairing", allowFrom: [] },
|
||||
beta: { dmPolicy: "pairing", allowFrom: [] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const message: ZaloInboundMessage = {
|
||||
threadId: "chat-1",
|
||||
isGroup: false,
|
||||
senderId: "attacker",
|
||||
senderName: "Attacker",
|
||||
groupName: undefined,
|
||||
timestampMs: Date.now(),
|
||||
msgId: "msg-1",
|
||||
content: "hello",
|
||||
raw: { source: "test" },
|
||||
};
|
||||
|
||||
await __testing.processMessage({
|
||||
message,
|
||||
account,
|
||||
config,
|
||||
runtime: createZalouserRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "zalouser",
|
||||
accountId: "beta",
|
||||
}),
|
||||
);
|
||||
expect(upsertPairingRequest).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "zalouser",
|
||||
id: "attacker",
|
||||
accountId: "beta",
|
||||
}),
|
||||
);
|
||||
expect(sendMessageZalouserMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
723
openclaw/extensions/zalouser/src/monitor.group-gating.test.ts
Normal file
723
openclaw/extensions/zalouser/src/monitor.group-gating.test.ts
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import "./monitor.send-mocks.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import { resolveZalouserAccountSync } from "./accounts.js";
|
||||
import { __testing } from "./monitor.js";
|
||||
import {
|
||||
sendDeliveredZalouserMock,
|
||||
sendMessageZalouserMock,
|
||||
sendSeenZalouserMock,
|
||||
sendTypingZalouserMock,
|
||||
} from "./monitor.send-mocks.js";
|
||||
import { setZalouserRuntime } from "./runtime.js";
|
||||
import { createZalouserRuntimeEnv } from "./test-helpers.js";
|
||||
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
|
||||
|
||||
function createAccount(): ResolvedZalouserAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
profile: "default",
|
||||
authenticated: true,
|
||||
config: {
|
||||
groupPolicy: "open",
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createConfig(): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const createRuntimeEnv = () => createZalouserRuntimeEnv();
|
||||
|
||||
function installRuntime(params: {
|
||||
commandAuthorized?: boolean;
|
||||
replyPayload?: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
|
||||
resolveCommandAuthorizedFromAuthorizers?: (params: {
|
||||
useAccessGroups: boolean;
|
||||
authorizers: Array<{ configured: boolean; allowed: boolean }>;
|
||||
}) => boolean;
|
||||
}) {
|
||||
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => {
|
||||
await dispatcherOptions.typingCallbacks?.onReplyStart?.();
|
||||
if (params.replyPayload) {
|
||||
await dispatcherOptions.deliver(params.replyPayload);
|
||||
}
|
||||
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx };
|
||||
});
|
||||
const resolveCommandAuthorizedFromAuthorizers = vi.fn(
|
||||
(input: {
|
||||
useAccessGroups: boolean;
|
||||
authorizers: Array<{ configured: boolean; allowed: boolean }>;
|
||||
}) => {
|
||||
if (params.resolveCommandAuthorizedFromAuthorizers) {
|
||||
return params.resolveCommandAuthorizedFromAuthorizers(input);
|
||||
}
|
||||
return params.commandAuthorized ?? false;
|
||||
},
|
||||
);
|
||||
const resolveAgentRoute = vi.fn((input: { peer?: { kind?: string; id?: string } }) => {
|
||||
const peerKind = input.peer?.kind === "direct" ? "direct" : "group";
|
||||
const peerId = input.peer?.id ?? "1";
|
||||
return {
|
||||
agentId: "main",
|
||||
sessionKey:
|
||||
peerKind === "direct" ? "agent:main:main" : `agent:main:zalouser:${peerKind}:${peerId}`,
|
||||
accountId: "default",
|
||||
mainSessionKey: "agent:main:main",
|
||||
};
|
||||
});
|
||||
const readAllowFromStore = vi.fn(async () => []);
|
||||
const readSessionUpdatedAt = vi.fn(
|
||||
(_params?: { storePath: string; sessionKey: string }): number | undefined => undefined,
|
||||
);
|
||||
const buildAgentSessionKey = vi.fn(
|
||||
(input: {
|
||||
agentId: string;
|
||||
channel: string;
|
||||
accountId?: string;
|
||||
peer?: { kind?: string; id?: string };
|
||||
dmScope?: string;
|
||||
}) => {
|
||||
const peerKind = input.peer?.kind === "direct" ? "direct" : "group";
|
||||
const peerId = input.peer?.id ?? "1";
|
||||
if (peerKind === "direct") {
|
||||
if (input.dmScope === "per-account-channel-peer") {
|
||||
return `agent:${input.agentId}:${input.channel}:${input.accountId ?? "default"}:direct:${peerId}`;
|
||||
}
|
||||
if (input.dmScope === "per-peer") {
|
||||
return `agent:${input.agentId}:direct:${peerId}`;
|
||||
}
|
||||
if (input.dmScope === "main" || !input.dmScope) {
|
||||
return "agent:main:main";
|
||||
}
|
||||
}
|
||||
return `agent:${input.agentId}:${input.channel}:${peerKind}:${peerId}`;
|
||||
},
|
||||
);
|
||||
|
||||
setZalouserRuntime({
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore,
|
||||
upsertPairingRequest: vi.fn(async () => ({ code: "PAIR", created: true })),
|
||||
buildPairingReply: vi.fn(() => "pair"),
|
||||
},
|
||||
commands: {
|
||||
shouldComputeCommandAuthorized: vi.fn((body: string) => body.trim().startsWith("/")),
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
isControlCommandMessage: vi.fn((body: string) => body.trim().startsWith("/")),
|
||||
shouldHandleTextCommands: vi.fn(() => true),
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes: vi.fn(() => []),
|
||||
matchesMentionWithExplicit: vi.fn(
|
||||
(input) => input.explicit?.isExplicitlyMentioned === true,
|
||||
),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: vi.fn((input) => {
|
||||
const cfg = input.cfg as OpenClawConfig;
|
||||
const groupCfg = cfg.channels?.zalouser?.groups ?? {};
|
||||
const typedGroupCfg = groupCfg as Record<string, { requireMention?: boolean }>;
|
||||
const groupEntry = input.groupId ? typedGroupCfg[input.groupId] : undefined;
|
||||
const defaultEntry = typedGroupCfg["*"];
|
||||
if (typeof groupEntry?.requireMention === "boolean") {
|
||||
return groupEntry.requireMention;
|
||||
}
|
||||
if (typeof defaultEntry?.requireMention === "boolean") {
|
||||
return defaultEntry.requireMention;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
},
|
||||
routing: {
|
||||
buildAgentSessionKey,
|
||||
resolveAgentRoute,
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: vi.fn(() => "/tmp"),
|
||||
readSessionUpdatedAt,
|
||||
recordInboundSession: vi.fn(async () => {}),
|
||||
},
|
||||
reply: {
|
||||
resolveEnvelopeFormatOptions: vi.fn(() => undefined),
|
||||
formatAgentEnvelope: vi.fn(({ body }) => body),
|
||||
finalizeInboundContext: vi.fn((ctx) => ctx),
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
},
|
||||
text: {
|
||||
resolveMarkdownTableMode: vi.fn(() => "code"),
|
||||
convertMarkdownTables: vi.fn((text: string) => text),
|
||||
resolveChunkMode: vi.fn(() => "length"),
|
||||
resolveTextChunkLimit: vi.fn(() => 1200),
|
||||
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
|
||||
return {
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
resolveAgentRoute,
|
||||
resolveCommandAuthorizedFromAuthorizers,
|
||||
readAllowFromStore,
|
||||
readSessionUpdatedAt,
|
||||
buildAgentSessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
function installGroupCommandAuthRuntime() {
|
||||
return installRuntime({
|
||||
resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
|
||||
useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
|
||||
});
|
||||
}
|
||||
|
||||
async function processGroupControlCommand(params: {
|
||||
account: ResolvedZalouserAccount;
|
||||
content?: string;
|
||||
commandContent?: string;
|
||||
}) {
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: params.content ?? "/new",
|
||||
commandContent: params.commandContent ?? "/new",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
}),
|
||||
account: params.account,
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
}
|
||||
|
||||
function createGroupMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
|
||||
return {
|
||||
threadId: "g-1",
|
||||
isGroup: true,
|
||||
senderId: "123",
|
||||
senderName: "Alice",
|
||||
groupName: "Team",
|
||||
content: "hello",
|
||||
timestampMs: Date.now(),
|
||||
msgId: "m-1",
|
||||
hasAnyMention: false,
|
||||
wasExplicitlyMentioned: false,
|
||||
canResolveExplicitMention: true,
|
||||
implicitMention: false,
|
||||
raw: { source: "test" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createDmMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
|
||||
return {
|
||||
threadId: "u-1",
|
||||
isGroup: false,
|
||||
senderId: "321",
|
||||
senderName: "Bob",
|
||||
groupName: undefined,
|
||||
content: "hello",
|
||||
timestampMs: Date.now(),
|
||||
msgId: "dm-1",
|
||||
raw: { source: "test" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("zalouser monitor group mention gating", () => {
|
||||
beforeEach(() => {
|
||||
sendMessageZalouserMock.mockClear();
|
||||
sendTypingZalouserMock.mockClear();
|
||||
sendDeliveredZalouserMock.mockClear();
|
||||
sendSeenZalouserMock.mockClear();
|
||||
});
|
||||
|
||||
async function processMessageWithDefaults(params: {
|
||||
message: ZaloInboundMessage;
|
||||
account?: ResolvedZalouserAccount;
|
||||
historyState?: {
|
||||
historyLimit: number;
|
||||
groupHistories: Map<
|
||||
string,
|
||||
Array<{ sender: string; body: string; timestamp?: number; messageId?: string }>
|
||||
>;
|
||||
};
|
||||
}) {
|
||||
await __testing.processMessage({
|
||||
message: params.message,
|
||||
account: params.account ?? createAccount(),
|
||||
config: createConfig(),
|
||||
runtime: createZalouserRuntimeEnv(),
|
||||
historyState: params.historyState,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectSkippedGroupMessage(message?: Partial<ZaloInboundMessage>) {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await processMessageWithDefaults({
|
||||
message: createGroupMessage(message),
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
expect(sendTypingZalouserMock).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
async function expectGroupCommandAuthorizers(params: {
|
||||
accountConfig: ResolvedZalouserAccount["config"];
|
||||
expectedAuthorizers: Array<{ configured: boolean; allowed: boolean }>;
|
||||
}) {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
|
||||
installGroupCommandAuthRuntime();
|
||||
await processGroupControlCommand({
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: params.accountConfig,
|
||||
},
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
const authCall = resolveCommandAuthorizedFromAuthorizers.mock.calls[0]?.[0];
|
||||
expect(authCall?.authorizers).toEqual(params.expectedAuthorizers);
|
||||
}
|
||||
|
||||
async function processOpenDmMessage(params?: {
|
||||
message?: Partial<ZaloInboundMessage>;
|
||||
readSessionUpdatedAt?: (input?: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
}) => number | undefined;
|
||||
}) {
|
||||
const runtime = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
if (params?.readSessionUpdatedAt) {
|
||||
runtime.readSessionUpdatedAt.mockImplementation(params.readSessionUpdatedAt);
|
||||
}
|
||||
const account = createAccount();
|
||||
await processMessageWithDefaults({
|
||||
message: createDmMessage(params?.message),
|
||||
account: {
|
||||
...account,
|
||||
config: {
|
||||
...account.config,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
});
|
||||
return runtime;
|
||||
}
|
||||
|
||||
async function expectDangerousNameMatching(params: {
|
||||
dangerouslyAllowNameMatching?: boolean;
|
||||
expectedDispatches: number;
|
||||
}) {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await processMessageWithDefaults({
|
||||
message: createGroupMessage({
|
||||
threadId: "g-attacker-001",
|
||||
groupName: "Trusted Team",
|
||||
senderId: "666",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
content: "ping @bot",
|
||||
}),
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: {
|
||||
...createAccount().config,
|
||||
...(params.dangerouslyAllowNameMatching ? { dangerouslyAllowNameMatching: true } : {}),
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["*"],
|
||||
groups: {
|
||||
"group:g-trusted-001": { enabled: true },
|
||||
"Trusted Team": { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(
|
||||
params.expectedDispatches,
|
||||
);
|
||||
return dispatchReplyWithBufferedBlockDispatcher;
|
||||
}
|
||||
|
||||
async function dispatchGroupMessage(params: {
|
||||
commandAuthorized: boolean;
|
||||
message: Partial<ZaloInboundMessage>;
|
||||
}) {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: params.commandAuthorized,
|
||||
});
|
||||
await processMessageWithDefaults({
|
||||
message: createGroupMessage(params.message),
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
return dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
||||
}
|
||||
|
||||
it("skips unmentioned group messages when requireMention=true", async () => {
|
||||
await expectSkippedGroupMessage();
|
||||
});
|
||||
|
||||
it("blocks mentioned group messages by default when groupPolicy is omitted", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
zalouser: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId: "default" });
|
||||
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "ping @bot",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
}),
|
||||
account,
|
||||
config: cfg,
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(account.config.groupPolicy).toBe("allowlist");
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed when requireMention=true but mention detection is unavailable", async () => {
|
||||
await expectSkippedGroupMessage({
|
||||
canResolveExplicitMention: false,
|
||||
hasAnyMention: false,
|
||||
wasExplicitlyMentioned: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
|
||||
const callArg = await dispatchGroupMessage({
|
||||
commandAuthorized: false,
|
||||
message: {
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
content: "ping @bot",
|
||||
},
|
||||
});
|
||||
expect(callArg?.ctx?.WasMentioned).toBe(true);
|
||||
expect(callArg?.ctx?.To).toBe("zalouser:group:g-1");
|
||||
expect(callArg?.ctx?.OriginatingTo).toBe("zalouser:group:g-1");
|
||||
expect(sendTypingZalouserMock).toHaveBeenCalledWith("g-1", {
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows authorized control commands to bypass mention gating", async () => {
|
||||
const callArg = await dispatchGroupMessage({
|
||||
commandAuthorized: true,
|
||||
message: {
|
||||
content: "/status",
|
||||
hasAnyMention: false,
|
||||
wasExplicitlyMentioned: false,
|
||||
},
|
||||
});
|
||||
expect(callArg?.ctx?.WasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
it("passes long markdown replies through once so formatting happens before chunking", async () => {
|
||||
const replyText = `**${"a".repeat(2501)}**`;
|
||||
installRuntime({
|
||||
commandAuthorized: false,
|
||||
replyPayload: { text: replyText },
|
||||
});
|
||||
|
||||
await __testing.processMessage({
|
||||
message: createDmMessage({
|
||||
content: "hello",
|
||||
}),
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: {
|
||||
...createAccount().config,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(sendMessageZalouserMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageZalouserMock).toHaveBeenCalledWith(
|
||||
"u-1",
|
||||
replyText,
|
||||
expect.objectContaining({
|
||||
isGroup: false,
|
||||
profile: "default",
|
||||
textMode: "markdown",
|
||||
textChunkMode: "length",
|
||||
textChunkLimit: 1200,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses commandContent for mention-prefixed control commands", async () => {
|
||||
const callArg = await dispatchGroupMessage({
|
||||
commandAuthorized: true,
|
||||
message: {
|
||||
content: "@Bot /new",
|
||||
commandContent: "/new",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
},
|
||||
});
|
||||
expect(callArg?.ctx?.CommandBody).toBe("/new");
|
||||
expect(callArg?.ctx?.BodyForCommands).toBe("/new");
|
||||
});
|
||||
|
||||
it("allows group control commands when only allowFrom is configured", async () => {
|
||||
await expectGroupCommandAuthorizers({
|
||||
accountConfig: {
|
||||
...createAccount().config,
|
||||
allowFrom: ["123"],
|
||||
},
|
||||
expectedAuthorizers: [
|
||||
{ configured: true, allowed: true },
|
||||
{ configured: true, allowed: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks routed allowlist groups without an explicit group sender allowlist", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "ping @bot",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
senderId: "456",
|
||||
}),
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: {
|
||||
...createAccount().config,
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["123"],
|
||||
groups: {
|
||||
"group:g-1": { enabled: true, requireMention: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks group messages when sender is not in groupAllowFrom", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "ping @bot",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
}),
|
||||
account: {
|
||||
...createAccount(),
|
||||
config: {
|
||||
...createAccount().config,
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["999"],
|
||||
groupAllowFrom: ["999"],
|
||||
},
|
||||
},
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not accept a different group id by matching only the mutable group name by default", async () => {
|
||||
await expectDangerousNameMatching({ expectedDispatches: 0 });
|
||||
});
|
||||
|
||||
it("accepts mutable group-name matches only when dangerouslyAllowNameMatching is enabled", async () => {
|
||||
const dispatchReplyWithBufferedBlockDispatcher = await expectDangerousNameMatching({
|
||||
dangerouslyAllowNameMatching: true,
|
||||
expectedDispatches: 1,
|
||||
});
|
||||
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
||||
expect(callArg?.ctx?.To).toBe("zalouser:group:g-attacker-001");
|
||||
});
|
||||
|
||||
it("allows group control commands when sender is in groupAllowFrom", async () => {
|
||||
await expectGroupCommandAuthorizers({
|
||||
accountConfig: {
|
||||
...createAccount().config,
|
||||
allowFrom: ["999"],
|
||||
groupAllowFrom: ["123"],
|
||||
},
|
||||
expectedAuthorizers: [
|
||||
{ configured: true, allowed: false },
|
||||
{ configured: true, allowed: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes DM messages with direct peer kind", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher, resolveAgentRoute, buildAgentSessionKey } =
|
||||
await processOpenDmMessage();
|
||||
|
||||
expect(resolveAgentRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
peer: { kind: "direct", id: "321" },
|
||||
}),
|
||||
);
|
||||
expect(buildAgentSessionKey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
peer: { kind: "direct", id: "321" },
|
||||
dmScope: "per-channel-peer",
|
||||
}),
|
||||
);
|
||||
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
||||
expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:direct:321");
|
||||
});
|
||||
|
||||
it("reuses the legacy DM session key when only the old group-shaped session exists", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = await processOpenDmMessage({
|
||||
readSessionUpdatedAt: (input?: { storePath: string; sessionKey: string }) =>
|
||||
input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined,
|
||||
});
|
||||
|
||||
const callArg = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
||||
expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:group:321");
|
||||
});
|
||||
|
||||
it("reads pairing store for open DM control commands", async () => {
|
||||
const { readAllowFromStore } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const account = createAccount();
|
||||
await __testing.processMessage({
|
||||
message: createDmMessage({ content: "/new", commandContent: "/new" }),
|
||||
account: {
|
||||
...account,
|
||||
config: {
|
||||
...account.config,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(readAllowFromStore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips pairing store read for open DM non-command messages", async () => {
|
||||
const { readAllowFromStore } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const account = createAccount();
|
||||
await __testing.processMessage({
|
||||
message: createDmMessage({ content: "hello there" }),
|
||||
account: {
|
||||
...account,
|
||||
config: {
|
||||
...account.config,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
config: createConfig(),
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(readAllowFromStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes skipped group messages as InboundHistory on the next processed message", async () => {
|
||||
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
|
||||
commandAuthorized: false,
|
||||
});
|
||||
const historyState = {
|
||||
historyLimit: 5,
|
||||
groupHistories: new Map<
|
||||
string,
|
||||
Array<{ sender: string; body: string; timestamp?: number; messageId?: string }>
|
||||
>(),
|
||||
};
|
||||
const account = createAccount();
|
||||
const config = createConfig();
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "first unmentioned line",
|
||||
hasAnyMention: false,
|
||||
wasExplicitlyMentioned: false,
|
||||
}),
|
||||
account,
|
||||
config,
|
||||
runtime: createRuntimeEnv(),
|
||||
historyState,
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "second line @bot",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
}),
|
||||
account,
|
||||
config,
|
||||
runtime: createRuntimeEnv(),
|
||||
historyState,
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
const firstDispatch = dispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
||||
expect(firstDispatch?.ctx?.InboundHistory).toEqual([
|
||||
expect.objectContaining({ sender: "Alice", body: "first unmentioned line" }),
|
||||
]);
|
||||
expect(String(firstDispatch?.ctx?.Body ?? "")).toContain("first unmentioned line");
|
||||
|
||||
await __testing.processMessage({
|
||||
message: createGroupMessage({
|
||||
content: "third line @bot",
|
||||
hasAnyMention: true,
|
||||
wasExplicitlyMentioned: true,
|
||||
}),
|
||||
account,
|
||||
config,
|
||||
runtime: createRuntimeEnv(),
|
||||
historyState,
|
||||
});
|
||||
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2);
|
||||
const secondDispatch = dispatchReplyWithBufferedBlockDispatcher.mock.calls[1]?.[0];
|
||||
expect(secondDispatch?.ctx?.InboundHistory).toEqual([]);
|
||||
});
|
||||
});
|
||||
20
openclaw/extensions/zalouser/src/monitor.send-mocks.ts
Normal file
20
openclaw/extensions/zalouser/src/monitor.send-mocks.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { vi } from "vitest";
|
||||
|
||||
const sendMocks = vi.hoisted(() => ({
|
||||
sendMessageZalouserMock: vi.fn(async () => {}),
|
||||
sendTypingZalouserMock: vi.fn(async () => {}),
|
||||
sendDeliveredZalouserMock: vi.fn(async () => {}),
|
||||
sendSeenZalouserMock: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
export const sendMessageZalouserMock = sendMocks.sendMessageZalouserMock;
|
||||
export const sendTypingZalouserMock = sendMocks.sendTypingZalouserMock;
|
||||
export const sendDeliveredZalouserMock = sendMocks.sendDeliveredZalouserMock;
|
||||
export const sendSeenZalouserMock = sendMocks.sendSeenZalouserMock;
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: sendMessageZalouserMock,
|
||||
sendTypingZalouser: sendTypingZalouserMock,
|
||||
sendDeliveredZalouser: sendDeliveredZalouserMock,
|
||||
sendSeenZalouser: sendSeenZalouserMock,
|
||||
}));
|
||||
1004
openclaw/extensions/zalouser/src/monitor.ts
Normal file
1004
openclaw/extensions/zalouser/src/monitor.ts
Normal file
File diff suppressed because it is too large
Load diff
60
openclaw/extensions/zalouser/src/probe.test.ts
Normal file
60
openclaw/extensions/zalouser/src/probe.test.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { probeZalouser } from "./probe.js";
|
||||
import { getZaloUserInfo } from "./zalo-js.js";
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
getZaloUserInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
|
||||
|
||||
describe("probeZalouser", () => {
|
||||
beforeEach(() => {
|
||||
mockGetUserInfo.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns ok=true with user when authenticated", async () => {
|
||||
mockGetUserInfo.mockResolvedValueOnce({
|
||||
userId: "123",
|
||||
displayName: "Alice",
|
||||
});
|
||||
|
||||
await expect(probeZalouser("default")).resolves.toEqual({
|
||||
ok: true,
|
||||
user: { userId: "123", displayName: "Alice" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns not authenticated when no user info is returned", async () => {
|
||||
mockGetUserInfo.mockResolvedValueOnce(null);
|
||||
await expect(probeZalouser("default")).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "Not authenticated",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns error when user lookup throws", async () => {
|
||||
mockGetUserInfo.mockRejectedValueOnce(new Error("network down"));
|
||||
await expect(probeZalouser("default")).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "network down",
|
||||
});
|
||||
});
|
||||
|
||||
it("times out when lookup takes too long", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockGetUserInfo.mockReturnValueOnce(new Promise(() => undefined));
|
||||
|
||||
const pending = probeZalouser("default", 10);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await expect(pending).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "Not authenticated",
|
||||
});
|
||||
});
|
||||
});
|
||||
35
openclaw/extensions/zalouser/src/probe.ts
Normal file
35
openclaw/extensions/zalouser/src/probe.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type { ZcaUserInfo } from "./types.js";
|
||||
import { getZaloUserInfo } from "./zalo-js.js";
|
||||
|
||||
export type ZalouserProbeResult = BaseProbeResult<string> & {
|
||||
user?: ZcaUserInfo;
|
||||
};
|
||||
|
||||
export async function probeZalouser(
|
||||
profile: string,
|
||||
timeoutMs?: number,
|
||||
): Promise<ZalouserProbeResult> {
|
||||
try {
|
||||
const user = timeoutMs
|
||||
? await Promise.race([
|
||||
getZaloUserInfo(profile),
|
||||
new Promise<null>((resolve) =>
|
||||
setTimeout(() => resolve(null), Math.max(timeoutMs, 1000)),
|
||||
),
|
||||
])
|
||||
: await getZaloUserInfo(profile);
|
||||
|
||||
if (!user) {
|
||||
return { ok: false, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
return { ok: true, user };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: formatErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
22
openclaw/extensions/zalouser/src/qr-temp-file.ts
Normal file
22
openclaw/extensions/zalouser/src/qr-temp-file.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import fsp from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/browser-security-runtime";
|
||||
|
||||
export async function writeQrDataUrlToTempFile(
|
||||
qrDataUrl: string,
|
||||
profile: string,
|
||||
): Promise<string | null> {
|
||||
const trimmed = qrDataUrl.trim();
|
||||
const match = trimmed.match(/^data:image\/png;base64,(.+)$/i);
|
||||
const base64 = (match?.[1] ?? "").trim();
|
||||
if (!base64) {
|
||||
return null;
|
||||
}
|
||||
const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
|
||||
const filePath = path.join(
|
||||
resolvePreferredOpenClawTmpDir(),
|
||||
`openclaw-zalouser-qr-${safeProfile}.png`,
|
||||
);
|
||||
await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
|
||||
return filePath;
|
||||
}
|
||||
19
openclaw/extensions/zalouser/src/reaction.test.ts
Normal file
19
openclaw/extensions/zalouser/src/reaction.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeZaloReactionIcon } from "./reaction.js";
|
||||
|
||||
describe("zalouser reaction alias normalization", () => {
|
||||
it("maps common aliases", () => {
|
||||
expect(normalizeZaloReactionIcon("like")).toBe("/-strong");
|
||||
expect(normalizeZaloReactionIcon("👍")).toBe("/-strong");
|
||||
expect(normalizeZaloReactionIcon("heart")).toBe("/-heart");
|
||||
expect(normalizeZaloReactionIcon("😂")).toBe(":>");
|
||||
});
|
||||
|
||||
it("defaults empty icon to like", () => {
|
||||
expect(normalizeZaloReactionIcon("")).toBe("/-strong");
|
||||
});
|
||||
|
||||
it("passes through unknown custom reactions", () => {
|
||||
expect(normalizeZaloReactionIcon("/custom")).toBe("/custom");
|
||||
});
|
||||
});
|
||||
32
openclaw/extensions/zalouser/src/reaction.ts
Normal file
32
openclaw/extensions/zalouser/src/reaction.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { Reactions } from "./zca-constants.js";
|
||||
|
||||
const REACTION_ALIAS_MAP = new Map<string, string>([
|
||||
["like", Reactions.LIKE],
|
||||
["👍", Reactions.LIKE],
|
||||
[":+1:", Reactions.LIKE],
|
||||
["heart", Reactions.HEART],
|
||||
["❤️", Reactions.HEART],
|
||||
["<3", Reactions.HEART],
|
||||
["haha", Reactions.HAHA],
|
||||
["laugh", Reactions.HAHA],
|
||||
["😂", Reactions.HAHA],
|
||||
["wow", Reactions.WOW],
|
||||
["😮", Reactions.WOW],
|
||||
["cry", Reactions.CRY],
|
||||
["😢", Reactions.CRY],
|
||||
["angry", Reactions.ANGRY],
|
||||
["😡", Reactions.ANGRY],
|
||||
]);
|
||||
|
||||
export function normalizeZaloReactionIcon(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return Reactions.LIKE;
|
||||
}
|
||||
return (
|
||||
REACTION_ALIAS_MAP.get(normalizeLowercaseStringOrEmpty(trimmed)) ??
|
||||
REACTION_ALIAS_MAP.get(trimmed) ??
|
||||
trimmed
|
||||
);
|
||||
}
|
||||
9
openclaw/extensions/zalouser/src/runtime.ts
Normal file
9
openclaw/extensions/zalouser/src/runtime.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "zalouser",
|
||||
errorMessage: "Zalouser runtime not initialized",
|
||||
});
|
||||
export { getZalouserRuntime, setZalouserRuntime };
|
||||
71
openclaw/extensions/zalouser/src/security-audit.ts
Normal file
71
openclaw/extensions/zalouser/src/security-audit.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
import type { ResolvedZalouserAccount } from "./accounts.js";
|
||||
|
||||
export function isZalouserMutableGroupEntry(raw: string): boolean {
|
||||
const text = raw.trim();
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
const normalized = text
|
||||
.replace(/^(zalouser|zlu):/i, "")
|
||||
.replace(/^group:/i, "")
|
||||
.trim();
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
if (/^\d+$/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return !/^g-\S+$/i.test(normalized);
|
||||
}
|
||||
|
||||
export function collectZalouserSecurityAuditFindings(params: {
|
||||
accountId?: string | null;
|
||||
account: ResolvedZalouserAccount;
|
||||
orderedAccountIds: string[];
|
||||
hasExplicitAccountPath: boolean;
|
||||
}) {
|
||||
const zalouserCfg = params.account.config ?? {};
|
||||
const accountId = params.accountId?.trim() || params.account.accountId || "default";
|
||||
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(zalouserCfg);
|
||||
const zalouserPathPrefix =
|
||||
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
|
||||
? `channels.zalouser.accounts.${accountId}`
|
||||
: "channels.zalouser";
|
||||
const mutableGroupEntries = new Set<string>();
|
||||
const groups = zalouserCfg.groups;
|
||||
if (groups && typeof groups === "object" && !Array.isArray(groups)) {
|
||||
for (const key of Object.keys(groups as Record<string, unknown>)) {
|
||||
if (!isZalouserMutableGroupEntry(key)) {
|
||||
continue;
|
||||
}
|
||||
mutableGroupEntries.add(`${zalouserPathPrefix}.groups:${key}`);
|
||||
}
|
||||
}
|
||||
if (mutableGroupEntries.size === 0) {
|
||||
return [];
|
||||
}
|
||||
const examples = Array.from(mutableGroupEntries).slice(0, 5);
|
||||
const more =
|
||||
mutableGroupEntries.size > examples.length
|
||||
? ` (+${mutableGroupEntries.size - examples.length} more)`
|
||||
: "";
|
||||
const severity: "info" | "warn" = dangerousNameMatchingEnabled ? "info" : "warn";
|
||||
return [
|
||||
{
|
||||
checkId: "channels.zalouser.groups.mutable_entries",
|
||||
severity,
|
||||
title: dangerousNameMatchingEnabled
|
||||
? "Zalouser group routing uses break-glass name matching"
|
||||
: "Zalouser group routing contains mutable group entries",
|
||||
detail: dangerousNameMatchingEnabled
|
||||
? "Zalouser group-name routing is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
|
||||
`Found: ${examples.join(", ")}${more}.`
|
||||
: "Zalouser group auth is ID-only by default, so unresolved group-name or slug entries are ignored for auth and can drift from the intended trusted group. " +
|
||||
`Found: ${examples.join(", ")}${more}.`,
|
||||
remediation: dangerousNameMatchingEnabled
|
||||
? "Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching."
|
||||
: "Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
|
||||
},
|
||||
];
|
||||
}
|
||||
395
openclaw/extensions/zalouser/src/send.test.ts
Normal file
395
openclaw/extensions/zalouser/src/send.test.ts
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
sendDeliveredZalouser,
|
||||
sendImageZalouser,
|
||||
sendLinkZalouser,
|
||||
sendMessageZalouser,
|
||||
sendReactionZalouser,
|
||||
sendSeenZalouser,
|
||||
sendTypingZalouser,
|
||||
} from "./send.js";
|
||||
import { parseZalouserTextStyles } from "./text-styles.js";
|
||||
import {
|
||||
sendZaloDeliveredEvent,
|
||||
sendZaloLink,
|
||||
sendZaloReaction,
|
||||
sendZaloSeenEvent,
|
||||
sendZaloTextMessage,
|
||||
sendZaloTypingEvent,
|
||||
} from "./zalo-js.js";
|
||||
import { TextStyle } from "./zca-constants.js";
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
sendZaloTextMessage: vi.fn(),
|
||||
sendZaloLink: vi.fn(),
|
||||
sendZaloTypingEvent: vi.fn(),
|
||||
sendZaloReaction: vi.fn(),
|
||||
sendZaloDeliveredEvent: vi.fn(),
|
||||
sendZaloSeenEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSendText = vi.mocked(sendZaloTextMessage);
|
||||
const mockSendLink = vi.mocked(sendZaloLink);
|
||||
const mockSendTyping = vi.mocked(sendZaloTypingEvent);
|
||||
const mockSendReaction = vi.mocked(sendZaloReaction);
|
||||
const mockSendDelivered = vi.mocked(sendZaloDeliveredEvent);
|
||||
const mockSendSeen = vi.mocked(sendZaloSeenEvent);
|
||||
|
||||
describe("zalouser send helpers", () => {
|
||||
beforeEach(() => {
|
||||
mockSendText.mockReset();
|
||||
mockSendLink.mockReset();
|
||||
mockSendTyping.mockReset();
|
||||
mockSendReaction.mockReset();
|
||||
mockSendDelivered.mockReset();
|
||||
mockSendSeen.mockReset();
|
||||
});
|
||||
|
||||
it("keeps plain text literal by default", async () => {
|
||||
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1" });
|
||||
|
||||
const result = await sendMessageZalouser("thread-1", "**hello**", {
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledWith(
|
||||
"thread-1",
|
||||
"**hello**",
|
||||
expect.objectContaining({
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: true, messageId: "mid-1" });
|
||||
});
|
||||
|
||||
it("formats markdown text when markdown mode is enabled", async () => {
|
||||
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-1b" });
|
||||
|
||||
await sendMessageZalouser("thread-1", "**hello**", {
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledWith(
|
||||
"thread-1",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
profile: "default",
|
||||
isGroup: true,
|
||||
textMode: "markdown",
|
||||
textStyles: [{ start: 0, len: 5, st: TextStyle.Bold }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("formats image captions in markdown mode", async () => {
|
||||
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2" });
|
||||
|
||||
await sendImageZalouser("thread-2", "https://example.com/a.png", {
|
||||
profile: "p2",
|
||||
caption: "_cap_",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledWith(
|
||||
"thread-2",
|
||||
"cap",
|
||||
expect.objectContaining({
|
||||
profile: "p2",
|
||||
caption: undefined,
|
||||
isGroup: false,
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
textMode: "markdown",
|
||||
textStyles: [{ start: 0, len: 3, st: TextStyle.Italic }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not keep the raw markdown caption as a media fallback after formatting", async () => {
|
||||
mockSendText.mockResolvedValueOnce({ ok: true, messageId: "mid-2b" });
|
||||
|
||||
await sendImageZalouser("thread-2", "https://example.com/a.png", {
|
||||
profile: "p2",
|
||||
caption: "```\n```",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledWith(
|
||||
"thread-2",
|
||||
"",
|
||||
expect.objectContaining({
|
||||
profile: "p2",
|
||||
caption: undefined,
|
||||
isGroup: false,
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
textMode: "markdown",
|
||||
textStyles: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rechunks normalized markdown text before sending to avoid transport truncation", async () => {
|
||||
const text = "\t".repeat(500) + "a".repeat(1500);
|
||||
const formatted = parseZalouserTextStyles(text);
|
||||
mockSendText
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2c-1" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2c-2" });
|
||||
|
||||
const result = await sendMessageZalouser("thread-2c", text, {
|
||||
profile: "p2c",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(formatted.text.length).toBeGreaterThan(2000);
|
||||
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
|
||||
expect(mockSendText.mock.calls.every((call) => call[1].length <= 2000)).toBe(true);
|
||||
expect(result).toEqual({ ok: true, messageId: "mid-2c-2" });
|
||||
});
|
||||
|
||||
it("preserves text styles when splitting long formatted markdown", async () => {
|
||||
const text = `**${"a".repeat(2501)}**`;
|
||||
mockSendText
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-1" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-2" });
|
||||
|
||||
const result = await sendMessageZalouser("thread-2d", text, {
|
||||
profile: "p2d",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"thread-2d",
|
||||
"a".repeat(2000),
|
||||
expect.objectContaining({
|
||||
profile: "p2d",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textStyles: [{ start: 0, len: 2000, st: TextStyle.Bold }],
|
||||
}),
|
||||
);
|
||||
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"thread-2d",
|
||||
"a".repeat(501),
|
||||
expect.objectContaining({
|
||||
profile: "p2d",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textStyles: [{ start: 0, len: 501, st: TextStyle.Bold }],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: true, messageId: "mid-2d-2" });
|
||||
});
|
||||
|
||||
it("preserves formatted text and styles when newline chunk mode splits after parsing", async () => {
|
||||
const text = `**${"a".repeat(1995)}**\n\nsecond paragraph`;
|
||||
const formatted = parseZalouserTextStyles(text);
|
||||
mockSendText
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-3" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-4" });
|
||||
|
||||
const result = await sendMessageZalouser("thread-2d-2", text, {
|
||||
profile: "p2d-2",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "newline",
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
|
||||
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"thread-2d-2",
|
||||
`${"a".repeat(1995)}\n\n`,
|
||||
expect.objectContaining({
|
||||
profile: "p2d-2",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "newline",
|
||||
textStyles: [{ start: 0, len: 1995, st: TextStyle.Bold }],
|
||||
}),
|
||||
);
|
||||
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"thread-2d-2",
|
||||
"second paragraph",
|
||||
expect.objectContaining({
|
||||
profile: "p2d-2",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkMode: "newline",
|
||||
textStyles: undefined,
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: true, messageId: "mid-2d-4" });
|
||||
});
|
||||
|
||||
it("respects an explicit text chunk limit when splitting formatted markdown", async () => {
|
||||
const text = `**${"a".repeat(1501)}**`;
|
||||
mockSendText
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-5" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2d-6" });
|
||||
|
||||
const result = await sendMessageZalouser("thread-2d-3", text, {
|
||||
profile: "p2d-3",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkLimit: 1200,
|
||||
} as never);
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"thread-2d-3",
|
||||
"a".repeat(1200),
|
||||
expect.objectContaining({
|
||||
profile: "p2d-3",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkLimit: 1200,
|
||||
textStyles: [{ start: 0, len: 1200, st: TextStyle.Bold }],
|
||||
}),
|
||||
);
|
||||
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"thread-2d-3",
|
||||
"a".repeat(301),
|
||||
expect.objectContaining({
|
||||
profile: "p2d-3",
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
textChunkLimit: 1200,
|
||||
textStyles: [{ start: 0, len: 301, st: TextStyle.Bold }],
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: true, messageId: "mid-2d-6" });
|
||||
});
|
||||
|
||||
it("sends overflow markdown captions as follow-up text after the media message", async () => {
|
||||
const caption = "\t".repeat(500) + "a".repeat(1500);
|
||||
const formatted = parseZalouserTextStyles(caption);
|
||||
mockSendText
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2e-1" })
|
||||
.mockResolvedValueOnce({ ok: true, messageId: "mid-2e-2" });
|
||||
|
||||
const result = await sendImageZalouser("thread-2e", "https://example.com/long.png", {
|
||||
profile: "p2e",
|
||||
caption,
|
||||
isGroup: false,
|
||||
textMode: "markdown",
|
||||
});
|
||||
|
||||
expect(mockSendText).toHaveBeenCalledTimes(2);
|
||||
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
|
||||
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"thread-2e",
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
profile: "p2e",
|
||||
caption: undefined,
|
||||
isGroup: false,
|
||||
mediaUrl: "https://example.com/long.png",
|
||||
textMode: "markdown",
|
||||
}),
|
||||
);
|
||||
expect(mockSendText).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"thread-2e",
|
||||
expect.any(String),
|
||||
expect.not.objectContaining({
|
||||
mediaUrl: "https://example.com/long.png",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ok: true, messageId: "mid-2e-2" });
|
||||
});
|
||||
|
||||
it("delegates link helper to JS transport", async () => {
|
||||
mockSendLink.mockResolvedValueOnce({ ok: false, error: "boom" });
|
||||
|
||||
const result = await sendLinkZalouser("thread-3", "https://openclaw.ai", {
|
||||
profile: "p3",
|
||||
isGroup: true,
|
||||
});
|
||||
|
||||
expect(mockSendLink).toHaveBeenCalledWith("thread-3", "https://openclaw.ai", {
|
||||
profile: "p3",
|
||||
isGroup: true,
|
||||
});
|
||||
expect(result).toEqual({ ok: false, error: "boom" });
|
||||
});
|
||||
|
||||
it("delegates typing helper to JS transport", async () => {
|
||||
await sendTypingZalouser("thread-4", { profile: "p4", isGroup: true });
|
||||
|
||||
expect(mockSendTyping).toHaveBeenCalledWith("thread-4", {
|
||||
profile: "p4",
|
||||
isGroup: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("delegates reaction helper to JS transport", async () => {
|
||||
mockSendReaction.mockResolvedValueOnce({ ok: true });
|
||||
|
||||
const result = await sendReactionZalouser({
|
||||
threadId: "thread-5",
|
||||
profile: "p5",
|
||||
isGroup: true,
|
||||
msgId: "100",
|
||||
cliMsgId: "200",
|
||||
emoji: "👍",
|
||||
});
|
||||
|
||||
expect(mockSendReaction).toHaveBeenCalledWith({
|
||||
profile: "p5",
|
||||
threadId: "thread-5",
|
||||
isGroup: true,
|
||||
msgId: "100",
|
||||
cliMsgId: "200",
|
||||
emoji: "👍",
|
||||
remove: undefined,
|
||||
});
|
||||
expect(result).toEqual({ ok: true, error: undefined });
|
||||
});
|
||||
|
||||
it("delegates delivered+seen helpers to JS transport", async () => {
|
||||
mockSendDelivered.mockResolvedValueOnce();
|
||||
mockSendSeen.mockResolvedValueOnce();
|
||||
|
||||
const message = {
|
||||
msgId: "100",
|
||||
cliMsgId: "200",
|
||||
uidFrom: "1",
|
||||
idTo: "2",
|
||||
msgType: "webchat",
|
||||
st: 1,
|
||||
at: 0,
|
||||
cmd: 0,
|
||||
ts: "123",
|
||||
};
|
||||
|
||||
await sendDeliveredZalouser({ profile: "p6", isGroup: true, message, isSeen: false });
|
||||
await sendSeenZalouser({ profile: "p6", isGroup: true, message });
|
||||
|
||||
expect(mockSendDelivered).toHaveBeenCalledWith({
|
||||
profile: "p6",
|
||||
isGroup: true,
|
||||
message,
|
||||
isSeen: false,
|
||||
});
|
||||
expect(mockSendSeen).toHaveBeenCalledWith({
|
||||
profile: "p6",
|
||||
isGroup: true,
|
||||
message,
|
||||
});
|
||||
});
|
||||
});
|
||||
272
openclaw/extensions/zalouser/src/send.ts
Normal file
272
openclaw/extensions/zalouser/src/send.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import { parseZalouserTextStyles } from "./text-styles.js";
|
||||
import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js";
|
||||
import {
|
||||
sendZaloDeliveredEvent,
|
||||
sendZaloLink,
|
||||
sendZaloReaction,
|
||||
sendZaloSeenEvent,
|
||||
sendZaloTextMessage,
|
||||
sendZaloTypingEvent,
|
||||
} from "./zalo-js.js";
|
||||
import { TextStyle } from "./zca-constants.js";
|
||||
|
||||
export type ZalouserSendOptions = ZaloSendOptions;
|
||||
export type ZalouserSendResult = ZaloSendResult;
|
||||
|
||||
const ZALO_TEXT_LIMIT = 2000;
|
||||
const DEFAULT_TEXT_CHUNK_MODE = "length";
|
||||
|
||||
type StyledTextChunk = {
|
||||
text: string;
|
||||
styles?: ZaloSendOptions["textStyles"];
|
||||
};
|
||||
|
||||
type TextChunkMode = NonNullable<ZaloSendOptions["textChunkMode"]>;
|
||||
|
||||
export async function sendMessageZalouser(
|
||||
threadId: string,
|
||||
text: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
const prepared =
|
||||
options.textMode === "markdown"
|
||||
? parseZalouserTextStyles(text)
|
||||
: { text, styles: options.textStyles };
|
||||
const textChunkLimit = options.textChunkLimit ?? ZALO_TEXT_LIMIT;
|
||||
const chunks = splitStyledText(
|
||||
prepared.text,
|
||||
(prepared.styles?.length ?? 0) > 0 ? prepared.styles : undefined,
|
||||
textChunkLimit,
|
||||
options.textChunkMode,
|
||||
);
|
||||
|
||||
let lastResult: ZalouserSendResult | null = null;
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
const chunkOptions =
|
||||
index === 0
|
||||
? { ...options, textStyles: chunk.styles }
|
||||
: {
|
||||
...options,
|
||||
caption: undefined,
|
||||
mediaLocalRoots: undefined,
|
||||
mediaUrl: undefined,
|
||||
textStyles: chunk.styles,
|
||||
};
|
||||
const result = await sendZaloTextMessage(threadId, chunk.text, chunkOptions);
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
lastResult = result;
|
||||
}
|
||||
|
||||
return lastResult ?? { ok: false, error: "No message content provided" };
|
||||
}
|
||||
|
||||
export async function sendImageZalouser(
|
||||
threadId: string,
|
||||
imageUrl: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
return await sendMessageZalouser(threadId, options.caption ?? "", {
|
||||
...options,
|
||||
caption: undefined,
|
||||
mediaUrl: imageUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendLinkZalouser(
|
||||
threadId: string,
|
||||
url: string,
|
||||
options: ZalouserSendOptions = {},
|
||||
): Promise<ZalouserSendResult> {
|
||||
return await sendZaloLink(threadId, url, options);
|
||||
}
|
||||
|
||||
export async function sendTypingZalouser(
|
||||
threadId: string,
|
||||
options: Pick<ZalouserSendOptions, "profile" | "isGroup"> = {},
|
||||
): Promise<void> {
|
||||
await sendZaloTypingEvent(threadId, options);
|
||||
}
|
||||
|
||||
export async function sendReactionZalouser(params: {
|
||||
threadId: string;
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
emoji: string;
|
||||
remove?: boolean;
|
||||
profile?: string;
|
||||
isGroup?: boolean;
|
||||
}): Promise<ZalouserSendResult> {
|
||||
const result = await sendZaloReaction({
|
||||
profile: params.profile,
|
||||
threadId: params.threadId,
|
||||
isGroup: params.isGroup,
|
||||
msgId: params.msgId,
|
||||
cliMsgId: params.cliMsgId,
|
||||
emoji: params.emoji,
|
||||
remove: params.remove,
|
||||
});
|
||||
return {
|
||||
ok: result.ok,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendDeliveredZalouser(params: {
|
||||
profile?: string;
|
||||
isGroup?: boolean;
|
||||
message: ZaloEventMessage;
|
||||
isSeen?: boolean;
|
||||
}): Promise<void> {
|
||||
await sendZaloDeliveredEvent(params);
|
||||
}
|
||||
|
||||
export async function sendSeenZalouser(params: {
|
||||
profile?: string;
|
||||
isGroup?: boolean;
|
||||
message: ZaloEventMessage;
|
||||
}): Promise<void> {
|
||||
await sendZaloSeenEvent(params);
|
||||
}
|
||||
|
||||
function splitStyledText(
|
||||
text: string,
|
||||
styles: ZaloSendOptions["textStyles"],
|
||||
limit: number,
|
||||
mode: ZaloSendOptions["textChunkMode"],
|
||||
): StyledTextChunk[] {
|
||||
if (text.length === 0) {
|
||||
return [{ text, styles: undefined }];
|
||||
}
|
||||
|
||||
const chunks: StyledTextChunk[] = [];
|
||||
for (const range of splitTextRanges(text, limit, mode ?? DEFAULT_TEXT_CHUNK_MODE)) {
|
||||
const { start, end } = range;
|
||||
chunks.push({
|
||||
text: text.slice(start, end),
|
||||
styles: sliceTextStyles(styles, start, end),
|
||||
});
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function sliceTextStyles(
|
||||
styles: ZaloSendOptions["textStyles"],
|
||||
start: number,
|
||||
end: number,
|
||||
): ZaloSendOptions["textStyles"] {
|
||||
if (!styles || styles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chunkStyles = styles
|
||||
.map((style) => {
|
||||
const overlapStart = Math.max(style.start, start);
|
||||
const overlapEnd = Math.min(style.start + style.len, end);
|
||||
if (overlapEnd <= overlapStart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (style.st === TextStyle.Indent) {
|
||||
return {
|
||||
start: overlapStart - start,
|
||||
len: overlapEnd - overlapStart,
|
||||
st: style.st,
|
||||
indentSize: style.indentSize,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
start: overlapStart - start,
|
||||
len: overlapEnd - overlapStart,
|
||||
st: style.st,
|
||||
};
|
||||
})
|
||||
.filter((style): style is NonNullable<typeof style> => style !== null);
|
||||
|
||||
return chunkStyles.length > 0 ? chunkStyles : undefined;
|
||||
}
|
||||
|
||||
function splitTextRanges(
|
||||
text: string,
|
||||
limit: number,
|
||||
mode: TextChunkMode,
|
||||
): Array<{ start: number; end: number }> {
|
||||
if (mode === "newline") {
|
||||
return splitTextRangesByPreferredBreaks(text, limit);
|
||||
}
|
||||
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
for (let start = 0; start < text.length; start += limit) {
|
||||
ranges.push({
|
||||
start,
|
||||
end: Math.min(text.length, start + limit),
|
||||
});
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function splitTextRangesByPreferredBreaks(
|
||||
text: string,
|
||||
limit: number,
|
||||
): Array<{ start: number; end: number }> {
|
||||
const ranges: Array<{ start: number; end: number }> = [];
|
||||
let start = 0;
|
||||
|
||||
while (start < text.length) {
|
||||
const maxEnd = Math.min(text.length, start + limit);
|
||||
let end = maxEnd;
|
||||
if (maxEnd < text.length) {
|
||||
end =
|
||||
findParagraphBreak(text, start, maxEnd) ??
|
||||
findLastBreak(text, "\n", start, maxEnd) ??
|
||||
findLastWhitespaceBreak(text, start, maxEnd) ??
|
||||
maxEnd;
|
||||
}
|
||||
|
||||
if (end <= start) {
|
||||
end = maxEnd;
|
||||
}
|
||||
|
||||
ranges.push({ start, end });
|
||||
start = end;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function findParagraphBreak(text: string, start: number, end: number): number | undefined {
|
||||
const slice = text.slice(start, end);
|
||||
const matches = slice.matchAll(/\n[\t ]*\n+/g);
|
||||
let lastMatch: RegExpMatchArray | undefined;
|
||||
for (const match of matches) {
|
||||
lastMatch = match;
|
||||
}
|
||||
if (!lastMatch || lastMatch.index === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return start + lastMatch.index + lastMatch[0].length;
|
||||
}
|
||||
|
||||
function findLastBreak(
|
||||
text: string,
|
||||
marker: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): number | undefined {
|
||||
const index = text.lastIndexOf(marker, end - 1);
|
||||
if (index < start) {
|
||||
return undefined;
|
||||
}
|
||||
return index + marker.length;
|
||||
}
|
||||
|
||||
function findLastWhitespaceBreak(text: string, start: number, end: number): number | undefined {
|
||||
for (let index = end - 1; index > start; index -= 1) {
|
||||
if (/\s/.test(text[index])) {
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
121
openclaw/extensions/zalouser/src/session-route.ts
Normal file
121
openclaw/extensions/zalouser/src/session-route.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import {
|
||||
buildChannelOutboundSessionRoute,
|
||||
type ChannelOutboundSessionRouteParams,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
export function stripZalouserTargetPrefix(raw: string): string {
|
||||
return raw
|
||||
.trim()
|
||||
.replace(/^(zalouser|zlu):/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function normalizeZalouserTarget(raw: string): string | undefined {
|
||||
const trimmed = stripZalouserTargetPrefix(raw);
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const lower = normalizeLowercaseStringOrEmpty(trimmed);
|
||||
if (lower.startsWith("group:")) {
|
||||
const id = trimmed.slice("group:".length).trim();
|
||||
return id ? `group:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("g:")) {
|
||||
const id = trimmed.slice("g:".length).trim();
|
||||
return id ? `group:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("user:")) {
|
||||
const id = trimmed.slice("user:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("dm:")) {
|
||||
const id = trimmed.slice("dm:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (lower.startsWith("u:")) {
|
||||
const id = trimmed.slice("u:".length).trim();
|
||||
return id ? `user:${id}` : undefined;
|
||||
}
|
||||
if (/^g-\S+$/i.test(trimmed)) {
|
||||
return `group:${trimmed}`;
|
||||
}
|
||||
if (/^u-\S+$/i.test(trimmed)) {
|
||||
return `user:${trimmed}`;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function parseZalouserOutboundTarget(raw: string): {
|
||||
threadId: string;
|
||||
isGroup: boolean;
|
||||
} {
|
||||
const normalized = normalizeZalouserTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("Zalouser target is required");
|
||||
}
|
||||
const lowered = normalizeLowercaseStringOrEmpty(normalized);
|
||||
if (lowered.startsWith("group:")) {
|
||||
const threadId = normalized.slice("group:".length).trim();
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser group target is missing group id");
|
||||
}
|
||||
return { threadId, isGroup: true };
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
const threadId = normalized.slice("user:".length).trim();
|
||||
if (!threadId) {
|
||||
throw new Error("Zalouser user target is missing user id");
|
||||
}
|
||||
return { threadId, isGroup: false };
|
||||
}
|
||||
// Backward-compatible fallback for bare IDs.
|
||||
// Group sends should use explicit `group:<id>` targets.
|
||||
return { threadId: normalized, isGroup: false };
|
||||
}
|
||||
|
||||
export function parseZalouserDirectoryGroupId(raw: string): string {
|
||||
const normalized = normalizeZalouserTarget(raw);
|
||||
if (!normalized) {
|
||||
throw new Error("Zalouser group target is required");
|
||||
}
|
||||
const lowered = normalizeLowercaseStringOrEmpty(normalized);
|
||||
if (lowered.startsWith("group:")) {
|
||||
const groupId = normalized.slice("group:".length).trim();
|
||||
if (!groupId) {
|
||||
throw new Error("Zalouser group target is missing group id");
|
||||
}
|
||||
return groupId;
|
||||
}
|
||||
if (lowered.startsWith("user:")) {
|
||||
throw new Error("Zalouser group members lookup requires a group target (group:<id>)");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveZalouserOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
|
||||
const normalized = normalizeZalouserTarget(params.target);
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const isGroup = (normalizeOptionalLowercaseString(normalized) ?? "").startsWith("group:");
|
||||
const peerId = normalized.replace(/^(group|user):/i, "").trim();
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "zalouser",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `zalouser:group:${peerId}` : `zalouser:${peerId}`,
|
||||
to: `zalouser:${peerId}`,
|
||||
});
|
||||
}
|
||||
33
openclaw/extensions/zalouser/src/setup-core.ts
Normal file
33
openclaw/extensions/zalouser/src/setup-core.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import {
|
||||
createDelegatedSetupWizardProxy,
|
||||
createPatchedAccountSetupAdapter,
|
||||
type ChannelSetupWizard,
|
||||
} from "openclaw/plugin-sdk/setup-runtime";
|
||||
|
||||
const channel = "zalouser" as const;
|
||||
|
||||
export const zalouserSetupAdapter = createPatchedAccountSetupAdapter({
|
||||
channelKey: channel,
|
||||
validateInput: () => null,
|
||||
buildPatch: () => ({}),
|
||||
});
|
||||
|
||||
export function createZalouserSetupWizardProxy(
|
||||
loadWizard: () => Promise<ChannelSetupWizard>,
|
||||
): ChannelSetupWizard {
|
||||
return createDelegatedSetupWizardProxy({
|
||||
channel,
|
||||
loadWizard,
|
||||
status: {
|
||||
configuredLabel: "logged in",
|
||||
unconfiguredLabel: "needs QR login",
|
||||
configuredHint: "recommended · logged in",
|
||||
unconfiguredHint: "recommended · QR login",
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 15,
|
||||
},
|
||||
credentials: [],
|
||||
delegatePrepare: true,
|
||||
delegateFinalize: true,
|
||||
});
|
||||
}
|
||||
363
openclaw/extensions/zalouser/src/setup-surface.test.ts
Normal file
363
openclaw/extensions/zalouser/src/setup-surface.test.ts
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
} from "../../../test/helpers/plugins/setup-wizard.js";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import "./zalo-js.test-mocks.js";
|
||||
import { zalouserSetupWizard } from "./setup-surface.js";
|
||||
import { zalouserSetupPlugin } from "./setup-test-helpers.js";
|
||||
|
||||
const zalouserConfigure = createPluginSetupWizardConfigure(zalouserSetupPlugin);
|
||||
|
||||
async function runSetup(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
prompter: ReturnType<typeof createTestWizardPrompter>;
|
||||
options?: Record<string, unknown>;
|
||||
forceAllowFrom?: boolean;
|
||||
}) {
|
||||
return await runSetupWizardConfigure({
|
||||
configure: zalouserConfigure,
|
||||
cfg: params.cfg,
|
||||
prompter: params.prompter,
|
||||
options: params.options,
|
||||
forceAllowFrom: params.forceAllowFrom,
|
||||
});
|
||||
}
|
||||
|
||||
describe("zalouser setup wizard", () => {
|
||||
function expectEnabledDefaultSetup(
|
||||
result: Awaited<ReturnType<typeof runSetup>>,
|
||||
dmPolicy?: "pairing" | "allowlist",
|
||||
) {
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.zalouser?.enabled).toBe(true);
|
||||
expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true);
|
||||
if (dmPolicy) {
|
||||
expect(result.cfg.channels?.zalouser?.dmPolicy).toBe(dmPolicy);
|
||||
}
|
||||
}
|
||||
|
||||
function createQuickstartPrompter(params?: {
|
||||
note?: ReturnType<typeof createTestWizardPrompter>["note"];
|
||||
seen?: string[];
|
||||
dmPolicy?: "pairing" | "allowlist";
|
||||
groupAccess?: boolean;
|
||||
groupPolicy?: "allowlist";
|
||||
textByMessage?: Record<string, string>;
|
||||
}) {
|
||||
const select = vi.fn(
|
||||
async ({ message, options }: { message: string; options: Array<{ value: string }> }) => {
|
||||
const first = options[0];
|
||||
if (!first) {
|
||||
throw new Error("no options");
|
||||
}
|
||||
params?.seen?.push(message);
|
||||
if (message === "Zalo Personal DM policy" && params?.dmPolicy) {
|
||||
return params.dmPolicy;
|
||||
}
|
||||
if (message === "Zalo groups access" && params?.groupPolicy) {
|
||||
return params.groupPolicy;
|
||||
}
|
||||
return first.value;
|
||||
},
|
||||
) as ReturnType<typeof createTestWizardPrompter>["select"];
|
||||
const text = vi.fn(
|
||||
async ({ message }: { message: string }) => params?.textByMessage?.[message] ?? "",
|
||||
) as ReturnType<typeof createTestWizardPrompter>["text"];
|
||||
return createTestWizardPrompter({
|
||||
...(params?.note ? { note: params.note } : {}),
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
params?.seen?.push(message);
|
||||
if (message === "Login via QR code now?") {
|
||||
return false;
|
||||
}
|
||||
if (message === "Configure Zalo groups access?") {
|
||||
return params?.groupAccess ?? false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
select,
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
it("enables the account without forcing QR login", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Login via QR code now?") {
|
||||
return false;
|
||||
}
|
||||
if (message === "Configure Zalo groups access?") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSetup({ prompter });
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.zalouser?.enabled).toBe(true);
|
||||
expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("prompts DM policy before group access in quickstart", async () => {
|
||||
const seen: string[] = [];
|
||||
const prompter = createQuickstartPrompter({ seen, dmPolicy: "pairing" });
|
||||
|
||||
const result = await runSetup({
|
||||
prompter,
|
||||
options: { quickstartDefaults: true },
|
||||
});
|
||||
|
||||
expectEnabledDefaultSetup(result, "pairing");
|
||||
expect(seen.indexOf("Zalo Personal DM policy")).toBeGreaterThanOrEqual(0);
|
||||
expect(seen.indexOf("Configure Zalo groups access?")).toBeGreaterThanOrEqual(0);
|
||||
expect(seen.indexOf("Zalo Personal DM policy")).toBeLessThan(
|
||||
seen.indexOf("Configure Zalo groups access?"),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows an empty quickstart DM allowlist with a warning", async () => {
|
||||
const note = vi.fn(async (_message: string, _title?: string) => {});
|
||||
const prompter = createQuickstartPrompter({
|
||||
note,
|
||||
dmPolicy: "allowlist",
|
||||
textByMessage: {
|
||||
"Zalouser allowFrom (name or user id)": "",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup({
|
||||
prompter,
|
||||
options: { quickstartDefaults: true },
|
||||
});
|
||||
|
||||
expectEnabledDefaultSetup(result, "allowlist");
|
||||
expect(result.cfg.channels?.zalouser?.allowFrom).toEqual([]);
|
||||
expect(
|
||||
note.mock.calls.some(([message]) => message.includes("No DM allowlist entries added yet.")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allows an empty group allowlist with a warning", async () => {
|
||||
const note = vi.fn(async (_message: string, _title?: string) => {});
|
||||
const prompter = createQuickstartPrompter({
|
||||
note,
|
||||
groupAccess: true,
|
||||
groupPolicy: "allowlist",
|
||||
textByMessage: {
|
||||
"Zalo groups allowlist (comma-separated)": "",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup({ prompter });
|
||||
|
||||
expect(result.cfg.channels?.zalouser?.groupPolicy).toBe("allowlist");
|
||||
expect(result.cfg.channels?.zalouser?.groups).toEqual({});
|
||||
expect(
|
||||
note.mock.calls.some(([message]) =>
|
||||
message.includes("No group allowlist entries added yet."),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("writes canonical enabled entries for configured groups", async () => {
|
||||
const prompter = createQuickstartPrompter({
|
||||
groupAccess: true,
|
||||
groupPolicy: "allowlist",
|
||||
textByMessage: {
|
||||
"Zalo groups allowlist (comma-separated)": "Family, Work",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runSetup({ prompter });
|
||||
|
||||
expect(result.cfg.channels?.zalouser?.groups).toEqual({
|
||||
Family: { enabled: true, requireMention: true },
|
||||
Work: { enabled: true, requireMention: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves non-quickstart forceAllowFrom behavior", async () => {
|
||||
const note = vi.fn(async (_message: string, _title?: string) => {});
|
||||
const seen: string[] = [];
|
||||
const prompter = createTestWizardPrompter({
|
||||
note,
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
seen.push(message);
|
||||
if (message === "Login via QR code now?") {
|
||||
return false;
|
||||
}
|
||||
if (message === "Configure Zalo groups access?") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
seen.push(message);
|
||||
if (message === "Zalouser allowFrom (name or user id)") {
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}) as ReturnType<typeof createTestWizardPrompter>["text"],
|
||||
});
|
||||
|
||||
const result = await runSetup({ prompter, forceAllowFrom: true });
|
||||
|
||||
expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("allowlist");
|
||||
expect(result.cfg.channels?.zalouser?.allowFrom).toEqual([]);
|
||||
expect(seen).not.toContain("Zalo Personal DM policy");
|
||||
expect(seen).toContain("Zalouser allowFrom (name or user id)");
|
||||
expect(
|
||||
note.mock.calls.some(([message]) => message.includes("No DM allowlist entries added yet.")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("allowlists the plugin when a plugin allowlist already exists", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Login via QR code now?") {
|
||||
return false;
|
||||
}
|
||||
if (message === "Configure Zalo groups access?") {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSetup({
|
||||
cfg: {
|
||||
plugins: {
|
||||
allow: ["telegram"],
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true);
|
||||
expect(result.cfg.plugins?.allow).toEqual(["telegram", "zalouser"]);
|
||||
});
|
||||
|
||||
it("reads the named-account DM policy instead of the channel root", () => {
|
||||
expect(
|
||||
zalouserSetupWizard.dmPolicy?.getCurrent(
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
dmPolicy: "disabled",
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "work",
|
||||
dmPolicy: "allowlist",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
"work",
|
||||
),
|
||||
).toBe("allowlist");
|
||||
});
|
||||
|
||||
it("reports account-scoped config keys for named accounts", () => {
|
||||
expect(zalouserSetupWizard.dmPolicy?.resolveConfigKeys?.({} as OpenClawConfig, "work")).toEqual(
|
||||
{
|
||||
policyKey: "channels.zalouser.accounts.work.dmPolicy",
|
||||
allowFromKey: "channels.zalouser.accounts.work.allowFrom",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount for omitted DM policy account context", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
zalouser: {
|
||||
defaultAccount: "work",
|
||||
dmPolicy: "disabled",
|
||||
allowFrom: ["123456789"],
|
||||
accounts: {
|
||||
work: {
|
||||
dmPolicy: "allowlist",
|
||||
profile: "work-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(zalouserSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist");
|
||||
expect(zalouserSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({
|
||||
policyKey: "channels.zalouser.accounts.work.dmPolicy",
|
||||
allowFromKey: "channels.zalouser.accounts.work.allowFrom",
|
||||
});
|
||||
|
||||
const next = zalouserSetupWizard.dmPolicy?.setPolicy(cfg, "open");
|
||||
expect(next?.channels?.zalouser?.dmPolicy).toBe("disabled");
|
||||
const workAccount = next?.channels?.zalouser?.accounts?.work as
|
||||
| { dmPolicy?: string; allowFrom?: Array<string | number> }
|
||||
| undefined;
|
||||
expect(workAccount?.dmPolicy).toBe("open");
|
||||
});
|
||||
|
||||
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', () => {
|
||||
const next = zalouserSetupWizard.dmPolicy?.setPolicy(
|
||||
{
|
||||
channels: {
|
||||
zalouser: {
|
||||
allowFrom: ["123456789"],
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
"open",
|
||||
"work",
|
||||
);
|
||||
|
||||
expect(next?.channels?.zalouser?.dmPolicy).toBeUndefined();
|
||||
const workAccount = next?.channels?.zalouser?.accounts?.work as
|
||||
| { dmPolicy?: string; allowFrom?: Array<string | number> }
|
||||
| undefined;
|
||||
expect(workAccount?.dmPolicy).toBe("open");
|
||||
expect(workAccount?.allowFrom).toEqual(["123456789", "*"]);
|
||||
});
|
||||
|
||||
it("shows the account-scoped current DM policy in quickstart notes", async () => {
|
||||
const note = vi.fn(async (_message: string, _title?: string) => {});
|
||||
const prompter = createQuickstartPrompter({ note, dmPolicy: "pairing" });
|
||||
|
||||
await runSetupWizardConfigure({
|
||||
configure: zalouserConfigure,
|
||||
cfg: {
|
||||
channels: {
|
||||
zalouser: {
|
||||
dmPolicy: "disabled",
|
||||
accounts: {
|
||||
work: {
|
||||
profile: "work",
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["123456789"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
prompter,
|
||||
options: { quickstartDefaults: true },
|
||||
accountOverrides: { zalouser: "work" },
|
||||
});
|
||||
|
||||
expect(
|
||||
note.mock.calls.some(([message]) =>
|
||||
message.includes("Current: dmPolicy=allowlist, allowFrom=123456789"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
470
openclaw/extensions/zalouser/src/setup-surface.ts
Normal file
470
openclaw/extensions/zalouser/src/setup-surface.ts
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
import {
|
||||
addWildcardAllowFrom,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatCliCommand,
|
||||
formatDocsLink,
|
||||
formatResolvedUnresolvedNote,
|
||||
mergeAllowFromEntries,
|
||||
normalizeAccountId,
|
||||
patchScopedAccountConfig,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
type DmPolicy,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
checkZcaAuthenticated,
|
||||
} from "./accounts.js";
|
||||
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
|
||||
import { zalouserSetupAdapter } from "./setup-core.js";
|
||||
import {
|
||||
logoutZaloProfile,
|
||||
resolveZaloAllowFromEntries,
|
||||
resolveZaloGroupsByEntries,
|
||||
startZaloQrLogin,
|
||||
waitForZaloQrLogin,
|
||||
} from "./zalo-js.js";
|
||||
|
||||
const channel = "zalouser" as const;
|
||||
const ZALOUSER_ALLOW_FROM_PLACEHOLDER = "Alice, 123456789, or leave empty to configure later";
|
||||
const ZALOUSER_GROUPS_PLACEHOLDER = "Family, Work, 123456789, or leave empty for now";
|
||||
const ZALOUSER_DM_ACCESS_TITLE = "Zalo Personal DM access";
|
||||
const ZALOUSER_ALLOWLIST_TITLE = "Zalo Personal allowlist";
|
||||
const ZALOUSER_GROUPS_TITLE = "Zalo groups";
|
||||
|
||||
function parseZalouserEntries(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function setZalouserAccountScopedConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
defaultPatch: Record<string, unknown>,
|
||||
accountPatch: Record<string, unknown> = defaultPatch,
|
||||
): OpenClawConfig {
|
||||
return patchScopedAccountConfig({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: defaultPatch,
|
||||
accountPatch,
|
||||
});
|
||||
}
|
||||
|
||||
function setZalouserDmPolicy(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
policy: DmPolicy,
|
||||
): OpenClawConfig {
|
||||
const resolvedAccountId = normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID;
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId: resolvedAccountId });
|
||||
return setZalouserAccountScopedConfig(
|
||||
cfg,
|
||||
resolvedAccountId,
|
||||
{
|
||||
dmPolicy: policy,
|
||||
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) } : {}),
|
||||
},
|
||||
{
|
||||
dmPolicy: policy,
|
||||
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function setZalouserGroupPolicy(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
groupPolicy: "open" | "allowlist" | "disabled",
|
||||
): OpenClawConfig {
|
||||
return setZalouserAccountScopedConfig(cfg, accountId, {
|
||||
groupPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
function setZalouserGroupAllowlist(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
groupKeys: string[],
|
||||
): OpenClawConfig {
|
||||
const groups = Object.fromEntries(
|
||||
groupKeys.map((key) => [key, { enabled: true, requireMention: true }]),
|
||||
);
|
||||
return setZalouserAccountScopedConfig(cfg, accountId, {
|
||||
groups,
|
||||
});
|
||||
}
|
||||
|
||||
function ensureZalouserPluginEnabled(cfg: OpenClawConfig): OpenClawConfig {
|
||||
const next: OpenClawConfig = {
|
||||
...cfg,
|
||||
plugins: {
|
||||
...cfg.plugins,
|
||||
entries: {
|
||||
...cfg.plugins?.entries,
|
||||
zalouser: {
|
||||
...cfg.plugins?.entries?.zalouser,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const allow = next.plugins?.allow;
|
||||
if (!Array.isArray(allow) || allow.includes(channel)) {
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
plugins: {
|
||||
...next.plugins,
|
||||
allow: [...allow, channel],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function noteZalouserHelp(
|
||||
prompter: Parameters<NonNullable<ChannelSetupWizard["prepare"]>>[0]["prompter"],
|
||||
): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"Zalo Personal Account login via QR code.",
|
||||
"",
|
||||
"This plugin uses zca-js directly (no external CLI dependency).",
|
||||
"",
|
||||
`Docs: ${formatDocsLink("/channels/zalouser", "zalouser")}`,
|
||||
].join("\n"),
|
||||
"Zalo Personal Setup",
|
||||
);
|
||||
}
|
||||
|
||||
async function promptZalouserAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
|
||||
accountId: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
|
||||
while (true) {
|
||||
const entry = await prompter.text({
|
||||
message: "Zalouser allowFrom (name or user id)",
|
||||
placeholder: ZALOUSER_ALLOW_FROM_PLACEHOLDER,
|
||||
initialValue: existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : undefined,
|
||||
});
|
||||
const parts = parseZalouserEntries(entry);
|
||||
if (parts.length === 0) {
|
||||
await prompter.note(
|
||||
[
|
||||
"No DM allowlist entries added yet.",
|
||||
"Direct chats will stay blocked until you add people later.",
|
||||
`Tip: use \`${formatCliCommand("openclaw directory peers list --channel zalouser")}\` to look up people after onboarding.`,
|
||||
].join("\n"),
|
||||
ZALOUSER_ALLOWLIST_TITLE,
|
||||
);
|
||||
return setZalouserAccountScopedConfig(cfg, accountId, {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [],
|
||||
});
|
||||
}
|
||||
const resolvedEntries = await resolveZaloAllowFromEntries({
|
||||
profile: resolved.profile,
|
||||
entries: parts,
|
||||
});
|
||||
|
||||
const unresolved = resolvedEntries.filter((item) => !item.resolved).map((item) => item.input);
|
||||
if (unresolved.length > 0) {
|
||||
await prompter.note(
|
||||
`Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or exact friend names.`,
|
||||
ZALOUSER_ALLOWLIST_TITLE,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedIds = resolvedEntries
|
||||
.filter((item) => item.resolved && item.id)
|
||||
.map((item) => item.id as string);
|
||||
const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds);
|
||||
|
||||
const notes = resolvedEntries
|
||||
.filter((item) => item.note)
|
||||
.map((item) => `${item.input} -> ${item.id} (${item.note})`);
|
||||
if (notes.length > 0) {
|
||||
await prompter.note(notes.join("\n"), ZALOUSER_ALLOWLIST_TITLE);
|
||||
}
|
||||
|
||||
return setZalouserAccountScopedConfig(cfg, accountId, {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const zalouserDmPolicy: ChannelSetupDmPolicy = {
|
||||
label: "Zalo Personal",
|
||||
channel,
|
||||
policyKey: "channels.zalouser.dmPolicy",
|
||||
allowFromKey: "channels.zalouser.allowFrom",
|
||||
resolveConfigKeys: (cfg, accountId) =>
|
||||
(accountId ?? resolveDefaultZalouserAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
|
||||
? {
|
||||
policyKey: `channels.zalouser.accounts.${accountId ?? resolveDefaultZalouserAccountId(cfg)}.dmPolicy`,
|
||||
allowFromKey: `channels.zalouser.accounts.${accountId ?? resolveDefaultZalouserAccountId(cfg)}.allowFrom`,
|
||||
}
|
||||
: {
|
||||
policyKey: "channels.zalouser.dmPolicy",
|
||||
allowFromKey: "channels.zalouser.allowFrom",
|
||||
},
|
||||
getCurrent: (cfg, accountId) =>
|
||||
resolveZalouserAccountSync({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultZalouserAccountId(cfg),
|
||||
}).config.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy, accountId) =>
|
||||
setZalouserDmPolicy(cfg, accountId ?? resolveDefaultZalouserAccountId(cfg), policy),
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const id =
|
||||
accountId && normalizeAccountId(accountId)
|
||||
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
|
||||
: resolveDefaultZalouserAccountId(cfg);
|
||||
return await promptZalouserAllowFrom({
|
||||
cfg: cfg,
|
||||
prompter,
|
||||
accountId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
async function promptZalouserQuickstartDmPolicy(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: Parameters<NonNullable<ChannelSetupWizard["prepare"]>>[0]["prompter"];
|
||||
accountId: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveZalouserAccountSync({ cfg, accountId });
|
||||
const existingPolicy = resolved.config.dmPolicy ?? "pairing";
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"Direct chats are configured separately from group chats.",
|
||||
"- pairing (default): unknown people get a pairing code",
|
||||
"- allowlist: only listed people can DM",
|
||||
"- open: anyone can DM",
|
||||
"- disabled: ignore DMs",
|
||||
"",
|
||||
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
|
||||
"If you choose allowlist now, you can leave it empty and add people later.",
|
||||
].join("\n"),
|
||||
ZALOUSER_DM_ACCESS_TITLE,
|
||||
);
|
||||
|
||||
const policy = (await prompter.select({
|
||||
message: "Zalo Personal DM policy",
|
||||
options: [
|
||||
{ value: "pairing", label: "Pairing (recommended)" },
|
||||
{ value: "allowlist", label: "Allowlist (specific users only)" },
|
||||
{ value: "open", label: "Open (public inbound DMs)" },
|
||||
{ value: "disabled", label: "Disabled (ignore DMs)" },
|
||||
],
|
||||
initialValue: existingPolicy,
|
||||
})) as DmPolicy;
|
||||
|
||||
if (policy === "allowlist") {
|
||||
return await promptZalouserAllowFrom({
|
||||
cfg,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
return setZalouserDmPolicy(cfg, accountId, policy);
|
||||
}
|
||||
|
||||
export { zalouserSetupAdapter } from "./setup-core.js";
|
||||
|
||||
export const zalouserSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: {
|
||||
configuredLabel: "logged in",
|
||||
unconfiguredLabel: "needs QR login",
|
||||
configuredHint: "recommended · logged in",
|
||||
unconfiguredHint: "recommended · QR login",
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 15,
|
||||
resolveConfigured: async ({ cfg, accountId }) => {
|
||||
const ids = accountId ? [accountId] : listZalouserAccountIds(cfg);
|
||||
for (const resolvedAccountId of ids) {
|
||||
const account = resolveZalouserAccountSync({ cfg, accountId: resolvedAccountId });
|
||||
if (await checkZcaAuthenticated(account.profile)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
resolveStatusLines: async ({ cfg, accountId, configured }) => {
|
||||
void cfg;
|
||||
const label =
|
||||
accountId && accountId !== DEFAULT_ACCOUNT_ID
|
||||
? `Zalo Personal (${accountId})`
|
||||
: "Zalo Personal";
|
||||
return [`${label}: ${configured ? "logged in" : "needs QR login"}`];
|
||||
},
|
||||
},
|
||||
prepare: async ({ cfg, accountId, prompter, options }) => {
|
||||
let next = cfg;
|
||||
const account = resolveZalouserAccountSync({ cfg: next, accountId });
|
||||
const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
|
||||
|
||||
if (!alreadyAuthenticated) {
|
||||
await noteZalouserHelp(prompter);
|
||||
const wantsLogin = await prompter.confirm({
|
||||
message: "Login via QR code now?",
|
||||
initialValue: true,
|
||||
});
|
||||
|
||||
if (wantsLogin) {
|
||||
const start = await startZaloQrLogin({ profile: account.profile, timeoutMs: 35_000 });
|
||||
if (start.qrDataUrl) {
|
||||
const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile);
|
||||
await prompter.note(
|
||||
[
|
||||
start.message,
|
||||
qrPath
|
||||
? `QR image saved to: ${qrPath}`
|
||||
: "Could not write QR image file; use gateway web login UI instead.",
|
||||
"Scan + approve on phone, then continue.",
|
||||
].join("\n"),
|
||||
"QR Login",
|
||||
);
|
||||
const scanned = await prompter.confirm({
|
||||
message: "Did you scan and approve the QR on your phone?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (scanned) {
|
||||
const waited = await waitForZaloQrLogin({
|
||||
profile: account.profile,
|
||||
timeoutMs: 120_000,
|
||||
});
|
||||
await prompter.note(waited.message, waited.connected ? "Success" : "Login pending");
|
||||
}
|
||||
} else {
|
||||
await prompter.note(start.message, "Login pending");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const keepSession = await prompter.confirm({
|
||||
message: "Zalo Personal already logged in. Keep session?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keepSession) {
|
||||
await logoutZaloProfile(account.profile);
|
||||
const start = await startZaloQrLogin({
|
||||
profile: account.profile,
|
||||
force: true,
|
||||
timeoutMs: 35_000,
|
||||
});
|
||||
if (start.qrDataUrl) {
|
||||
const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile);
|
||||
await prompter.note(
|
||||
[start.message, qrPath ? `QR image saved to: ${qrPath}` : undefined]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
"QR Login",
|
||||
);
|
||||
const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 120_000 });
|
||||
await prompter.note(waited.message, waited.connected ? "Success" : "Login pending");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next = setZalouserAccountScopedConfig(
|
||||
next,
|
||||
accountId,
|
||||
{ profile: account.profile !== "default" ? account.profile : undefined },
|
||||
{ profile: account.profile, enabled: true },
|
||||
);
|
||||
|
||||
if (options?.quickstartDefaults) {
|
||||
next = await promptZalouserQuickstartDmPolicy({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
|
||||
return { cfg: next };
|
||||
},
|
||||
credentials: [],
|
||||
groupAccess: {
|
||||
label: "Zalo groups",
|
||||
placeholder: ZALOUSER_GROUPS_PLACEHOLDER,
|
||||
currentPolicy: ({ cfg, accountId }) =>
|
||||
resolveZalouserAccountSync({ cfg, accountId }).config.groupPolicy ?? "allowlist",
|
||||
currentEntries: ({ cfg, accountId }) =>
|
||||
Object.keys(resolveZalouserAccountSync({ cfg, accountId }).config.groups ?? {}),
|
||||
updatePrompt: ({ cfg, accountId }) =>
|
||||
Boolean(resolveZalouserAccountSync({ cfg, accountId }).config.groups),
|
||||
setPolicy: ({ cfg, accountId, policy }) => setZalouserGroupPolicy(cfg, accountId, policy),
|
||||
resolveAllowlist: async ({ cfg, accountId, entries, prompter }) => {
|
||||
if (entries.length === 0) {
|
||||
await prompter.note(
|
||||
[
|
||||
"No group allowlist entries added yet.",
|
||||
"Group chats will stay blocked until you add groups later.",
|
||||
`Tip: use \`${formatCliCommand("openclaw directory groups list --channel zalouser")}\` after onboarding to find group IDs.`,
|
||||
"Mention requirement stays on by default for groups you allow later.",
|
||||
].join("\n"),
|
||||
ZALOUSER_GROUPS_TITLE,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
const updatedAccount = resolveZalouserAccountSync({ cfg: cfg, accountId });
|
||||
try {
|
||||
const resolved = await resolveZaloGroupsByEntries({
|
||||
profile: updatedAccount.profile,
|
||||
entries,
|
||||
});
|
||||
const resolvedIds = resolved
|
||||
.filter((entry) => entry.resolved && entry.id)
|
||||
.map((entry) => entry.id as string);
|
||||
const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input);
|
||||
const keys = [...resolvedIds, ...unresolved.map((entry) => entry.trim()).filter(Boolean)];
|
||||
const resolution = formatResolvedUnresolvedNote({
|
||||
resolved: resolvedIds,
|
||||
unresolved,
|
||||
});
|
||||
if (resolution) {
|
||||
await prompter.note(resolution, ZALOUSER_GROUPS_TITLE);
|
||||
}
|
||||
return keys;
|
||||
} catch (err) {
|
||||
await prompter.note(
|
||||
`Group lookup failed; keeping entries as typed. ${String(err)}`,
|
||||
ZALOUSER_GROUPS_TITLE,
|
||||
);
|
||||
return entries.map((entry) => entry.trim()).filter(Boolean);
|
||||
}
|
||||
},
|
||||
applyAllowlist: ({ cfg, accountId, resolved }) =>
|
||||
setZalouserGroupAllowlist(cfg, accountId, resolved as string[]),
|
||||
},
|
||||
finalize: async ({ cfg, accountId, forceAllowFrom, options, prompter }) => {
|
||||
let next = cfg;
|
||||
if (forceAllowFrom && !options?.quickstartDefaults) {
|
||||
next = await promptZalouserAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
}
|
||||
return { cfg: ensureZalouserPluginEnabled(next) };
|
||||
},
|
||||
dmPolicy: zalouserDmPolicy,
|
||||
};
|
||||
42
openclaw/extensions/zalouser/src/setup-test-helpers.ts
Normal file
42
openclaw/extensions/zalouser/src/setup-test-helpers.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import {
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
} from "./accounts.js";
|
||||
import { zalouserSetupAdapter } from "./setup-core.js";
|
||||
import { zalouserSetupWizard } from "./setup-surface.js";
|
||||
|
||||
export const zalouserSetupPlugin = {
|
||||
id: "zalouser",
|
||||
meta: {
|
||||
id: "zalouser",
|
||||
label: "ZaloUser",
|
||||
selectionLabel: "ZaloUser",
|
||||
docsPath: "/channels/zalouser",
|
||||
blurb: "Unofficial Zalo personal account connector.",
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
|
||||
},
|
||||
config: {
|
||||
listAccountIds: (cfg: unknown) => listZalouserAccountIds(cfg as never),
|
||||
defaultAccountId: (cfg: unknown) => resolveDefaultZalouserAccountId(cfg as never),
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
|
||||
resolveZalouserAccountSync({ cfg, accountId }),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: createScopedDmSecurityResolver({
|
||||
channelKey: "zalouser",
|
||||
resolvePolicy: (account: ReturnType<typeof resolveZalouserAccountSync>) =>
|
||||
account.config.dmPolicy,
|
||||
resolveAllowFrom: (account: ReturnType<typeof resolveZalouserAccountSync>) =>
|
||||
account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw: string) => raw.trim().replace(/^(zalouser|zlu):/i, ""),
|
||||
}),
|
||||
},
|
||||
setup: zalouserSetupAdapter,
|
||||
setupWizard: zalouserSetupWizard,
|
||||
} as const;
|
||||
92
openclaw/extensions/zalouser/src/shared.ts
Normal file
92
openclaw/extensions/zalouser/src/shared.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import {
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
checkZcaAuthenticated,
|
||||
type ResolvedZalouserAccount,
|
||||
} from "./accounts.js";
|
||||
import type { ChannelPlugin } from "./channel-api.js";
|
||||
import { buildChannelConfigSchema, formatAllowFromLowercase } from "./channel-api.js";
|
||||
import { ZalouserConfigSchema } from "./config-schema.js";
|
||||
import { zalouserDoctor } from "./doctor.js";
|
||||
|
||||
export const zalouserMeta = {
|
||||
id: "zalouser",
|
||||
label: "Zalo Personal",
|
||||
selectionLabel: "Zalo (Personal Account)",
|
||||
docsPath: "/channels/zalouser",
|
||||
docsLabel: "zalouser",
|
||||
blurb: "Zalo personal account via QR code login.",
|
||||
aliases: ["zlu"],
|
||||
order: 85,
|
||||
quickstartAllowFrom: false,
|
||||
} satisfies ChannelPlugin<ResolvedZalouserAccount>["meta"];
|
||||
|
||||
const zalouserConfigAdapter = createScopedChannelConfigAdapter<ResolvedZalouserAccount>({
|
||||
sectionKey: "zalouser",
|
||||
listAccountIds: listZalouserAccountIds,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveZalouserAccountSync),
|
||||
defaultAccountId: resolveDefaultZalouserAccountId,
|
||||
clearBaseFields: [
|
||||
"profile",
|
||||
"name",
|
||||
"dmPolicy",
|
||||
"allowFrom",
|
||||
"historyLimit",
|
||||
"groupAllowFrom",
|
||||
"groupPolicy",
|
||||
"groups",
|
||||
"messagePrefix",
|
||||
],
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
|
||||
});
|
||||
|
||||
export function createZalouserPluginBase(params: {
|
||||
setupWizard: NonNullable<ChannelPlugin<ResolvedZalouserAccount>["setupWizard"]>;
|
||||
setup: NonNullable<ChannelPlugin<ResolvedZalouserAccount>["setup"]>;
|
||||
}): Pick<
|
||||
ChannelPlugin<ResolvedZalouserAccount>,
|
||||
| "id"
|
||||
| "meta"
|
||||
| "setupWizard"
|
||||
| "capabilities"
|
||||
| "doctor"
|
||||
| "reload"
|
||||
| "configSchema"
|
||||
| "config"
|
||||
| "setup"
|
||||
> {
|
||||
return {
|
||||
id: "zalouser",
|
||||
meta: zalouserMeta,
|
||||
setupWizard: params.setupWizard,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
reactions: true,
|
||||
threads: false,
|
||||
polls: false,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
doctor: zalouserDoctor,
|
||||
reload: { configPrefixes: ["channels.zalouser"] },
|
||||
configSchema: buildChannelConfigSchema(ZalouserConfigSchema),
|
||||
config: {
|
||||
...zalouserConfigAdapter,
|
||||
isConfigured: async (account) => await checkZcaAuthenticated(account.profile),
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
}),
|
||||
},
|
||||
setup: params.setup,
|
||||
};
|
||||
}
|
||||
31
openclaw/extensions/zalouser/src/status-issues.test.ts
Normal file
31
openclaw/extensions/zalouser/src/status-issues.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { expectOpenDmPolicyConfigIssue } from "../../../test/helpers/plugins/status-issues.js";
|
||||
import { collectZalouserStatusIssues } from "./status-issues.js";
|
||||
|
||||
describe("collectZalouserStatusIssues", () => {
|
||||
it("flags missing auth when configured is false", () => {
|
||||
const issues = collectZalouserStatusIssues([
|
||||
{
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
lastError: "not authenticated",
|
||||
},
|
||||
]);
|
||||
expect(issues).toHaveLength(1);
|
||||
expect(issues[0]?.kind).toBe("auth");
|
||||
expect(issues[0]?.message).toMatch(/Not authenticated/i);
|
||||
});
|
||||
|
||||
it("warns when dmPolicy is open", () => {
|
||||
expectOpenDmPolicyConfigIssue({
|
||||
collectIssues: collectZalouserStatusIssues,
|
||||
account: {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
dmPolicy: "open",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
58
openclaw/extensions/zalouser/src/status-issues.ts
Normal file
58
openclaw/extensions/zalouser/src/status-issues.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelStatusIssue,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import {
|
||||
coerceStatusIssueAccountId,
|
||||
readStatusIssueFields,
|
||||
} from "openclaw/plugin-sdk/extension-shared";
|
||||
|
||||
const ZALOUSER_STATUS_FIELDS = [
|
||||
"accountId",
|
||||
"enabled",
|
||||
"configured",
|
||||
"dmPolicy",
|
||||
"lastError",
|
||||
] as const;
|
||||
|
||||
export function collectZalouserStatusIssues(
|
||||
accounts: ChannelAccountSnapshot[],
|
||||
): ChannelStatusIssue[] {
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
for (const entry of accounts) {
|
||||
const account = readStatusIssueFields(entry, ZALOUSER_STATUS_FIELDS);
|
||||
if (!account) {
|
||||
continue;
|
||||
}
|
||||
const accountId = coerceStatusIssueAccountId(account.accountId) ?? "default";
|
||||
const enabled = account.enabled !== false;
|
||||
if (!enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const configured = account.configured === true;
|
||||
|
||||
if (!configured) {
|
||||
issues.push({
|
||||
channel: "zalouser",
|
||||
accountId,
|
||||
kind: "auth",
|
||||
message: "Not authenticated (no saved Zalo session).",
|
||||
fix: "Run: openclaw channels login --channel zalouser",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (account.dmPolicy === "open") {
|
||||
issues.push({
|
||||
channel: "zalouser",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message:
|
||||
'Zalo Personal dmPolicy is "open", allowing any user to message the bot without pairing.',
|
||||
fix: 'Set channels.zalouser.dmPolicy to "pairing" or "allowlist" to restrict access.',
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
26
openclaw/extensions/zalouser/src/test-helpers.ts
Normal file
26
openclaw/extensions/zalouser/src/test-helpers.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { RuntimeEnv } from "../runtime-api.js";
|
||||
import type { ResolvedZalouserAccount } from "./types.js";
|
||||
|
||||
export function createZalouserRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
};
|
||||
}
|
||||
|
||||
export function createDefaultResolvedZalouserAccount(
|
||||
overrides: Partial<ResolvedZalouserAccount> = {},
|
||||
): ResolvedZalouserAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
profile: "default",
|
||||
name: "test",
|
||||
enabled: true,
|
||||
authenticated: true,
|
||||
config: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
203
openclaw/extensions/zalouser/src/text-styles.test.ts
Normal file
203
openclaw/extensions/zalouser/src/text-styles.test.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { parseZalouserTextStyles } from "./text-styles.js";
|
||||
import { TextStyle } from "./zca-constants.js";
|
||||
|
||||
describe("parseZalouserTextStyles", () => {
|
||||
it("renders inline markdown emphasis as Zalo style ranges", () => {
|
||||
expect(parseZalouserTextStyles("**bold** *italic* ~~strike~~")).toEqual({
|
||||
text: "bold italic strike",
|
||||
styles: [
|
||||
{ start: 0, len: 4, st: TextStyle.Bold },
|
||||
{ start: 5, len: 6, st: TextStyle.Italic },
|
||||
{ start: 12, len: 6, st: TextStyle.StrikeThrough },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps inline code and plain math markers literal", () => {
|
||||
expect(parseZalouserTextStyles("before `inline *code*` after\n2 * 3 * 4")).toEqual({
|
||||
text: "before `inline *code*` after\n2 * 3 * 4",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves backslash escapes inside code spans and fenced code blocks", () => {
|
||||
expect(parseZalouserTextStyles("before `\\*` after\n```ts\n\\*\\_\\\\\n```")).toEqual({
|
||||
text: "before `\\*` after\n\\*\\_\\\\",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("closes fenced code blocks when the input uses CRLF newlines", () => {
|
||||
expect(parseZalouserTextStyles("```\r\n*code*\r\n```\r\n**after**")).toEqual({
|
||||
text: "*code*\nafter",
|
||||
styles: [{ start: 7, len: 5, st: TextStyle.Bold }],
|
||||
});
|
||||
});
|
||||
|
||||
it("maps headings, block quotes, and lists into line styles", () => {
|
||||
expect(parseZalouserTextStyles(["# Title", "> quoted", " - nested"].join("\n"))).toEqual({
|
||||
text: "Title\nquoted\nnested",
|
||||
styles: [
|
||||
{ start: 0, len: 5, st: TextStyle.Bold },
|
||||
{ start: 0, len: 5, st: TextStyle.Big },
|
||||
{ start: 6, len: 6, st: TextStyle.Indent, indentSize: 1 },
|
||||
{ start: 13, len: 6, st: TextStyle.UnorderedList },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats 1-3 leading spaces as markdown padding for headings and lists", () => {
|
||||
expect(parseZalouserTextStyles(" # Title\n 1. item\n - bullet")).toEqual({
|
||||
text: "Title\nitem\nbullet",
|
||||
styles: [
|
||||
{ start: 0, len: 5, st: TextStyle.Bold },
|
||||
{ start: 0, len: 5, st: TextStyle.Big },
|
||||
{ start: 6, len: 4, st: TextStyle.OrderedList },
|
||||
{ start: 11, len: 6, st: TextStyle.UnorderedList },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips fenced code markers and preserves leading indentation with nbsp", () => {
|
||||
expect(parseZalouserTextStyles("```ts\n const x = 1\n\treturn x\n```")).toEqual({
|
||||
text: "\u00A0\u00A0const x = 1\n\u00A0\u00A0\u00A0\u00A0return x",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats tilde fences as literal code blocks", () => {
|
||||
expect(parseZalouserTextStyles("~~~bash\n*cmd*\n~~~")).toEqual({
|
||||
text: "*cmd*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats fences indented under list items as literal code blocks", () => {
|
||||
expect(parseZalouserTextStyles(" ```\n*cmd*\n ```")).toEqual({
|
||||
text: "*cmd*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats quoted backtick fences as literal code blocks", () => {
|
||||
expect(parseZalouserTextStyles("> ```js\n> *cmd*\n> ```")).toEqual({
|
||||
text: "*cmd*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats quoted tilde fences as literal code blocks", () => {
|
||||
expect(parseZalouserTextStyles("> ~~~\n> *cmd*\n> ~~~")).toEqual({
|
||||
text: "*cmd*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves quote-prefixed lines inside normal fenced code blocks", () => {
|
||||
expect(parseZalouserTextStyles("```\n> prompt\n```")).toEqual({
|
||||
text: "> prompt",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat quote-prefixed fence text inside code as a closing fence", () => {
|
||||
expect(parseZalouserTextStyles("```\n> ```\n*still code*\n```")).toEqual({
|
||||
text: "> ```\n*still code*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats indented blockquotes as quoted lines", () => {
|
||||
expect(parseZalouserTextStyles(" > quoted")).toEqual({
|
||||
text: "quoted",
|
||||
styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 1 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats spaced nested blockquotes as deeper quoted lines", () => {
|
||||
expect(parseZalouserTextStyles("> > quoted")).toEqual({
|
||||
text: "quoted",
|
||||
styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 2 }],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats indented quoted fences as literal code blocks", () => {
|
||||
expect(parseZalouserTextStyles(" > ```\n > *cmd*\n > ```")).toEqual({
|
||||
text: "*cmd*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats spaced nested quoted fences as literal code blocks", () => {
|
||||
expect(parseZalouserTextStyles("> > ```\n> > code\n> > ```")).toEqual({
|
||||
text: "code",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves inner quote markers inside quoted fenced code blocks", () => {
|
||||
expect(parseZalouserTextStyles("> ```\n>> prompt\n> ```")).toEqual({
|
||||
text: "> prompt",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps quote indentation on heading lines", () => {
|
||||
expect(parseZalouserTextStyles("> # Title")).toEqual({
|
||||
text: "Title",
|
||||
styles: [
|
||||
{ start: 0, len: 5, st: TextStyle.Bold },
|
||||
{ start: 0, len: 5, st: TextStyle.Big },
|
||||
{ start: 0, len: 5, st: TextStyle.Indent, indentSize: 1 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unmatched fences literal", () => {
|
||||
expect(parseZalouserTextStyles("```python")).toEqual({
|
||||
text: "```python",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps unclosed fenced blocks literal until eof", () => {
|
||||
expect(parseZalouserTextStyles("```python\n\\*not italic*\n_next_")).toEqual({
|
||||
text: "```python\n\\*not italic*\n_next_",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("supports nested markdown and tag styles regardless of order", () => {
|
||||
expect(parseZalouserTextStyles("**{red}x{/red}** {red}**y**{/red}")).toEqual({
|
||||
text: "x y",
|
||||
styles: [
|
||||
{ start: 0, len: 1, st: TextStyle.Bold },
|
||||
{ start: 0, len: 1, st: TextStyle.Red },
|
||||
{ start: 2, len: 1, st: TextStyle.Red },
|
||||
{ start: 2, len: 1, st: TextStyle.Bold },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("treats small text tags as normal text", () => {
|
||||
expect(parseZalouserTextStyles("{small}tiny{/small}")).toEqual({
|
||||
text: "tiny",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps escaped markers literal", () => {
|
||||
expect(parseZalouserTextStyles("\\*literal\\* \\{underline}tag{/underline}")).toEqual({
|
||||
text: "*literal* {underline}tag{/underline}",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps indented code blocks literal", () => {
|
||||
expect(parseZalouserTextStyles(" *cmd*")).toEqual({
|
||||
text: "\u00A0\u00A0\u00A0\u00A0*cmd*",
|
||||
styles: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
540
openclaw/extensions/zalouser/src/text-styles.ts
Normal file
540
openclaw/extensions/zalouser/src/text-styles.ts
Normal file
|
|
@ -0,0 +1,540 @@
|
|||
import { TextStyle, type Style } from "./zca-constants.js";
|
||||
|
||||
const ESCAPE_SENTINEL_START = "\u0001";
|
||||
const ESCAPE_SENTINEL_END = "\u0002";
|
||||
|
||||
type InlineStyle = (typeof TextStyle)[keyof typeof TextStyle];
|
||||
|
||||
type LineStyle = {
|
||||
lineIndex: number;
|
||||
style: InlineStyle;
|
||||
indentSize?: number;
|
||||
};
|
||||
|
||||
type Segment = {
|
||||
text: string;
|
||||
styles: InlineStyle[];
|
||||
};
|
||||
|
||||
type InlineMarker = {
|
||||
pattern: RegExp;
|
||||
extractText: (match: RegExpExecArray) => string;
|
||||
resolveStyles?: (match: RegExpExecArray) => InlineStyle[];
|
||||
literal?: boolean;
|
||||
};
|
||||
|
||||
type ResolvedInlineMatch = {
|
||||
match: RegExpExecArray;
|
||||
marker: InlineMarker;
|
||||
styles: InlineStyle[];
|
||||
text: string;
|
||||
priority: number;
|
||||
};
|
||||
|
||||
type FenceMarker = {
|
||||
char: "`" | "~";
|
||||
length: number;
|
||||
indent: number;
|
||||
};
|
||||
|
||||
type ActiveFence = FenceMarker & {
|
||||
quoteIndent: number;
|
||||
};
|
||||
|
||||
const TAG_STYLE_MAP: Record<string, InlineStyle | null> = {
|
||||
red: TextStyle.Red,
|
||||
orange: TextStyle.Orange,
|
||||
yellow: TextStyle.Yellow,
|
||||
green: TextStyle.Green,
|
||||
small: null,
|
||||
big: TextStyle.Big,
|
||||
underline: TextStyle.Underline,
|
||||
};
|
||||
|
||||
const INLINE_MARKERS: InlineMarker[] = [
|
||||
{
|
||||
pattern: /`([^`\n]+)`/g,
|
||||
extractText: (match) => match[0],
|
||||
literal: true,
|
||||
},
|
||||
{
|
||||
pattern: /\\([*_~#\\{}>+\-`])/g,
|
||||
extractText: (match) => match[1],
|
||||
literal: true,
|
||||
},
|
||||
{
|
||||
pattern: new RegExp(`\\{(${Object.keys(TAG_STYLE_MAP).join("|")})\\}(.+?)\\{/\\1\\}`, "g"),
|
||||
extractText: (match) => match[2],
|
||||
resolveStyles: (match) => {
|
||||
const style = TAG_STYLE_MAP[match[1]];
|
||||
return style ? [style] : [];
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: /(?<!\*)\*\*\*(?=\S)([^\n]*?\S)(?<!\*)\*\*\*(?!\*)/g,
|
||||
extractText: (match) => match[1],
|
||||
resolveStyles: () => [TextStyle.Bold, TextStyle.Italic],
|
||||
},
|
||||
{
|
||||
pattern: /(?<!\*)\*\*(?![\s*])([^\n]*?\S)(?<!\*)\*\*(?!\*)/g,
|
||||
extractText: (match) => match[1],
|
||||
resolveStyles: () => [TextStyle.Bold],
|
||||
},
|
||||
{
|
||||
pattern: /(?<![\w_])__(?![\s_])([^\n]*?\S)(?<!_)__(?![\w_])/g,
|
||||
extractText: (match) => match[1],
|
||||
resolveStyles: () => [TextStyle.Bold],
|
||||
},
|
||||
{
|
||||
pattern: /(?<!~)~~(?=\S)([^\n]*?\S)(?<!~)~~(?!~)/g,
|
||||
extractText: (match) => match[1],
|
||||
resolveStyles: () => [TextStyle.StrikeThrough],
|
||||
},
|
||||
{
|
||||
pattern: /(?<!\*)\*(?![\s*])([^\n]*?\S)(?<!\*)\*(?!\*)/g,
|
||||
extractText: (match) => match[1],
|
||||
resolveStyles: () => [TextStyle.Italic],
|
||||
},
|
||||
{
|
||||
pattern: /(?<![\w_])_(?![\s_])([^\n]*?\S)(?<!_)_(?![\w_])/g,
|
||||
extractText: (match) => match[1],
|
||||
resolveStyles: () => [TextStyle.Italic],
|
||||
},
|
||||
];
|
||||
|
||||
export function parseZalouserTextStyles(input: string): { text: string; styles: Style[] } {
|
||||
const allStyles: Style[] = [];
|
||||
|
||||
const escapeMap: string[] = [];
|
||||
const lines = input.replace(/\r\n?/g, "\n").split("\n");
|
||||
const lineStyles: LineStyle[] = [];
|
||||
const processedLines: string[] = [];
|
||||
let activeFence: ActiveFence | null = null;
|
||||
|
||||
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
||||
const rawLine = lines[lineIndex];
|
||||
const { text: unquotedLine, indent: baseIndent } = stripQuotePrefix(rawLine);
|
||||
|
||||
if (activeFence) {
|
||||
const codeLine =
|
||||
activeFence.quoteIndent > 0
|
||||
? stripQuotePrefix(rawLine, activeFence.quoteIndent).text
|
||||
: rawLine;
|
||||
if (isClosingFence(codeLine, activeFence)) {
|
||||
activeFence = null;
|
||||
continue;
|
||||
}
|
||||
processedLines.push(
|
||||
escapeLiteralText(
|
||||
normalizeCodeBlockLeadingWhitespace(stripCodeFenceIndent(codeLine, activeFence.indent)),
|
||||
escapeMap,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let line = unquotedLine;
|
||||
const openingFence = resolveOpeningFence(rawLine);
|
||||
if (openingFence) {
|
||||
const fenceLine = openingFence.quoteIndent > 0 ? unquotedLine : rawLine;
|
||||
if (!hasClosingFence(lines, lineIndex + 1, openingFence)) {
|
||||
processedLines.push(escapeLiteralText(fenceLine, escapeMap));
|
||||
activeFence = openingFence;
|
||||
continue;
|
||||
}
|
||||
activeFence = openingFence;
|
||||
continue;
|
||||
}
|
||||
|
||||
const outputLineIndex = processedLines.length;
|
||||
if (isIndentedCodeBlockLine(line)) {
|
||||
if (baseIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: baseIndent,
|
||||
});
|
||||
}
|
||||
processedLines.push(escapeLiteralText(normalizeCodeBlockLeadingWhitespace(line), escapeMap));
|
||||
continue;
|
||||
}
|
||||
|
||||
const { text: markdownLine, size: markdownPadding } = stripOptionalMarkdownPadding(line);
|
||||
|
||||
const headingMatch = markdownLine.match(/^(#{1,4})\s(.*)$/);
|
||||
if (headingMatch) {
|
||||
const depth = headingMatch[1].length;
|
||||
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Bold });
|
||||
if (depth === 1) {
|
||||
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Big });
|
||||
}
|
||||
if (baseIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: baseIndent,
|
||||
});
|
||||
}
|
||||
processedLines.push(headingMatch[2]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const indentMatch = markdownLine.match(/^(\s+)(.*)$/);
|
||||
let indentLevel = 0;
|
||||
let content = markdownLine;
|
||||
if (indentMatch) {
|
||||
indentLevel = clampIndent(indentMatch[1].length);
|
||||
content = indentMatch[2];
|
||||
}
|
||||
const totalIndent = Math.min(5, baseIndent + indentLevel);
|
||||
|
||||
if (/^[-*+]\s\[[ xX]\]\s/.test(content)) {
|
||||
if (totalIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: totalIndent,
|
||||
});
|
||||
}
|
||||
processedLines.push(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedListMatch = content.match(/^(\d+)\.\s(.*)$/);
|
||||
if (orderedListMatch) {
|
||||
if (totalIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: totalIndent,
|
||||
});
|
||||
}
|
||||
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.OrderedList });
|
||||
processedLines.push(orderedListMatch[2]);
|
||||
continue;
|
||||
}
|
||||
|
||||
const unorderedListMatch = content.match(/^[-*+]\s(.*)$/);
|
||||
if (unorderedListMatch) {
|
||||
if (totalIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: totalIndent,
|
||||
});
|
||||
}
|
||||
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.UnorderedList });
|
||||
processedLines.push(unorderedListMatch[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (markdownPadding > 0) {
|
||||
if (baseIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: baseIndent,
|
||||
});
|
||||
}
|
||||
processedLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (totalIndent > 0) {
|
||||
lineStyles.push({
|
||||
lineIndex: outputLineIndex,
|
||||
style: TextStyle.Indent,
|
||||
indentSize: totalIndent,
|
||||
});
|
||||
processedLines.push(content);
|
||||
continue;
|
||||
}
|
||||
|
||||
processedLines.push(line);
|
||||
}
|
||||
|
||||
const segments = parseInlineSegments(processedLines.join("\n"));
|
||||
|
||||
let plainText = "";
|
||||
for (const segment of segments) {
|
||||
const start = plainText.length;
|
||||
plainText += segment.text;
|
||||
for (const style of segment.styles) {
|
||||
allStyles.push({ start, len: segment.text.length, st: style } as Style);
|
||||
}
|
||||
}
|
||||
|
||||
if (escapeMap.length > 0) {
|
||||
const escapeRegex = new RegExp(`${ESCAPE_SENTINEL_START}(\\d+)${ESCAPE_SENTINEL_END}`, "g");
|
||||
const shifts: Array<{ pos: number; delta: number }> = [];
|
||||
let cumulativeDelta = 0;
|
||||
|
||||
for (const match of plainText.matchAll(escapeRegex)) {
|
||||
const escapeIndex = Number.parseInt(match[1], 10);
|
||||
cumulativeDelta += match[0].length - escapeMap[escapeIndex].length;
|
||||
shifts.push({ pos: (match.index ?? 0) + match[0].length, delta: cumulativeDelta });
|
||||
}
|
||||
|
||||
for (const style of allStyles) {
|
||||
let startDelta = 0;
|
||||
let endDelta = 0;
|
||||
const end = style.start + style.len;
|
||||
for (const shift of shifts) {
|
||||
if (shift.pos <= style.start) {
|
||||
startDelta = shift.delta;
|
||||
}
|
||||
if (shift.pos <= end) {
|
||||
endDelta = shift.delta;
|
||||
}
|
||||
}
|
||||
style.start -= startDelta;
|
||||
style.len -= endDelta - startDelta;
|
||||
}
|
||||
|
||||
plainText = plainText.replace(
|
||||
escapeRegex,
|
||||
(_match, index) => escapeMap[Number.parseInt(index, 10)],
|
||||
);
|
||||
}
|
||||
|
||||
const finalLines = plainText.split("\n");
|
||||
let offset = 0;
|
||||
for (let lineIndex = 0; lineIndex < finalLines.length; lineIndex += 1) {
|
||||
const lineLength = finalLines[lineIndex].length;
|
||||
if (lineLength > 0) {
|
||||
for (const lineStyle of lineStyles) {
|
||||
if (lineStyle.lineIndex !== lineIndex) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lineStyle.style === TextStyle.Indent) {
|
||||
allStyles.push({
|
||||
start: offset,
|
||||
len: lineLength,
|
||||
st: TextStyle.Indent,
|
||||
indentSize: lineStyle.indentSize,
|
||||
});
|
||||
} else {
|
||||
allStyles.push({ start: offset, len: lineLength, st: lineStyle.style } as Style);
|
||||
}
|
||||
}
|
||||
}
|
||||
offset += lineLength + 1;
|
||||
}
|
||||
|
||||
return { text: plainText, styles: allStyles };
|
||||
}
|
||||
|
||||
function clampIndent(spaceCount: number): number {
|
||||
return Math.min(5, Math.max(1, Math.floor(spaceCount / 2)));
|
||||
}
|
||||
|
||||
function stripOptionalMarkdownPadding(line: string): { text: string; size: number } {
|
||||
const match = line.match(/^( {1,3})(?=\S)/);
|
||||
if (!match) {
|
||||
return { text: line, size: 0 };
|
||||
}
|
||||
return {
|
||||
text: line.slice(match[1].length),
|
||||
size: match[1].length,
|
||||
};
|
||||
}
|
||||
|
||||
function hasClosingFence(lines: string[], startIndex: number, fence: ActiveFence): boolean {
|
||||
for (let index = startIndex; index < lines.length; index += 1) {
|
||||
const candidate =
|
||||
fence.quoteIndent > 0 ? stripQuotePrefix(lines[index], fence.quoteIndent).text : lines[index];
|
||||
if (isClosingFence(candidate, fence)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function resolveOpeningFence(line: string): ActiveFence | null {
|
||||
const directFence = parseFenceMarker(line);
|
||||
if (directFence) {
|
||||
return { ...directFence, quoteIndent: 0 };
|
||||
}
|
||||
|
||||
const quoted = stripQuotePrefix(line);
|
||||
if (quoted.indent === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quotedFence = parseFenceMarker(quoted.text);
|
||||
if (!quotedFence) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...quotedFence,
|
||||
quoteIndent: quoted.indent,
|
||||
};
|
||||
}
|
||||
|
||||
function stripQuotePrefix(
|
||||
line: string,
|
||||
maxDepth = Number.POSITIVE_INFINITY,
|
||||
): { text: string; indent: number } {
|
||||
let cursor = 0;
|
||||
while (cursor < line.length && cursor < 3 && line[cursor] === " ") {
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
let removedDepth = 0;
|
||||
let consumedCursor = cursor;
|
||||
while (removedDepth < maxDepth && consumedCursor < line.length && line[consumedCursor] === ">") {
|
||||
removedDepth += 1;
|
||||
consumedCursor += 1;
|
||||
if (line[consumedCursor] === " ") {
|
||||
consumedCursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedDepth === 0) {
|
||||
return { text: line, indent: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
text: line.slice(consumedCursor),
|
||||
indent: Math.min(5, removedDepth),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFenceMarker(line: string): FenceMarker | null {
|
||||
const match = line.match(/^([ ]{0,3})(`{3,}|~{3,})(.*)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const marker = match[2];
|
||||
const char = marker[0];
|
||||
if (char !== "`" && char !== "~") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
char,
|
||||
length: marker.length,
|
||||
indent: match[1].length,
|
||||
};
|
||||
}
|
||||
|
||||
function isClosingFence(line: string, fence: FenceMarker): boolean {
|
||||
const match = line.match(/^([ ]{0,3})(`{3,}|~{3,})[ \t]*$/);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
return match[2][0] === fence.char && match[2].length >= fence.length;
|
||||
}
|
||||
|
||||
function escapeLiteralText(input: string, escapeMap: string[]): string {
|
||||
return input.replace(/[\\*_~{}`]/g, (ch) => {
|
||||
const index = escapeMap.length;
|
||||
escapeMap.push(ch);
|
||||
return `\x01${index}\x02`;
|
||||
});
|
||||
}
|
||||
|
||||
function parseInlineSegments(text: string, inheritedStyles: InlineStyle[] = []): Segment[] {
|
||||
const segments: Segment[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const nextMatch = findNextInlineMatch(text, cursor);
|
||||
if (!nextMatch) {
|
||||
pushSegment(segments, text.slice(cursor), inheritedStyles);
|
||||
break;
|
||||
}
|
||||
|
||||
if (nextMatch.match.index > cursor) {
|
||||
pushSegment(segments, text.slice(cursor, nextMatch.match.index), inheritedStyles);
|
||||
}
|
||||
|
||||
const combinedStyles = [...inheritedStyles, ...nextMatch.styles];
|
||||
if (nextMatch.marker.literal) {
|
||||
pushSegment(segments, nextMatch.text, combinedStyles);
|
||||
} else {
|
||||
segments.push(...parseInlineSegments(nextMatch.text, combinedStyles));
|
||||
}
|
||||
|
||||
cursor = nextMatch.match.index + nextMatch.match[0].length;
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function findNextInlineMatch(text: string, startIndex: number): ResolvedInlineMatch | null {
|
||||
let bestMatch: ResolvedInlineMatch | null = null;
|
||||
|
||||
for (const [priority, marker] of INLINE_MARKERS.entries()) {
|
||||
const regex = new RegExp(marker.pattern.source, marker.pattern.flags);
|
||||
regex.lastIndex = startIndex;
|
||||
const match = regex.exec(text);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
bestMatch &&
|
||||
(match.index > bestMatch.match.index ||
|
||||
(match.index === bestMatch.match.index && priority > bestMatch.priority))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
bestMatch = {
|
||||
match,
|
||||
marker,
|
||||
text: marker.extractText(match),
|
||||
styles: marker.resolveStyles?.(match) ?? [],
|
||||
priority,
|
||||
};
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function pushSegment(segments: Segment[], text: string, styles: InlineStyle[]): void {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSegment = segments.at(-1);
|
||||
if (lastSegment && sameStyles(lastSegment.styles, styles)) {
|
||||
lastSegment.text += text;
|
||||
return;
|
||||
}
|
||||
|
||||
segments.push({
|
||||
text,
|
||||
styles: [...styles],
|
||||
});
|
||||
}
|
||||
|
||||
function sameStyles(left: InlineStyle[], right: InlineStyle[]): boolean {
|
||||
return left.length === right.length && left.every((style, index) => style === right[index]);
|
||||
}
|
||||
|
||||
function normalizeCodeBlockLeadingWhitespace(line: string): string {
|
||||
return line.replace(/^[ \t]+/, (leadingWhitespace) =>
|
||||
leadingWhitespace.replace(/\t/g, "\u00A0\u00A0\u00A0\u00A0").replace(/ /g, "\u00A0"),
|
||||
);
|
||||
}
|
||||
|
||||
function isIndentedCodeBlockLine(line: string): boolean {
|
||||
return /^(?: {4,}|\t)/.test(line);
|
||||
}
|
||||
|
||||
function stripCodeFenceIndent(line: string, indent: number): string {
|
||||
let consumed = 0;
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < line.length && consumed < indent && line[cursor] === " ") {
|
||||
cursor += 1;
|
||||
consumed += 1;
|
||||
}
|
||||
|
||||
return line.slice(cursor);
|
||||
}
|
||||
212
openclaw/extensions/zalouser/src/tool.test.ts
Normal file
212
openclaw/extensions/zalouser/src/tool.test.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
|
||||
import { createZalouserTool, executeZalouserTool } from "./tool.js";
|
||||
import {
|
||||
checkZaloAuthenticated,
|
||||
getZaloUserInfo,
|
||||
listZaloFriendsMatching,
|
||||
listZaloGroupsMatching,
|
||||
} from "./zalo-js.js";
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageZalouser: vi.fn(),
|
||||
sendImageZalouser: vi.fn(),
|
||||
sendLinkZalouser: vi.fn(),
|
||||
sendReactionZalouser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
checkZaloAuthenticated: vi.fn(),
|
||||
getZaloUserInfo: vi.fn(),
|
||||
listZaloFriendsMatching: vi.fn(),
|
||||
listZaloGroupsMatching: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockSendMessage = vi.mocked(sendMessageZalouser);
|
||||
const mockSendImage = vi.mocked(sendImageZalouser);
|
||||
const mockSendLink = vi.mocked(sendLinkZalouser);
|
||||
const mockCheckAuth = vi.mocked(checkZaloAuthenticated);
|
||||
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
|
||||
const mockListFriends = vi.mocked(listZaloFriendsMatching);
|
||||
const mockListGroups = vi.mocked(listZaloGroupsMatching);
|
||||
|
||||
function extractDetails(result: { content?: Array<{ type: string; text?: string }> }): unknown {
|
||||
const text = result.content?.[0]?.text ?? "{}";
|
||||
return JSON.parse(text) as unknown;
|
||||
}
|
||||
|
||||
describe("executeZalouserTool", () => {
|
||||
beforeEach(() => {
|
||||
mockSendMessage.mockReset();
|
||||
mockSendImage.mockReset();
|
||||
mockSendLink.mockReset();
|
||||
mockCheckAuth.mockReset();
|
||||
mockGetUserInfo.mockReset();
|
||||
mockListFriends.mockReset();
|
||||
mockListGroups.mockReset();
|
||||
});
|
||||
|
||||
it("returns error when send action is missing required fields", async () => {
|
||||
const result = await executeZalouserTool("tool-1", { action: "send" });
|
||||
expect(extractDetails(result)).toEqual({
|
||||
error: "threadId and message required for send action",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends text message for send action", async () => {
|
||||
mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-1" });
|
||||
const result = await executeZalouserTool("tool-1", {
|
||||
action: "send",
|
||||
threadId: "t-1",
|
||||
message: "hello",
|
||||
profile: "work",
|
||||
isGroup: true,
|
||||
});
|
||||
expect(mockSendMessage).toHaveBeenCalledWith("t-1", "hello", {
|
||||
profile: "work",
|
||||
isGroup: true,
|
||||
});
|
||||
expect(extractDetails(result)).toEqual({ success: true, messageId: "m-1" });
|
||||
});
|
||||
|
||||
it("defaults send routing from ambient deliveryContext target", async () => {
|
||||
mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-ambient" });
|
||||
const tool = createZalouserTool({
|
||||
deliveryContext: {
|
||||
channel: "zalouser",
|
||||
to: "zalouser:g-ambient",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("tool-1", {
|
||||
action: "send",
|
||||
message: "hello",
|
||||
});
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith("g-ambient", "hello", {
|
||||
profile: undefined,
|
||||
isGroup: true,
|
||||
});
|
||||
expect(extractDetails(result)).toEqual({ success: true, messageId: "m-ambient" });
|
||||
});
|
||||
|
||||
it("keeps explicit threadId over ambient delivery defaults", async () => {
|
||||
mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-explicit" });
|
||||
const tool = createZalouserTool({
|
||||
deliveryContext: {
|
||||
channel: "zalouser",
|
||||
to: "zalouser:g-ambient",
|
||||
},
|
||||
});
|
||||
|
||||
await tool.execute("tool-1", {
|
||||
action: "send",
|
||||
threadId: "u-explicit",
|
||||
message: "hello",
|
||||
isGroup: false,
|
||||
});
|
||||
|
||||
expect(mockSendMessage).toHaveBeenCalledWith("u-explicit", "hello", {
|
||||
profile: undefined,
|
||||
isGroup: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not route send actions from foreign ambient thread defaults", async () => {
|
||||
const tool = createZalouserTool({
|
||||
deliveryContext: {
|
||||
channel: "slack",
|
||||
to: "channel:C123",
|
||||
threadId: "1710000000.000100",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await tool.execute("tool-1", {
|
||||
action: "send",
|
||||
message: "hello",
|
||||
});
|
||||
|
||||
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||
expect(extractDetails(result)).toEqual({
|
||||
error: "threadId and message required for send action",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns tool error when send action fails", async () => {
|
||||
mockSendMessage.mockResolvedValueOnce({ ok: false, error: "blocked" });
|
||||
const result = await executeZalouserTool("tool-1", {
|
||||
action: "send",
|
||||
threadId: "t-1",
|
||||
message: "hello",
|
||||
});
|
||||
expect(extractDetails(result)).toEqual({ error: "blocked" });
|
||||
});
|
||||
|
||||
it("routes image and link actions to correct helpers", async () => {
|
||||
mockSendImage.mockResolvedValueOnce({ ok: true, messageId: "img-1" });
|
||||
const imageResult = await executeZalouserTool("tool-1", {
|
||||
action: "image",
|
||||
threadId: "g-1",
|
||||
url: "https://example.com/image.jpg",
|
||||
message: "caption",
|
||||
isGroup: true,
|
||||
});
|
||||
expect(mockSendImage).toHaveBeenCalledWith("g-1", "https://example.com/image.jpg", {
|
||||
profile: undefined,
|
||||
caption: "caption",
|
||||
isGroup: true,
|
||||
});
|
||||
expect(extractDetails(imageResult)).toEqual({ success: true, messageId: "img-1" });
|
||||
|
||||
mockSendLink.mockResolvedValueOnce({ ok: true, messageId: "lnk-1" });
|
||||
const linkResult = await executeZalouserTool("tool-1", {
|
||||
action: "link",
|
||||
threadId: "t-2",
|
||||
url: "https://openclaw.ai",
|
||||
message: "read this",
|
||||
});
|
||||
expect(mockSendLink).toHaveBeenCalledWith("t-2", "https://openclaw.ai", {
|
||||
profile: undefined,
|
||||
caption: "read this",
|
||||
isGroup: undefined,
|
||||
});
|
||||
expect(extractDetails(linkResult)).toEqual({ success: true, messageId: "lnk-1" });
|
||||
});
|
||||
|
||||
it("returns friends/groups lists", async () => {
|
||||
mockListFriends.mockResolvedValueOnce([{ userId: "1", displayName: "Alice" }]);
|
||||
mockListGroups.mockResolvedValueOnce([{ groupId: "2", name: "Work" }]);
|
||||
|
||||
const friends = await executeZalouserTool("tool-1", {
|
||||
action: "friends",
|
||||
profile: "work",
|
||||
query: "ali",
|
||||
});
|
||||
expect(mockListFriends).toHaveBeenCalledWith("work", "ali");
|
||||
expect(extractDetails(friends)).toEqual([{ userId: "1", displayName: "Alice" }]);
|
||||
|
||||
const groups = await executeZalouserTool("tool-1", {
|
||||
action: "groups",
|
||||
profile: "work",
|
||||
query: "wrk",
|
||||
});
|
||||
expect(mockListGroups).toHaveBeenCalledWith("work", "wrk");
|
||||
expect(extractDetails(groups)).toEqual([{ groupId: "2", name: "Work" }]);
|
||||
});
|
||||
|
||||
it("reports me + status actions", async () => {
|
||||
mockGetUserInfo.mockResolvedValueOnce({ userId: "7", displayName: "Me" });
|
||||
mockCheckAuth.mockResolvedValueOnce(true);
|
||||
|
||||
const me = await executeZalouserTool("tool-1", { action: "me", profile: "work" });
|
||||
expect(mockGetUserInfo).toHaveBeenCalledWith("work");
|
||||
expect(extractDetails(me)).toEqual({ userId: "7", displayName: "Me" });
|
||||
|
||||
const status = await executeZalouserTool("tool-1", { action: "status", profile: "work" });
|
||||
expect(mockCheckAuth).toHaveBeenCalledWith("work");
|
||||
expect(extractDetails(status)).toEqual({
|
||||
authenticated: true,
|
||||
output: "authenticated",
|
||||
});
|
||||
});
|
||||
});
|
||||
210
openclaw/extensions/zalouser/src/tool.ts
Normal file
210
openclaw/extensions/zalouser/src/tool.ts
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import type { AnyAgentTool, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
|
||||
import { parseZalouserOutboundTarget } from "./session-route.js";
|
||||
import {
|
||||
checkZaloAuthenticated,
|
||||
getZaloUserInfo,
|
||||
listZaloFriendsMatching,
|
||||
listZaloGroupsMatching,
|
||||
} from "./zalo-js.js";
|
||||
|
||||
const ACTIONS = ["send", "image", "link", "friends", "groups", "me", "status"] as const;
|
||||
|
||||
type AgentToolResult = {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
details: unknown;
|
||||
};
|
||||
|
||||
function stringEnum<T extends readonly string[]>(
|
||||
values: T,
|
||||
options: { description?: string } = {},
|
||||
) {
|
||||
return Type.Unsafe<T[number]>({
|
||||
type: "string",
|
||||
enum: [...values],
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
export const ZalouserToolSchema = Type.Object(
|
||||
{
|
||||
action: stringEnum(ACTIONS, { description: `Action to perform: ${ACTIONS.join(", ")}` }),
|
||||
threadId: Type.Optional(Type.String({ description: "Thread ID for messaging" })),
|
||||
message: Type.Optional(Type.String({ description: "Message text" })),
|
||||
isGroup: Type.Optional(Type.Boolean({ description: "Is group chat" })),
|
||||
profile: Type.Optional(Type.String({ description: "Profile name" })),
|
||||
query: Type.Optional(Type.String({ description: "Search query" })),
|
||||
url: Type.Optional(Type.String({ description: "URL for media/link" })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
type ToolParams = {
|
||||
action: (typeof ACTIONS)[number];
|
||||
threadId?: string;
|
||||
message?: string;
|
||||
isGroup?: boolean;
|
||||
profile?: string;
|
||||
query?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type ZalouserToolContext = Pick<OpenClawPluginToolContext, "deliveryContext">;
|
||||
|
||||
function json(payload: unknown): AgentToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
||||
details: payload,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAmbientZalouserTarget(context?: ZalouserToolContext): {
|
||||
threadId?: string;
|
||||
isGroup?: boolean;
|
||||
} {
|
||||
const deliveryContext = context?.deliveryContext;
|
||||
const rawTarget = deliveryContext?.to;
|
||||
if (
|
||||
(deliveryContext?.channel === undefined || deliveryContext.channel === "zalouser") &&
|
||||
typeof rawTarget === "string" &&
|
||||
rawTarget.trim()
|
||||
) {
|
||||
try {
|
||||
return parseZalouserOutboundTarget(rawTarget);
|
||||
} catch {
|
||||
// Ignore unrelated delivery targets; explicit tool params still win.
|
||||
}
|
||||
}
|
||||
if (deliveryContext?.channel && deliveryContext.channel !== "zalouser") {
|
||||
return {};
|
||||
}
|
||||
const ambientThreadId = deliveryContext?.threadId;
|
||||
if (typeof ambientThreadId === "string" && ambientThreadId.trim()) {
|
||||
return { threadId: ambientThreadId.trim() };
|
||||
}
|
||||
if (typeof ambientThreadId === "number" && Number.isFinite(ambientThreadId)) {
|
||||
return { threadId: String(ambientThreadId) };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function resolveZalouserSendTarget(params: ToolParams, context?: ZalouserToolContext) {
|
||||
const explicitThreadId = typeof params.threadId === "string" ? params.threadId.trim() : "";
|
||||
const ambientTarget = resolveAmbientZalouserTarget(context);
|
||||
return {
|
||||
threadId: explicitThreadId || ambientTarget.threadId,
|
||||
isGroup: typeof params.isGroup === "boolean" ? params.isGroup : ambientTarget.isGroup,
|
||||
};
|
||||
}
|
||||
|
||||
export async function executeZalouserTool(
|
||||
_toolCallId: string,
|
||||
params: ToolParams,
|
||||
_signal?: AbortSignal,
|
||||
_onUpdate?: unknown,
|
||||
context?: ZalouserToolContext,
|
||||
): Promise<AgentToolResult> {
|
||||
try {
|
||||
switch (params.action) {
|
||||
case "send": {
|
||||
const target = resolveZalouserSendTarget(params, context);
|
||||
if (!target.threadId || !params.message) {
|
||||
throw new Error("threadId and message required for send action");
|
||||
}
|
||||
const result = await sendMessageZalouser(target.threadId, params.message, {
|
||||
profile: params.profile,
|
||||
isGroup: target.isGroup,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to send message");
|
||||
}
|
||||
return json({ success: true, messageId: result.messageId });
|
||||
}
|
||||
|
||||
case "image": {
|
||||
const target = resolveZalouserSendTarget(params, context);
|
||||
if (!target.threadId) {
|
||||
throw new Error("threadId required for image action");
|
||||
}
|
||||
if (!params.url) {
|
||||
throw new Error("url required for image action");
|
||||
}
|
||||
const result = await sendImageZalouser(target.threadId, params.url, {
|
||||
profile: params.profile,
|
||||
caption: params.message,
|
||||
isGroup: target.isGroup,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to send image");
|
||||
}
|
||||
return json({ success: true, messageId: result.messageId });
|
||||
}
|
||||
|
||||
case "link": {
|
||||
const target = resolveZalouserSendTarget(params, context);
|
||||
if (!target.threadId || !params.url) {
|
||||
throw new Error("threadId and url required for link action");
|
||||
}
|
||||
const result = await sendLinkZalouser(target.threadId, params.url, {
|
||||
profile: params.profile,
|
||||
caption: params.message,
|
||||
isGroup: target.isGroup,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.error || "Failed to send link");
|
||||
}
|
||||
return json({ success: true, messageId: result.messageId });
|
||||
}
|
||||
|
||||
case "friends": {
|
||||
const rows = await listZaloFriendsMatching(params.profile, params.query);
|
||||
return json(rows);
|
||||
}
|
||||
|
||||
case "groups": {
|
||||
const rows = await listZaloGroupsMatching(params.profile, params.query);
|
||||
return json(rows);
|
||||
}
|
||||
|
||||
case "me": {
|
||||
const info = await getZaloUserInfo(params.profile);
|
||||
return json(info ?? { error: "Not authenticated" });
|
||||
}
|
||||
|
||||
case "status": {
|
||||
const authenticated = await checkZaloAuthenticated(params.profile);
|
||||
return json({
|
||||
authenticated,
|
||||
output: authenticated ? "authenticated" : "not authenticated",
|
||||
});
|
||||
}
|
||||
|
||||
default: {
|
||||
params.action satisfies never;
|
||||
throw new Error(
|
||||
`Unknown action: ${String(params.action)}. Valid actions: send, image, link, friends, groups, me, status`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return json({
|
||||
error: formatErrorMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createZalouserTool(context?: ZalouserToolContext): AnyAgentTool {
|
||||
return {
|
||||
name: "zalouser",
|
||||
label: "Zalo Personal",
|
||||
description:
|
||||
"Send messages and access data via Zalo personal account. " +
|
||||
"Actions: send (text message), image (send image URL), link (send link), " +
|
||||
"friends (list/search friends), groups (list groups), me (profile info), status (auth check).",
|
||||
parameters: ZalouserToolSchema,
|
||||
execute: async (toolCallId, params, signal, onUpdate) =>
|
||||
await executeZalouserTool(toolCallId, params as ToolParams, signal, onUpdate, context),
|
||||
} satisfies AnyAgentTool;
|
||||
}
|
||||
125
openclaw/extensions/zalouser/src/types.ts
Normal file
125
openclaw/extensions/zalouser/src/types.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import type { Style } from "./zca-constants.js";
|
||||
|
||||
export type ZcaFriend = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type ZaloGroup = {
|
||||
groupId: string;
|
||||
name: string;
|
||||
memberCount?: number;
|
||||
};
|
||||
|
||||
export type ZaloGroupMember = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type ZaloEventMessage = {
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
uidFrom: string;
|
||||
idTo: string;
|
||||
msgType: string;
|
||||
st: number;
|
||||
at: number;
|
||||
cmd: number;
|
||||
ts: string | number;
|
||||
};
|
||||
|
||||
export type ZaloInboundMessage = {
|
||||
threadId: string;
|
||||
isGroup: boolean;
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
groupName?: string;
|
||||
content: string;
|
||||
commandContent?: string;
|
||||
timestampMs: number;
|
||||
msgId?: string;
|
||||
cliMsgId?: string;
|
||||
hasAnyMention?: boolean;
|
||||
wasExplicitlyMentioned?: boolean;
|
||||
canResolveExplicitMention?: boolean;
|
||||
implicitMention?: boolean;
|
||||
eventMessage?: ZaloEventMessage;
|
||||
raw: unknown;
|
||||
};
|
||||
|
||||
export type ZcaUserInfo = {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type ZaloSendOptions = {
|
||||
profile?: string;
|
||||
mediaUrl?: string;
|
||||
caption?: string;
|
||||
isGroup?: boolean;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
textMode?: "markdown" | "plain";
|
||||
textChunkMode?: "length" | "newline";
|
||||
textChunkLimit?: number;
|
||||
textStyles?: Style[];
|
||||
};
|
||||
|
||||
export type ZaloSendResult = {
|
||||
ok: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ZaloGroupContext = {
|
||||
groupId: string;
|
||||
name?: string;
|
||||
members?: string[];
|
||||
};
|
||||
|
||||
export type ZaloAuthStatus = {
|
||||
connected: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ZalouserToolConfig = { allow?: string[]; deny?: string[] };
|
||||
|
||||
export type ZalouserGroupConfig = {
|
||||
enabled?: boolean;
|
||||
requireMention?: boolean;
|
||||
tools?: ZalouserToolConfig;
|
||||
};
|
||||
|
||||
type ZalouserSharedConfig = {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
profile?: string;
|
||||
dangerouslyAllowNameMatching?: boolean;
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
historyLimit?: number;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
groups?: Record<string, ZalouserGroupConfig>;
|
||||
messagePrefix?: string;
|
||||
responsePrefix?: string;
|
||||
};
|
||||
|
||||
export type ZalouserAccountConfig = ZalouserSharedConfig;
|
||||
|
||||
export type ZalouserConfig = ZalouserSharedConfig & {
|
||||
defaultAccount?: string;
|
||||
accounts?: Record<string, ZalouserAccountConfig>;
|
||||
};
|
||||
|
||||
export type ResolvedZalouserAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
profile: string;
|
||||
authenticated: boolean;
|
||||
config: ZalouserAccountConfig;
|
||||
};
|
||||
89
openclaw/extensions/zalouser/src/zalo-js.test-mocks.ts
Normal file
89
openclaw/extensions/zalouser/src/zalo-js.test-mocks.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { vi, type Mock } from "vitest";
|
||||
|
||||
type ZaloJsModule = typeof import("./zalo-js.js");
|
||||
type ZaloJsMocks = {
|
||||
checkZaloAuthenticatedMock: Mock<ZaloJsModule["checkZaloAuthenticated"]>;
|
||||
getZaloUserInfoMock: Mock<ZaloJsModule["getZaloUserInfo"]>;
|
||||
listZaloFriendsMock: Mock<ZaloJsModule["listZaloFriends"]>;
|
||||
listZaloFriendsMatchingMock: Mock<ZaloJsModule["listZaloFriendsMatching"]>;
|
||||
listZaloGroupMembersMock: Mock<ZaloJsModule["listZaloGroupMembers"]>;
|
||||
listZaloGroupsMock: Mock<ZaloJsModule["listZaloGroups"]>;
|
||||
listZaloGroupsMatchingMock: Mock<ZaloJsModule["listZaloGroupsMatching"]>;
|
||||
logoutZaloProfileMock: Mock<ZaloJsModule["logoutZaloProfile"]>;
|
||||
resolveZaloAllowFromEntriesMock: Mock<ZaloJsModule["resolveZaloAllowFromEntries"]>;
|
||||
resolveZaloGroupContextMock: Mock<ZaloJsModule["resolveZaloGroupContext"]>;
|
||||
resolveZaloGroupsByEntriesMock: Mock<ZaloJsModule["resolveZaloGroupsByEntries"]>;
|
||||
startZaloListenerMock: Mock<ZaloJsModule["startZaloListener"]>;
|
||||
startZaloQrLoginMock: Mock<ZaloJsModule["startZaloQrLogin"]>;
|
||||
waitForZaloQrLoginMock: Mock<ZaloJsModule["waitForZaloQrLogin"]>;
|
||||
};
|
||||
|
||||
const zaloJsMocks = vi.hoisted(
|
||||
(): ZaloJsMocks => ({
|
||||
checkZaloAuthenticatedMock: vi.fn(async () => false),
|
||||
getZaloUserInfoMock: vi.fn(async () => null),
|
||||
listZaloFriendsMock: vi.fn(async () => []),
|
||||
listZaloFriendsMatchingMock: vi.fn(async () => []),
|
||||
listZaloGroupMembersMock: vi.fn(async () => []),
|
||||
listZaloGroupsMock: vi.fn(async () => []),
|
||||
listZaloGroupsMatchingMock: vi.fn(async () => []),
|
||||
logoutZaloProfileMock: vi.fn(async () => ({
|
||||
cleared: true,
|
||||
loggedOut: true,
|
||||
message: "Logged out and cleared local session.",
|
||||
})),
|
||||
resolveZaloAllowFromEntriesMock: vi.fn(async ({ entries }: { entries: string[] }) =>
|
||||
entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })),
|
||||
),
|
||||
resolveZaloGroupContextMock: vi.fn(async (_profile, groupId) => ({
|
||||
groupId,
|
||||
name: undefined,
|
||||
members: [],
|
||||
})),
|
||||
resolveZaloGroupsByEntriesMock: vi.fn(async ({ entries }: { entries: string[] }) =>
|
||||
entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })),
|
||||
),
|
||||
startZaloListenerMock: vi.fn(async () => ({ stop: vi.fn() })),
|
||||
startZaloQrLoginMock: vi.fn(async () => ({
|
||||
message: "qr pending",
|
||||
qrDataUrl: undefined,
|
||||
})),
|
||||
waitForZaloQrLoginMock: vi.fn(async () => ({
|
||||
connected: false,
|
||||
message: "login pending",
|
||||
})),
|
||||
}),
|
||||
);
|
||||
|
||||
export const checkZaloAuthenticatedMock = zaloJsMocks.checkZaloAuthenticatedMock;
|
||||
export const getZaloUserInfoMock = zaloJsMocks.getZaloUserInfoMock;
|
||||
export const listZaloFriendsMock = zaloJsMocks.listZaloFriendsMock;
|
||||
export const listZaloFriendsMatchingMock = zaloJsMocks.listZaloFriendsMatchingMock;
|
||||
export const listZaloGroupMembersMock = zaloJsMocks.listZaloGroupMembersMock;
|
||||
export const listZaloGroupsMock = zaloJsMocks.listZaloGroupsMock;
|
||||
export const listZaloGroupsMatchingMock = zaloJsMocks.listZaloGroupsMatchingMock;
|
||||
export const logoutZaloProfileMock = zaloJsMocks.logoutZaloProfileMock;
|
||||
export const resolveZaloAllowFromEntriesMock = zaloJsMocks.resolveZaloAllowFromEntriesMock;
|
||||
export const resolveZaloGroupContextMock = zaloJsMocks.resolveZaloGroupContextMock;
|
||||
export const resolveZaloGroupsByEntriesMock = zaloJsMocks.resolveZaloGroupsByEntriesMock;
|
||||
export const startZaloListenerMock: Mock<ZaloJsModule["startZaloListener"]> =
|
||||
zaloJsMocks.startZaloListenerMock;
|
||||
export const startZaloQrLoginMock = zaloJsMocks.startZaloQrLoginMock;
|
||||
export const waitForZaloQrLoginMock = zaloJsMocks.waitForZaloQrLoginMock;
|
||||
|
||||
vi.mock("./zalo-js.js", () => ({
|
||||
checkZaloAuthenticated: checkZaloAuthenticatedMock,
|
||||
getZaloUserInfo: getZaloUserInfoMock,
|
||||
listZaloFriends: listZaloFriendsMock,
|
||||
listZaloFriendsMatching: listZaloFriendsMatchingMock,
|
||||
listZaloGroupMembers: listZaloGroupMembersMock,
|
||||
listZaloGroups: listZaloGroupsMock,
|
||||
listZaloGroupsMatching: listZaloGroupsMatchingMock,
|
||||
logoutZaloProfile: logoutZaloProfileMock,
|
||||
resolveZaloAllowFromEntries: resolveZaloAllowFromEntriesMock,
|
||||
resolveZaloGroupContext: resolveZaloGroupContextMock,
|
||||
resolveZaloGroupsByEntries: resolveZaloGroupsByEntriesMock,
|
||||
startZaloListener: startZaloListenerMock,
|
||||
startZaloQrLogin: startZaloQrLoginMock,
|
||||
waitForZaloQrLogin: waitForZaloQrLoginMock,
|
||||
}));
|
||||
1708
openclaw/extensions/zalouser/src/zalo-js.ts
Normal file
1708
openclaw/extensions/zalouser/src/zalo-js.ts
Normal file
File diff suppressed because it is too large
Load diff
24
openclaw/extensions/zalouser/src/zca-client.test.ts
Normal file
24
openclaw/extensions/zalouser/src/zca-client.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("zca-client runtime loading", () => {
|
||||
it("does not import zca-js until a session is created", async () => {
|
||||
vi.clearAllMocks();
|
||||
const runtimeFactory = vi.fn(() => ({
|
||||
Zalo: class MockZalo {
|
||||
constructor(public readonly options?: { logging?: boolean; selfListen?: boolean }) {}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock("zca-js", runtimeFactory);
|
||||
|
||||
const zcaClient = await import("./zca-client.js");
|
||||
expect(runtimeFactory).not.toHaveBeenCalled();
|
||||
|
||||
const client = await zcaClient.createZalo({ logging: false, selfListen: true });
|
||||
|
||||
expect(runtimeFactory).toHaveBeenCalledTimes(1);
|
||||
expect(client).toMatchObject({
|
||||
options: { logging: false, selfListen: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
259
openclaw/extensions/zalouser/src/zca-client.ts
Normal file
259
openclaw/extensions/zalouser/src/zca-client.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import {
|
||||
LoginQRCallbackEventType,
|
||||
Reactions,
|
||||
TextStyle,
|
||||
ThreadType,
|
||||
type Style,
|
||||
} from "./zca-constants.js";
|
||||
|
||||
type ZcaJsRuntime = {
|
||||
Zalo: unknown;
|
||||
};
|
||||
let zcaJsRuntimePromise: Promise<ZcaJsRuntime> | null = null;
|
||||
|
||||
async function loadZcaJsRuntime(): Promise<ZcaJsRuntime> {
|
||||
// Keep zca-js behind a runtime boundary so bundled metadata/contracts can load
|
||||
// without resolving its optional WebSocket dependency tree.
|
||||
zcaJsRuntimePromise ??= import("zca-js").then((mod) => mod as unknown as ZcaJsRuntime);
|
||||
return await zcaJsRuntimePromise;
|
||||
}
|
||||
|
||||
export { LoginQRCallbackEventType, Reactions, TextStyle, ThreadType };
|
||||
export type { Style };
|
||||
|
||||
export type Credentials = {
|
||||
imei: string;
|
||||
cookie: unknown;
|
||||
userAgent: string;
|
||||
language?: string;
|
||||
};
|
||||
|
||||
export type User = {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
zaloName: string;
|
||||
avatar: string;
|
||||
};
|
||||
|
||||
export type GroupInfo = {
|
||||
groupId: string;
|
||||
name: string;
|
||||
totalMember?: number;
|
||||
memberIds?: unknown[];
|
||||
currentMems?: Array<{
|
||||
id?: unknown;
|
||||
dName?: string;
|
||||
zaloName?: string;
|
||||
avatar?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type Message = {
|
||||
type: number;
|
||||
threadId: string;
|
||||
isSelf: boolean;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type LoginQRCallbackEvent =
|
||||
| {
|
||||
type: 0;
|
||||
data: {
|
||||
code: string;
|
||||
image: string;
|
||||
};
|
||||
actions: {
|
||||
saveToFile: (qrPath?: string) => Promise<unknown>;
|
||||
retry: () => unknown;
|
||||
abort: () => unknown;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 1;
|
||||
data: null;
|
||||
actions: {
|
||||
retry: () => unknown;
|
||||
abort: () => unknown;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 2;
|
||||
data: {
|
||||
avatar: string;
|
||||
display_name: string;
|
||||
};
|
||||
actions: {
|
||||
retry: () => unknown;
|
||||
abort: () => unknown;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 3;
|
||||
data: {
|
||||
code: string;
|
||||
};
|
||||
actions: {
|
||||
retry: () => unknown;
|
||||
abort: () => unknown;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 4;
|
||||
data: {
|
||||
cookie: unknown;
|
||||
imei: string;
|
||||
userAgent: string;
|
||||
};
|
||||
actions: null;
|
||||
};
|
||||
|
||||
export type Listener = {
|
||||
on(event: "message", callback: (message: Message) => void): void;
|
||||
on(event: "error", callback: (error: unknown) => void): void;
|
||||
on(event: "closed", callback: (code: number, reason: string) => void): void;
|
||||
off(event: "message", callback: (message: Message) => void): void;
|
||||
off(event: "error", callback: (error: unknown) => void): void;
|
||||
off(event: "closed", callback: (code: number, reason: string) => void): void;
|
||||
start(opts?: { retryOnClose?: boolean }): void;
|
||||
stop(): void;
|
||||
};
|
||||
|
||||
type DeliveryEventMessage = {
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
uidFrom: string;
|
||||
idTo: string;
|
||||
msgType: string;
|
||||
st: number;
|
||||
at: number;
|
||||
cmd: number;
|
||||
ts: string | number;
|
||||
};
|
||||
|
||||
type DeliveryEventMessages = DeliveryEventMessage | DeliveryEventMessage[];
|
||||
|
||||
export type API = {
|
||||
listener: Listener;
|
||||
getContext(): {
|
||||
imei: string;
|
||||
userAgent: string;
|
||||
language?: string;
|
||||
};
|
||||
getCookie(): {
|
||||
toJSON(): {
|
||||
cookies: unknown[];
|
||||
};
|
||||
};
|
||||
fetchAccountInfo(): Promise<User | { profile: User }>;
|
||||
getAllFriends(): Promise<User[]>;
|
||||
getOwnId(): string;
|
||||
getAllGroups(): Promise<{
|
||||
gridVerMap: Record<string, string>;
|
||||
}>;
|
||||
getGroupInfo(groupId: string | string[]): Promise<{
|
||||
gridInfoMap: Record<string, GroupInfo & { memVerList?: unknown }>;
|
||||
}>;
|
||||
getGroupMembersInfo(memberId: string | string[]): Promise<{
|
||||
profiles: Record<
|
||||
string,
|
||||
{
|
||||
id?: string;
|
||||
displayName?: string;
|
||||
zaloName?: string;
|
||||
avatar?: string;
|
||||
}
|
||||
>;
|
||||
}>;
|
||||
sendMessage(
|
||||
message: string | Record<string, unknown>,
|
||||
threadId: string,
|
||||
type?: number,
|
||||
): Promise<{
|
||||
msgId?: string | number;
|
||||
message?: { msgId?: string | number } | null;
|
||||
attachment?: Array<{ msgId?: string | number }>;
|
||||
}>;
|
||||
uploadAttachment(
|
||||
sources:
|
||||
| string
|
||||
| {
|
||||
data: Buffer;
|
||||
filename: `${string}.${string}`;
|
||||
metadata: {
|
||||
totalSize: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
}
|
||||
| Array<
|
||||
| string
|
||||
| {
|
||||
data: Buffer;
|
||||
filename: `${string}.${string}`;
|
||||
metadata: {
|
||||
totalSize: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
}
|
||||
>,
|
||||
threadId: string,
|
||||
type?: number,
|
||||
): Promise<
|
||||
Array<{
|
||||
fileType: "image" | "video" | "others";
|
||||
fileUrl?: string;
|
||||
msgId?: string | number;
|
||||
fileId?: string;
|
||||
fileName?: string;
|
||||
}>
|
||||
>;
|
||||
sendVoice(
|
||||
options: {
|
||||
voiceUrl: string;
|
||||
ttl?: number;
|
||||
},
|
||||
threadId: string,
|
||||
type?: number,
|
||||
): Promise<{ msgId?: string | number }>;
|
||||
sendLink(
|
||||
payload: { link: string; msg?: string },
|
||||
threadId: string,
|
||||
type?: number,
|
||||
): Promise<{ msgId?: string | number }>;
|
||||
sendTypingEvent(threadId: string, type?: number, destType?: number): Promise<{ status: number }>;
|
||||
addReaction(
|
||||
icon: string | { rType: number; source: number; icon: string },
|
||||
dest: {
|
||||
data: {
|
||||
msgId: string;
|
||||
cliMsgId: string;
|
||||
};
|
||||
threadId: string;
|
||||
type: number;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
sendDeliveredEvent(
|
||||
isSeen: boolean,
|
||||
messages: DeliveryEventMessages,
|
||||
type?: number,
|
||||
): Promise<unknown>;
|
||||
sendSeenEvent(messages: DeliveryEventMessages, type?: number): Promise<unknown>;
|
||||
};
|
||||
|
||||
type ZaloCtor = new (options?: { logging?: boolean; selfListen?: boolean }) => {
|
||||
login(credentials: Credentials): Promise<API>;
|
||||
loginQR(
|
||||
options?: { userAgent?: string; language?: string; qrPath?: string },
|
||||
callback?: (event: LoginQRCallbackEvent) => unknown,
|
||||
): Promise<API>;
|
||||
};
|
||||
|
||||
export async function createZalo(
|
||||
options?: ConstructorParameters<ZaloCtor>[0],
|
||||
): Promise<InstanceType<ZaloCtor>> {
|
||||
const zcaJs = await loadZcaJsRuntime();
|
||||
const Zalo = zcaJs.Zalo as ZaloCtor;
|
||||
return new Zalo(options);
|
||||
}
|
||||
55
openclaw/extensions/zalouser/src/zca-constants.ts
Normal file
55
openclaw/extensions/zalouser/src/zca-constants.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
export const ThreadType = {
|
||||
User: 0,
|
||||
Group: 1,
|
||||
} as const;
|
||||
|
||||
export const LoginQRCallbackEventType = {
|
||||
QRCodeGenerated: 0,
|
||||
QRCodeExpired: 1,
|
||||
QRCodeScanned: 2,
|
||||
QRCodeDeclined: 3,
|
||||
GotLoginInfo: 4,
|
||||
} as const;
|
||||
|
||||
export const Reactions = {
|
||||
HEART: "/-heart",
|
||||
LIKE: "/-strong",
|
||||
HAHA: ":>",
|
||||
WOW: ":o",
|
||||
CRY: ":-((",
|
||||
ANGRY: ":-h",
|
||||
NONE: "",
|
||||
} as const;
|
||||
|
||||
// Mirror zca-js sendMessage style constants locally because the package root
|
||||
// typing surface does not consistently expose TextStyle/Style to tsgo.
|
||||
export const TextStyle = {
|
||||
Bold: "b",
|
||||
Italic: "i",
|
||||
Underline: "u",
|
||||
StrikeThrough: "s",
|
||||
Red: "c_db342e",
|
||||
Orange: "c_f27806",
|
||||
Yellow: "c_f7b503",
|
||||
Green: "c_15a85f",
|
||||
Small: "f_13",
|
||||
Big: "f_18",
|
||||
UnorderedList: "lst_1",
|
||||
OrderedList: "lst_2",
|
||||
Indent: "ind_$",
|
||||
} as const;
|
||||
|
||||
type TextStyleValue = (typeof TextStyle)[keyof typeof TextStyle];
|
||||
|
||||
export type Style =
|
||||
| {
|
||||
start: number;
|
||||
len: number;
|
||||
st: Exclude<TextStyleValue, typeof TextStyle.Indent>;
|
||||
}
|
||||
| {
|
||||
start: number;
|
||||
len: number;
|
||||
st: typeof TextStyle.Indent;
|
||||
indentSize?: number;
|
||||
};
|
||||
22
openclaw/extensions/zalouser/src/zca-js-exports.d.ts
vendored
Normal file
22
openclaw/extensions/zalouser/src/zca-js-exports.d.ts
vendored
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
declare module "zca-js" {
|
||||
export const ThreadType: {
|
||||
User: number;
|
||||
Group: number;
|
||||
};
|
||||
|
||||
export const LoginQRCallbackEventType: {
|
||||
QRCodeGenerated: number;
|
||||
QRCodeExpired: number;
|
||||
QRCodeScanned: number;
|
||||
QRCodeDeclined: number;
|
||||
GotLoginInfo: number;
|
||||
};
|
||||
|
||||
export const Reactions: Record<string, string>;
|
||||
|
||||
export class Zalo {
|
||||
constructor(options?: { logging?: boolean; selfListen?: boolean });
|
||||
login(credentials: unknown): Promise<unknown>;
|
||||
loginQR(options?: unknown, callback?: (event: unknown) => unknown): Promise<unknown>;
|
||||
}
|
||||
}
|
||||
21
openclaw/extensions/zalouser/test-api.ts
Normal file
21
openclaw/extensions/zalouser/test-api.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export { sendMessageZalouser } from "./src/send.js";
|
||||
export { parseZalouserOutboundTarget } from "./src/session-route.js";
|
||||
export {
|
||||
checkZcaAuthenticated,
|
||||
getZcaUserInfo,
|
||||
listZalouserAccountIds,
|
||||
resolveDefaultZalouserAccountId,
|
||||
resolveZalouserAccountSync,
|
||||
} from "./src/accounts.js";
|
||||
export {
|
||||
checkZaloAuthenticated,
|
||||
getZaloUserInfo,
|
||||
listZaloFriendsMatching,
|
||||
listZaloGroupMembers,
|
||||
listZaloGroupsMatching,
|
||||
logoutZaloProfile,
|
||||
resolveZaloAllowFromEntries,
|
||||
resolveZaloGroupsByEntries,
|
||||
startZaloQrLogin,
|
||||
waitForZaloQrLogin,
|
||||
} from "./src/zalo-js.js";
|
||||
16
openclaw/extensions/zalouser/tsconfig.json
Normal file
16
openclaw/extensions/zalouser/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue