重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View 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).

View 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";

View 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";

View file

@ -0,0 +1,2 @@
export { collectZalouserSecurityAuditFindings } from "./src/security-audit.js";
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/doctor-contract.js";

View file

@ -0,0 +1 @@
export { normalizeCompatibilityConfig, legacyConfigRules } from "./src/doctor-contract.js";

View 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" });
},
});

View file

@ -0,0 +1,12 @@
{
"id": "zalouser",
"channels": ["zalouser"],
"channelEnvVars": {
"zalouser": ["ZALOUSER_PROFILE", "ZCA_PROFILE"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View 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
}
}
}

View 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";

View file

@ -0,0 +1,4 @@
// Zalo User does not expose secret-contract surfaces.
export const secretTargetRegistryEntries: readonly [] = [];
export function collectRuntimeConfigAssignments(): void {}

View 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",
},
});

View 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";

View file

@ -0,0 +1 @@
export { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js";

View 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,
};
});

View 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();
});
});

View 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";

View 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";

View 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>",
},
};

View 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");
});
});

View 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";

View 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);
});
});

View 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 });
}
});
});

View 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,
}),
};

View 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,
});
});
});

View 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 };

View 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);

View 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;
}

View 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);
}

View 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).",
]),
);
});
});

View 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,
};

View 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);
});
});

View 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;
}

View 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");
});
});

View 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;
}

View 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();
});
});

View 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([]);
});
});

View 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,
}));

File diff suppressed because it is too large Load diff

View 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",
});
});
});

View 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),
};
}
}

View 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;
}

View 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");
});
});

View 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
);
}

View 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 };

View 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.",
},
];
}

View 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,
});
});
});

View 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;
}

View 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}`,
});
}

View 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,
});
}

View 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);
});
});

View 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,
};

View 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;

View 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,
};
}

View 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",
},
});
});
});

View 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;
}

View 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,
};
}

View 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: [],
});
});
});

View 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);
}

View 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",
});
});
});

View 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;
}

View 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;
};

View 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,
}));

File diff suppressed because it is too large Load diff

View 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 },
});
});
});

View 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);
}

View 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;
};

View 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>;
}
}

View 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";

View 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"
]
}