Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
// Zalouser plugin module implements accounts behavior.
export { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js";

View File

@@ -0,0 +1,15 @@
// Zalouser plugin module implements accounts mocks behavior.
import { vi } from "vitest";
import { createDefaultResolvedZalouserAccount } from "./test-helpers.js";
vi.mock("./accounts.js", () => {
return {
listZalouserAccountIds: () => ["default"],
resolveDefaultZalouserAccountId: () => "default",
resolveZalouserAccountSync: () => createDefaultResolvedZalouserAccount(),
resolveZalouserAccount: async () => createDefaultResolvedZalouserAccount(),
listEnabledZalouserAccounts: async () => [createDefaultResolvedZalouserAccount()],
getZcaUserInfo: async () => null,
checkZcaAuthenticated: async () => false,
};
});

View File

@@ -0,0 +1,299 @@
// Zalouser tests cover accounts plugin behavior.
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import {
getZcaUserInfo,
listEnabledZalouserAccounts,
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccount,
resolveZalouserAccountSync,
} from "./accounts.js";
import { checkZaloAuthenticated, getZaloUserInfo } from "./zalo-js.js";
vi.mock("./zalo-js.js", () => ({
checkZaloAuthenticated: vi.fn(),
getZaloUserInfo: vi.fn(),
}));
const mockCheckAuthenticated = vi.mocked(checkZaloAuthenticated);
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
const originalZalouserProfile = process.env.ZALOUSER_PROFILE;
const originalZcaProfile = process.env.ZCA_PROFILE;
function asConfig(value: unknown): OpenClawConfig {
return value as OpenClawConfig;
}
describe("zalouser account resolution", () => {
beforeEach(() => {
mockCheckAuthenticated.mockReset();
mockGetUserInfo.mockReset();
delete process.env.ZALOUSER_PROFILE;
delete process.env.ZCA_PROFILE;
});
afterEach(() => {
if (originalZalouserProfile === undefined) {
delete process.env.ZALOUSER_PROFILE;
} else {
process.env.ZALOUSER_PROFILE = originalZalouserProfile;
}
if (originalZcaProfile === undefined) {
delete process.env.ZCA_PROFILE;
} else {
process.env.ZCA_PROFILE = originalZcaProfile;
}
});
it("returns default account id when no accounts are configured", () => {
expect(listZalouserAccountIds(asConfig({}))).toEqual([DEFAULT_ACCOUNT_ID]);
});
it("returns sorted configured account ids", () => {
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
work: {},
personal: {},
default: {},
},
},
},
});
expect(listZalouserAccountIds(cfg)).toEqual(["default", "personal", "work"]);
});
it("preserves top-level default account when named accounts are configured", () => {
const cfg = asConfig({
channels: {
zalouser: {
profile: "personal",
accounts: {
work: { enabled: false },
},
},
},
});
expect(listZalouserAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveDefaultZalouserAccountId(cfg)).toBe("default");
expect(resolveZalouserAccountSync({ cfg }).profile).toBe("personal");
});
it("uses configured defaultAccount when present", () => {
const cfg = asConfig({
channels: {
zalouser: {
defaultAccount: "work",
accounts: {
default: {},
work: {},
},
},
},
});
expect(resolveDefaultZalouserAccountId(cfg)).toBe("work");
});
it("falls back to default account when configured defaultAccount is missing", () => {
const cfg = asConfig({
channels: {
zalouser: {
defaultAccount: "missing",
accounts: {
default: {},
work: {},
},
},
},
});
expect(resolveDefaultZalouserAccountId(cfg)).toBe("default");
});
it("falls back to first sorted configured account when default is absent", () => {
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
zzz: {},
aaa: {},
},
},
},
});
expect(resolveDefaultZalouserAccountId(cfg)).toBe("aaa");
});
it("resolves sync account by merging base + account config", () => {
const cfg = asConfig({
channels: {
zalouser: {
enabled: true,
dmPolicy: "pairing",
accounts: {
work: {
enabled: false,
name: "Work",
dmPolicy: "allowlist",
allowFrom: ["123"],
},
},
},
},
});
const resolved = resolveZalouserAccountSync({ cfg, accountId: "work" });
expect(resolved.accountId).toBe("work");
expect(resolved.enabled).toBe(false);
expect(resolved.name).toBe("Work");
expect(resolved.config.dmPolicy).toBe("allowlist");
expect(resolved.config.allowFrom).toEqual(["123"]);
});
it("uses configured defaultAccount when accountId is omitted", () => {
const cfg = asConfig({
channels: {
zalouser: {
defaultAccount: "work",
accounts: {
work: {
name: "Work",
profile: "work-profile",
},
},
},
},
});
const resolved = resolveZalouserAccountSync({ cfg });
expect(resolved.accountId).toBe("work");
expect(resolved.name).toBe("Work");
expect(resolved.profile).toBe("work-profile");
});
it("resolves account config when account key casing differs from normalized id", () => {
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
Work: {
name: "Work",
},
},
},
},
});
const resolved = resolveZalouserAccountSync({ cfg, accountId: "work" });
expect(resolved.accountId).toBe("work");
expect(resolved.name).toBe("Work");
});
it("defaults group policy to allowlist when unset", () => {
const cfg = asConfig({
channels: {
zalouser: {
enabled: true,
},
},
});
const resolved = resolveZalouserAccountSync({ cfg, accountId: "default" });
expect(resolved.config.groupPolicy).toBe("allowlist");
});
it("resolves profile precedence correctly", () => {
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
work: {},
},
},
},
});
process.env.ZALOUSER_PROFILE = "zalo-env";
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("zalo-env");
delete process.env.ZALOUSER_PROFILE;
process.env.ZCA_PROFILE = "zca-env";
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("zca-env");
delete process.env.ZCA_PROFILE;
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("work");
});
it("uses explicit profile from config over env fallback", () => {
process.env.ZALOUSER_PROFILE = "env-profile";
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
work: {
profile: "explicit-profile",
},
},
},
},
});
expect(resolveZalouserAccountSync({ cfg, accountId: "work" }).profile).toBe("explicit-profile");
});
it("checks authentication during async account resolution", async () => {
mockCheckAuthenticated.mockResolvedValueOnce(true);
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
default: {},
},
},
},
});
const resolved = await resolveZalouserAccount({ cfg, accountId: "default" });
expect(mockCheckAuthenticated).toHaveBeenCalledWith("default");
expect(resolved.authenticated).toBe(true);
});
it("filters disabled accounts when listing enabled accounts", async () => {
mockCheckAuthenticated.mockResolvedValue(true);
const cfg = asConfig({
channels: {
zalouser: {
accounts: {
default: { enabled: true },
work: { enabled: false },
},
},
},
});
const accounts = await listEnabledZalouserAccounts(cfg);
expect(accounts.map((account) => account.accountId)).toEqual(["default"]);
});
it("maps account info helper from zalo-js", async () => {
mockGetUserInfo.mockResolvedValueOnce({
userId: "123",
displayName: "Alice",
avatar: "https://example.com/avatar.png",
});
expect(await getZcaUserInfo("default")).toEqual({
userId: "123",
displayName: "Alice",
});
mockGetUserInfo.mockResolvedValueOnce(null);
expect(await getZcaUserInfo("default")).toBeNull();
});
});

View File

@@ -0,0 +1,133 @@
// Zalouser plugin module implements accounts behavior.
import {
createAccountListHelpers,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js";
const loadZalouserAccountsRuntime = createLazyRuntimeModule(() => import("./accounts.runtime.js"));
const {
listAccountIds: listZalouserAccountIds,
resolveDefaultAccountId: resolveDefaultZalouserAccountId,
} = createAccountListHelpers("zalouser", {
implicitDefaultAccount: {
channelKeys: ["profile"],
envVars: ["ZALOUSER_PROFILE", "ZCA_PROFILE"],
},
});
export { listZalouserAccountIds, resolveDefaultZalouserAccountId };
function mergeZalouserAccountConfig(cfg: OpenClawConfig, accountId: string): ZalouserAccountConfig {
const merged = resolveMergedAccountConfig<ZalouserAccountConfig>({
channelConfig: cfg.channels?.zalouser as ZalouserAccountConfig | undefined,
accounts: (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts as
| Record<string, Partial<ZalouserAccountConfig>>
| undefined,
accountId,
omitKeys: ["defaultAccount"],
});
return {
...merged,
// Match Telegram's safe default: groups stay allowlisted unless explicitly opened.
groupPolicy: merged.groupPolicy ?? "allowlist",
};
}
function resolveProfile(config: ZalouserAccountConfig, accountId: string): string {
if (config.profile?.trim()) {
return config.profile.trim();
}
if (process.env.ZALOUSER_PROFILE?.trim()) {
return process.env.ZALOUSER_PROFILE.trim();
}
if (process.env.ZCA_PROFILE?.trim()) {
return process.env.ZCA_PROFILE.trim();
}
if (accountId !== DEFAULT_ACCOUNT_ID) {
return accountId;
}
return "default";
}
function resolveZalouserAccountBase(params: { cfg: OpenClawConfig; accountId?: string | null }) {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultZalouserAccountId(params.cfg),
);
const baseEnabled =
(params.cfg.channels?.zalouser as ZalouserConfig | undefined)?.enabled !== false;
const merged = mergeZalouserAccountConfig(params.cfg, accountId);
return {
accountId,
enabled: baseEnabled && merged.enabled !== false,
merged,
profile: resolveProfile(merged, accountId),
};
}
export async function resolveZalouserAccount(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): Promise<ResolvedZalouserAccount> {
const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params);
const authenticated = await (await loadZalouserAccountsRuntime()).checkZaloAuthenticated(profile);
return {
accountId,
name: normalizeOptionalString(merged.name),
enabled,
profile,
authenticated,
config: merged,
};
}
export function resolveZalouserAccountSync(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): ResolvedZalouserAccount {
const { accountId, enabled, merged, profile } = resolveZalouserAccountBase(params);
return {
accountId,
name: normalizeOptionalString(merged.name),
enabled,
profile,
authenticated: false,
config: merged,
};
}
export async function listEnabledZalouserAccounts(
cfg: OpenClawConfig,
): Promise<ResolvedZalouserAccount[]> {
const ids = listZalouserAccountIds(cfg);
const accounts = await Promise.all(
ids.map((accountId) => resolveZalouserAccount({ cfg, accountId })),
);
return accounts.filter((account) => account.enabled);
}
export async function getZcaUserInfo(
profile: string,
): Promise<{ userId?: string; displayName?: string } | null> {
const info = await (await loadZalouserAccountsRuntime()).getZaloUserInfo(profile);
if (!info) {
return null;
}
return {
userId: info.userId,
displayName: info.displayName,
};
}
export async function checkZcaAuthenticated(profile: string): Promise<boolean> {
return await (await loadZalouserAccountsRuntime()).checkZaloAuthenticated(profile);
}
export type { ResolvedZalouserAccount } from "./types.js";

View File

@@ -0,0 +1,21 @@
// Zalouser API module exposes the plugin public contract.
export { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
export type {
ChannelDirectoryEntry,
ChannelGroupContext,
ChannelMessageActionAdapter,
} from "openclaw/plugin-sdk/channel-contract";
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
export type { ChannelPlugin } from "openclaw/plugin-sdk/core";
export {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
type OpenClawConfig,
} from "openclaw/plugin-sdk/core";
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-contracts";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
isNumericTargetId,
sendPayloadWithChunkedTextAndMedia,
} from "openclaw/plugin-sdk/reply-payload";

View File

@@ -0,0 +1,494 @@
// Zalouser plugin module implements channel.adapters behavior.
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import {
defineChannelMessageAdapter,
type ChannelMessageSendResult,
} from "openclaw/plugin-sdk/channel-outbound";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
createEmptyChannelResult,
type ChannelOutboundAdapter,
type OutboundDeliveryResult,
} from "openclaw/plugin-sdk/channel-send-result";
import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
checkZcaAuthenticated,
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
type ResolvedZalouserAccount,
} from "./accounts.js";
import type {
ChannelGroupContext,
ChannelMessageActionAdapter,
GroupToolPolicyConfig,
OpenClawConfig,
} from "./channel-api.js";
import {
DEFAULT_ACCOUNT_ID,
chunkTextForOutbound,
isDangerousNameMatchingEnabled,
isNumericTargetId,
normalizeAccountId,
sendPayloadWithChunkedTextAndMedia,
} from "./channel-api.js";
import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js";
import { resolveZalouserReactionMessageIds } from "./message-sid.js";
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
import { getZalouserRuntime } from "./runtime.js";
import {
normalizeZalouserTarget,
parseZalouserOutboundTarget,
resolveZalouserOutboundSessionRoute,
} from "./session-route.js";
import type { ZaloSendResult } from "./types.js";
const loadZalouserChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
const ZALOUSER_TEXT_CHUNK_LIMIT = 2000;
type ZalouserSendTextContext = {
to: string;
text: string;
accountId?: string | null;
cfg: OpenClawConfig;
onDeliveryResult?: (result: ChannelMessageSendResult) => Promise<void> | void;
};
type ZalouserSendMediaContext = ZalouserSendTextContext & {
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};
export function resolveZalouserQrProfile(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
return process.env.ZALOUSER_PROFILE?.trim() || process.env.ZCA_PROFILE?.trim() || "default";
}
return normalized;
}
function resolveZalouserOutboundChunkMode(cfg: OpenClawConfig, accountId?: string) {
return getZalouserRuntime().channel.text.resolveChunkMode(cfg, "zalouser", accountId);
}
function resolveZalouserOutboundTextChunkLimit(cfg: OpenClawConfig, accountId?: string) {
return getZalouserRuntime().channel.text.resolveTextChunkLimit(cfg, "zalouser", accountId, {
fallbackLimit: ZALOUSER_TEXT_CHUNK_LIMIT,
});
}
function toZalouserMessageSendResult(result: ZaloSendResult): ChannelMessageSendResult {
return {
messageId: result.messageId,
receipt: result.receipt,
};
}
function resolveZalouserGroupPolicyEntry(params: ChannelGroupContext) {
const account = resolveZalouserAccountSync({
cfg: params.cfg,
accountId: params.accountId ?? undefined,
});
const groups = account.config.groups ?? {};
return findZalouserGroupEntry(
groups,
buildZalouserGroupCandidates({
groupId: params.groupId,
groupChannel: params.groupChannel,
includeWildcard: true,
allowNameMatching: isDangerousNameMatchingEnabled(account.config),
}),
);
}
function resolveZalouserGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
return resolveZalouserGroupPolicyEntry(params)?.tools;
}
function resolveZalouserRequireMention(params: ChannelGroupContext): boolean {
const entry = resolveZalouserGroupPolicyEntry(params);
if (typeof entry?.requireMention === "boolean") {
return entry.requireMention;
}
return true;
}
async function sendZalouserTextFromContext({
to,
text,
accountId,
cfg,
onDeliveryResult,
}: ZalouserSendTextContext) {
const { sendMessageZalouser } = await loadZalouserChannelRuntime();
const account = resolveZalouserAccountSync({ cfg, accountId });
const target = parseZalouserOutboundTarget(to);
const result = await sendMessageZalouser(target.threadId, text, {
profile: account.profile,
isGroup: target.isGroup,
textMode: "markdown",
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
onDeliveryResult: async (progress) => {
await onDeliveryResult?.(toZalouserMessageSendResult(progress));
},
});
return toZalouserMessageSendResult(result);
}
async function sendZalouserMediaFromContext({
to,
text,
mediaUrl,
accountId,
cfg,
mediaLocalRoots,
mediaReadFile,
onDeliveryResult,
}: ZalouserSendMediaContext) {
const { sendMessageZalouser } = await loadZalouserChannelRuntime();
const account = resolveZalouserAccountSync({ cfg, accountId });
const target = parseZalouserOutboundTarget(to);
const result = await sendMessageZalouser(target.threadId, text, {
profile: account.profile,
isGroup: target.isGroup,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
textMode: "markdown",
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
onDeliveryResult: async (progress) => {
await onDeliveryResult?.(toZalouserMessageSendResult(progress));
},
});
return toZalouserMessageSendResult(result);
}
function adaptZalouserOutboundProgress(
onDeliveryResult: ((result: OutboundDeliveryResult) => Promise<void> | void) | undefined,
) {
return onDeliveryResult
? async (result: ChannelMessageSendResult) => {
await onDeliveryResult(toZalouserOutboundDeliveryResult(result));
}
: undefined;
}
function toZalouserOutboundDeliveryResult(
result: ChannelMessageSendResult,
): OutboundDeliveryResult {
return createEmptyChannelResult("zalouser", {
messageId:
result.messageId ??
result.receipt.primaryPlatformMessageId ??
result.receipt.platformMessageIds[0],
receipt: result.receipt,
});
}
const zalouserRawSendResultAdapter: Pick<ChannelOutboundAdapter, "sendText" | "sendMedia"> = {
sendText: async ({ onDeliveryResult, ...ctx }) =>
toZalouserOutboundDeliveryResult(
await sendZalouserTextFromContext({
...ctx,
onDeliveryResult: adaptZalouserOutboundProgress(onDeliveryResult),
}),
),
sendMedia: async ({ onDeliveryResult, ...ctx }) =>
toZalouserOutboundDeliveryResult(
await sendZalouserMediaFromContext({
...ctx,
onDeliveryResult: adaptZalouserOutboundProgress(onDeliveryResult),
}),
),
};
export const zalouserMessageAdapter = defineChannelMessageAdapter({
id: "zalouser",
durableFinal: {
capabilities: {
text: true,
media: true,
messageSendingHooks: true,
},
},
send: {
text: sendZalouserTextFromContext,
media: sendZalouserMediaFromContext,
},
});
const resolveZalouserDmPolicy = createScopedDmSecurityResolver<ResolvedZalouserAccount>({
channelKey: "zalouser",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => raw.trim().replace(/^(zalouser|zlu):/i, ""),
});
export const zalouserGroupsAdapter = {
resolveRequireMention: resolveZalouserRequireMention,
resolveToolPolicy: resolveZalouserGroupToolPolicy,
};
export const zalouserMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: ({ cfg, accountId }) => {
const accounts = accountId
? [resolveZalouserAccountSync({ cfg, accountId })].filter((account) => account.enabled)
: listZalouserAccountIds(cfg)
.map((resolvedAccountId) =>
resolveZalouserAccountSync({ cfg, accountId: resolvedAccountId }),
)
.filter((account) => account.enabled);
if (accounts.length === 0) {
return null;
}
return { actions: ["react"] };
},
supportsAction: ({ action }) => action === "react",
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
if (action !== "react") {
throw new Error(`Zalouser action ${action} not supported`);
}
const { sendReactionZalouser } = await loadZalouserChannelRuntime();
const account = resolveZalouserAccountSync({ cfg, accountId });
const threadId =
(typeof params.threadId === "string" ? params.threadId.trim() : "") ||
(typeof params.to === "string" ? params.to.trim() : "") ||
(typeof params.chatId === "string" ? params.chatId.trim() : "") ||
(toolContext?.currentChannelId?.trim() ?? "");
if (!threadId) {
throw new Error("Zalouser react requires threadId (or to/chatId).");
}
const emoji = typeof params.emoji === "string" ? params.emoji.trim() : "";
if (!emoji) {
throw new Error("Zalouser react requires emoji.");
}
const ids = resolveZalouserReactionMessageIds({
messageId: typeof params.messageId === "string" ? params.messageId : undefined,
cliMsgId: typeof params.cliMsgId === "string" ? params.cliMsgId : undefined,
currentMessageId: toolContext?.currentMessageId,
});
if (!ids) {
throw new Error(
"Zalouser react requires messageId + cliMsgId (or a current message context id).",
);
}
const result = await sendReactionZalouser({
profile: account.profile,
threadId,
isGroup: params.isGroup === true,
msgId: ids.msgId,
cliMsgId: ids.cliMsgId,
emoji,
remove: params.remove === true,
});
if (!result.ok) {
throw new Error(result.error || "Failed to react on Zalo message");
}
return {
content: [
{
type: "text" as const,
text:
params.remove === true
? `Removed reaction ${emoji} from ${ids.msgId}`
: `Reacted ${emoji} on ${ids.msgId}`,
},
],
details: {
messageId: ids.msgId,
cliMsgId: ids.cliMsgId,
threadId,
},
};
},
};
export const zalouserResolverAdapter = {
resolveTargets: async ({
cfg,
accountId,
inputs,
kind,
runtime,
}: {
cfg: OpenClawConfig;
accountId?: string | null;
inputs: string[];
kind: "user" | "group";
runtime: RuntimeEnv;
}) => {
const results = [];
for (const input of inputs) {
const trimmed = input.trim();
if (!trimmed) {
results.push({ input, resolved: false, note: "empty input" });
continue;
}
if (/^\d+$/.test(trimmed)) {
results.push({ input, resolved: true, id: trimmed });
continue;
}
try {
const runtimeModule = await loadZalouserChannelRuntime();
const account = resolveZalouserAccountSync({
cfg,
accountId: accountId ?? resolveDefaultZalouserAccountId(cfg),
});
if (kind === "user") {
const friends = await runtimeModule.listZaloFriendsMatching(account.profile, trimmed);
const best = friends[0];
results.push({
input,
resolved: Boolean(best?.userId),
id: best?.userId,
name: best?.displayName,
note: friends.length > 1 ? "multiple matches; chose first" : undefined,
});
} else {
const groups = await runtimeModule.listZaloGroupsMatching(account.profile, trimmed);
const best =
groups.find(
(group) =>
normalizeLowercaseStringOrEmpty(group.name) ===
normalizeLowercaseStringOrEmpty(trimmed),
) ?? groups[0];
results.push({
input,
resolved: Boolean(best?.groupId),
id: best?.groupId,
name: best?.name,
note: groups.length > 1 ? "multiple matches; chose first" : undefined,
});
}
} catch (err) {
runtime.error?.(`zalouser resolve failed: ${String(err)}`);
results.push({ input, resolved: false, note: "lookup failed" });
}
}
return results;
},
};
export const zalouserAuthAdapter = {
login: async ({
cfg,
accountId,
runtime,
}: {
cfg: OpenClawConfig;
accountId?: string | null;
runtime: RuntimeEnv;
}) => {
const { startZaloQrLogin, waitForZaloQrLogin } = await loadZalouserChannelRuntime();
const account = resolveZalouserAccountSync({
cfg,
accountId: accountId ?? resolveDefaultZalouserAccountId(cfg),
});
runtime.log(
`Generating QR login for Zalo Personal (account: ${account.accountId}, profile: ${account.profile})...`,
);
const started = await startZaloQrLogin({
profile: account.profile,
timeoutMs: 35_000,
});
if (!started.qrDataUrl) {
throw new Error(started.message || "Failed to start QR login");
}
const qrPath = await writeQrDataUrlToTempFile(started.qrDataUrl, account.profile);
if (qrPath) {
runtime.log(`Scan QR image: ${qrPath}`);
} else {
runtime.log("QR generated but could not be written to a temp file.");
}
const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 180_000 });
if (!waited.connected) {
throw new Error(waited.message || "Zalouser login failed");
}
runtime.log(waited.message);
},
};
export const zalouserSecurityAdapter = {
resolveDmPolicy: resolveZalouserDmPolicy,
collectAuditFindings: async (params: {
accountId?: string | null;
account: ResolvedZalouserAccount;
orderedAccountIds: string[];
hasExplicitAccountPath: boolean;
}) => (await loadZalouserChannelRuntime()).collectZalouserSecurityAuditFindings(params),
};
export const zalouserThreadingAdapter = {
resolveReplyToMode: createStaticReplyToModeResolver("off"),
};
export const zalouserPairingTextAdapter = {
idLabel: "zalouserUserId",
message: "Your pairing request has been approved.",
normalizeAllowEntry: createPairingPrefixStripper(/^(zalouser|zlu):/i),
notify: async ({ cfg, id, message }: { cfg: OpenClawConfig; id: string; message: string }) => {
const { sendMessageZalouser } = await loadZalouserChannelRuntime();
const account = resolveZalouserAccountSync({ cfg });
const authenticated = await checkZcaAuthenticated(account.profile);
if (!authenticated) {
throw new Error("Zalouser not authenticated");
}
await sendMessageZalouser(id, message, {
profile: account.profile,
});
},
};
export const zalouserOutboundAdapter = {
deliveryMode: "direct" as const,
chunker: chunkTextForOutbound,
chunkerMode: "markdown" as const,
sendPayload: async (
ctx: { payload: object } & Parameters<
NonNullable<typeof zalouserRawSendResultAdapter.sendText>
>[0],
) =>
await sendPayloadWithChunkedTextAndMedia({
ctx,
sendText: (nextCtx) => zalouserRawSendResultAdapter.sendText!(nextCtx),
sendMedia: (nextCtx) => zalouserRawSendResultAdapter.sendMedia!(nextCtx),
emptyResult: createEmptyChannelResult("zalouser"),
}),
...zalouserRawSendResultAdapter,
};
export const zalouserMessagingAdapter = {
targetPrefixes: ["zalouser", "zlu"],
normalizeTarget: (raw: string) => normalizeZalouserTarget(raw),
resolveOutboundSessionRoute: (
params: Parameters<typeof resolveZalouserOutboundSessionRoute>[0],
) => resolveZalouserOutboundSessionRoute(params),
targetResolver: {
looksLikeId: (raw: string) => {
const normalized = normalizeZalouserTarget(raw);
if (!normalized) {
return false;
}
if (/^group:[^\s]+$/i.test(normalized) || /^user:[^\s]+$/i.test(normalized)) {
return true;
}
return isNumericTargetId(normalized);
},
hint: "<user:id|group:id>",
},
};

View File

@@ -0,0 +1,60 @@
// Zalouser tests cover channelirectory plugin behavior.
import { beforeEach, describe, expect, it } from "vitest";
import "./accounts.test-mocks.js";
import { listZalouserDirectoryGroupMembers } from "./directory.js";
import "./zalo-js.test-mocks.js";
import { listZaloGroupMembersMock } from "./zalo-js.test-mocks.js";
describe("zalouser directory group members", () => {
beforeEach(() => {
listZaloGroupMembersMock.mockClear();
});
it("accepts prefixed group ids from directory groups list output", async () => {
await listZalouserDirectoryGroupMembers(
{
cfg: {},
accountId: "default",
groupId: "group:1471383327500481391",
},
{
listZaloGroupMembers: async (profile, groupId) =>
await listZaloGroupMembersMock(profile, groupId),
},
);
expect(listZaloGroupMembersMock).toHaveBeenLastCalledWith("default", "1471383327500481391");
});
it("keeps backward compatibility for raw group ids", async () => {
await listZalouserDirectoryGroupMembers(
{
cfg: {},
accountId: "default",
groupId: "1471383327500481391",
},
{
listZaloGroupMembers: async (profile, groupId) =>
await listZaloGroupMembersMock(profile, groupId),
},
);
expect(listZaloGroupMembersMock).toHaveBeenLastCalledWith("default", "1471383327500481391");
});
it("accepts provider-native g- group ids without stripping the prefix", async () => {
await listZalouserDirectoryGroupMembers(
{
cfg: {},
accountId: "default",
groupId: "g-1471383327500481391",
},
{
listZaloGroupMembers: async (profile, groupId) =>
await listZaloGroupMembersMock(profile, groupId),
},
);
expect(listZaloGroupMembersMock).toHaveBeenLastCalledWith("default", "g-1471383327500481391");
});
});

View File

@@ -0,0 +1,13 @@
// Zalouser plugin module implements channel behavior.
export { probeZalouser } from "./probe.js";
export { collectZalouserSecurityAuditFindings } from "./security-audit.js";
export { sendMessageZalouser, sendReactionZalouser } from "./send.js";
export {
listZaloFriendsMatching,
listZaloGroupMembers,
listZaloGroupsMatching,
logoutZaloProfile,
startZaloQrLogin,
waitForZaloQrLogin,
getZaloUserInfo,
} from "./zalo-js.js";

View File

@@ -0,0 +1,365 @@
// Zalouser tests cover channel.sendpayload plugin behavior.
import {
installChannelOutboundPayloadContractSuite,
primeChannelOutboundSendMock,
type OutboundPayloadHarnessParams,
} from "openclaw/plugin-sdk/channel-contract-testing";
import {
createMessageReceiptFromOutboundResults,
verifyChannelMessageAdapterCapabilityProofs,
} from "openclaw/plugin-sdk/channel-outbound";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./accounts.test-mocks.js";
import "./zalo-js.test-mocks.js";
import type { ReplyPayload } from "../runtime-api.js";
import { zalouserPlugin } from "./channel.js";
import { setZalouserRuntime } from "./runtime.js";
import * as sendModule from "./send.js";
vi.mock("./send.js", () => ({
sendMessageZalouser: vi.fn().mockResolvedValue({ ok: true, messageId: "zlu-1" } as never),
sendReactionZalouser: vi.fn().mockResolvedValue({ ok: true } as never),
}));
function baseCtx(payload: ReplyPayload) {
return {
cfg: {},
to: "user:987654321",
text: "",
payload,
};
}
type ZalouserOutbound = NonNullable<typeof zalouserPlugin.outbound>;
type ZalouserSendPayload = NonNullable<ZalouserOutbound["sendPayload"]>;
type ZalouserMessageAdapter = NonNullable<typeof zalouserPlugin.message>;
type ZalouserMessageSender = NonNullable<ZalouserMessageAdapter["send"]>;
function requireZalouserSendPayload(): ZalouserSendPayload {
const sendPayload = zalouserPlugin.outbound?.sendPayload;
if (!sendPayload) {
throw new Error("Expected Zalouser outbound sendPayload");
}
return sendPayload;
}
function requireZalouserMessageAdapter(): ZalouserMessageAdapter {
const adapter = zalouserPlugin.message;
if (!adapter) {
throw new Error("Expected Zalouser message adapter");
}
return adapter;
}
function requireZalouserTextSender(
adapter: ZalouserMessageAdapter,
): NonNullable<ZalouserMessageSender["text"]> {
const text = adapter.send?.text;
if (!text) {
throw new Error("Expected Zalouser message adapter text sender");
}
return text;
}
function requireZalouserMediaSender(
adapter: ZalouserMessageAdapter,
): NonNullable<ZalouserMessageSender["media"]> {
const media = adapter.send?.media;
if (!media) {
throw new Error("Expected Zalouser message adapter media sender");
}
return media;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be a record`);
}
return value as Record<string, unknown>;
}
function requireSendOptions(
mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>,
): Record<string, unknown> {
return requireRecord(requireSendCall(mockedSend)[2], "Zalouser send options");
}
function requireSendCall(
mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>,
): unknown[] {
const [call] = mockedSend.mock.calls as unknown[][];
if (!call) {
throw new Error("expected Zalouser send call");
}
return call;
}
describe("zalouserPlugin outbound sendPayload", () => {
let mockedSend: ReturnType<typeof vi.mocked<(typeof import("./send.js"))["sendMessageZalouser"]>>;
beforeEach(() => {
setZalouserRuntime({
channel: {
text: {
resolveChunkMode: vi.fn(() => "length"),
resolveTextChunkLimit: vi.fn(() => 1200),
},
},
} as never);
mockedSend = vi.mocked(sendModule.sendMessageZalouser);
primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" });
});
it("group target delegates with isGroup=true and stripped threadId", async () => {
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g1" } as never);
const sendPayload = requireZalouserSendPayload();
const result = await sendPayload({
...baseCtx({ text: "hello group" }),
to: "group:1471383327500481391",
});
expect(mockedSend).toHaveBeenCalledOnce();
const sendCall = requireSendCall(mockedSend);
expect(sendCall[0]).toBe("1471383327500481391");
expect(sendCall[1]).toBe("hello group");
const options = requireSendOptions(mockedSend);
expect(options.isGroup).toBe(true);
expect(options.textMode).toBe("markdown");
expect(result.channel).toBe("zalouser");
expect(result.messageId).toBe("zlu-g1");
});
it("treats bare numeric targets as direct chats for backward compatibility", async () => {
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-d1" } as never);
const sendPayload = requireZalouserSendPayload();
const result = await sendPayload({
...baseCtx({ text: "hello" }),
to: "987654321",
});
expect(mockedSend).toHaveBeenCalledOnce();
const sendCall = requireSendCall(mockedSend);
expect(sendCall[0]).toBe("987654321");
expect(sendCall[1]).toBe("hello");
const options = requireSendOptions(mockedSend);
expect(options.isGroup).toBe(false);
expect(options.textMode).toBe("markdown");
expect(result.channel).toBe("zalouser");
expect(result.messageId).toBe("zlu-d1");
});
it("preserves provider-native group ids when sending to raw g- targets", async () => {
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-g-native" } as never);
const sendPayload = requireZalouserSendPayload();
const result = await sendPayload({
...baseCtx({ text: "hello native group" }),
to: "g-1471383327500481391",
});
expect(mockedSend).toHaveBeenCalledOnce();
const sendCall = requireSendCall(mockedSend);
expect(sendCall[0]).toBe("g-1471383327500481391");
expect(sendCall[1]).toBe("hello native group");
const options = requireSendOptions(mockedSend);
expect(options.isGroup).toBe(true);
expect(options.textMode).toBe("markdown");
expect(result.channel).toBe("zalouser");
expect(result.messageId).toBe("zlu-g-native");
});
it("passes long markdown through once so formatting happens before chunking", async () => {
const text = `**${"a".repeat(2501)}**`;
mockedSend.mockResolvedValue({ ok: true, messageId: "zlu-code" } as never);
const sendPayload = requireZalouserSendPayload();
const result = await sendPayload({
...baseCtx({ text }),
to: "987654321",
});
expect(mockedSend).toHaveBeenCalledTimes(1);
const sendCall = requireSendCall(mockedSend);
expect(sendCall[0]).toBe("987654321");
expect(sendCall[1]).toBe(text);
const options = requireSendOptions(mockedSend);
expect(options.isGroup).toBe(false);
expect(options.textMode).toBe("markdown");
expect(options.textChunkMode).toBe("length");
expect(options.textChunkLimit).toBe(1200);
expect(result.channel).toBe("zalouser");
expect(result.messageId).toBe("zlu-code");
});
it("forwards internal chunk progress through the outbound adapter", async () => {
mockedSend.mockImplementationOnce(async (_threadId, _text, options) => {
const onDeliveryResult = options?.onDeliveryResult;
if (!onDeliveryResult) {
throw new Error("missing progress callback");
}
await onDeliveryResult({ ok: true, messageId: "zlu-part-1" } as never);
await onDeliveryResult({ ok: true, messageId: "zlu-part-2" } as never);
return { ok: true, messageId: "zlu-part-2" } as never;
});
const onDeliveryResult = vi.fn();
const sendPayload = requireZalouserSendPayload();
await sendPayload({
...baseCtx({ text: "chunked internally" }),
to: "987654321",
onDeliveryResult,
});
expect(onDeliveryResult.mock.calls.map((call) => call[0]?.messageId)).toEqual([
"zlu-part-1",
"zlu-part-2",
]);
});
it("forwards internal chunk progress through the message adapter", async () => {
const receipt = createMessageReceiptFromOutboundResults({
results: [{ channel: "zalouser", messageId: "zlu-message-part" }],
kind: "text",
});
mockedSend.mockImplementationOnce(async (_threadId, _text, options) => {
const onDeliveryResult = options?.onDeliveryResult;
if (!onDeliveryResult) {
throw new Error("missing progress callback");
}
await onDeliveryResult({ ok: true, messageId: "zlu-message-part", receipt } as never);
return { ok: true, messageId: "zlu-message-part", receipt } as never;
});
const onDeliveryResult = vi.fn();
const sendText = requireZalouserTextSender(requireZalouserMessageAdapter());
await sendText({
cfg: {},
to: "user:987654321",
text: "chunked internally",
onDeliveryResult,
});
expect(onDeliveryResult).toHaveBeenCalledOnce();
expect(onDeliveryResult.mock.calls[0]?.[0]?.messageId).toBe("zlu-message-part");
expect(onDeliveryResult.mock.calls[0]?.[0]?.receipt).toBe(receipt);
});
it("declares message adapter durable text and media with receipt proofs", async () => {
mockedSend.mockImplementation(async (_threadId, _text, opts: { mediaUrl?: string } = {}) =>
opts.mediaUrl
? {
ok: true,
messageId: "zlu-media-1",
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "zalouser", messageId: "zlu-media-1" }],
kind: "media",
}),
}
: {
ok: true,
messageId: "zlu-text-1",
receipt: createMessageReceiptFromOutboundResults({
results: [{ channel: "zalouser", messageId: "zlu-text-1" }],
kind: "text",
}),
},
);
const adapter = requireZalouserMessageAdapter();
const sendText = requireZalouserTextSender(adapter);
const sendMedia = requireZalouserMediaSender(adapter);
const proofs = await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "zalouser",
adapter,
proofs: {
text: async () => {
const result = await sendText({
cfg: {},
to: "user:987654321",
text: "hello",
});
expect(result.receipt.platformMessageIds).toEqual(["zlu-text-1"]);
},
media: async () => {
const result = await sendMedia({
cfg: {},
to: "user:987654321",
text: "image",
mediaUrl: "https://example.com/image.png",
});
expect(result.receipt.platformMessageIds).toEqual(["zlu-media-1"]);
},
messageSendingHooks: () => {
expect(adapter.durableFinal?.capabilities?.messageSendingHooks).toBe(true);
},
},
});
const proofStatusByCapability = new Map(
proofs.map((proof) => [proof.capability, proof.status] as const),
);
expect(proofStatusByCapability.get("text")).toBe("verified");
expect(proofStatusByCapability.get("media")).toBe("verified");
expect(proofStatusByCapability.get("messageSendingHooks")).toBe("verified");
});
});
describe("zalouserPlugin outbound payload contract", () => {
function createZalouserHarness(params: OutboundPayloadHarnessParams) {
const mockedSend = vi.mocked(sendModule.sendMessageZalouser);
setZalouserRuntime({
channel: {
text: {
resolveChunkMode: vi.fn(() => "length"),
resolveTextChunkLimit: vi.fn(() => 1200),
},
},
} as never);
primeChannelOutboundSendMock(mockedSend, { ok: true, messageId: "zlu-1" }, params.sendResults);
const ctx = {
cfg: {},
to: "user:987654321",
text: "",
payload: params.payload,
};
const sendPayload = requireZalouserSendPayload();
return {
run: async () => await sendPayload(ctx),
sendMock: mockedSend,
to: "987654321",
};
}
installChannelOutboundPayloadContractSuite({
channel: "zalouser",
chunking: { mode: "passthrough", longTextLength: 3000 },
createHarness: createZalouserHarness,
});
});
describe("zalouserPlugin messaging target normalization", () => {
it("normalizes user/group aliases to canonical targets", () => {
const normalize = zalouserPlugin.messaging?.normalizeTarget;
if (!normalize) {
throw new Error("normalizeTarget unavailable");
}
expect(normalize("zlu:g:30003")).toBe("group:30003");
expect(normalize("zalouser:u:20002")).toBe("user:20002");
expect(normalize("zlu:g-30003")).toBe("group:g-30003");
expect(normalize("zalouser:u-20002")).toBe("user:u-20002");
expect(normalize("20002")).toBe("20002");
});
it("treats canonical and provider-native user/group targets as ids", () => {
const looksLikeId = zalouserPlugin.messaging?.targetResolver?.looksLikeId;
if (!looksLikeId) {
throw new Error("looksLikeId unavailable");
}
expect(looksLikeId("user:20002")).toBe(true);
expect(looksLikeId("group:30003")).toBe(true);
expect(looksLikeId("g-30003")).toBe(true);
expect(looksLikeId("u-20002")).toBe(true);
expect(looksLikeId("Alice Nguyen")).toBe(false);
});
});

View File

@@ -0,0 +1,31 @@
// Zalouser tests cover channel.setup plugin behavior.
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import "./zalo-js.test-mocks.js";
import { zalouserSetupPlugin } from "./setup-test-helpers.js";
const zalouserSetupGetStatus = createPluginSetupWizardStatus(zalouserSetupPlugin);
describe("zalouser setup plugin", () => {
it("builds setup status without an initialized runtime", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-setup-"));
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
const status = await zalouserSetupGetStatus({
cfg: {},
accountOverrides: {},
});
expect(status.channel).toBe("zalouser");
expect(status.configured).toBe(false);
expect(status.statusLines).toEqual(["Zalo Personal: needs QR login"]);
});
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,13 @@
// Zalouser plugin module implements channel.setup behavior.
import type { ResolvedZalouserAccount } from "./accounts.js";
import type { ChannelPlugin } from "./channel-api.js";
import { zalouserSetupAdapter } from "./setup-core.js";
import { zalouserSetupWizard } from "./setup-surface.js";
import { createZalouserPluginBase } from "./shared.js";
export const zalouserSetupPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
...createZalouserPluginBase({
setupWizard: zalouserSetupWizard,
setup: zalouserSetupAdapter,
}),
};

View File

@@ -0,0 +1,440 @@
// Zalouser tests cover channel plugin behavior.
import { createNonExitingRuntimeEnv } from "openclaw/plugin-sdk/plugin-test-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./zalo-js.test-mocks.js";
import {
zalouserAuthAdapter,
zalouserGroupsAdapter,
zalouserMessageActions,
zalouserOutboundAdapter,
zalouserPairingTextAdapter,
zalouserResolverAdapter,
zalouserSecurityAdapter,
} from "./channel.adapters.js";
import { setZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
import {
listZaloFriendsMatchingMock,
startZaloQrLoginMock,
waitForZaloQrLoginMock,
} from "./zalo-js.test-mocks.js";
vi.mock("./qr-temp-file.js", () => ({
writeQrDataUrlToTempFile: vi.fn(async () => null),
}));
vi.mock("./send.js", async () => {
const actual = (await vi.importActual("./send.js")) as Record<string, unknown>;
return {
...actual,
sendMessageZalouser: vi.fn(async () => ({ ok: true, messageId: "mid-1" })),
sendReactionZalouser: vi.fn(async () => ({ ok: true })),
};
});
const mockSendMessage = vi.mocked(sendMessageZalouser);
const mockSendReaction = vi.mocked(sendReactionZalouser);
function requireZalouserSendText() {
const sendText = zalouserOutboundAdapter.sendText;
if (!sendText) {
throw new Error("zalouser outbound.sendText unavailable");
}
return sendText;
}
function getResolveToolPolicy() {
const resolveToolPolicy = zalouserGroupsAdapter.resolveToolPolicy;
if (!resolveToolPolicy) {
throw new Error("resolveToolPolicy unavailable");
}
return resolveToolPolicy;
}
function requireZalouserResolveRequireMention() {
const resolveRequireMention = zalouserGroupsAdapter.resolveRequireMention;
if (!resolveRequireMention) {
throw new Error("resolveRequireMention unavailable");
}
return resolveRequireMention;
}
function requireZalouserPairingNormalizer() {
const normalizeAllowEntry = zalouserPairingTextAdapter.normalizeAllowEntry;
if (!normalizeAllowEntry) {
throw new Error("pairing.normalizeAllowEntry unavailable");
}
return normalizeAllowEntry;
}
function resolveGroupToolPolicy(
groups: Record<string, { tools: { allow?: string[]; deny?: string[] } }>,
groupId: string,
) {
return getResolveToolPolicy()({
cfg: {
channels: {
zalouser: {
groups,
},
},
},
accountId: "default",
groupId,
groupChannel: groupId,
});
}
describe("zalouser outbound", () => {
beforeEach(() => {
mockSendMessage.mockClear();
setZalouserRuntime({
channel: {
text: {
resolveChunkMode: vi.fn(() => "newline"),
resolveTextChunkLimit: vi.fn(() => 10),
},
},
} as never);
});
it("passes markdown chunk settings through sendText", async () => {
const sendText = requireZalouserSendText();
const result = await sendText({
cfg: { channels: { zalouser: { enabled: true } } } as never,
to: "group:123456",
text: "hello world\nthis is a test",
accountId: "default",
} as never);
expect(mockSendMessage).toHaveBeenCalledWith(
"123456",
"hello world\nthis is a test",
expect.objectContaining({
profile: "default",
isGroup: true,
textMode: "markdown",
textChunkMode: "newline",
textChunkLimit: 10,
onDeliveryResult: expect.any(Function),
}),
);
expect(result).toEqual({
channel: "zalouser",
messageId: "mid-1",
receipt: undefined,
});
});
it("uses the selected account profile for direct outbound messages", async () => {
const sendText = requireZalouserSendText();
const result = await sendText({
cfg: {
channels: {
zalouser: {
accounts: {
work: {
profile: "work-profile",
},
},
},
},
} as never,
to: "user:987654",
text: "hello user",
accountId: "work",
} as never);
expect(mockSendMessage).toHaveBeenCalledWith(
"987654",
"hello user",
expect.objectContaining({
profile: "work-profile",
isGroup: false,
textMode: "markdown",
textChunkMode: "newline",
textChunkLimit: 10,
onDeliveryResult: expect.any(Function),
}),
);
expect(result).toEqual({
channel: "zalouser",
messageId: "mid-1",
receipt: undefined,
});
});
it("keeps the default account profile for unscoped outbound messages", async () => {
const sendText = requireZalouserSendText();
await sendText({
cfg: { channels: { zalouser: { enabled: true } } } as never,
to: "user:111222",
text: "hello default",
} as never);
expect(mockSendMessage).toHaveBeenCalledWith(
"111222",
"hello default",
expect.objectContaining({
profile: "default",
isGroup: false,
textMode: "markdown",
textChunkMode: "newline",
textChunkLimit: 10,
onDeliveryResult: expect.any(Function),
}),
);
});
});
describe("zalouser outbound chunking", () => {
it("chunks outbound text without requiring Zalouser runtime initialization", () => {
const chunker = zalouserOutboundAdapter.chunker;
if (!chunker) {
throw new Error("zalouser outbound.chunker unavailable");
}
expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
});
});
describe("zalouser channel policies", () => {
beforeEach(() => {
mockSendReaction.mockClear();
mockSendReaction.mockResolvedValue({ ok: true } as never);
});
it("normalizes dm allowlist entries after trimming channel prefixes", () => {
const resolveDmPolicy = zalouserSecurityAdapter.resolveDmPolicy;
if (!resolveDmPolicy) {
throw new Error("resolveDmPolicy unavailable");
}
const cfg = {
channels: {
zalouser: {
dmPolicy: "allowlist",
allowFrom: [" zlu:123456 "],
},
},
} as never;
const account = {
accountId: "default",
enabled: true,
authenticated: false,
profile: "default",
config: {
dmPolicy: "allowlist",
allowFrom: [" zlu:123456 "],
},
} as never;
const result = resolveDmPolicy({ cfg, account });
if (!result) {
throw new Error("zalouser resolveDmPolicy returned null");
}
expect(result.policy).toBe("allowlist");
expect(result.allowFrom).toEqual([" zlu:123456 "]);
expect(result.normalizeEntry?.(" zlu:123456 ")).toBe("123456");
});
it("normalizes pairing allowlist entries after trimming channel prefixes", () => {
const normalizeAllowEntry = requireZalouserPairingNormalizer();
expect(normalizeAllowEntry(" zlu:123456 ")).toBe("123456");
expect(normalizeAllowEntry(" zalouser:654321 ")).toBe("654321");
});
it("resolves requireMention from group config", () => {
const resolveRequireMention = requireZalouserResolveRequireMention();
const requireMention = resolveRequireMention({
cfg: {
channels: {
zalouser: {
groups: {
"123": { requireMention: false },
},
},
},
},
accountId: "default",
groupId: "123",
groupChannel: "123",
});
expect(requireMention).toBe(false);
});
it("resolves group tool policy by explicit group id", () => {
const policy = resolveGroupToolPolicy({ "123": { tools: { allow: ["search"] } } }, "123");
expect(policy).toEqual({ allow: ["search"] });
});
it("falls back to wildcard group policy", () => {
const policy = resolveGroupToolPolicy({ "*": { tools: { deny: ["system.run"] } } }, "missing");
expect(policy).toEqual({ deny: ["system.run"] });
});
it("handles react action", async () => {
const actions = zalouserMessageActions;
expect(
actions?.describeMessageTool?.({ cfg: { channels: { zalouser: { enabled: true } } } })
?.actions,
).toEqual(["react"]);
const result = await actions?.handleAction?.({
channel: "zalouser",
action: "react",
params: {
threadId: "123456",
messageId: "111",
cliMsgId: "222",
emoji: "👍",
},
cfg: {
channels: {
zalouser: {
enabled: true,
profile: "default",
},
},
},
});
expect(mockSendReaction).toHaveBeenCalledWith({
profile: "default",
threadId: "123456",
isGroup: false,
msgId: "111",
cliMsgId: "222",
emoji: "👍",
remove: false,
});
expect(result).toEqual({
content: [{ type: "text", text: "Reacted 👍 on 111" }],
details: {
messageId: "111",
cliMsgId: "222",
threadId: "123456",
},
});
});
it("honors the selected Zalouser account during discovery", () => {
const actions = zalouserMessageActions;
const cfg = {
channels: {
zalouser: {
enabled: true,
profile: "default",
accounts: {
default: {
enabled: false,
profile: "default",
},
work: {
enabled: true,
profile: "work",
},
},
},
},
};
expect(actions?.describeMessageTool?.({ cfg, accountId: "default" })).toBeNull();
expect(actions?.describeMessageTool?.({ cfg, accountId: "work" })?.actions).toEqual(["react"]);
});
});
describe("zalouser account resolution", () => {
beforeEach(() => {
listZaloFriendsMatchingMock.mockReset();
startZaloQrLoginMock.mockReset();
waitForZaloQrLoginMock.mockReset();
});
it("uses the configured default account for omitted target lookup", async () => {
const resolveTargets = zalouserResolverAdapter.resolveTargets;
if (!resolveTargets) {
throw new Error("zalouser resolver.resolveTargets unavailable");
}
listZaloFriendsMatchingMock.mockResolvedValue([
{ userId: "42", displayName: "Work User" } as never,
]);
const result = await resolveTargets({
cfg: {
channels: {
zalouser: {
defaultAccount: "work",
accounts: {
work: {
profile: "work-profile",
},
},
},
},
} as never,
inputs: ["Work User"],
kind: "user",
runtime: createNonExitingRuntimeEnv(),
});
expect(listZaloFriendsMatchingMock).toHaveBeenCalledWith("work-profile", "Work User");
expect(result).toEqual([
{
input: "Work User",
resolved: true,
id: "42",
name: "Work User",
note: undefined,
},
]);
});
it("uses the configured default account for omitted qr login", async () => {
const login = zalouserAuthAdapter.login;
if (!login) {
throw new Error("zalouser auth.login unavailable");
}
startZaloQrLoginMock.mockResolvedValue({
message: "qr ready",
qrDataUrl: "data:image/png;base64,abc",
} as never);
waitForZaloQrLoginMock.mockResolvedValue({
connected: true,
userId: "u-1",
displayName: "Work User",
} as never);
const runtime = createNonExitingRuntimeEnv();
await login({
cfg: {
channels: {
zalouser: {
defaultAccount: "work",
accounts: {
work: {
profile: "work-profile",
},
},
},
},
} as never,
runtime,
});
expect(startZaloQrLoginMock).toHaveBeenCalledWith({
profile: "work-profile",
timeoutMs: 35_000,
});
expect(waitForZaloQrLoginMock).toHaveBeenCalledWith({
profile: "work-profile",
timeoutMs: 180_000,
});
});
});

View File

@@ -0,0 +1,222 @@
// Zalouser plugin module implements channel behavior.
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
createAsyncComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import {
checkZcaAuthenticated,
resolveZalouserAccountSync,
type ResolvedZalouserAccount,
} from "./accounts.js";
import type { ChannelDirectoryEntry, ChannelPlugin } from "./channel-api.js";
import { DEFAULT_ACCOUNT_ID } from "./channel-api.js";
import {
zalouserAuthAdapter,
zalouserGroupsAdapter,
zalouserMessageAdapter,
zalouserMessageActions,
zalouserMessagingAdapter,
zalouserOutboundAdapter,
zalouserPairingTextAdapter,
resolveZalouserQrProfile,
zalouserResolverAdapter,
zalouserSecurityAdapter,
zalouserThreadingAdapter,
} from "./channel.adapters.js";
import { listZalouserDirectoryGroupMembers } from "./directory.js";
import type { ZalouserProbeResult } from "./probe.js";
import { createZalouserSetupWizardProxy, zalouserSetupAdapter } from "./setup-core.js";
import { createZalouserPluginBase } from "./shared.js";
import { collectZalouserStatusIssues } from "./status-issues.js";
const loadZalouserChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
const zalouserSetupWizardProxy = createZalouserSetupWizardProxy(
async () => (await import("./setup-surface.js")).zalouserSetupWizard,
);
function mapUser(params: {
id: string;
name?: string | null;
avatarUrl?: string | null;
raw?: unknown;
}): ChannelDirectoryEntry {
return {
kind: "user",
id: params.id,
name: params.name ?? undefined,
avatarUrl: params.avatarUrl ?? undefined,
raw: params.raw,
};
}
function mapGroup(params: {
id: string;
name?: string | null;
raw?: unknown;
}): ChannelDirectoryEntry {
return {
kind: "group",
id: params.id,
name: params.name ?? undefined,
raw: params.raw,
};
}
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount, ZalouserProbeResult> =
createChatChannelPlugin({
base: {
...createZalouserPluginBase({
setupWizard: zalouserSetupWizardProxy,
setup: zalouserSetupAdapter,
}),
groups: zalouserGroupsAdapter,
actions: zalouserMessageActions,
messaging: zalouserMessagingAdapter,
directory: {
self: async ({ cfg, accountId }) => {
const { getZaloUserInfo } = await loadZalouserChannelRuntime();
const account = resolveZalouserAccountSync({ cfg, accountId });
const parsed = await getZaloUserInfo(account.profile);
if (!parsed?.userId) {
return null;
}
return mapUser({
id: parsed.userId,
name: parsed.displayName ?? null,
avatarUrl: parsed.avatar ?? null,
raw: parsed,
});
},
listPeers: async ({ cfg, accountId, query, limit }) => {
const { listZaloFriendsMatching } = await loadZalouserChannelRuntime();
const account = resolveZalouserAccountSync({ cfg, accountId });
const friends = await listZaloFriendsMatching(account.profile, query);
const rows = friends.map((friend) =>
mapUser({
id: friend.userId,
name: friend.displayName ?? null,
avatarUrl: friend.avatar ?? null,
raw: friend,
}),
);
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const { listZaloGroupsMatching } = await loadZalouserChannelRuntime();
const account = resolveZalouserAccountSync({ cfg, accountId });
const groups = await listZaloGroupsMatching(account.profile, query);
const rows = groups.map((group) =>
mapGroup({
id: `group:${group.groupId}`,
name: group.name ?? null,
raw: group,
}),
);
return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows;
},
listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
const { listZaloGroupMembers } = await loadZalouserChannelRuntime();
return await listZalouserDirectoryGroupMembers(
{
cfg,
accountId: accountId ?? undefined,
groupId,
limit: limit ?? undefined,
},
{ listZaloGroupMembers },
);
},
},
resolver: zalouserResolverAdapter,
auth: zalouserAuthAdapter,
message: zalouserMessageAdapter,
status: createAsyncComputedAccountStatusAdapter<ResolvedZalouserAccount, ZalouserProbeResult>(
{
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
collectStatusIssues: collectZalouserStatusIssues,
buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot),
probeAccount: async ({ account, timeoutMs }) =>
(await loadZalouserChannelRuntime()).probeZalouser(account.profile, timeoutMs),
resolveAccountSnapshot: async ({ account, runtime }) => {
const configured = await checkZcaAuthenticated(account.profile);
const configError = "not authenticated";
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
extra: {
dmPolicy: account.config.dmPolicy ?? "pairing",
lastError: configured
? (runtime?.lastError ?? null)
: (runtime?.lastError ?? configError),
},
};
},
},
),
gateway: {
startAccount: async (ctx) => {
const { getZaloUserInfo } = await loadZalouserChannelRuntime();
const account = ctx.account;
let userLabel = "";
try {
const userInfo = await getZaloUserInfo(account.profile);
if (userInfo?.displayName) {
userLabel = ` (${userInfo.displayName})`;
}
ctx.setStatus({
accountId: account.accountId,
profile: userInfo,
});
} catch {
// ignore probe errors
}
const statusSink = createAccountStatusSink({
accountId: ctx.accountId,
setStatus: ctx.setStatus,
});
ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`);
const { monitorZalouserProvider } = await import("./monitor.js");
return monitorZalouserProvider({
account,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink,
});
},
loginWithQrStart: async (params) => {
const { startZaloQrLogin } = await loadZalouserChannelRuntime();
const profile = resolveZalouserQrProfile(params.accountId);
return await startZaloQrLogin({
profile,
force: params.force,
timeoutMs: params.timeoutMs,
});
},
loginWithQrWait: async (params) => {
const { waitForZaloQrLogin } = await loadZalouserChannelRuntime();
const profile = resolveZalouserQrProfile(params.accountId);
return await waitForZaloQrLogin({
profile,
timeoutMs: params.timeoutMs,
});
},
logoutAccount: async (ctx) =>
await (
await loadZalouserChannelRuntime()
).logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)),
},
},
security: zalouserSecurityAdapter,
threading: zalouserThreadingAdapter,
pairing: {
text: zalouserPairingTextAdapter,
},
outbound: zalouserOutboundAdapter,
});

View File

@@ -0,0 +1,34 @@
// Zalouser helper module supports config schema behavior.
import {
AllowFromListSchema,
buildCatchallMultiAccountChannelSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
ToolPolicySchema,
} from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
const groupConfigSchema = z.object({
enabled: z.boolean().optional(),
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
});
const zalouserAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
profile: z.string().optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
dmPolicy: DmPolicySchema.optional(),
allowFrom: AllowFromListSchema,
historyLimit: z.number().int().min(0).optional(),
groupAllowFrom: AllowFromListSchema,
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
groups: z.object({}).catchall(groupConfigSchema).optional(),
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),
});
export const ZalouserConfigSchema = buildCatchallMultiAccountChannelSchema(zalouserAccountSchema);

View File

@@ -0,0 +1,55 @@
// Zalouser plugin module implements directory behavior.
import { resolveZalouserAccountSync } from "./accounts.js";
import type { ChannelDirectoryEntry, OpenClawConfig } from "./channel-api.js";
import { parseZalouserDirectoryGroupId } from "./session-route.js";
type ZalouserDirectoryDeps = {
listZaloGroupMembers: (
profile: string,
groupId: string,
) => Promise<
Array<{
userId: string;
displayName?: string | null;
avatar?: string | null;
}>
>;
};
function mapUser(params: {
id: string;
name?: string | null;
avatarUrl?: string | null;
raw?: unknown;
}): ChannelDirectoryEntry {
return {
kind: "user",
id: params.id,
name: params.name ?? undefined,
avatarUrl: params.avatarUrl ?? undefined,
raw: params.raw,
};
}
export async function listZalouserDirectoryGroupMembers(
params: {
cfg: OpenClawConfig;
accountId?: string;
groupId: string;
limit?: number;
},
deps: ZalouserDirectoryDeps,
) {
const account = resolveZalouserAccountSync({ cfg: params.cfg, accountId: params.accountId });
const normalizedGroupId = parseZalouserDirectoryGroupId(params.groupId);
const members = await deps.listZaloGroupMembers(account.profile, normalizedGroupId);
const rows = members.map((member) =>
mapUser({
id: member.userId,
name: member.displayName,
avatarUrl: member.avatar ?? null,
raw: member,
}),
);
return typeof params.limit === "number" && params.limit > 0 ? rows.slice(0, params.limit) : rows;
}

View File

@@ -0,0 +1,157 @@
// Zalouser plugin module implements doctor contract behavior.
import type {
ChannelDoctorConfigMutation,
ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
type ZalouserChannelsConfig = NonNullable<OpenClawConfig["channels"]>;
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function hasLegacyZalouserGroupAllowAlias(value: unknown): boolean {
const group = asObjectRecord(value);
return Boolean(group && typeof group.allow === "boolean");
}
function hasLegacyZalouserGroupAllowAliases(value: unknown): boolean {
const groups = asObjectRecord(value);
return Boolean(
groups && Object.values(groups).some((group) => hasLegacyZalouserGroupAllowAlias(group)),
);
}
function hasLegacyZalouserAccountGroupAllowAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => {
const accountRecord = asObjectRecord(account);
return Boolean(accountRecord && hasLegacyZalouserGroupAllowAliases(accountRecord.groups));
});
}
function normalizeZalouserGroupAllowAliases(params: {
groups: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { groups: Record<string, unknown>; changed: boolean } {
let changed = false;
const nextGroups: Record<string, unknown> = { ...params.groups };
for (const [groupId, groupValue] of Object.entries(params.groups)) {
const group = asObjectRecord(groupValue);
if (!group || typeof group.allow !== "boolean") {
continue;
}
const nextGroup = { ...group };
if (typeof nextGroup.enabled !== "boolean") {
nextGroup.enabled = group.allow;
}
delete nextGroup.allow;
nextGroups[groupId] = nextGroup;
changed = true;
params.changes.push(
`Moved ${params.pathPrefix}.${groupId}.allow → ${params.pathPrefix}.${groupId}.enabled (${String(nextGroup.enabled)}).`,
);
}
return { groups: nextGroups, changed };
}
function normalizeZalouserCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
const channels = asObjectRecord(cfg.channels);
const zalouser = asObjectRecord(channels?.zalouser);
if (!zalouser) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updatedZalouser: Record<string, unknown> = zalouser;
let changed = false;
const groups = asObjectRecord(updatedZalouser.groups);
if (groups) {
const normalized = normalizeZalouserGroupAllowAliases({
groups,
pathPrefix: "channels.zalouser.groups",
changes,
});
if (normalized.changed) {
updatedZalouser = { ...updatedZalouser, groups: normalized.groups };
changed = true;
}
}
const accounts = asObjectRecord(updatedZalouser.accounts);
if (accounts) {
let accountsChanged = false;
const nextAccounts: Record<string, unknown> = { ...accounts };
for (const [accountId, accountValue] of Object.entries(accounts)) {
const account = asObjectRecord(accountValue);
if (!account) {
continue;
}
const accountGroups = asObjectRecord(account.groups);
if (!accountGroups) {
continue;
}
const normalized = normalizeZalouserGroupAllowAliases({
groups: accountGroups,
pathPrefix: `channels.zalouser.accounts.${accountId}.groups`,
changes,
});
if (!normalized.changed) {
continue;
}
nextAccounts[accountId] = {
...account,
groups: normalized.groups,
};
accountsChanged = true;
}
if (accountsChanged) {
updatedZalouser = { ...updatedZalouser, accounts: nextAccounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
zalouser: updatedZalouser as ZalouserChannelsConfig["zalouser"],
},
},
changes,
};
}
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "zalouser", "groups"],
message:
'channels.zalouser.groups.<id>.allow is legacy; use channels.zalouser.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyZalouserGroupAllowAliases,
},
{
path: ["channels", "zalouser", "accounts"],
message:
'channels.zalouser.accounts.<id>.groups.<id>.allow is legacy; use channels.zalouser.accounts.<id>.groups.<id>.enabled instead. Run "openclaw doctor --fix".',
match: hasLegacyZalouserAccountGroupAllowAliases,
},
];
export function normalizeCompatibilityConfig(params: {
cfg: OpenClawConfig;
}): ChannelDoctorConfigMutation {
return normalizeZalouserCompatibilityConfig(params.cfg);
}

View File

@@ -0,0 +1,88 @@
// Zalouser tests cover doctor plugin behavior.
import { describe, expect, it } from "vitest";
import { zalouserDoctor } from "./doctor.js";
function getZaloUserCompatibilityNormalizer(): NonNullable<
typeof zalouserDoctor.normalizeCompatibilityConfig
> {
const normalize = zalouserDoctor.normalizeCompatibilityConfig;
if (!normalize) {
throw new Error("Expected zalouser doctor to expose normalizeCompatibilityConfig");
}
return normalize;
}
describe("zalouser doctor", () => {
it("warns when mutable group names rely on disabled name matching", async () => {
const warnings = await Promise.resolve(
zalouserDoctor.collectMutableAllowlistWarnings?.({
cfg: {
channels: {
zalouser: {
groups: {
"group:trusted": {
enabled: true,
},
},
},
},
} as never,
}) ?? [],
);
expect(
warnings.some((warning: string) =>
warning.includes("mutable allowlist entry across zalouser"),
),
).toBe(true);
expect(
warnings.some((warning: string) =>
warning.includes("channels.zalouser.groups: group:trusted"),
),
).toBe(true);
});
it("normalizes legacy group allow aliases to enabled", () => {
const normalize = getZaloUserCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
zalouser: {
groups: {
"group:trusted": {
allow: true,
},
},
accounts: {
work: {
groups: {
"group:legacy": {
allow: false,
},
},
},
},
},
},
} as never,
});
expect(result.config.channels?.zalouser?.groups?.["group:trusted"]).toEqual({
enabled: true,
});
expect(
(
result.config.channels?.zalouser?.accounts?.work as
| { groups?: Record<string, unknown> }
| undefined
)?.groups?.["group:legacy"],
).toEqual({
enabled: false,
});
expect(result.changes).toEqual([
"Moved channels.zalouser.groups.group:trusted.allow → channels.zalouser.groups.group:trusted.enabled (true).",
"Moved channels.zalouser.accounts.work.groups.group:legacy.allow → channels.zalouser.accounts.work.groups.group:legacy.enabled (false).",
]);
});
});

View File

@@ -0,0 +1,38 @@
// Zalouser plugin module implements doctor behavior.
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract.js";
import { isZalouserMutableGroupEntry } from "./security-audit.js";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
const collectZalouserMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "zalouser",
detector: isZalouserMutableGroupEntry,
collectLists: (scope) => {
const groups = asObjectRecord(scope.account.groups);
return groups
? [
{
pathLabel: `${scope.prefix}.groups`,
list: Object.keys(groups),
},
]
: [];
},
});
export const zalouserDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOnly",
groupModel: "hybrid",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
legacyConfigRules,
normalizeCompatibilityConfig,
collectMutableAllowlistWarnings: collectZalouserMutableAllowlistWarnings,
};

View File

@@ -0,0 +1,62 @@
// Zalouser tests cover group policy plugin behavior.
import { describe, expect, it } from "vitest";
import {
buildZalouserGroupCandidates,
findZalouserGroupEntry,
isZalouserGroupEntryAllowed,
normalizeZalouserGroupSlug,
} from "./group-policy.js";
describe("zalouser group policy helpers", () => {
it("normalizes group slug names", () => {
expect(normalizeZalouserGroupSlug(" Team Alpha ")).toBe("team-alpha");
expect(normalizeZalouserGroupSlug("#Roadmap Updates")).toBe("roadmap-updates");
});
it("builds ordered candidates with optional aliases", () => {
expect(
buildZalouserGroupCandidates({
groupId: "123",
groupChannel: "chan-1",
groupName: "Team Alpha",
includeGroupIdAlias: true,
}),
).toEqual(["123", "group:123", "chan-1", "Team Alpha", "team-alpha", "*"]);
});
it("builds id-only candidates when name matching is disabled", () => {
expect(
buildZalouserGroupCandidates({
groupId: "123",
groupChannel: "chan-1",
groupName: "Team Alpha",
includeGroupIdAlias: true,
allowNameMatching: false,
}),
).toEqual(["123", "group:123", "*"]);
});
it("finds the first matching group entry", () => {
const groups = {
"group:123": { enabled: true },
"team-alpha": { requireMention: false },
"*": { requireMention: true },
};
const entry = findZalouserGroupEntry(
groups,
buildZalouserGroupCandidates({
groupId: "123",
groupName: "Team Alpha",
includeGroupIdAlias: true,
}),
);
expect(entry).toEqual({ enabled: true });
});
it("evaluates allow/enable flags", () => {
expect(isZalouserGroupEntryAllowed({ enabled: true })).toBe(true);
expect(isZalouserGroupEntryAllowed({ allow: false } as never)).toBe(false);
expect(isZalouserGroupEntryAllowed({ enabled: false })).toBe(false);
expect(isZalouserGroupEntryAllowed(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,84 @@
// Zalouser plugin module implements group policy behavior.
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ZalouserGroupConfig } from "./types.js";
type ZalouserGroups = Record<string, ZalouserGroupConfig>;
function toGroupCandidate(value?: string | null): string {
return value?.trim() ?? "";
}
export function normalizeZalouserGroupSlug(raw?: string | null): string {
const trimmed = normalizeOptionalLowercaseString(raw) ?? "";
if (!trimmed) {
return "";
}
return trimmed
.replace(/^#/, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function buildZalouserGroupCandidates(params: {
groupId?: string | null;
groupChannel?: string | null;
groupName?: string | null;
includeGroupIdAlias?: boolean;
includeWildcard?: boolean;
allowNameMatching?: boolean;
}): string[] {
const seen = new Set<string>();
const out: string[] = [];
const push = (value?: string | null) => {
const normalized = toGroupCandidate(value);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
out.push(normalized);
};
const groupId = toGroupCandidate(params.groupId);
const groupChannel = toGroupCandidate(params.groupChannel);
const groupName = toGroupCandidate(params.groupName);
push(groupId);
if (params.includeGroupIdAlias === true && groupId) {
push(`group:${groupId}`);
}
if (params.allowNameMatching !== false) {
push(groupChannel);
push(groupName);
if (groupName) {
push(normalizeZalouserGroupSlug(groupName));
}
}
if (params.includeWildcard !== false) {
push("*");
}
return out;
}
export function findZalouserGroupEntry(
groups: ZalouserGroups | undefined,
candidates: string[],
): ZalouserGroupConfig | undefined {
if (!groups) {
return undefined;
}
for (const candidate of candidates) {
const entry = groups[candidate];
if (entry) {
return entry;
}
}
return undefined;
}
export function isZalouserGroupEntryAllowed(entry: ZalouserGroupConfig | undefined): boolean {
if (!entry) {
return false;
}
const legacyAllow = (entry as ZalouserGroupConfig & { allow?: unknown }).allow;
return legacyAllow !== false && entry.enabled !== false;
}

View File

@@ -0,0 +1,67 @@
// Zalouser tests cover message sid plugin behavior.
import { describe, expect, it } from "vitest";
import {
formatZalouserMessageSidFull,
parseZalouserMessageSidFull,
resolveZalouserMessageSid,
resolveZalouserReactionMessageIds,
} from "./message-sid.js";
describe("zalouser message sid helpers", () => {
it("parses MessageSidFull pairs", () => {
expect(parseZalouserMessageSidFull("111:222")).toEqual({
msgId: "111",
cliMsgId: "222",
});
expect(parseZalouserMessageSidFull("111")).toBeNull();
expect(parseZalouserMessageSidFull(undefined)).toBeNull();
});
it("resolves reaction ids from explicit params first", () => {
expect(
resolveZalouserReactionMessageIds({
messageId: "m-1",
cliMsgId: "c-1",
currentMessageId: "x:y",
}),
).toEqual({
msgId: "m-1",
cliMsgId: "c-1",
});
});
it("resolves reaction ids from current message sid full", () => {
expect(
resolveZalouserReactionMessageIds({
currentMessageId: "m-2:c-2",
}),
).toEqual({
msgId: "m-2",
cliMsgId: "c-2",
});
});
it("falls back to duplicated current id when no pair is available", () => {
expect(
resolveZalouserReactionMessageIds({
currentMessageId: "solo",
}),
).toEqual({
msgId: "solo",
cliMsgId: "solo",
});
});
it("formats message sid fields for context payload", () => {
expect(formatZalouserMessageSidFull({ msgId: "1", cliMsgId: "2" })).toBe("1:2");
expect(formatZalouserMessageSidFull({ msgId: "1" })).toBe("1");
expect(formatZalouserMessageSidFull({ cliMsgId: "2" })).toBe("2");
expect(formatZalouserMessageSidFull({})).toBeUndefined();
});
it("resolves primary message sid with fallback timestamp", () => {
expect(resolveZalouserMessageSid({ msgId: "1", cliMsgId: "2", fallback: "t" })).toBe("1");
expect(resolveZalouserMessageSid({ cliMsgId: "2", fallback: "t" })).toBe("2");
expect(resolveZalouserMessageSid({ fallback: "t" })).toBe("t");
});
});

View File

@@ -0,0 +1,81 @@
// Zalouser plugin module implements message sid behavior.
function toMessageSidPart(value?: string | number | null): string {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === "number" && Number.isFinite(value)) {
return String(Math.trunc(value));
}
return "";
}
export function parseZalouserMessageSidFull(
value?: string | number | null,
): { msgId: string; cliMsgId: string } | null {
const raw = toMessageSidPart(value);
if (!raw) {
return null;
}
const [msgIdPart, cliMsgIdPart] = raw.split(":").map((entry) => entry.trim());
if (!msgIdPart || !cliMsgIdPart) {
return null;
}
return { msgId: msgIdPart, cliMsgId: cliMsgIdPart };
}
export function resolveZalouserReactionMessageIds(params: {
messageId?: string;
cliMsgId?: string;
currentMessageId?: string | number;
}): { msgId: string; cliMsgId: string } | null {
const explicitMessageId = toMessageSidPart(params.messageId);
const explicitCliMsgId = toMessageSidPart(params.cliMsgId);
if (explicitMessageId && explicitCliMsgId) {
return { msgId: explicitMessageId, cliMsgId: explicitCliMsgId };
}
const parsedFromCurrent = parseZalouserMessageSidFull(params.currentMessageId);
if (parsedFromCurrent) {
return parsedFromCurrent;
}
const currentRaw = toMessageSidPart(params.currentMessageId);
if (!currentRaw) {
return null;
}
if (explicitMessageId && !explicitCliMsgId) {
return { msgId: explicitMessageId, cliMsgId: currentRaw };
}
if (!explicitMessageId && explicitCliMsgId) {
return { msgId: currentRaw, cliMsgId: explicitCliMsgId };
}
return { msgId: currentRaw, cliMsgId: currentRaw };
}
export function formatZalouserMessageSidFull(params: {
msgId?: string | null;
cliMsgId?: string | null;
}): string | undefined {
const msgId = toMessageSidPart(params.msgId);
const cliMsgId = toMessageSidPart(params.cliMsgId);
if (!msgId && !cliMsgId) {
return undefined;
}
if (msgId && cliMsgId) {
return `${msgId}:${cliMsgId}`;
}
return msgId || cliMsgId || undefined;
}
export function resolveZalouserMessageSid(params: {
msgId?: string | null;
cliMsgId?: string | null;
fallback?: string | null;
}): string | undefined {
const msgId = toMessageSidPart(params.msgId);
const cliMsgId = toMessageSidPart(params.cliMsgId);
if (msgId || cliMsgId) {
return msgId || cliMsgId;
}
return toMessageSidPart(params.fallback) || undefined;
}

View File

@@ -0,0 +1,123 @@
// Zalouser tests cover monitor.account scope plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import "./monitor.send.test-mocks.js";
import { testing } from "./monitor.js";
import "./zalo-js.test-mocks.js";
import { sendMessageZalouserMock } from "./monitor.send.test-mocks.js";
import { setZalouserRuntime } from "./runtime.js";
import { createZalouserRuntimeEnv } from "./test-helpers.js";
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be a record`);
}
return value as Record<string, unknown>;
}
describe("zalouser monitor pairing account scoping", () => {
it("scopes DM pairing-store reads and pairing requests to accountId", async () => {
const readAllowFromStore = vi.fn(
async (
channelOrParams:
| string
| {
channel?: string;
accountId?: string;
},
_env?: NodeJS.ProcessEnv,
accountId?: string,
) => {
const scopedAccountId =
typeof channelOrParams === "object" && channelOrParams !== null
? channelOrParams.accountId
: accountId;
return scopedAccountId === "beta" ? [] : ["attacker"];
},
);
const upsertPairingRequest = vi.fn(
async (_params: { channel: string; id: string; accountId?: string }) => ({
code: "PAIRME88",
created: true,
}),
);
setZalouserRuntime({
logging: {
shouldLogVerbose: () => false,
},
channel: {
pairing: {
readAllowFromStore,
upsertPairingRequest,
buildPairingReply: vi.fn(() => "pairing reply"),
},
commands: {
shouldComputeCommandAuthorized: vi.fn(() => false),
resolveCommandAuthorizedFromAuthorizers: vi.fn(() => false),
isControlCommandMessage: vi.fn(() => false),
},
},
} as unknown as PluginRuntime);
const account: ResolvedZalouserAccount = {
accountId: "beta",
enabled: true,
profile: "beta",
authenticated: true,
config: {
dmPolicy: "pairing",
allowFrom: [],
},
};
const config: OpenClawConfig = {
channels: {
zalouser: {
accounts: {
alpha: { dmPolicy: "pairing", allowFrom: [] },
beta: { dmPolicy: "pairing", allowFrom: [] },
},
},
},
};
const message: ZaloInboundMessage = {
threadId: "chat-1",
isGroup: false,
senderId: "attacker",
senderName: "Attacker",
groupName: undefined,
timestampMs: Date.now(),
msgId: "msg-1",
content: "hello",
raw: { source: "test" },
};
await testing.processMessage({
message,
account,
config,
runtime: createZalouserRuntimeEnv(),
});
expect(readAllowFromStore).toHaveBeenCalledOnce();
const allowStoreParams = requireRecord(
readAllowFromStore.mock.calls[0]?.[0],
"allow store params",
);
expect(allowStoreParams.channel).toBe("zalouser");
expect(allowStoreParams.accountId).toBe("beta");
expect(upsertPairingRequest).toHaveBeenCalledOnce();
const pairingRequest = requireRecord(
upsertPairingRequest.mock.calls[0]?.[0],
"pairing request params",
);
expect(pairingRequest.channel).toBe("zalouser");
expect(pairingRequest.id).toBe("attacker");
expect(pairingRequest.accountId).toBe("beta");
expect(sendMessageZalouserMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,975 @@
// Zalouser tests cover monitor.group gating plugin behavior.
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import "./monitor.send.test-mocks.js";
import "./zalo-js.test-mocks.js";
import { resolveZalouserAccountSync } from "./accounts.js";
import { testing, monitorZalouserProvider } from "./monitor.js";
import {
sendDeliveredZalouserMock,
sendMessageZalouserMock,
sendSeenZalouserMock,
sendTypingZalouserMock,
} from "./monitor.send.test-mocks.js";
import { setZalouserRuntime } from "./runtime.js";
import { createZalouserRuntimeEnv } from "./test-helpers.js";
import type { ResolvedZalouserAccount, ZaloInboundMessage } from "./types.js";
import {
listZaloFriendsMock,
listZaloGroupsMock,
startZaloListenerMock,
} from "./zalo-js.test-mocks.js";
function createAccount(): ResolvedZalouserAccount {
return {
accountId: "default",
enabled: true,
profile: "default",
authenticated: true,
config: {
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
groups: {
"*": { requireMention: true },
},
},
};
}
function createConfig(): OpenClawConfig {
return {
channels: {
zalouser: {
enabled: true,
dmPolicy: "open",
allowFrom: ["*"],
groups: {
"*": { requireMention: true },
},
},
},
};
}
const createRuntimeEnv = () => createZalouserRuntimeEnv();
type DispatchReplyCallArg = {
ctx?: {
Body?: string;
BodyForCommands?: string;
CommandAuthorized?: boolean;
CommandBody?: string;
InboundHistory?: unknown;
OriginatingTo?: string;
ReplyToBody?: string;
ReplyToId?: string;
ReplyToIsQuote?: boolean;
SessionKey?: string;
To?: string;
WasMentioned?: boolean;
};
};
function mockCallArg(mock: unknown, label: string, index = 0) {
const call = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls?.at(index);
if (!call) {
throw new Error(`Expected ${label} call ${index + 1}`);
}
return call[0];
}
function dispatchReplyCall(mock: unknown, index = 0): DispatchReplyCallArg {
return mockCallArg(mock, "dispatch reply", index) as DispatchReplyCallArg;
}
function installRuntime(params: {
commandAuthorized?: boolean;
replyPayload?: { text?: string; mediaUrl?: string; mediaUrls?: string[] };
resolveCommandAuthorizedFromAuthorizers?: (params: {
useAccessGroups: boolean;
authorizers: Array<{ configured: boolean; allowed: boolean }>;
}) => boolean;
}) {
const dispatchReplyWithBufferedBlockDispatcher = vi.fn(async ({ dispatcherOptions, ctx }) => {
await dispatcherOptions.typingCallbacks?.onReplyStart?.();
if (params.replyPayload) {
await dispatcherOptions.deliver(params.replyPayload);
}
return { queuedFinal: false, counts: { tool: 0, block: 0, final: 0 }, ctx };
});
const resolveCommandAuthorizedFromAuthorizers = vi.fn(
(input: {
useAccessGroups: boolean;
authorizers: Array<{ configured: boolean; allowed: boolean }>;
}) => {
if (params.resolveCommandAuthorizedFromAuthorizers) {
return params.resolveCommandAuthorizedFromAuthorizers(input);
}
return params.commandAuthorized ?? false;
},
);
const resolveAgentRoute = vi.fn((input: { peer?: { kind?: string; id?: string } }) => {
const peerKind = input.peer?.kind === "direct" ? "direct" : "group";
const peerId = input.peer?.id ?? "1";
return {
agentId: "main",
sessionKey:
peerKind === "direct" ? "agent:main:main" : `agent:main:zalouser:${peerKind}:${peerId}`,
accountId: "default",
mainSessionKey: "agent:main:main",
};
});
const readAllowFromStore = vi.fn(async () => []);
const readSessionUpdatedAt = vi.fn(
(_params?: { storePath: string; sessionKey: string }): number | undefined => undefined,
);
type ResolvedTurn = Parameters<PluginRuntime["channel"]["inbound"]["dispatchReply"]>[0];
const dispatchAssembled = vi.fn(async (turn: ResolvedTurn) => {
await turn.recordInboundSession({
storePath: turn.storePath,
sessionKey: turn.ctxPayload.SessionKey ?? turn.routeSessionKey,
ctx: turn.ctxPayload,
groupResolution: turn.record?.groupResolution,
createIfMissing: turn.record?.createIfMissing,
updateLastRoute: turn.record?.updateLastRoute,
onRecordError: turn.record?.onRecordError ?? (() => undefined),
});
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
cfg: turn.cfg,
agentId: turn.agentId,
channel: "zalouser",
accountId: turn.accountId,
...turn.replyPipeline,
});
const dispatchResult = await turn.dispatchReplyWithBufferedBlockDispatcher({
ctx: turn.ctxPayload,
cfg: turn.cfg,
dispatcherOptions: {
...replyPipeline,
...turn.dispatcherOptions,
deliver: async (...args: Parameters<typeof turn.delivery.deliver>) => {
await turn.delivery.deliver(...args);
},
onError: turn.delivery.onError,
},
replyOptions: {
onModelSelected,
...turn.replyOptions,
},
replyResolver: turn.replyResolver,
});
return {
admission: { kind: "dispatch" as const },
dispatched: true,
ctxPayload: turn.ctxPayload,
routeSessionKey: turn.routeSessionKey,
dispatchResult,
};
});
const buildContext = vi.fn(
(paramsLocal: Parameters<PluginRuntime["channel"]["inbound"]["buildContext"]>[0]) =>
({
Body: paramsLocal.message.body ?? paramsLocal.message.rawBody,
BodyForAgent: paramsLocal.message.bodyForAgent ?? paramsLocal.message.rawBody,
InboundHistory: paramsLocal.message.inboundHistory,
RawBody: paramsLocal.message.rawBody,
CommandBody: paramsLocal.message.commandBody ?? paramsLocal.message.rawBody,
BodyForCommands: paramsLocal.message.commandBody ?? paramsLocal.message.rawBody,
From: paramsLocal.from,
To: paramsLocal.reply.to,
SessionKey: paramsLocal.route.dispatchSessionKey ?? paramsLocal.route.routeSessionKey,
AccountId: paramsLocal.route.accountId ?? paramsLocal.accountId,
ChatType: paramsLocal.conversation.kind,
ConversationLabel: paramsLocal.conversation.label,
SenderName: paramsLocal.sender.name,
SenderId: paramsLocal.sender.id,
Provider: paramsLocal.provider ?? paramsLocal.channel,
Surface: paramsLocal.surface ?? paramsLocal.provider ?? paramsLocal.channel,
MessageSid: paramsLocal.messageId,
MessageSidFull: paramsLocal.messageIdFull,
OriginatingChannel: paramsLocal.channel,
OriginatingTo: paramsLocal.reply.originatingTo,
...paramsLocal.extra,
}) as Awaited<ReturnType<PluginRuntime["channel"]["inbound"]["buildContext"]>>,
);
const buildAgentSessionKey = vi.fn(
(input: {
agentId: string;
channel: string;
accountId?: string;
peer?: { kind?: string; id?: string };
dmScope?: string;
}) => {
const peerKind = input.peer?.kind === "direct" ? "direct" : "group";
const peerId = input.peer?.id ?? "1";
if (peerKind === "direct") {
if (input.dmScope === "per-account-channel-peer") {
return `agent:${input.agentId}:${input.channel}:${input.accountId ?? "default"}:direct:${peerId}`;
}
if (input.dmScope === "per-peer") {
return `agent:${input.agentId}:direct:${peerId}`;
}
if (input.dmScope === "main" || !input.dmScope) {
return "agent:main:main";
}
}
return `agent:${input.agentId}:${input.channel}:${peerKind}:${peerId}`;
},
);
setZalouserRuntime({
logging: {
shouldLogVerbose: () => false,
},
channel: {
pairing: {
readAllowFromStore,
upsertPairingRequest: vi.fn(async () => ({ code: "PAIR", created: true })),
buildPairingReply: vi.fn(() => "pair"),
},
commands: {
shouldComputeCommandAuthorized: vi.fn((body: string) => body.trim().startsWith("/")),
resolveCommandAuthorizedFromAuthorizers,
isControlCommandMessage: vi.fn((body: string) => body.trim().startsWith("/")),
shouldHandleTextCommands: vi.fn(() => true),
},
mentions: {
buildMentionRegexes: vi.fn(() => []),
matchesMentionWithExplicit: vi.fn(
(input) => input.explicit?.isExplicitlyMentioned === true,
),
},
groups: {
resolveRequireMention: vi.fn((input) => {
const cfg = input.cfg as OpenClawConfig;
const groupCfg = cfg.channels?.zalouser?.groups ?? {};
const typedGroupCfg = groupCfg as Record<string, { requireMention?: boolean }>;
const groupEntry = input.groupId ? typedGroupCfg[input.groupId] : undefined;
const defaultEntry = typedGroupCfg["*"];
if (typeof groupEntry?.requireMention === "boolean") {
return groupEntry.requireMention;
}
if (typeof defaultEntry?.requireMention === "boolean") {
return defaultEntry.requireMention;
}
return true;
}),
},
routing: {
buildAgentSessionKey,
resolveAgentRoute,
},
session: {
resolveStorePath: vi.fn(() => "/tmp"),
readSessionUpdatedAt,
recordInboundSession: vi.fn(async () => {}),
},
reply: {
resolveEnvelopeFormatOptions: vi.fn(() => undefined),
formatAgentEnvelope: vi.fn(({ body }) => body),
finalizeInboundContext: vi.fn((ctx) => ctx),
dispatchReplyWithBufferedBlockDispatcher,
},
inbound: {
dispatchReply:
dispatchAssembled as unknown as PluginRuntime["channel"]["inbound"]["dispatchReply"],
buildContext:
buildContext as unknown as PluginRuntime["channel"]["inbound"]["buildContext"],
},
text: {
resolveMarkdownTableMode: vi.fn(() => "code"),
convertMarkdownTables: vi.fn((text: string) => text),
resolveChunkMode: vi.fn(() => "length"),
resolveTextChunkLimit: vi.fn(() => 1200),
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
},
},
} as unknown as PluginRuntime);
return {
dispatchReplyWithBufferedBlockDispatcher,
resolveAgentRoute,
resolveCommandAuthorizedFromAuthorizers,
readAllowFromStore,
readSessionUpdatedAt,
buildAgentSessionKey,
};
}
function installGroupCommandAuthRuntime() {
return installRuntime({
resolveCommandAuthorizedFromAuthorizers: ({ useAccessGroups, authorizers }) =>
useAccessGroups && authorizers.some((entry) => entry.configured && entry.allowed),
});
}
async function processGroupControlCommand(params: {
account: ResolvedZalouserAccount;
content?: string;
commandContent?: string;
}) {
await testing.processMessage({
message: createGroupMessage({
content: params.content ?? "/new",
commandContent: params.commandContent ?? "/new",
hasAnyMention: true,
wasExplicitlyMentioned: true,
}),
account: params.account,
config: createConfig(),
runtime: createRuntimeEnv(),
});
}
function createGroupMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
return {
threadId: "g-1",
isGroup: true,
senderId: "123",
senderName: "Alice",
groupName: "Team",
content: "hello",
timestampMs: Date.now(),
msgId: "m-1",
hasAnyMention: false,
wasExplicitlyMentioned: false,
canResolveExplicitMention: true,
implicitMention: false,
raw: { source: "test" },
...overrides,
};
}
function createDmMessage(overrides: Partial<ZaloInboundMessage> = {}): ZaloInboundMessage {
return {
threadId: "u-1",
isGroup: false,
senderId: "321",
senderName: "Bob",
groupName: undefined,
content: "hello",
timestampMs: Date.now(),
msgId: "dm-1",
raw: { source: "test" },
...overrides,
};
}
describe("zalouser monitor group mention gating", () => {
beforeEach(() => {
sendMessageZalouserMock.mockClear();
sendTypingZalouserMock.mockClear();
sendDeliveredZalouserMock.mockClear();
sendSeenZalouserMock.mockClear();
listZaloFriendsMock.mockReset();
listZaloFriendsMock.mockResolvedValue([]);
listZaloGroupsMock.mockReset();
listZaloGroupsMock.mockResolvedValue([]);
startZaloListenerMock.mockReset();
startZaloListenerMock.mockResolvedValue({ stop: vi.fn() });
});
async function processMessageWithDefaults(params: {
message: ZaloInboundMessage;
account?: ResolvedZalouserAccount;
historyState?: {
historyLimit: number;
groupHistories: Map<
string,
Array<{ sender: string; body: string; timestamp?: number; messageId?: string }>
>;
};
}) {
await testing.processMessage({
message: params.message,
account: params.account ?? createAccount(),
config: createConfig(),
runtime: createZalouserRuntimeEnv(),
historyState: params.historyState,
});
}
async function expectSkippedGroupMessage(message?: Partial<ZaloInboundMessage>) {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await processMessageWithDefaults({
message: createGroupMessage(message),
});
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
expect(sendTypingZalouserMock).not.toHaveBeenCalled();
}
async function startMonitorForStartupResolution(
accountConfig: ResolvedZalouserAccount["config"],
) {
installRuntime({ commandAuthorized: false });
const abortController = new AbortController();
abortController.abort();
await monitorZalouserProvider({
account: {
...createAccount(),
config: accountConfig,
},
config: createConfig(),
runtime: createRuntimeEnv(),
abortSignal: abortController.signal,
});
}
async function expectGroupCommandAuthorizers(params: {
accountConfig: ResolvedZalouserAccount["config"];
expectedCommandAuthorized: boolean;
}) {
const { dispatchReplyWithBufferedBlockDispatcher, resolveCommandAuthorizedFromAuthorizers } =
installGroupCommandAuthRuntime();
await processGroupControlCommand({
account: {
...createAccount(),
config: params.accountConfig,
},
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
expect(resolveCommandAuthorizedFromAuthorizers).not.toHaveBeenCalled();
const callArg = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
expect(callArg?.ctx?.CommandAuthorized).toBe(params.expectedCommandAuthorized);
}
async function processOpenDmMessage(params?: {
message?: Partial<ZaloInboundMessage>;
readSessionUpdatedAt?: (input?: {
storePath: string;
sessionKey: string;
}) => number | undefined;
}) {
const runtime = installRuntime({
commandAuthorized: false,
});
if (params?.readSessionUpdatedAt) {
runtime.readSessionUpdatedAt.mockImplementation(params.readSessionUpdatedAt);
}
const account = createAccount();
await processMessageWithDefaults({
message: createDmMessage(params?.message),
account: {
...account,
config: {
...account.config,
dmPolicy: "open",
},
},
});
return runtime;
}
async function expectDangerousNameMatching(params: {
dangerouslyAllowNameMatching?: boolean;
expectedDispatches: number;
}) {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await processMessageWithDefaults({
message: createGroupMessage({
threadId: "g-attacker-001",
groupName: "Trusted Team",
senderId: "666",
hasAnyMention: true,
wasExplicitlyMentioned: true,
content: "ping @bot",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
...(params.dangerouslyAllowNameMatching ? { dangerouslyAllowNameMatching: true } : {}),
groupPolicy: "allowlist",
groupAllowFrom: ["*"],
groups: {
"group:g-trusted-001": { enabled: true },
"Trusted Team": { enabled: true },
},
},
},
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(
params.expectedDispatches,
);
return dispatchReplyWithBufferedBlockDispatcher;
}
async function dispatchGroupMessage(params: {
commandAuthorized: boolean;
message: Partial<ZaloInboundMessage>;
}) {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: params.commandAuthorized,
});
await processMessageWithDefaults({
message: createGroupMessage(params.message),
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
return dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
}
it("skips unmentioned group messages when requireMention=true", async () => {
await expectSkippedGroupMessage();
});
it("blocks mentioned group messages by default when groupPolicy is omitted", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
const cfg: OpenClawConfig = {
channels: {
zalouser: {
enabled: true,
},
},
};
const account = resolveZalouserAccountSync({ cfg, accountId: "default" });
await testing.processMessage({
message: createGroupMessage({
content: "ping @bot",
hasAnyMention: true,
wasExplicitlyMentioned: true,
}),
account,
config: cfg,
runtime: createRuntimeEnv(),
});
expect(account.config.groupPolicy).toBe("allowlist");
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("fails closed when requireMention=true but mention detection is unavailable", async () => {
await expectSkippedGroupMessage({
canResolveExplicitMention: false,
hasAnyMention: false,
wasExplicitlyMentioned: false,
});
});
it("dispatches explicitly-mentioned group messages and marks WasMentioned", async () => {
const callArg = await dispatchGroupMessage({
commandAuthorized: false,
message: {
hasAnyMention: true,
wasExplicitlyMentioned: true,
content: "ping @bot",
},
});
expect(callArg?.ctx?.WasMentioned).toBe(true);
expect(callArg?.ctx?.To).toBe("zalouser:group:g-1");
expect(callArg?.ctx?.OriginatingTo).toBe("zalouser:group:g-1");
expect(sendTypingZalouserMock).toHaveBeenCalledWith("g-1", {
profile: "default",
isGroup: true,
});
});
it("allows authorized control commands to bypass mention gating", async () => {
const callArg = await dispatchGroupMessage({
commandAuthorized: true,
message: {
content: "/status",
hasAnyMention: false,
wasExplicitlyMentioned: false,
},
});
expect(callArg?.ctx?.WasMentioned).toBe(true);
});
it("passes long markdown replies through once so formatting happens before chunking", async () => {
const replyText = `**${"a".repeat(2501)}**`;
installRuntime({
commandAuthorized: false,
replyPayload: { text: replyText },
});
await testing.processMessage({
message: createDmMessage({
content: "hello",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
dmPolicy: "open",
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(sendMessageZalouserMock).toHaveBeenCalledTimes(1);
expect(sendMessageZalouserMock).toHaveBeenCalledWith("u-1", replyText, {
isGroup: false,
profile: "default",
textMode: "markdown",
textChunkMode: "length",
textChunkLimit: 1200,
});
});
it("allows DM senders from static access groups", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await testing.processMessage({
message: createDmMessage({ senderId: "321" }),
account: {
...createAccount(),
config: {
...createAccount().config,
dmPolicy: "allowlist",
allowFrom: ["accessGroup:operators"],
},
},
config: {
...createConfig(),
accessGroups: {
operators: {
type: "message.senders",
members: { zalouser: ["321"] },
},
},
},
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
});
it("uses commandContent for mention-prefixed control commands", async () => {
const callArg = await dispatchGroupMessage({
commandAuthorized: true,
message: {
content: "@Bot /new",
commandContent: "/new",
hasAnyMention: true,
wasExplicitlyMentioned: true,
},
});
expect(callArg?.ctx?.CommandBody).toBe("/new");
expect(callArg?.ctx?.BodyForCommands).toBe("/new");
});
it("allows group control commands when only allowFrom is configured", async () => {
await expectGroupCommandAuthorizers({
accountConfig: {
...createAccount().config,
allowFrom: ["123"],
},
expectedCommandAuthorized: true,
});
});
it("blocks routed allowlist groups without an explicit group sender allowlist", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await testing.processMessage({
message: createGroupMessage({
content: "ping @bot",
hasAnyMention: true,
wasExplicitlyMentioned: true,
senderId: "456",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
groupPolicy: "allowlist",
allowFrom: ["123"],
groups: {
"group:g-1": { enabled: true, requireMention: true },
},
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("allows group senders from static access groups", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await testing.processMessage({
message: createGroupMessage({
content: "ping @bot",
hasAnyMention: true,
wasExplicitlyMentioned: true,
senderId: "123",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
groupPolicy: "allowlist",
groupAllowFrom: ["accessGroup:operators"],
},
},
config: {
...createConfig(),
accessGroups: {
operators: {
type: "message.senders",
members: { zalouser: ["123"] },
},
},
},
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
});
it("blocks group messages when sender is not in groupAllowFrom", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
await testing.processMessage({
message: createGroupMessage({
content: "ping @bot",
hasAnyMention: true,
wasExplicitlyMentioned: true,
}),
account: {
...createAccount(),
config: {
...createAccount().config,
groupPolicy: "allowlist",
allowFrom: ["999"],
groupAllowFrom: ["999"],
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
it("does not accept a different group id by matching only the mutable group name by default", async () => {
await expectDangerousNameMatching({ expectedDispatches: 0 });
});
it("accepts mutable group-name matches only when dangerouslyAllowNameMatching is enabled", async () => {
const dispatchReplyWithBufferedBlockDispatcher = await expectDangerousNameMatching({
dangerouslyAllowNameMatching: true,
expectedDispatches: 1,
});
const callArg = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
expect(callArg?.ctx?.To).toBe("zalouser:group:g-attacker-001");
});
it("does not resolve mutable allowlist or group names at startup by default", async () => {
listZaloFriendsMock.mockResolvedValue([{ userId: "999", displayName: "Alice" }]);
listZaloGroupsMock.mockResolvedValue([{ groupId: "g-other", name: "Trusted Team" }]);
await startMonitorForStartupResolution({
...createAccount().config,
dmPolicy: "allowlist",
allowFrom: ["Alice"],
groupPolicy: "allowlist",
groupAllowFrom: ["Alice"],
groups: {
"Trusted Team": { enabled: true },
},
});
expect(listZaloFriendsMock).not.toHaveBeenCalled();
expect(listZaloGroupsMock).not.toHaveBeenCalled();
});
it("resolves mutable allowlist and group names at startup when enabled", async () => {
listZaloFriendsMock.mockResolvedValue([{ userId: "123", displayName: "Alice" }]);
listZaloGroupsMock.mockResolvedValue([{ groupId: "g-trusted", name: "Trusted Team" }]);
await startMonitorForStartupResolution({
...createAccount().config,
dangerouslyAllowNameMatching: true,
dmPolicy: "allowlist",
allowFrom: ["Alice"],
groupPolicy: "allowlist",
groupAllowFrom: ["Alice"],
groups: {
"Trusted Team": { enabled: true },
},
});
expect(listZaloFriendsMock).toHaveBeenCalledWith("default");
expect(listZaloGroupsMock).toHaveBeenCalledWith("default");
});
it("allows group control commands when sender is in groupAllowFrom", async () => {
await expectGroupCommandAuthorizers({
accountConfig: {
...createAccount().config,
allowFrom: ["999"],
groupAllowFrom: ["123"],
},
expectedCommandAuthorized: true,
});
});
it("routes DM messages with direct peer kind", async () => {
const { dispatchReplyWithBufferedBlockDispatcher, resolveAgentRoute, buildAgentSessionKey } =
await processOpenDmMessage();
const routeInput = mockCallArg(resolveAgentRoute, "resolve agent route") as {
peer?: unknown;
};
expect(routeInput?.peer).toEqual({ kind: "direct", id: "321" });
const sessionKeyInput = mockCallArg(buildAgentSessionKey, "build agent session key") as {
dmScope?: string;
peer?: unknown;
};
expect(sessionKeyInput?.peer).toEqual({ kind: "direct", id: "321" });
expect(sessionKeyInput?.dmScope).toBe("per-channel-peer");
const callArg = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:direct:321");
});
it("surfaces quote metadata in inbound reply context", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = await processOpenDmMessage({
message: {
quotedGlobalMsgId: "987654321234",
quotedOwnerId: "555444333",
quotedBody: "Previous bot message content",
},
});
const callArg = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
expect(callArg?.ctx?.ReplyToId).toBe("987654321234");
expect(callArg?.ctx?.ReplyToBody).toBe("Previous bot message content");
expect(callArg?.ctx?.ReplyToIsQuote).toBe(true);
});
it("reuses the legacy DM session key when only the old group-shaped session exists", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = await processOpenDmMessage({
readSessionUpdatedAt: (input?: { storePath: string; sessionKey: string }) =>
input?.sessionKey === "agent:main:zalouser:group:321" ? 123 : undefined,
});
const callArg = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
expect(callArg?.ctx?.SessionKey).toBe("agent:main:zalouser:group:321");
});
it("skips pairing store read for open DM control commands", async () => {
const { readAllowFromStore } = installRuntime({
commandAuthorized: false,
});
const account = createAccount();
await testing.processMessage({
message: createDmMessage({ content: "/new", commandContent: "/new" }),
account: {
...account,
config: {
...account.config,
dmPolicy: "open",
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(readAllowFromStore).not.toHaveBeenCalled();
});
it("skips pairing store read for open DM non-command messages", async () => {
const { readAllowFromStore } = installRuntime({
commandAuthorized: false,
});
const account = createAccount();
await testing.processMessage({
message: createDmMessage({ content: "hello there" }),
account: {
...account,
config: {
...account.config,
dmPolicy: "open",
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(readAllowFromStore).not.toHaveBeenCalled();
});
it("includes skipped group messages as InboundHistory on the next processed message", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
});
const historyState = {
historyLimit: 5,
groupHistories: new Map<
string,
Array<{ sender: string; body: string; timestamp?: number; messageId?: string }>
>(),
};
const account = createAccount();
const config = createConfig();
await testing.processMessage({
message: createGroupMessage({
content: "first unmentioned line",
msgId: "history-1",
timestampMs: 1700000000000,
hasAnyMention: false,
wasExplicitlyMentioned: false,
}),
account,
config,
runtime: createRuntimeEnv(),
historyState,
});
expect(dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
await testing.processMessage({
message: createGroupMessage({
content: "second line @bot",
hasAnyMention: true,
wasExplicitlyMentioned: true,
}),
account,
config,
runtime: createRuntimeEnv(),
historyState,
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
const firstDispatch = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher);
expect(firstDispatch?.ctx?.InboundHistory).toEqual([
{
sender: "Alice",
body: "first unmentioned line",
messageId: "history-1",
timestamp: 1700000000000,
},
]);
expect(firstDispatch?.ctx?.Body ?? "").toContain("first unmentioned line");
await testing.processMessage({
message: createGroupMessage({
content: "third line @bot",
hasAnyMention: true,
wasExplicitlyMentioned: true,
}),
account,
config,
runtime: createRuntimeEnv(),
historyState,
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(2);
const secondDispatch = dispatchReplyCall(dispatchReplyWithBufferedBlockDispatcher, 1);
expect(secondDispatch?.ctx?.InboundHistory).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,21 @@
// Zalouser plugin module implements monitor.send mocks behavior.
import { vi } from "vitest";
const sendMocks = vi.hoisted(() => ({
sendMessageZalouserMock: vi.fn(async () => {}),
sendTypingZalouserMock: vi.fn(async () => {}),
sendDeliveredZalouserMock: vi.fn(async () => {}),
sendSeenZalouserMock: vi.fn(async () => {}),
}));
export const sendMessageZalouserMock = sendMocks.sendMessageZalouserMock;
export const sendTypingZalouserMock = sendMocks.sendTypingZalouserMock;
export const sendDeliveredZalouserMock = sendMocks.sendDeliveredZalouserMock;
export const sendSeenZalouserMock = sendMocks.sendSeenZalouserMock;
vi.mock("./send.js", () => ({
sendMessageZalouser: sendMessageZalouserMock,
sendTypingZalouser: sendTypingZalouserMock,
sendDeliveredZalouser: sendDeliveredZalouserMock,
sendSeenZalouser: sendSeenZalouserMock,
}));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
// Zalouser tests cover probe plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { probeZalouser } from "./probe.js";
import { getZaloUserInfo } from "./zalo-js.js";
vi.mock("./zalo-js.js", () => ({
getZaloUserInfo: vi.fn(),
}));
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
describe("probeZalouser", () => {
beforeEach(() => {
mockGetUserInfo.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("returns ok=true with user when authenticated", async () => {
mockGetUserInfo.mockResolvedValueOnce({
userId: "123",
displayName: "Alice",
});
await expect(probeZalouser("default")).resolves.toEqual({
ok: true,
user: { userId: "123", displayName: "Alice" },
});
});
it("returns not authenticated when no user info is returned", async () => {
mockGetUserInfo.mockResolvedValueOnce(null);
await expect(probeZalouser("default")).resolves.toEqual({
ok: false,
error: "Not authenticated",
});
});
it("returns error when user lookup throws", async () => {
mockGetUserInfo.mockRejectedValueOnce(new Error("network down"));
await expect(probeZalouser("default")).resolves.toEqual({
ok: false,
error: "network down",
});
});
it("times out when lookup takes too long", async () => {
vi.useFakeTimers();
mockGetUserInfo.mockReturnValueOnce(new Promise(() => {}));
const pending = probeZalouser("default", 10);
await vi.advanceTimersByTimeAsync(1000);
await expect(pending).resolves.toEqual({
ok: false,
error: "Not authenticated",
});
});
it("caps oversized lookup timeout before scheduling", async () => {
vi.useFakeTimers();
mockGetUserInfo.mockReturnValueOnce(new Promise(() => {}));
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
void probeZalouser("default", Number.MAX_SAFE_INTEGER);
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
});
});

View File

@@ -0,0 +1,37 @@
// Zalouser plugin module implements probe behavior.
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import type { ZcaUserInfo } from "./types.js";
import { getZaloUserInfo } from "./zalo-js.js";
export type ZalouserProbeResult = BaseProbeResult<string> & {
user?: ZcaUserInfo;
};
export async function probeZalouser(
profile: string,
timeoutMs?: number,
): Promise<ZalouserProbeResult> {
try {
const user = timeoutMs
? await Promise.race([
getZaloUserInfo(profile),
new Promise<null>((resolve) => {
setTimeout(() => resolve(null), resolveTimerTimeoutMs(timeoutMs, 1000, 1000));
}),
])
: await getZaloUserInfo(profile);
if (!user) {
return { ok: false, error: "Not authenticated" };
}
return { ok: true, user };
} catch (error) {
return {
ok: false,
error: formatErrorMessage(error),
};
}
}

View File

@@ -0,0 +1,23 @@
// Zalouser plugin module implements qr temp file behavior.
import fsp from "node:fs/promises";
import path from "node:path";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
export async function writeQrDataUrlToTempFile(
qrDataUrl: string,
profile: string,
): Promise<string | null> {
const trimmed = qrDataUrl.trim();
const match = trimmed.match(/^data:image\/png;base64,(.+)$/i);
const base64 = (match?.[1] ?? "").trim();
if (!base64) {
return null;
}
const safeProfile = profile.replace(/[^a-zA-Z0-9_-]+/g, "-") || "default";
const filePath = path.join(
resolvePreferredOpenClawTmpDir(),
`openclaw-zalouser-qr-${safeProfile}.png`,
);
await fsp.writeFile(filePath, Buffer.from(base64, "base64"));
return filePath;
}

View File

@@ -0,0 +1,20 @@
// Zalouser tests cover reaction plugin behavior.
import { describe, expect, it } from "vitest";
import { normalizeZaloReactionIcon } from "./reaction.js";
describe("zalouser reaction alias normalization", () => {
it("maps common aliases", () => {
expect(normalizeZaloReactionIcon("like")).toBe("/-strong");
expect(normalizeZaloReactionIcon("👍")).toBe("/-strong");
expect(normalizeZaloReactionIcon("heart")).toBe("/-heart");
expect(normalizeZaloReactionIcon("😂")).toBe(":>");
});
it("defaults empty icon to like", () => {
expect(normalizeZaloReactionIcon("")).toBe("/-strong");
});
it("passes through unknown custom reactions", () => {
expect(normalizeZaloReactionIcon("/custom")).toBe("/custom");
});
});

View File

@@ -0,0 +1,33 @@
// Zalouser plugin module implements reaction behavior.
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { Reactions } from "./zca-constants.js";
const REACTION_ALIAS_MAP = new Map<string, string>([
["like", Reactions.LIKE],
["👍", Reactions.LIKE],
[":+1:", Reactions.LIKE],
["heart", Reactions.HEART],
["❤️", Reactions.HEART],
["<3", Reactions.HEART],
["haha", Reactions.HAHA],
["laugh", Reactions.HAHA],
["😂", Reactions.HAHA],
["wow", Reactions.WOW],
["😮", Reactions.WOW],
["cry", Reactions.CRY],
["😢", Reactions.CRY],
["angry", Reactions.ANGRY],
["😡", Reactions.ANGRY],
]);
export function normalizeZaloReactionIcon(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return Reactions.LIKE;
}
return (
REACTION_ALIAS_MAP.get(normalizeLowercaseStringOrEmpty(trimmed)) ??
REACTION_ALIAS_MAP.get(trimmed) ??
trimmed
);
}

View File

@@ -0,0 +1,10 @@
// Zalouser plugin module implements runtime behavior.
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
const { setRuntime: setZalouserRuntime, getRuntime: getZalouserRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "zalouser",
errorMessage: "Zalouser runtime not initialized",
});
export { getZalouserRuntime, setZalouserRuntime };

View File

@@ -0,0 +1,84 @@
// Zalouser tests cover security audit plugin behavior.
import { describe, expect, it } from "vitest";
import { collectZalouserSecurityAuditFindings } from "./security-audit.js";
import type { ResolvedZalouserAccount, ZalouserAccountConfig } from "./types.js";
function createAccount(config: ZalouserAccountConfig): ResolvedZalouserAccount {
return {
accountId: "default",
enabled: true,
profile: "default",
authenticated: true,
config,
};
}
describe("Zalouser security audit findings", () => {
const cases: Array<{
name: string;
config: ZalouserAccountConfig;
expectedSeverity: "info" | "warn";
expectedTitle: string;
expectedRemediation: string;
detailIncludes: string[];
detailExcludes?: string[];
}> = [
{
name: "warns when group routing contains mutable group entries",
config: {
enabled: true,
groups: {
"Ops Room": { enabled: true },
"group:g-123": { enabled: true },
},
} satisfies ZalouserAccountConfig,
expectedSeverity: "warn",
expectedTitle: "Zalouser group routing contains mutable group entries",
expectedRemediation:
"Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
detailIncludes: ["channels.zalouser.groups:Ops Room"],
detailExcludes: ["group:g-123"],
},
{
name: "marks mutable group routing as break-glass when dangerous matching is enabled",
config: {
enabled: true,
dangerouslyAllowNameMatching: true,
groups: {
"Ops Room": { enabled: true },
},
} satisfies ZalouserAccountConfig,
expectedSeverity: "info",
expectedTitle: "Zalouser group routing uses break-glass name matching",
expectedRemediation:
"Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching.",
detailIncludes: ["out-of-scope"],
},
];
it.each(cases)("$name", (testCase) => {
const findings = collectZalouserSecurityAuditFindings({
account: createAccount(testCase.config),
accountId: "default",
orderedAccountIds: ["default"],
hasExplicitAccountPath: false,
});
const finding = findings.find(
(entry) => entry.checkId === "channels.zalouser.groups.mutable_entries",
);
if (!finding) {
throw new Error("expected mutable Zalo User group finding");
}
expect(finding.checkId).toBe("channels.zalouser.groups.mutable_entries");
expect(finding.severity).toBe(testCase.expectedSeverity);
expect(finding.title).toBe(testCase.expectedTitle);
expect(finding.remediation).toBe(testCase.expectedRemediation);
for (const snippet of testCase.detailIncludes) {
expect(finding.detail).toContain(snippet);
}
for (const snippet of testCase.detailExcludes ?? []) {
expect(finding.detail).not.toContain(snippet);
}
});
});

View File

@@ -0,0 +1,72 @@
// Zalouser plugin module implements security audit behavior.
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import type { ResolvedZalouserAccount } from "./accounts.js";
export function isZalouserMutableGroupEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^(zalouser|zlu):/i, "")
.replace(/^group:/i, "")
.trim();
if (!normalized) {
return false;
}
if (/^\d+$/.test(normalized)) {
return false;
}
return !/^g-\S+$/i.test(normalized);
}
export function collectZalouserSecurityAuditFindings(params: {
accountId?: string | null;
account: ResolvedZalouserAccount;
orderedAccountIds: string[];
hasExplicitAccountPath: boolean;
}) {
const zalouserCfg = params.account.config ?? {};
const accountId = params.accountId?.trim() || params.account.accountId || "default";
const dangerousNameMatchingEnabled = isDangerousNameMatchingEnabled(zalouserCfg);
const zalouserPathPrefix =
params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath
? `channels.zalouser.accounts.${accountId}`
: "channels.zalouser";
const mutableGroupEntries = new Set<string>();
const groups = zalouserCfg.groups;
if (groups && typeof groups === "object" && !Array.isArray(groups)) {
for (const key of Object.keys(groups as Record<string, unknown>)) {
if (!isZalouserMutableGroupEntry(key)) {
continue;
}
mutableGroupEntries.add(`${zalouserPathPrefix}.groups:${key}`);
}
}
if (mutableGroupEntries.size === 0) {
return [];
}
const examples = Array.from(mutableGroupEntries).slice(0, 5);
const more =
mutableGroupEntries.size > examples.length
? ` (+${mutableGroupEntries.size - examples.length} more)`
: "";
const severity: "info" | "warn" = dangerousNameMatchingEnabled ? "info" : "warn";
return [
{
checkId: "channels.zalouser.groups.mutable_entries",
severity,
title: dangerousNameMatchingEnabled
? "Zalouser group routing uses break-glass name matching"
: "Zalouser group routing contains mutable group entries",
detail: dangerousNameMatchingEnabled
? "Zalouser group-name routing is explicitly enabled via dangerouslyAllowNameMatching. This mutable-identity mode is operator-selected break-glass behavior and out-of-scope for vulnerability reports by itself. " +
`Found: ${examples.join(", ")}${more}.`
: "Zalouser group auth is ID-only by default, so unresolved group-name or slug entries are ignored for auth and can drift from the intended trusted group. " +
`Found: ${examples.join(", ")}${more}.`,
remediation: dangerousNameMatchingEnabled
? "Prefer stable Zalo group IDs (for example group:<id> or provider-native g- ids), then disable dangerouslyAllowNameMatching."
: "Prefer stable Zalo group IDs in channels.zalouser.groups, or explicitly opt in with dangerouslyAllowNameMatching=true if you accept mutable group-name matching.",
},
];
}

View File

@@ -0,0 +1,32 @@
// Zalouser plugin module implements send receipt behavior.
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-outbound";
export function createZalouserSendReceipt(params: {
messageId?: string;
platformMessageIds?: readonly (string | null | undefined)[];
threadId?: string;
kind?: MessageReceiptPartKind;
}): MessageReceipt {
const platformMessageIds = (params.platformMessageIds ?? [params.messageId])
.map((messageId) => messageId?.trim())
.filter((messageId): messageId is string => Boolean(messageId));
const threadId = params.threadId?.trim();
return createMessageReceiptFromOutboundResults({
results: platformMessageIds.map((messageId) => {
const result: { channel: string; messageId: string; conversationId?: string } = {
channel: "zalouser",
messageId,
};
if (threadId) {
result.conversationId = threadId;
}
return result;
}),
...(threadId ? { threadId } : {}),
kind: params.kind ?? "unknown",
});
}

View File

@@ -0,0 +1,445 @@
// Zalouser tests cover send plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createZalouserSendReceipt } from "./send-receipt.js";
import {
sendDeliveredZalouser,
sendImageZalouser,
sendLinkZalouser,
sendMessageZalouser,
sendReactionZalouser,
sendSeenZalouser,
sendTypingZalouser,
} from "./send.js";
import { parseZalouserTextStyles } from "./text-styles.js";
import {
sendZaloDeliveredEvent,
sendZaloLink,
sendZaloReaction,
sendZaloSeenEvent,
sendZaloTextMessage,
sendZaloTypingEvent,
} from "./zalo-js.js";
import { TextStyle } from "./zca-constants.js";
vi.mock("./zalo-js.js", () => ({
sendZaloTextMessage: vi.fn(),
sendZaloLink: vi.fn(),
sendZaloTypingEvent: vi.fn(),
sendZaloReaction: vi.fn(),
sendZaloDeliveredEvent: vi.fn(),
sendZaloSeenEvent: vi.fn(),
}));
const mockSendText = vi.mocked(sendZaloTextMessage);
const mockSendLink = vi.mocked(sendZaloLink);
const mockSendTyping = vi.mocked(sendZaloTypingEvent);
const mockSendReaction = vi.mocked(sendZaloReaction);
const mockSendDelivered = vi.mocked(sendZaloDeliveredEvent);
const mockSendSeen = vi.mocked(sendZaloSeenEvent);
function sendResult(
messageId: string,
threadId = "thread",
): {
ok: true;
messageId: string;
receipt: ReturnType<typeof createZalouserSendReceipt>;
} {
return {
ok: true,
messageId,
receipt: createZalouserSendReceipt({ messageId, threadId, kind: "text" }),
};
}
function sendFailure(error: string, threadId = "thread") {
return {
ok: false,
error,
receipt: createZalouserSendReceipt({ threadId, kind: "unknown" }),
};
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null) {
throw new Error(`${label} was not an object`);
}
return value as Record<string, unknown>;
}
function expectRecordFields(record: Record<string, unknown>, fields: Record<string, unknown>) {
for (const [key, value] of Object.entries(fields)) {
expect(record[key]).toEqual(value);
}
}
function expectResultFields(result: unknown, fields: Record<string, unknown>) {
expectRecordFields(requireRecord(result, "send result"), fields);
}
function requireSendTextCall(callIndex: number): unknown[] {
const call = (mockSendText.mock.calls as unknown[][])[callIndex];
if (!call) {
throw new Error(`expected send text call ${callIndex + 1}`);
}
return call;
}
function requireSendTextOptions(callIndex: number): Record<string, unknown> {
return requireRecord(
requireSendTextCall(callIndex)[2],
`send text call ${callIndex + 1} options`,
);
}
function expectSendTextOptions(callIndex: number, fields: Record<string, unknown>) {
expectRecordFields(requireSendTextOptions(callIndex), fields);
}
describe("zalouser send helpers", () => {
beforeEach(() => {
mockSendText.mockReset();
mockSendLink.mockReset();
mockSendTyping.mockReset();
mockSendReaction.mockReset();
mockSendDelivered.mockReset();
mockSendSeen.mockReset();
});
it("keeps plain text literal by default", async () => {
mockSendText.mockResolvedValueOnce(sendResult("mid-1", "thread-1"));
const result = await sendMessageZalouser("thread-1", "**hello**", {
profile: "default",
isGroup: true,
});
expect(requireSendTextCall(0)[0]).toBe("thread-1");
expect(requireSendTextCall(0)[1]).toBe("**hello**");
expectSendTextOptions(0, { profile: "default", isGroup: true });
expectResultFields(result, { ok: true, messageId: "mid-1" });
expect(result.receipt.primaryPlatformMessageId).toBe("mid-1");
});
it("formats markdown text when markdown mode is enabled", async () => {
mockSendText.mockResolvedValueOnce(sendResult("mid-1b", "thread-1"));
await sendMessageZalouser("thread-1", "**hello**", {
profile: "default",
isGroup: true,
textMode: "markdown",
});
expect(requireSendTextCall(0)[0]).toBe("thread-1");
expect(requireSendTextCall(0)[1]).toBe("hello");
expectSendTextOptions(0, {
profile: "default",
isGroup: true,
textMode: "markdown",
textStyles: [{ start: 0, len: 5, st: TextStyle.Bold }],
});
});
it("formats image captions in markdown mode", async () => {
mockSendText.mockResolvedValueOnce(sendResult("mid-2", "thread-2"));
await sendImageZalouser("thread-2", "https://example.com/a.png", {
profile: "p2",
caption: "_cap_",
isGroup: false,
textMode: "markdown",
});
expect(requireSendTextCall(0)[0]).toBe("thread-2");
expect(requireSendTextCall(0)[1]).toBe("cap");
expectSendTextOptions(0, {
profile: "p2",
caption: undefined,
isGroup: false,
mediaUrl: "https://example.com/a.png",
textMode: "markdown",
textStyles: [{ start: 0, len: 3, st: TextStyle.Italic }],
});
});
it("does not keep the raw markdown caption as a media fallback after formatting", async () => {
mockSendText.mockResolvedValueOnce(sendResult("mid-2b", "thread-2"));
await sendImageZalouser("thread-2", "https://example.com/a.png", {
profile: "p2",
caption: "```\n```",
isGroup: false,
textMode: "markdown",
});
expect(requireSendTextCall(0)[0]).toBe("thread-2");
expect(requireSendTextCall(0)[1]).toBe("");
expectSendTextOptions(0, {
profile: "p2",
caption: undefined,
isGroup: false,
mediaUrl: "https://example.com/a.png",
textMode: "markdown",
textStyles: undefined,
});
});
it("rechunks normalized markdown text before sending to avoid transport truncation", async () => {
const text = "\t".repeat(500) + "a".repeat(1500);
const formatted = parseZalouserTextStyles(text);
mockSendText
.mockResolvedValueOnce(sendResult("mid-2c-1", "thread-2c"))
.mockResolvedValueOnce(sendResult("mid-2c-2", "thread-2c"));
const result = await sendMessageZalouser("thread-2c", text, {
profile: "p2c",
isGroup: false,
textMode: "markdown",
});
expect(formatted.text.length).toBeGreaterThan(2000);
expect(mockSendText).toHaveBeenCalledTimes(2);
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
expect(
mockSendText.mock.calls
.map((call, index) => ({ index, length: call[1].length }))
.filter((call) => call.length > 2000),
).toStrictEqual([]);
expectResultFields(result, { ok: true, messageId: "mid-2c-2" });
});
it("reports each completed internal chunk before a later chunk fails", async () => {
const firstResult = sendResult("mid-progress-1", "thread-progress");
mockSendText
.mockResolvedValueOnce(firstResult)
.mockResolvedValueOnce(sendFailure("second chunk failed", "thread-progress"));
const onDeliveryResult = vi.fn();
await expect(
sendMessageZalouser("thread-progress", "a".repeat(2001), {
textChunkLimit: 2000,
onDeliveryResult,
}),
).rejects.toThrow("second chunk failed");
expect(mockSendText).toHaveBeenCalledTimes(2);
expect(onDeliveryResult).toHaveBeenCalledOnce();
expect(onDeliveryResult).toHaveBeenCalledWith(firstResult);
expect(requireSendTextOptions(0)).not.toHaveProperty("onDeliveryResult");
});
it("preserves text styles when splitting long formatted markdown", async () => {
const text = `**${"a".repeat(2501)}**`;
mockSendText
.mockResolvedValueOnce(sendResult("mid-2d-1", "thread-2d"))
.mockResolvedValueOnce(sendResult("mid-2d-2", "thread-2d"));
const result = await sendMessageZalouser("thread-2d", text, {
profile: "p2d",
isGroup: false,
textMode: "markdown",
});
expect(requireSendTextCall(0)[0]).toBe("thread-2d");
expect(requireSendTextCall(0)[1]).toBe("a".repeat(2000));
expectSendTextOptions(0, {
profile: "p2d",
isGroup: false,
textMode: "markdown",
textStyles: [{ start: 0, len: 2000, st: TextStyle.Bold }],
});
expect(requireSendTextCall(1)[0]).toBe("thread-2d");
expect(requireSendTextCall(1)[1]).toBe("a".repeat(501));
expectSendTextOptions(1, {
profile: "p2d",
isGroup: false,
textMode: "markdown",
textStyles: [{ start: 0, len: 501, st: TextStyle.Bold }],
});
expectResultFields(result, { ok: true, messageId: "mid-2d-2" });
});
it("preserves formatted text and styles when newline chunk mode splits after parsing", async () => {
const text = `**${"a".repeat(1995)}**\n\nsecond paragraph`;
const formatted = parseZalouserTextStyles(text);
mockSendText
.mockResolvedValueOnce(sendResult("mid-2d-3", "thread-2d-2"))
.mockResolvedValueOnce(sendResult("mid-2d-4", "thread-2d-2"));
const result = await sendMessageZalouser("thread-2d-2", text, {
profile: "p2d-2",
isGroup: false,
textMode: "markdown",
textChunkMode: "newline",
});
expect(mockSendText).toHaveBeenCalledTimes(2);
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
expect(requireSendTextCall(0)[0]).toBe("thread-2d-2");
expect(requireSendTextCall(0)[1]).toBe(`${"a".repeat(1995)}\n\n`);
expectSendTextOptions(0, {
profile: "p2d-2",
isGroup: false,
textMode: "markdown",
textChunkMode: "newline",
textStyles: [{ start: 0, len: 1995, st: TextStyle.Bold }],
});
expect(requireSendTextCall(1)[0]).toBe("thread-2d-2");
expect(requireSendTextCall(1)[1]).toBe("second paragraph");
expectSendTextOptions(1, {
profile: "p2d-2",
isGroup: false,
textMode: "markdown",
textChunkMode: "newline",
textStyles: undefined,
});
expectResultFields(result, { ok: true, messageId: "mid-2d-4" });
});
it("respects an explicit text chunk limit when splitting formatted markdown", async () => {
const text = `**${"a".repeat(1501)}**`;
mockSendText
.mockResolvedValueOnce(sendResult("mid-2d-5", "thread-2d-3"))
.mockResolvedValueOnce(sendResult("mid-2d-6", "thread-2d-3"));
const result = await sendMessageZalouser("thread-2d-3", text, {
profile: "p2d-3",
isGroup: false,
textMode: "markdown",
textChunkLimit: 1200,
} as never);
expect(mockSendText).toHaveBeenCalledTimes(2);
expect(requireSendTextCall(0)[0]).toBe("thread-2d-3");
expect(requireSendTextCall(0)[1]).toBe("a".repeat(1200));
expectSendTextOptions(0, {
profile: "p2d-3",
isGroup: false,
textMode: "markdown",
textChunkLimit: 1200,
textStyles: [{ start: 0, len: 1200, st: TextStyle.Bold }],
});
expect(requireSendTextCall(1)[0]).toBe("thread-2d-3");
expect(requireSendTextCall(1)[1]).toBe("a".repeat(301));
expectSendTextOptions(1, {
profile: "p2d-3",
isGroup: false,
textMode: "markdown",
textChunkLimit: 1200,
textStyles: [{ start: 0, len: 301, st: TextStyle.Bold }],
});
expectResultFields(result, { ok: true, messageId: "mid-2d-6" });
});
it("sends overflow markdown captions as follow-up text after the media message", async () => {
const caption = "\t".repeat(500) + "a".repeat(1500);
const formatted = parseZalouserTextStyles(caption);
mockSendText
.mockResolvedValueOnce(sendResult("mid-2e-1", "thread-2e"))
.mockResolvedValueOnce(sendResult("mid-2e-2", "thread-2e"));
const result = await sendImageZalouser("thread-2e", "https://example.com/long.png", {
profile: "p2e",
caption,
isGroup: false,
textMode: "markdown",
});
expect(mockSendText).toHaveBeenCalledTimes(2);
expect(mockSendText.mock.calls.map((call) => call[1]).join("")).toBe(formatted.text);
expect(requireSendTextCall(0)[0]).toBe("thread-2e");
expect(typeof requireSendTextCall(0)[1]).toBe("string");
expectSendTextOptions(0, {
profile: "p2e",
caption: undefined,
isGroup: false,
mediaUrl: "https://example.com/long.png",
textMode: "markdown",
});
expect(requireSendTextCall(1)[0]).toBe("thread-2e");
expect(typeof requireSendTextCall(1)[1]).toBe("string");
expect(requireSendTextOptions(1).mediaUrl).toBeUndefined();
expectResultFields(result, { ok: true, messageId: "mid-2e-2" });
});
it("delegates link helper to JS transport", async () => {
mockSendLink.mockResolvedValueOnce(sendFailure("boom", "thread-3"));
const result = await sendLinkZalouser("thread-3", "https://openclaw.ai", {
profile: "p3",
isGroup: true,
});
expect(mockSendLink).toHaveBeenCalledWith("thread-3", "https://openclaw.ai", {
profile: "p3",
isGroup: true,
});
expectResultFields(result, { ok: false, error: "boom" });
});
it("delegates typing helper to JS transport", async () => {
await sendTypingZalouser("thread-4", { profile: "p4", isGroup: true });
expect(mockSendTyping).toHaveBeenCalledWith("thread-4", {
profile: "p4",
isGroup: true,
});
});
it("delegates reaction helper to JS transport", async () => {
mockSendReaction.mockResolvedValueOnce({ ok: true });
const result = await sendReactionZalouser({
threadId: "thread-5",
profile: "p5",
isGroup: true,
msgId: "100",
cliMsgId: "200",
emoji: "👍",
});
expect(mockSendReaction).toHaveBeenCalledWith({
profile: "p5",
threadId: "thread-5",
isGroup: true,
msgId: "100",
cliMsgId: "200",
emoji: "👍",
remove: undefined,
});
expectResultFields(result, { ok: true, error: undefined });
expect(result.receipt.platformMessageIds).toStrictEqual([]);
});
it("delegates delivered+seen helpers to JS transport", async () => {
mockSendDelivered.mockResolvedValueOnce();
mockSendSeen.mockResolvedValueOnce();
const message = {
msgId: "100",
cliMsgId: "200",
uidFrom: "1",
idTo: "2",
msgType: "webchat",
st: 1,
at: 0,
cmd: 0,
ts: "123",
};
await sendDeliveredZalouser({ profile: "p6", isGroup: true, message, isSeen: false });
await sendSeenZalouser({ profile: "p6", isGroup: true, message });
expect(mockSendDelivered).toHaveBeenCalledWith({
profile: "p6",
isGroup: true,
message,
isSeen: false,
});
expect(mockSendSeen).toHaveBeenCalledWith({
profile: "p6",
isGroup: true,
message,
});
});
});

View File

@@ -0,0 +1,286 @@
// Zalouser plugin module implements send behavior.
import { createZalouserSendReceipt } from "./send-receipt.js";
import { parseZalouserTextStyles } from "./text-styles.js";
import type { ZaloEventMessage, ZaloSendOptions, ZaloSendResult } from "./types.js";
import {
sendZaloDeliveredEvent,
sendZaloLink,
sendZaloReaction,
sendZaloSeenEvent,
sendZaloTextMessage,
sendZaloTypingEvent,
} from "./zalo-js.js";
import { TextStyle } from "./zca-constants.js";
type ZalouserSendOptions = ZaloSendOptions & {
/** Persist each concrete platform send before the next internal chunk starts. */
onDeliveryResult?: (result: ZaloSendResult) => Promise<void> | void;
};
type ZalouserSendResult = ZaloSendResult;
const ZALO_TEXT_LIMIT = 2000;
const DEFAULT_TEXT_CHUNK_MODE = "length";
type StyledTextChunk = {
text: string;
styles?: ZaloSendOptions["textStyles"];
};
type TextChunkMode = NonNullable<ZaloSendOptions["textChunkMode"]>;
export async function sendMessageZalouser(
threadId: string,
text: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
const { onDeliveryResult, ...transportOptions } = options;
const prepared =
transportOptions.textMode === "markdown"
? parseZalouserTextStyles(text)
: { text, styles: transportOptions.textStyles };
const textChunkLimit = transportOptions.textChunkLimit ?? ZALO_TEXT_LIMIT;
const chunks = splitStyledText(
prepared.text,
(prepared.styles?.length ?? 0) > 0 ? prepared.styles : undefined,
textChunkLimit,
transportOptions.textChunkMode,
);
let lastResult: ZalouserSendResult | null = null;
for (const [index, chunk] of chunks.entries()) {
const chunkOptions =
index === 0
? { ...transportOptions, textStyles: chunk.styles }
: {
...transportOptions,
caption: undefined,
mediaLocalRoots: undefined,
mediaUrl: undefined,
textStyles: chunk.styles,
};
const result = await sendZaloTextMessage(threadId, chunk.text, chunkOptions);
if (!result.ok) {
throw new Error(result.error || "Failed to send Zalouser message");
}
await onDeliveryResult?.(result);
lastResult = result;
}
return (
lastResult ?? {
ok: false,
error: "No message content provided",
receipt: createZalouserSendReceipt({ threadId, kind: "text" }),
}
);
}
export async function sendImageZalouser(
threadId: string,
imageUrl: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
return await sendMessageZalouser(threadId, options.caption ?? "", {
...options,
caption: undefined,
mediaUrl: imageUrl,
});
}
export async function sendLinkZalouser(
threadId: string,
url: string,
options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> {
return await sendZaloLink(threadId, url, options);
}
export async function sendTypingZalouser(
threadId: string,
options: Pick<ZalouserSendOptions, "profile" | "isGroup"> = {},
): Promise<void> {
await sendZaloTypingEvent(threadId, options);
}
export async function sendReactionZalouser(params: {
threadId: string;
msgId: string;
cliMsgId: string;
emoji: string;
remove?: boolean;
profile?: string;
isGroup?: boolean;
}): Promise<ZalouserSendResult> {
const result = await sendZaloReaction({
profile: params.profile,
threadId: params.threadId,
isGroup: params.isGroup,
msgId: params.msgId,
cliMsgId: params.cliMsgId,
emoji: params.emoji,
remove: params.remove,
});
return {
ok: result.ok,
error: result.error,
receipt: createZalouserSendReceipt({ threadId: params.threadId, kind: "unknown" }),
};
}
export async function sendDeliveredZalouser(params: {
profile?: string;
isGroup?: boolean;
message: ZaloEventMessage;
isSeen?: boolean;
}): Promise<void> {
await sendZaloDeliveredEvent(params);
}
export async function sendSeenZalouser(params: {
profile?: string;
isGroup?: boolean;
message: ZaloEventMessage;
}): Promise<void> {
await sendZaloSeenEvent(params);
}
function splitStyledText(
text: string,
styles: ZaloSendOptions["textStyles"],
limit: number,
mode: ZaloSendOptions["textChunkMode"],
): StyledTextChunk[] {
if (text.length === 0) {
return [{ text, styles: undefined }];
}
const chunks: StyledTextChunk[] = [];
for (const range of splitTextRanges(text, limit, mode ?? DEFAULT_TEXT_CHUNK_MODE)) {
const { start, end } = range;
chunks.push({
text: text.slice(start, end),
styles: sliceTextStyles(styles, start, end),
});
}
return chunks;
}
function sliceTextStyles(
styles: ZaloSendOptions["textStyles"],
start: number,
end: number,
): ZaloSendOptions["textStyles"] {
if (!styles || styles.length === 0) {
return undefined;
}
const chunkStyles = styles
.map((style) => {
const overlapStart = Math.max(style.start, start);
const overlapEnd = Math.min(style.start + style.len, end);
if (overlapEnd <= overlapStart) {
return null;
}
if (style.st === TextStyle.Indent) {
return {
start: overlapStart - start,
len: overlapEnd - overlapStart,
st: style.st,
indentSize: style.indentSize,
};
}
return {
start: overlapStart - start,
len: overlapEnd - overlapStart,
st: style.st,
};
})
.filter((style): style is NonNullable<typeof style> => style !== null);
return chunkStyles.length > 0 ? chunkStyles : undefined;
}
function splitTextRanges(
text: string,
limit: number,
mode: TextChunkMode,
): Array<{ start: number; end: number }> {
if (mode === "newline") {
return splitTextRangesByPreferredBreaks(text, limit);
}
const ranges: Array<{ start: number; end: number }> = [];
for (let start = 0; start < text.length; start += limit) {
ranges.push({
start,
end: Math.min(text.length, start + limit),
});
}
return ranges;
}
function splitTextRangesByPreferredBreaks(
text: string,
limit: number,
): Array<{ start: number; end: number }> {
const ranges: Array<{ start: number; end: number }> = [];
let start = 0;
while (start < text.length) {
const maxEnd = Math.min(text.length, start + limit);
let end = maxEnd;
if (maxEnd < text.length) {
end =
findParagraphBreak(text, start, maxEnd) ??
findLastBreak(text, "\n", start, maxEnd) ??
findLastWhitespaceBreak(text, start, maxEnd) ??
maxEnd;
}
if (end <= start) {
end = maxEnd;
}
ranges.push({ start, end });
start = end;
}
return ranges;
}
function findParagraphBreak(text: string, start: number, end: number): number | undefined {
const slice = text.slice(start, end);
const matches = slice.matchAll(/\n[\t ]*\n+/g);
let lastMatch: RegExpMatchArray | undefined;
for (const match of matches) {
lastMatch = match;
}
if (!lastMatch || lastMatch.index === undefined) {
return undefined;
}
return start + lastMatch.index + lastMatch[0].length;
}
function findLastBreak(
text: string,
marker: string,
start: number,
end: number,
): number | undefined {
const index = text.lastIndexOf(marker, end - 1);
if (index < start) {
return undefined;
}
return index + marker.length;
}
function findLastWhitespaceBreak(text: string, start: number, end: number): number | undefined {
for (let index = end - 1; index > start; index -= 1) {
if (/\s/.test(text[index])) {
return index + 1;
}
}
return undefined;
}

View File

@@ -0,0 +1,122 @@
// Zalouser plugin module implements session route behavior.
import {
buildChannelOutboundSessionRoute,
type ChannelOutboundSessionRouteParams,
} from "openclaw/plugin-sdk/core";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
function stripZalouserTargetPrefix(raw: string): string {
return raw
.trim()
.replace(/^(zalouser|zlu):/i, "")
.trim();
}
export function normalizeZalouserTarget(raw: string): string | undefined {
const trimmed = stripZalouserTargetPrefix(raw);
if (!trimmed) {
return undefined;
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
if (lower.startsWith("group:")) {
const id = trimmed.slice("group:".length).trim();
return id ? `group:${id}` : undefined;
}
if (lower.startsWith("g:")) {
const id = trimmed.slice("g:".length).trim();
return id ? `group:${id}` : undefined;
}
if (lower.startsWith("user:")) {
const id = trimmed.slice("user:".length).trim();
return id ? `user:${id}` : undefined;
}
if (lower.startsWith("dm:")) {
const id = trimmed.slice("dm:".length).trim();
return id ? `user:${id}` : undefined;
}
if (lower.startsWith("u:")) {
const id = trimmed.slice("u:".length).trim();
return id ? `user:${id}` : undefined;
}
if (/^g-\S+$/i.test(trimmed)) {
return `group:${trimmed}`;
}
if (/^u-\S+$/i.test(trimmed)) {
return `user:${trimmed}`;
}
return trimmed;
}
export function parseZalouserOutboundTarget(raw: string): {
threadId: string;
isGroup: boolean;
} {
const normalized = normalizeZalouserTarget(raw);
if (!normalized) {
throw new Error("Zalouser target is required");
}
const lowered = normalizeLowercaseStringOrEmpty(normalized);
if (lowered.startsWith("group:")) {
const threadId = normalized.slice("group:".length).trim();
if (!threadId) {
throw new Error("Zalouser group target is missing group id");
}
return { threadId, isGroup: true };
}
if (lowered.startsWith("user:")) {
const threadId = normalized.slice("user:".length).trim();
if (!threadId) {
throw new Error("Zalouser user target is missing user id");
}
return { threadId, isGroup: false };
}
// Backward-compatible fallback for bare IDs.
// Group sends should use explicit `group:<id>` targets.
return { threadId: normalized, isGroup: false };
}
export function parseZalouserDirectoryGroupId(raw: string): string {
const normalized = normalizeZalouserTarget(raw);
if (!normalized) {
throw new Error("Zalouser group target is required");
}
const lowered = normalizeLowercaseStringOrEmpty(normalized);
if (lowered.startsWith("group:")) {
const groupId = normalized.slice("group:".length).trim();
if (!groupId) {
throw new Error("Zalouser group target is missing group id");
}
return groupId;
}
if (lowered.startsWith("user:")) {
throw new Error("Zalouser group members lookup requires a group target (group:<id>)");
}
return normalized;
}
export function resolveZalouserOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
const normalized = normalizeZalouserTarget(params.target);
if (!normalized) {
return null;
}
const isGroup = (normalizeOptionalLowercaseString(normalized) ?? "").startsWith("group:");
const peerId = normalized.replace(/^(group|user):/i, "").trim();
return buildChannelOutboundSessionRoute({
cfg: params.cfg,
agentId: params.agentId,
channel: "zalouser",
accountId: params.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: peerId,
},
chatType: isGroup ? "group" : "direct",
from: isGroup ? `zalouser:group:${peerId}` : `zalouser:${peerId}`,
to: `zalouser:${peerId}`,
});
}

View File

@@ -0,0 +1,37 @@
// Zalouser plugin module implements setup core behavior.
import {
createDelegatedSetupWizardProxy,
createPatchedAccountSetupAdapter,
createSetupTranslator,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup-runtime";
const t = createSetupTranslator();
const channel = "zalouser" as const;
export const zalouserSetupAdapter = createPatchedAccountSetupAdapter({
channelKey: channel,
validateInput: () => null,
buildPatch: () => ({}),
});
export function createZalouserSetupWizardProxy(
loadWizard: () => Promise<ChannelSetupWizard>,
): ChannelSetupWizard {
return createDelegatedSetupWizardProxy({
channel,
loadWizard,
status: {
configuredLabel: t("wizard.channels.statusLoggedIn"),
unconfiguredLabel: t("wizard.channels.statusNeedsQrLogin"),
configuredHint: t("wizard.channels.statusRecommendedLoggedIn"),
unconfiguredHint: t("wizard.channels.statusRecommendedQrLogin"),
configuredScore: 1,
unconfiguredScore: 15,
},
credentials: [],
delegatePrepare: true,
delegateFinalize: true,
});
}

View File

@@ -0,0 +1,370 @@
// Zalouser tests cover setup surface plugin behavior.
import {
createPluginSetupWizardConfigure,
createTestWizardPrompter,
runSetupWizardConfigure,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import "./zalo-js.test-mocks.js";
import { zalouserSetupWizard } from "./setup-surface.js";
import { zalouserSetupPlugin } from "./setup-test-helpers.js";
const zalouserConfigure = createPluginSetupWizardConfigure(zalouserSetupPlugin);
async function runSetup(params: {
cfg?: OpenClawConfig;
prompter: ReturnType<typeof createTestWizardPrompter>;
options?: Record<string, unknown>;
forceAllowFrom?: boolean;
}) {
return await runSetupWizardConfigure({
configure: zalouserConfigure,
cfg: params.cfg,
prompter: params.prompter,
options: params.options,
forceAllowFrom: params.forceAllowFrom,
});
}
describe("zalouser setup wizard", () => {
function expectEnabledDefaultSetup(
result: Awaited<ReturnType<typeof runSetup>>,
dmPolicy?: "pairing" | "allowlist",
) {
expect(result.accountId).toBe("default");
const channelConfig = result.cfg.channels?.zalouser;
if (!channelConfig) {
throw new Error("expected Zalo Personal channel config");
}
const pluginEntry = result.cfg.plugins?.entries?.zalouser;
if (!pluginEntry) {
throw new Error("expected Zalo Personal plugin entry");
}
expect(channelConfig.enabled).toBe(true);
expect(pluginEntry.enabled).toBe(true);
if (dmPolicy) {
expect(channelConfig.dmPolicy).toBe(dmPolicy);
}
}
function createQuickstartPrompter(params?: {
note?: ReturnType<typeof createTestWizardPrompter>["note"];
seen?: string[];
dmPolicy?: "pairing" | "allowlist";
groupAccess?: boolean;
groupPolicy?: "allowlist";
textByMessage?: Record<string, string>;
}) {
const select = vi.fn(
async ({ message, options }: { message: string; options: Array<{ value: string }> }) => {
const first = options[0];
if (!first) {
throw new Error("no options");
}
params?.seen?.push(message);
if (message === "Zalo Personal DM policy" && params?.dmPolicy) {
return params.dmPolicy;
}
if (message === "Zalo groups access" && params?.groupPolicy) {
return params.groupPolicy;
}
return first.value;
},
) as ReturnType<typeof createTestWizardPrompter>["select"];
const text = vi.fn(
async ({ message }: { message: string }) => params?.textByMessage?.[message] ?? "",
) as ReturnType<typeof createTestWizardPrompter>["text"];
return createTestWizardPrompter({
...(params?.note ? { note: params.note } : {}),
confirm: vi.fn(async ({ message }: { message: string }) => {
params?.seen?.push(message);
if (message === "Login via QR code now?") {
return false;
}
if (message === "Configure Zalo groups access?") {
return params?.groupAccess ?? false;
}
return false;
}),
select,
text,
});
}
it("enables the account without forcing QR login", async () => {
const prompter = createTestWizardPrompter({
confirm: vi.fn(async ({ message }: { message: string }) => {
if (message === "Login via QR code now?") {
return false;
}
if (message === "Configure Zalo groups access?") {
return false;
}
return false;
}),
});
const result = await runSetup({ prompter });
expectEnabledDefaultSetup(result);
});
it("prompts DM policy before group access in quickstart", async () => {
const seen: string[] = [];
const prompter = createQuickstartPrompter({ seen, dmPolicy: "pairing" });
const result = await runSetup({
prompter,
options: { quickstartDefaults: true },
});
expectEnabledDefaultSetup(result, "pairing");
expect(seen.indexOf("Zalo Personal DM policy")).toBeGreaterThanOrEqual(0);
expect(seen.indexOf("Configure Zalo groups access?")).toBeGreaterThanOrEqual(0);
expect(seen.indexOf("Zalo Personal DM policy")).toBeLessThan(
seen.indexOf("Configure Zalo groups access?"),
);
});
it("allows an empty quickstart DM allowlist with a warning", async () => {
const note = vi.fn(async (_message: string, _title?: string) => {});
const prompter = createQuickstartPrompter({
note,
dmPolicy: "allowlist",
textByMessage: {
"Zalouser allowFrom (name or user id)": "",
},
});
const result = await runSetup({
prompter,
options: { quickstartDefaults: true },
});
expectEnabledDefaultSetup(result, "allowlist");
expect(result.cfg.channels?.zalouser?.allowFrom).toStrictEqual([]);
expect(
note.mock.calls.some(([message]) => message.includes("No DM allowlist entries added yet.")),
).toBe(true);
});
it("allows an empty group allowlist with a warning", async () => {
const note = vi.fn(async (_message: string, _title?: string) => {});
const prompter = createQuickstartPrompter({
note,
groupAccess: true,
groupPolicy: "allowlist",
textByMessage: {
"Zalo groups allowlist (comma-separated)": "",
},
});
const result = await runSetup({ prompter });
expect(result.cfg.channels?.zalouser?.groupPolicy).toBe("allowlist");
expect(result.cfg.channels?.zalouser?.groups).toStrictEqual({});
expect(
note.mock.calls.some(([message]) =>
message.includes("No group allowlist entries added yet."),
),
).toBe(true);
});
it("writes canonical enabled entries for configured groups", async () => {
const prompter = createQuickstartPrompter({
groupAccess: true,
groupPolicy: "allowlist",
textByMessage: {
"Zalo groups allowlist (comma-separated)": "Family, Work",
},
});
const result = await runSetup({ prompter });
expect(result.cfg.channels?.zalouser?.groups).toEqual({
Family: { enabled: true, requireMention: true },
Work: { enabled: true, requireMention: true },
});
});
it("preserves non-quickstart forceAllowFrom behavior", async () => {
const note = vi.fn(async (_message: string, _title?: string) => {});
const seen: string[] = [];
const prompter = createTestWizardPrompter({
note,
confirm: vi.fn(async ({ message }: { message: string }) => {
seen.push(message);
if (message === "Login via QR code now?") {
return false;
}
if (message === "Configure Zalo groups access?") {
return false;
}
return false;
}),
text: vi.fn(async ({ message }: { message: string }) => {
seen.push(message);
if (message === "Zalouser allowFrom (name or user id)") {
return "";
}
return "";
}) as ReturnType<typeof createTestWizardPrompter>["text"],
});
const result = await runSetup({ prompter, forceAllowFrom: true });
expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("allowlist");
expect(result.cfg.channels?.zalouser?.allowFrom).toStrictEqual([]);
expect(seen).not.toContain("Zalo Personal DM policy");
expect(seen).toContain("Zalouser allowFrom (name or user id)");
expect(
note.mock.calls.some(([message]) => message.includes("No DM allowlist entries added yet.")),
).toBe(true);
});
it("allowlists the plugin when a plugin allowlist already exists", async () => {
const prompter = createTestWizardPrompter({
confirm: vi.fn(async ({ message }: { message: string }) => {
if (message === "Login via QR code now?") {
return false;
}
if (message === "Configure Zalo groups access?") {
return false;
}
return false;
}),
});
const result = await runSetup({
cfg: {
plugins: {
allow: ["telegram"],
},
} as OpenClawConfig,
prompter,
});
expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true);
expect(result.cfg.plugins?.allow).toEqual(["telegram", "zalouser"]);
});
it("reads the named-account DM policy instead of the channel root", () => {
expect(
zalouserSetupWizard.dmPolicy?.getCurrent(
{
channels: {
zalouser: {
dmPolicy: "disabled",
accounts: {
work: {
profile: "work",
dmPolicy: "allowlist",
},
},
},
},
} as OpenClawConfig,
"work",
),
).toBe("allowlist");
});
it("reports account-scoped config keys for named accounts", () => {
expect(zalouserSetupWizard.dmPolicy?.resolveConfigKeys?.({} as OpenClawConfig, "work")).toEqual(
{
policyKey: "channels.zalouser.accounts.work.dmPolicy",
allowFromKey: "channels.zalouser.accounts.work.allowFrom",
},
);
});
it("uses configured defaultAccount for omitted DM policy account context", () => {
const cfg = {
channels: {
zalouser: {
defaultAccount: "work",
dmPolicy: "disabled",
allowFrom: ["123456789"],
accounts: {
work: {
dmPolicy: "allowlist",
profile: "work-profile",
},
},
},
},
} as OpenClawConfig;
expect(zalouserSetupWizard.dmPolicy?.getCurrent(cfg)).toBe("allowlist");
expect(zalouserSetupWizard.dmPolicy?.resolveConfigKeys?.(cfg)).toEqual({
policyKey: "channels.zalouser.accounts.work.dmPolicy",
allowFromKey: "channels.zalouser.accounts.work.allowFrom",
});
const next = zalouserSetupWizard.dmPolicy?.setPolicy(cfg, "open");
expect(next?.channels?.zalouser?.dmPolicy).toBe("disabled");
const workAccount = next?.channels?.zalouser?.accounts?.work as
| { dmPolicy?: string; allowFrom?: Array<string | number> }
| undefined;
expect(workAccount?.dmPolicy).toBe("open");
});
it('writes open policy state to the named account and preserves inherited allowFrom with "*"', () => {
const next = zalouserSetupWizard.dmPolicy?.setPolicy(
{
channels: {
zalouser: {
allowFrom: ["123456789"],
accounts: {
work: {
profile: "work",
},
},
},
},
} as OpenClawConfig,
"open",
"work",
);
expect(next?.channels?.zalouser?.dmPolicy).toBeUndefined();
const workAccount = next?.channels?.zalouser?.accounts?.work as
| { dmPolicy?: string; allowFrom?: Array<string | number> }
| undefined;
expect(workAccount?.dmPolicy).toBe("open");
expect(workAccount?.allowFrom).toEqual(["123456789", "*"]);
});
it("shows the account-scoped current DM policy in quickstart notes", async () => {
const note = vi.fn(async (_message: string, _title?: string) => {});
const prompter = createQuickstartPrompter({ note, dmPolicy: "pairing" });
await runSetupWizardConfigure({
configure: zalouserConfigure,
cfg: {
channels: {
zalouser: {
dmPolicy: "disabled",
accounts: {
work: {
profile: "work",
dmPolicy: "allowlist",
allowFrom: ["123456789"],
},
},
},
},
} as OpenClawConfig,
prompter,
options: { quickstartDefaults: true },
accountOverrides: { zalouser: "work" },
});
expect(
note.mock.calls.some(([message]) =>
message.includes("Current: dmPolicy=allowlist, allowFrom=123456789"),
),
).toBe(true);
});
});

View File

@@ -0,0 +1,484 @@
// Zalouser plugin module implements setup surface behavior.
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
formatCliCommand,
formatDocsLink,
formatResolvedUnresolvedNote,
mergeAllowFromEntries,
normalizeAccountId,
patchScopedAccountConfig,
createSetupTranslator,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
checkZcaAuthenticated,
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
} from "./accounts.js";
import { writeQrDataUrlToTempFile } from "./qr-temp-file.js";
import {
logoutZaloProfile,
resolveZaloAllowFromEntries,
resolveZaloGroupsByEntries,
startZaloQrLogin,
waitForZaloQrLogin,
} from "./zalo-js.js";
const t = createSetupTranslator();
const channel = "zalouser" as const;
const ZALOUSER_ALLOW_FROM_PLACEHOLDER = t("wizard.zalouser.allowFromPlaceholder");
const ZALOUSER_GROUPS_PLACEHOLDER = t("wizard.zalouser.groupsPlaceholder");
const ZALOUSER_DM_ACCESS_TITLE = t("wizard.zalouser.dmAccessTitle");
const ZALOUSER_ALLOWLIST_TITLE = t("wizard.zalouser.allowlistTitle");
const ZALOUSER_GROUPS_TITLE = t("wizard.zalouser.groupsTitle");
function parseZalouserEntries(raw: string): string[] {
return normalizeStringEntries(raw.split(/[\n,;]+/g));
}
function setZalouserAccountScopedConfig(
cfg: OpenClawConfig,
accountId: string,
defaultPatch: Record<string, unknown>,
accountPatch: Record<string, unknown> = defaultPatch,
): OpenClawConfig {
return patchScopedAccountConfig({
cfg,
channelKey: channel,
accountId,
patch: defaultPatch,
accountPatch,
});
}
function setZalouserDmPolicy(
cfg: OpenClawConfig,
accountId: string,
policy: DmPolicy,
): OpenClawConfig {
const resolvedAccountId = normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID;
const resolved = resolveZalouserAccountSync({ cfg, accountId: resolvedAccountId });
return setZalouserAccountScopedConfig(
cfg,
resolvedAccountId,
{
dmPolicy: policy,
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) } : {}),
},
{
dmPolicy: policy,
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) } : {}),
},
);
}
function setZalouserGroupPolicy(
cfg: OpenClawConfig,
accountId: string,
groupPolicy: "open" | "allowlist" | "disabled",
): OpenClawConfig {
return setZalouserAccountScopedConfig(cfg, accountId, {
groupPolicy,
});
}
function setZalouserGroupAllowlist(
cfg: OpenClawConfig,
accountId: string,
groupKeys: string[],
): OpenClawConfig {
const groups = Object.fromEntries(
groupKeys.map((key) => [key, { enabled: true, requireMention: true }]),
);
return setZalouserAccountScopedConfig(cfg, accountId, {
groups,
});
}
function ensureZalouserPluginEnabled(cfg: OpenClawConfig): OpenClawConfig {
const next: OpenClawConfig = {
...cfg,
plugins: {
...cfg.plugins,
entries: {
...cfg.plugins?.entries,
zalouser: {
...cfg.plugins?.entries?.zalouser,
enabled: true,
},
},
},
};
const allow = next.plugins?.allow;
if (!Array.isArray(allow) || allow.includes(channel)) {
return next;
}
return {
...next,
plugins: {
...next.plugins,
allow: [...allow, channel],
},
};
}
async function noteZalouserHelp(
prompter: Parameters<NonNullable<ChannelSetupWizard["prepare"]>>[0]["prompter"],
): Promise<void> {
await prompter.note(
[
t("wizard.zalouser.helpQrLogin"),
"",
t("wizard.zalouser.helpZcaJs"),
"",
`Docs: ${formatDocsLink("/channels/zalouser", "zalouser")}`,
].join("\n"),
t("wizard.zalouser.setupTitle"),
);
}
async function promptZalouserAllowFrom(params: {
cfg: OpenClawConfig;
prompter: Parameters<NonNullable<ChannelSetupDmPolicy["promptAllowFrom"]>>[0]["prompter"];
accountId: string;
}): Promise<OpenClawConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveZalouserAccountSync({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
while (true) {
const entry = await prompter.text({
message: t("wizard.zalouser.allowFromPrompt"),
placeholder: ZALOUSER_ALLOW_FROM_PLACEHOLDER,
initialValue: existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : undefined,
});
const parts = parseZalouserEntries(entry);
if (parts.length === 0) {
await prompter.note(
[
t("wizard.zalouser.noDmAllowlist"),
t("wizard.zalouser.directChatsBlocked"),
t("wizard.zalouser.peersLookupTip", {
command: formatCliCommand("openclaw directory peers list --channel zalouser"),
}),
].join("\n"),
ZALOUSER_ALLOWLIST_TITLE,
);
return setZalouserAccountScopedConfig(cfg, accountId, {
dmPolicy: "allowlist",
allowFrom: [],
});
}
const resolvedEntries = await resolveZaloAllowFromEntries({
profile: resolved.profile,
entries: parts,
});
const unresolved = resolvedEntries.filter((item) => !item.resolved).map((item) => item.input);
if (unresolved.length > 0) {
await prompter.note(
t("wizard.zalouser.couldNotResolve", { entries: unresolved.join(", ") }),
ZALOUSER_ALLOWLIST_TITLE,
);
continue;
}
const resolvedIds = resolvedEntries
.filter((item) => item.resolved && item.id)
.map((item) => item.id as string);
const unique = mergeAllowFromEntries(existingAllowFrom, resolvedIds);
const notes = resolvedEntries
.filter((item) => item.note)
.map((item) => `${item.input} -> ${item.id} (${item.note})`);
if (notes.length > 0) {
await prompter.note(notes.join("\n"), ZALOUSER_ALLOWLIST_TITLE);
}
return setZalouserAccountScopedConfig(cfg, accountId, {
dmPolicy: "allowlist",
allowFrom: unique,
});
}
}
const zalouserDmPolicy: ChannelSetupDmPolicy = {
label: "Zalo Personal",
channel,
policyKey: "channels.zalouser.dmPolicy",
allowFromKey: "channels.zalouser.allowFrom",
resolveConfigKeys: (cfg, accountId) =>
(accountId ?? resolveDefaultZalouserAccountId(cfg)) !== DEFAULT_ACCOUNT_ID
? {
policyKey: `channels.zalouser.accounts.${accountId ?? resolveDefaultZalouserAccountId(cfg)}.dmPolicy`,
allowFromKey: `channels.zalouser.accounts.${accountId ?? resolveDefaultZalouserAccountId(cfg)}.allowFrom`,
}
: {
policyKey: "channels.zalouser.dmPolicy",
allowFromKey: "channels.zalouser.allowFrom",
},
getCurrent: (cfg, accountId) =>
resolveZalouserAccountSync({
cfg,
accountId: accountId ?? resolveDefaultZalouserAccountId(cfg),
}).config.dmPolicy ?? "pairing",
setPolicy: (cfg, policy, accountId) =>
setZalouserDmPolicy(cfg, accountId ?? resolveDefaultZalouserAccountId(cfg), policy),
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const id =
accountId && normalizeAccountId(accountId)
? (normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultZalouserAccountId(cfg);
return await promptZalouserAllowFrom({
cfg,
prompter,
accountId: id,
});
},
};
async function promptZalouserQuickstartDmPolicy(params: {
cfg: OpenClawConfig;
prompter: Parameters<NonNullable<ChannelSetupWizard["prepare"]>>[0]["prompter"];
accountId: string;
}): Promise<OpenClawConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveZalouserAccountSync({ cfg, accountId });
const existingPolicy = resolved.config.dmPolicy ?? "pairing";
const existingAllowFrom = resolved.config.allowFrom ?? [];
const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
await prompter.note(
[
t("wizard.zalouser.dmHelpSeparate"),
t("wizard.zalouser.dmHelpPairing"),
t("wizard.zalouser.dmHelpAllowlist"),
t("wizard.zalouser.dmHelpOpen"),
t("wizard.zalouser.dmHelpDisabled"),
"",
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
t("wizard.zalouser.dmHelpAllowlistEmpty"),
].join("\n"),
ZALOUSER_DM_ACCESS_TITLE,
);
const policy = (await prompter.select({
message: t("wizard.zalouser.dmPolicyPrompt"),
options: [
{ value: "pairing", label: t("wizard.channels.dmPolicyPairing") },
{ value: "allowlist", label: t("wizard.channels.dmPolicyAllowlistOption") },
{ value: "open", label: t("wizard.channels.dmPolicyOpenOption") },
{ value: "disabled", label: t("wizard.channels.dmPolicyDisabledOption") },
],
initialValue: existingPolicy,
})) as DmPolicy;
if (policy === "allowlist") {
return await promptZalouserAllowFrom({
cfg,
prompter,
accountId,
});
}
return setZalouserDmPolicy(cfg, accountId, policy);
}
export { zalouserSetupAdapter } from "./setup-core.js";
export const zalouserSetupWizard: ChannelSetupWizard = {
channel,
status: {
configuredLabel: t("wizard.channels.statusLoggedIn"),
unconfiguredLabel: t("wizard.channels.statusNeedsQrLogin"),
configuredHint: t("wizard.channels.statusRecommendedLoggedIn"),
unconfiguredHint: t("wizard.channels.statusRecommendedQrLogin"),
configuredScore: 1,
unconfiguredScore: 15,
resolveConfigured: async ({ cfg, accountId }) => {
const ids = accountId ? [accountId] : listZalouserAccountIds(cfg);
for (const resolvedAccountId of ids) {
const account = resolveZalouserAccountSync({ cfg, accountId: resolvedAccountId });
if (await checkZcaAuthenticated(account.profile)) {
return true;
}
}
return false;
},
resolveStatusLines: async ({ cfg, accountId, configured }) => {
void cfg;
const label =
accountId && accountId !== DEFAULT_ACCOUNT_ID
? `Zalo Personal (${accountId})`
: "Zalo Personal";
return [`${label}: ${configured ? "logged in" : "needs QR login"}`];
},
},
prepare: async ({ cfg, accountId, prompter, options }) => {
let next = cfg;
const account = resolveZalouserAccountSync({ cfg: next, accountId });
const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
if (!alreadyAuthenticated) {
await noteZalouserHelp(prompter);
const wantsLogin = await prompter.confirm({
message: t("wizard.zalouser.loginQrPrompt"),
initialValue: true,
});
if (wantsLogin) {
const start = await startZaloQrLogin({ profile: account.profile, timeoutMs: 35_000 });
if (start.qrDataUrl) {
const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile);
await prompter.note(
[
start.message,
qrPath
? t("wizard.zalouser.qrImageSaved", { path: qrPath })
: t("wizard.zalouser.qrImageWriteFailed"),
t("wizard.zalouser.scanApproveContinue"),
].join("\n"),
t("wizard.zalouser.qrLoginTitle"),
);
const scanned = await prompter.confirm({
message: t("wizard.zalouser.qrScannedPrompt"),
initialValue: true,
});
if (scanned) {
const waited = await waitForZaloQrLogin({
profile: account.profile,
timeoutMs: 120_000,
});
await prompter.note(
waited.message,
waited.connected ? t("common.done") : t("wizard.zalouser.loginPendingTitle"),
);
}
} else {
await prompter.note(start.message, t("wizard.zalouser.loginPendingTitle"));
}
}
} else {
const keepSession = await prompter.confirm({
message: t("wizard.zalouser.keepSessionPrompt"),
initialValue: true,
});
if (!keepSession) {
await logoutZaloProfile(account.profile);
const start = await startZaloQrLogin({
profile: account.profile,
force: true,
timeoutMs: 35_000,
});
if (start.qrDataUrl) {
const qrPath = await writeQrDataUrlToTempFile(start.qrDataUrl, account.profile);
await prompter.note(
[
start.message,
qrPath ? t("wizard.zalouser.qrImageSaved", { path: qrPath }) : undefined,
]
.filter(Boolean)
.join("\n"),
t("wizard.zalouser.qrLoginTitle"),
);
const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 120_000 });
await prompter.note(
waited.message,
waited.connected ? t("common.done") : t("wizard.zalouser.loginPendingTitle"),
);
}
}
}
next = setZalouserAccountScopedConfig(
next,
accountId,
{ profile: account.profile !== "default" ? account.profile : undefined },
{ profile: account.profile, enabled: true },
);
if (options?.quickstartDefaults) {
next = await promptZalouserQuickstartDmPolicy({
cfg: next,
prompter,
accountId,
});
}
return { cfg: next };
},
credentials: [],
groupAccess: {
label: "Zalo groups",
placeholder: ZALOUSER_GROUPS_PLACEHOLDER,
currentPolicy: ({ cfg, accountId }) =>
resolveZalouserAccountSync({ cfg, accountId }).config.groupPolicy ?? "allowlist",
currentEntries: ({ cfg, accountId }) =>
Object.keys(resolveZalouserAccountSync({ cfg, accountId }).config.groups ?? {}),
updatePrompt: ({ cfg, accountId }) =>
Boolean(resolveZalouserAccountSync({ cfg, accountId }).config.groups),
setPolicy: ({ cfg, accountId, policy }) => setZalouserGroupPolicy(cfg, accountId, policy),
resolveAllowlist: async ({ cfg, accountId, entries, prompter }) => {
if (entries.length === 0) {
await prompter.note(
[
t("wizard.zalouser.noGroupAllowlist"),
t("wizard.zalouser.groupChatsBlocked"),
t("wizard.zalouser.groupsLookupTip", {
command: formatCliCommand("openclaw directory groups list --channel zalouser"),
}),
t("wizard.zalouser.groupMentionRequirement"),
].join("\n"),
ZALOUSER_GROUPS_TITLE,
);
return [];
}
const updatedAccount = resolveZalouserAccountSync({ cfg, accountId });
try {
const resolved = await resolveZaloGroupsByEntries({
profile: updatedAccount.profile,
entries,
});
const resolvedIds = resolved
.filter((entry) => entry.resolved && entry.id)
.map((entry) => entry.id as string);
const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input);
const keys = [...resolvedIds, ...normalizeStringEntries(unresolved)];
const resolution = formatResolvedUnresolvedNote({
resolved: resolvedIds,
unresolved,
});
if (resolution) {
await prompter.note(resolution, ZALOUSER_GROUPS_TITLE);
}
return keys;
} catch (err) {
await prompter.note(
t("wizard.zalouser.groupLookupFailed", { error: String(err) }),
ZALOUSER_GROUPS_TITLE,
);
return normalizeStringEntries(entries);
}
},
applyAllowlist: ({ cfg, accountId, resolved }) =>
setZalouserGroupAllowlist(cfg, accountId, resolved as string[]),
},
finalize: async ({ cfg, accountId, forceAllowFrom, options, prompter }) => {
let next = cfg;
if (forceAllowFrom && !options?.quickstartDefaults) {
next = await promptZalouserAllowFrom({
cfg: next,
prompter,
accountId,
});
}
return { cfg: ensureZalouserPluginEnabled(next) };
},
dmPolicy: zalouserDmPolicy,
};

View File

@@ -0,0 +1,43 @@
// Zalouser helper module supports setup test helpers behavior.
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import type { OpenClawConfig } from "../runtime-api.js";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
} from "./accounts.js";
import { zalouserSetupAdapter } from "./setup-core.js";
import { zalouserSetupWizard } from "./setup-surface.js";
export const zalouserSetupPlugin = {
id: "zalouser",
meta: {
id: "zalouser",
label: "ZaloUser",
selectionLabel: "ZaloUser",
docsPath: "/channels/zalouser",
blurb: "Unofficial Zalo personal account connector.",
},
capabilities: {
chatTypes: ["direct", "group"] as Array<"direct" | "group">,
},
config: {
listAccountIds: (cfg: unknown) => listZalouserAccountIds(cfg as never),
defaultAccountId: (cfg: unknown) => resolveDefaultZalouserAccountId(cfg as never),
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) =>
resolveZalouserAccountSync({ cfg, accountId }),
},
security: {
resolveDmPolicy: createScopedDmSecurityResolver({
channelKey: "zalouser",
resolvePolicy: (account: ReturnType<typeof resolveZalouserAccountSync>) =>
account.config.dmPolicy,
resolveAllowFrom: (account: ReturnType<typeof resolveZalouserAccountSync>) =>
account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw: string) => raw.trim().replace(/^(zalouser|zlu):/i, ""),
}),
},
setup: zalouserSetupAdapter,
setupWizard: zalouserSetupWizard,
} as const;

View File

@@ -0,0 +1,93 @@
// Zalouser plugin module implements shared behavior.
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
checkZcaAuthenticated,
type ResolvedZalouserAccount,
} from "./accounts.js";
import type { ChannelPlugin } from "./channel-api.js";
import { buildChannelConfigSchema, formatAllowFromLowercase } from "./channel-api.js";
import { ZalouserConfigSchema } from "./config-schema.js";
import { zalouserDoctor } from "./doctor.js";
const zalouserMeta: ChannelPlugin<ResolvedZalouserAccount>["meta"] = {
id: "zalouser",
label: "Zalo Personal",
selectionLabel: "Zalo (Personal Account)",
docsPath: "/channels/zalouser",
docsLabel: "zalouser",
blurb: "Zalo personal account via QR code login.",
aliases: ["zlu"],
order: 85,
quickstartAllowFrom: false,
};
const zalouserConfigAdapter = createScopedChannelConfigAdapter<ResolvedZalouserAccount>({
sectionKey: "zalouser",
listAccountIds: listZalouserAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveZalouserAccountSync),
defaultAccountId: resolveDefaultZalouserAccountId,
clearBaseFields: [
"profile",
"name",
"dmPolicy",
"allowFrom",
"historyLimit",
"groupAllowFrom",
"groupPolicy",
"groups",
"messagePrefix",
],
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
});
export function createZalouserPluginBase(params: {
setupWizard: NonNullable<ChannelPlugin<ResolvedZalouserAccount>["setupWizard"]>;
setup: NonNullable<ChannelPlugin<ResolvedZalouserAccount>["setup"]>;
}): Pick<
ChannelPlugin<ResolvedZalouserAccount>,
| "id"
| "meta"
| "setupWizard"
| "capabilities"
| "doctor"
| "reload"
| "configSchema"
| "config"
| "setup"
> {
return {
id: "zalouser",
meta: zalouserMeta,
setupWizard: params.setupWizard,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
threads: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
doctor: zalouserDoctor,
reload: { configPrefixes: ["channels.zalouser"] },
configSchema: buildChannelConfigSchema(ZalouserConfigSchema),
config: {
...zalouserConfigAdapter,
isConfigured: async (account) => await checkZcaAuthenticated(account.profile),
describeAccount: (account) =>
describeAccountSnapshot({
account,
}),
},
setup: params.setup,
};
}

View File

@@ -0,0 +1,32 @@
// Zalouser tests cover status issues plugin behavior.
import { expectOpenDmPolicyConfigIssue } from "openclaw/plugin-sdk/channel-test-helpers";
import { describe, expect, it } from "vitest";
import { collectZalouserStatusIssues } from "./status-issues.js";
describe("collectZalouserStatusIssues", () => {
it("flags missing auth when configured is false", () => {
const issues = collectZalouserStatusIssues([
{
accountId: "default",
enabled: true,
configured: false,
lastError: "not authenticated",
},
]);
expect(issues).toHaveLength(1);
expect(issues[0]?.kind).toBe("auth");
expect(issues[0]?.message).toMatch(/Not authenticated/i);
});
it("warns when dmPolicy is open", () => {
expectOpenDmPolicyConfigIssue({
collectIssues: collectZalouserStatusIssues,
account: {
accountId: "default",
enabled: true,
configured: true,
dmPolicy: "open",
},
});
});
});

View File

@@ -0,0 +1,59 @@
// Zalouser plugin module implements status issues behavior.
import type {
ChannelAccountSnapshot,
ChannelStatusIssue,
} from "openclaw/plugin-sdk/channel-contract";
import {
coerceStatusIssueAccountId,
readStatusIssueFields,
} from "openclaw/plugin-sdk/extension-shared";
const ZALOUSER_STATUS_FIELDS = [
"accountId",
"enabled",
"configured",
"dmPolicy",
"lastError",
] as const;
export function collectZalouserStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) {
const account = readStatusIssueFields(entry, ZALOUSER_STATUS_FIELDS);
if (!account) {
continue;
}
const accountId = coerceStatusIssueAccountId(account.accountId) ?? "default";
const enabled = account.enabled !== false;
if (!enabled) {
continue;
}
const configured = account.configured === true;
if (!configured) {
issues.push({
channel: "zalouser",
accountId,
kind: "auth",
message: "Not authenticated (no saved Zalo session).",
fix: "Run: openclaw channels login --channel zalouser",
});
continue;
}
if (account.dmPolicy === "open") {
issues.push({
channel: "zalouser",
accountId,
kind: "config",
message:
'Zalo Personal dmPolicy is "open", allowing any user to message the bot without pairing.',
fix: 'Set channels.zalouser.dmPolicy to "pairing" or "allowlist" to restrict access.',
});
}
}
return issues;
}

View File

@@ -0,0 +1,27 @@
// Zalouser helper module supports test helpers behavior.
import type { RuntimeEnv } from "../runtime-api.js";
import type { ResolvedZalouserAccount } from "./types.js";
export function createZalouserRuntimeEnv(): RuntimeEnv {
return {
log: () => {},
error: () => {},
exit: ((code: number): never => {
throw new Error(`exit ${code}`);
}) as RuntimeEnv["exit"],
};
}
export function createDefaultResolvedZalouserAccount(
overrides: Partial<ResolvedZalouserAccount> = {},
): ResolvedZalouserAccount {
return {
accountId: "default",
profile: "default",
name: "test",
enabled: true,
authenticated: true,
config: {},
...overrides,
};
}

View File

@@ -0,0 +1,204 @@
// Zalouser tests cover text styles plugin behavior.
import { describe, expect, it } from "vitest";
import { parseZalouserTextStyles } from "./text-styles.js";
import { TextStyle } from "./zca-constants.js";
describe("parseZalouserTextStyles", () => {
it("renders inline markdown emphasis as Zalo style ranges", () => {
expect(parseZalouserTextStyles("**bold** *italic* ~~strike~~")).toEqual({
text: "bold italic strike",
styles: [
{ start: 0, len: 4, st: TextStyle.Bold },
{ start: 5, len: 6, st: TextStyle.Italic },
{ start: 12, len: 6, st: TextStyle.StrikeThrough },
],
});
});
it("keeps inline code and plain math markers literal", () => {
expect(parseZalouserTextStyles("before `inline *code*` after\n2 * 3 * 4")).toEqual({
text: "before `inline *code*` after\n2 * 3 * 4",
styles: [],
});
});
it("preserves backslash escapes inside code spans and fenced code blocks", () => {
expect(parseZalouserTextStyles("before `\\*` after\n```ts\n\\*\\_\\\\\n```")).toEqual({
text: "before `\\*` after\n\\*\\_\\\\",
styles: [],
});
});
it("closes fenced code blocks when the input uses CRLF newlines", () => {
expect(parseZalouserTextStyles("```\r\n*code*\r\n```\r\n**after**")).toEqual({
text: "*code*\nafter",
styles: [{ start: 7, len: 5, st: TextStyle.Bold }],
});
});
it("maps headings, block quotes, and lists into line styles", () => {
expect(parseZalouserTextStyles(["# Title", "> quoted", " - nested"].join("\n"))).toEqual({
text: "Title\nquoted\nnested",
styles: [
{ start: 0, len: 5, st: TextStyle.Bold },
{ start: 0, len: 5, st: TextStyle.Big },
{ start: 6, len: 6, st: TextStyle.Indent, indentSize: 1 },
{ start: 13, len: 6, st: TextStyle.UnorderedList },
],
});
});
it("treats 1-3 leading spaces as markdown padding for headings and lists", () => {
expect(parseZalouserTextStyles(" # Title\n 1. item\n - bullet")).toEqual({
text: "Title\nitem\nbullet",
styles: [
{ start: 0, len: 5, st: TextStyle.Bold },
{ start: 0, len: 5, st: TextStyle.Big },
{ start: 6, len: 4, st: TextStyle.OrderedList },
{ start: 11, len: 6, st: TextStyle.UnorderedList },
],
});
});
it("strips fenced code markers and preserves leading indentation with nbsp", () => {
expect(parseZalouserTextStyles("```ts\n const x = 1\n\treturn x\n```")).toEqual({
text: "\u00A0\u00A0const x = 1\n\u00A0\u00A0\u00A0\u00A0return x",
styles: [],
});
});
it("treats tilde fences as literal code blocks", () => {
expect(parseZalouserTextStyles("~~~bash\n*cmd*\n~~~")).toEqual({
text: "*cmd*",
styles: [],
});
});
it("treats fences indented under list items as literal code blocks", () => {
expect(parseZalouserTextStyles(" ```\n*cmd*\n ```")).toEqual({
text: "*cmd*",
styles: [],
});
});
it("treats quoted backtick fences as literal code blocks", () => {
expect(parseZalouserTextStyles("> ```js\n> *cmd*\n> ```")).toEqual({
text: "*cmd*",
styles: [],
});
});
it("treats quoted tilde fences as literal code blocks", () => {
expect(parseZalouserTextStyles("> ~~~\n> *cmd*\n> ~~~")).toEqual({
text: "*cmd*",
styles: [],
});
});
it("preserves quote-prefixed lines inside normal fenced code blocks", () => {
expect(parseZalouserTextStyles("```\n> prompt\n```")).toEqual({
text: "> prompt",
styles: [],
});
});
it("does not treat quote-prefixed fence text inside code as a closing fence", () => {
expect(parseZalouserTextStyles("```\n> ```\n*still code*\n```")).toEqual({
text: "> ```\n*still code*",
styles: [],
});
});
it("treats indented blockquotes as quoted lines", () => {
expect(parseZalouserTextStyles(" > quoted")).toEqual({
text: "quoted",
styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 1 }],
});
});
it("treats spaced nested blockquotes as deeper quoted lines", () => {
expect(parseZalouserTextStyles("> > quoted")).toEqual({
text: "quoted",
styles: [{ start: 0, len: 6, st: TextStyle.Indent, indentSize: 2 }],
});
});
it("treats indented quoted fences as literal code blocks", () => {
expect(parseZalouserTextStyles(" > ```\n > *cmd*\n > ```")).toEqual({
text: "*cmd*",
styles: [],
});
});
it("treats spaced nested quoted fences as literal code blocks", () => {
expect(parseZalouserTextStyles("> > ```\n> > code\n> > ```")).toEqual({
text: "code",
styles: [],
});
});
it("preserves inner quote markers inside quoted fenced code blocks", () => {
expect(parseZalouserTextStyles("> ```\n>> prompt\n> ```")).toEqual({
text: "> prompt",
styles: [],
});
});
it("keeps quote indentation on heading lines", () => {
expect(parseZalouserTextStyles("> # Title")).toEqual({
text: "Title",
styles: [
{ start: 0, len: 5, st: TextStyle.Bold },
{ start: 0, len: 5, st: TextStyle.Big },
{ start: 0, len: 5, st: TextStyle.Indent, indentSize: 1 },
],
});
});
it("keeps unmatched fences literal", () => {
expect(parseZalouserTextStyles("```python")).toEqual({
text: "```python",
styles: [],
});
});
it("keeps unclosed fenced blocks literal until eof", () => {
expect(parseZalouserTextStyles("```python\n\\*not italic*\n_next_")).toEqual({
text: "```python\n\\*not italic*\n_next_",
styles: [],
});
});
it("supports nested markdown and tag styles regardless of order", () => {
expect(parseZalouserTextStyles("**{red}x{/red}** {red}**y**{/red}")).toEqual({
text: "x y",
styles: [
{ start: 0, len: 1, st: TextStyle.Bold },
{ start: 0, len: 1, st: TextStyle.Red },
{ start: 2, len: 1, st: TextStyle.Red },
{ start: 2, len: 1, st: TextStyle.Bold },
],
});
});
it("treats small text tags as normal text", () => {
expect(parseZalouserTextStyles("{small}tiny{/small}")).toEqual({
text: "tiny",
styles: [],
});
});
it("keeps escaped markers literal", () => {
expect(parseZalouserTextStyles("\\*literal\\* \\{underline}tag{/underline}")).toEqual({
text: "*literal* {underline}tag{/underline}",
styles: [],
});
});
it("keeps indented code blocks literal", () => {
expect(parseZalouserTextStyles(" *cmd*")).toEqual({
text: "\u00A0\u00A0\u00A0\u00A0*cmd*",
styles: [],
});
});
});

View File

@@ -0,0 +1,541 @@
// Zalouser plugin module implements text styles behavior.
import { TextStyle, type Style } from "./zca-constants.js";
const ESCAPE_SENTINEL_START = "\u0001";
const ESCAPE_SENTINEL_END = "\u0002";
type InlineStyle = (typeof TextStyle)[keyof typeof TextStyle];
type LineStyle = {
lineIndex: number;
style: InlineStyle;
indentSize?: number;
};
type Segment = {
text: string;
styles: InlineStyle[];
};
type InlineMarker = {
pattern: RegExp;
extractText: (match: RegExpExecArray) => string;
resolveStyles?: (match: RegExpExecArray) => InlineStyle[];
literal?: boolean;
};
type ResolvedInlineMatch = {
match: RegExpExecArray;
marker: InlineMarker;
styles: InlineStyle[];
text: string;
priority: number;
};
type FenceMarker = {
char: "`" | "~";
length: number;
indent: number;
};
type ActiveFence = FenceMarker & {
quoteIndent: number;
};
const TAG_STYLE_MAP: Record<string, InlineStyle | null> = {
red: TextStyle.Red,
orange: TextStyle.Orange,
yellow: TextStyle.Yellow,
green: TextStyle.Green,
small: null,
big: TextStyle.Big,
underline: TextStyle.Underline,
};
const INLINE_MARKERS: InlineMarker[] = [
{
pattern: /`([^`\n]+)`/g,
extractText: (match) => match[0],
literal: true,
},
{
pattern: /\\([*_~#\\{}>+\-`])/g,
extractText: (match) => match[1],
literal: true,
},
{
pattern: new RegExp(`\\{(${Object.keys(TAG_STYLE_MAP).join("|")})\\}(.+?)\\{/\\1\\}`, "g"),
extractText: (match) => match[2],
resolveStyles: (match) => {
const style = TAG_STYLE_MAP[match[1]];
return style ? [style] : [];
},
},
{
pattern: /(?<!\*)\*\*\*(?=\S)([^\n]*?\S)(?<!\*)\*\*\*(?!\*)/g,
extractText: (match) => match[1],
resolveStyles: () => [TextStyle.Bold, TextStyle.Italic],
},
{
pattern: /(?<!\*)\*\*(?![\s*])([^\n]*?\S)(?<!\*)\*\*(?!\*)/g,
extractText: (match) => match[1],
resolveStyles: () => [TextStyle.Bold],
},
{
pattern: /(?<![\w_])__(?![\s_])([^\n]*?\S)(?<!_)__(?![\w_])/g,
extractText: (match) => match[1],
resolveStyles: () => [TextStyle.Bold],
},
{
pattern: /(?<!~)~~(?=\S)([^\n]*?\S)(?<!~)~~(?!~)/g,
extractText: (match) => match[1],
resolveStyles: () => [TextStyle.StrikeThrough],
},
{
pattern: /(?<!\*)\*(?![\s*])([^\n]*?\S)(?<!\*)\*(?!\*)/g,
extractText: (match) => match[1],
resolveStyles: () => [TextStyle.Italic],
},
{
pattern: /(?<![\w_])_(?![\s_])([^\n]*?\S)(?<!_)_(?![\w_])/g,
extractText: (match) => match[1],
resolveStyles: () => [TextStyle.Italic],
},
];
export function parseZalouserTextStyles(input: string): { text: string; styles: Style[] } {
const allStyles: Style[] = [];
const escapeMap: string[] = [];
const lines = input.replace(/\r\n?/g, "\n").split("\n");
const lineStyles: LineStyle[] = [];
const processedLines: string[] = [];
let activeFence: ActiveFence | null = null;
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const rawLine = lines[lineIndex];
const { text: unquotedLine, indent: baseIndent } = stripQuotePrefix(rawLine);
if (activeFence) {
const codeLine =
activeFence.quoteIndent > 0
? stripQuotePrefix(rawLine, activeFence.quoteIndent).text
: rawLine;
if (isClosingFence(codeLine, activeFence)) {
activeFence = null;
continue;
}
processedLines.push(
escapeLiteralText(
normalizeCodeBlockLeadingWhitespace(stripCodeFenceIndent(codeLine, activeFence.indent)),
escapeMap,
),
);
continue;
}
const line = unquotedLine;
const openingFence = resolveOpeningFence(rawLine);
if (openingFence) {
const fenceLine = openingFence.quoteIndent > 0 ? unquotedLine : rawLine;
if (!hasClosingFence(lines, lineIndex + 1, openingFence)) {
processedLines.push(escapeLiteralText(fenceLine, escapeMap));
activeFence = openingFence;
continue;
}
activeFence = openingFence;
continue;
}
const outputLineIndex = processedLines.length;
if (isIndentedCodeBlockLine(line)) {
if (baseIndent > 0) {
lineStyles.push({
lineIndex: outputLineIndex,
style: TextStyle.Indent,
indentSize: baseIndent,
});
}
processedLines.push(escapeLiteralText(normalizeCodeBlockLeadingWhitespace(line), escapeMap));
continue;
}
const { text: markdownLine, size: markdownPadding } = stripOptionalMarkdownPadding(line);
const headingMatch = markdownLine.match(/^(#{1,4})\s(.*)$/);
if (headingMatch) {
const depth = headingMatch[1].length;
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Bold });
if (depth === 1) {
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.Big });
}
if (baseIndent > 0) {
lineStyles.push({
lineIndex: outputLineIndex,
style: TextStyle.Indent,
indentSize: baseIndent,
});
}
processedLines.push(headingMatch[2]);
continue;
}
const indentMatch = markdownLine.match(/^(\s+)(.*)$/);
let indentLevel = 0;
let content = markdownLine;
if (indentMatch) {
indentLevel = clampIndent(indentMatch[1].length);
content = indentMatch[2];
}
const totalIndent = Math.min(5, baseIndent + indentLevel);
if (/^[-*+]\s\[[ xX]\]\s/.test(content)) {
if (totalIndent > 0) {
lineStyles.push({
lineIndex: outputLineIndex,
style: TextStyle.Indent,
indentSize: totalIndent,
});
}
processedLines.push(content);
continue;
}
const orderedListMatch = content.match(/^(\d+)\.\s(.*)$/);
if (orderedListMatch) {
if (totalIndent > 0) {
lineStyles.push({
lineIndex: outputLineIndex,
style: TextStyle.Indent,
indentSize: totalIndent,
});
}
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.OrderedList });
processedLines.push(orderedListMatch[2]);
continue;
}
const unorderedListMatch = content.match(/^[-*+]\s(.*)$/);
if (unorderedListMatch) {
if (totalIndent > 0) {
lineStyles.push({
lineIndex: outputLineIndex,
style: TextStyle.Indent,
indentSize: totalIndent,
});
}
lineStyles.push({ lineIndex: outputLineIndex, style: TextStyle.UnorderedList });
processedLines.push(unorderedListMatch[1]);
continue;
}
if (markdownPadding > 0) {
if (baseIndent > 0) {
lineStyles.push({
lineIndex: outputLineIndex,
style: TextStyle.Indent,
indentSize: baseIndent,
});
}
processedLines.push(line);
continue;
}
if (totalIndent > 0) {
lineStyles.push({
lineIndex: outputLineIndex,
style: TextStyle.Indent,
indentSize: totalIndent,
});
processedLines.push(content);
continue;
}
processedLines.push(line);
}
const segments = parseInlineSegments(processedLines.join("\n"));
let plainText = "";
for (const segment of segments) {
const start = plainText.length;
plainText += segment.text;
for (const style of segment.styles) {
allStyles.push({ start, len: segment.text.length, st: style } as Style);
}
}
if (escapeMap.length > 0) {
const escapeRegex = new RegExp(`${ESCAPE_SENTINEL_START}(\\d+)${ESCAPE_SENTINEL_END}`, "g");
const shifts: Array<{ pos: number; delta: number }> = [];
let cumulativeDelta = 0;
for (const match of plainText.matchAll(escapeRegex)) {
const escapeIndex = Number.parseInt(match[1], 10);
cumulativeDelta += match[0].length - escapeMap[escapeIndex].length;
shifts.push({ pos: (match.index ?? 0) + match[0].length, delta: cumulativeDelta });
}
for (const style of allStyles) {
let startDelta = 0;
let endDelta = 0;
const end = style.start + style.len;
for (const shift of shifts) {
if (shift.pos <= style.start) {
startDelta = shift.delta;
}
if (shift.pos <= end) {
endDelta = shift.delta;
}
}
style.start -= startDelta;
style.len -= endDelta - startDelta;
}
plainText = plainText.replace(
escapeRegex,
(_match, index) => escapeMap[Number.parseInt(index, 10)],
);
}
const finalLines = plainText.split("\n");
let offset = 0;
for (let lineIndex = 0; lineIndex < finalLines.length; lineIndex += 1) {
const lineLength = finalLines[lineIndex].length;
if (lineLength > 0) {
for (const lineStyle of lineStyles) {
if (lineStyle.lineIndex !== lineIndex) {
continue;
}
if (lineStyle.style === TextStyle.Indent) {
allStyles.push({
start: offset,
len: lineLength,
st: TextStyle.Indent,
indentSize: lineStyle.indentSize,
});
} else {
allStyles.push({ start: offset, len: lineLength, st: lineStyle.style } as Style);
}
}
}
offset += lineLength + 1;
}
return { text: plainText, styles: allStyles };
}
function clampIndent(spaceCount: number): number {
return Math.min(5, Math.max(1, Math.floor(spaceCount / 2)));
}
function stripOptionalMarkdownPadding(line: string): { text: string; size: number } {
const match = line.match(/^( {1,3})(?=\S)/);
if (!match) {
return { text: line, size: 0 };
}
return {
text: line.slice(match[1].length),
size: match[1].length,
};
}
function hasClosingFence(lines: string[], startIndex: number, fence: ActiveFence): boolean {
for (let index = startIndex; index < lines.length; index += 1) {
const candidate =
fence.quoteIndent > 0 ? stripQuotePrefix(lines[index], fence.quoteIndent).text : lines[index];
if (isClosingFence(candidate, fence)) {
return true;
}
}
return false;
}
function resolveOpeningFence(line: string): ActiveFence | null {
const directFence = parseFenceMarker(line);
if (directFence) {
return { ...directFence, quoteIndent: 0 };
}
const quoted = stripQuotePrefix(line);
if (quoted.indent === 0) {
return null;
}
const quotedFence = parseFenceMarker(quoted.text);
if (!quotedFence) {
return null;
}
return {
...quotedFence,
quoteIndent: quoted.indent,
};
}
function stripQuotePrefix(
line: string,
maxDepth = Number.POSITIVE_INFINITY,
): { text: string; indent: number } {
let cursor = 0;
while (cursor < line.length && cursor < 3 && line[cursor] === " ") {
cursor += 1;
}
let removedDepth = 0;
let consumedCursor = cursor;
while (removedDepth < maxDepth && consumedCursor < line.length && line[consumedCursor] === ">") {
removedDepth += 1;
consumedCursor += 1;
if (line[consumedCursor] === " ") {
consumedCursor += 1;
}
}
if (removedDepth === 0) {
return { text: line, indent: 0 };
}
return {
text: line.slice(consumedCursor),
indent: Math.min(5, removedDepth),
};
}
function parseFenceMarker(line: string): FenceMarker | null {
const match = line.match(/^([ ]{0,3})(`{3,}|~{3,})(.*)$/);
if (!match) {
return null;
}
const marker = match[2];
const char = marker[0];
if (char !== "`" && char !== "~") {
return null;
}
return {
char,
length: marker.length,
indent: match[1].length,
};
}
function isClosingFence(line: string, fence: FenceMarker): boolean {
const match = line.match(/^([ ]{0,3})(`{3,}|~{3,})[ \t]*$/);
if (!match) {
return false;
}
return match[2][0] === fence.char && match[2].length >= fence.length;
}
function escapeLiteralText(input: string, escapeMap: string[]): string {
return input.replace(/[\\*_~{}`]/g, (ch) => {
const index = escapeMap.length;
escapeMap.push(ch);
return `\x01${index}\x02`;
});
}
function parseInlineSegments(text: string, inheritedStyles: InlineStyle[] = []): Segment[] {
const segments: Segment[] = [];
let cursor = 0;
while (cursor < text.length) {
const nextMatch = findNextInlineMatch(text, cursor);
if (!nextMatch) {
pushSegment(segments, text.slice(cursor), inheritedStyles);
break;
}
if (nextMatch.match.index > cursor) {
pushSegment(segments, text.slice(cursor, nextMatch.match.index), inheritedStyles);
}
const combinedStyles = [...inheritedStyles, ...nextMatch.styles];
if (nextMatch.marker.literal) {
pushSegment(segments, nextMatch.text, combinedStyles);
} else {
segments.push(...parseInlineSegments(nextMatch.text, combinedStyles));
}
cursor = nextMatch.match.index + nextMatch.match[0].length;
}
return segments;
}
function findNextInlineMatch(text: string, startIndex: number): ResolvedInlineMatch | null {
let bestMatch: ResolvedInlineMatch | null = null;
for (const [priority, marker] of INLINE_MARKERS.entries()) {
const regex = new RegExp(marker.pattern.source, marker.pattern.flags);
regex.lastIndex = startIndex;
const match = regex.exec(text);
if (!match) {
continue;
}
if (
bestMatch &&
(match.index > bestMatch.match.index ||
(match.index === bestMatch.match.index && priority > bestMatch.priority))
) {
continue;
}
bestMatch = {
match,
marker,
text: marker.extractText(match),
styles: marker.resolveStyles?.(match) ?? [],
priority,
};
}
return bestMatch;
}
function pushSegment(segments: Segment[], text: string, styles: InlineStyle[]): void {
if (!text) {
return;
}
const lastSegment = segments.at(-1);
if (lastSegment && sameStyles(lastSegment.styles, styles)) {
lastSegment.text += text;
return;
}
segments.push({
text,
styles: [...styles],
});
}
function sameStyles(left: InlineStyle[], right: InlineStyle[]): boolean {
return left.length === right.length && left.every((style, index) => style === right[index]);
}
function normalizeCodeBlockLeadingWhitespace(line: string): string {
return line.replace(/^[ \t]+/, (leadingWhitespace) =>
leadingWhitespace.replace(/\t/g, "\u00A0\u00A0\u00A0\u00A0").replace(/ /g, "\u00A0"),
);
}
function isIndentedCodeBlockLine(line: string): boolean {
return /^(?: {4,}|\t)/.test(line);
}
function stripCodeFenceIndent(line: string, indent: number): string {
let consumed = 0;
let cursor = 0;
while (cursor < line.length && consumed < indent && line[cursor] === " ") {
cursor += 1;
consumed += 1;
}
return line.slice(cursor);
}

View File

@@ -0,0 +1,213 @@
// Zalouser tests cover tool plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
import { createZalouserTool, executeZalouserTool } from "./tool.js";
import {
checkZaloAuthenticated,
getZaloUserInfo,
listZaloFriendsMatching,
listZaloGroupsMatching,
} from "./zalo-js.js";
vi.mock("./send.js", () => ({
sendMessageZalouser: vi.fn(),
sendImageZalouser: vi.fn(),
sendLinkZalouser: vi.fn(),
sendReactionZalouser: vi.fn(),
}));
vi.mock("./zalo-js.js", () => ({
checkZaloAuthenticated: vi.fn(),
getZaloUserInfo: vi.fn(),
listZaloFriendsMatching: vi.fn(),
listZaloGroupsMatching: vi.fn(),
}));
const mockSendMessage = vi.mocked(sendMessageZalouser);
const mockSendImage = vi.mocked(sendImageZalouser);
const mockSendLink = vi.mocked(sendLinkZalouser);
const mockCheckAuth = vi.mocked(checkZaloAuthenticated);
const mockGetUserInfo = vi.mocked(getZaloUserInfo);
const mockListFriends = vi.mocked(listZaloFriendsMatching);
const mockListGroups = vi.mocked(listZaloGroupsMatching);
function extractDetails(result: { content?: Array<{ type: string; text?: string }> }): unknown {
const text = result.content?.[0]?.text ?? "{}";
return JSON.parse(text) as unknown;
}
describe("executeZalouserTool", () => {
beforeEach(() => {
mockSendMessage.mockReset();
mockSendImage.mockReset();
mockSendLink.mockReset();
mockCheckAuth.mockReset();
mockGetUserInfo.mockReset();
mockListFriends.mockReset();
mockListGroups.mockReset();
});
it("returns error when send action is missing required fields", async () => {
const result = await executeZalouserTool("tool-1", { action: "send" });
expect(extractDetails(result)).toEqual({
error: "threadId and message required for send action",
});
});
it("sends text message for send action", async () => {
mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-1" } as never);
const result = await executeZalouserTool("tool-1", {
action: "send",
threadId: "t-1",
message: "hello",
profile: "work",
isGroup: true,
});
expect(mockSendMessage).toHaveBeenCalledWith("t-1", "hello", {
profile: "work",
isGroup: true,
});
expect(extractDetails(result)).toEqual({ success: true, messageId: "m-1" });
});
it("defaults send routing from ambient deliveryContext target", async () => {
mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-ambient" } as never);
const tool = createZalouserTool({
deliveryContext: {
channel: "zalouser",
to: "zalouser:g-ambient",
},
});
const result = await tool.execute("tool-1", {
action: "send",
message: "hello",
});
expect(mockSendMessage).toHaveBeenCalledWith("g-ambient", "hello", {
profile: undefined,
isGroup: true,
});
expect(extractDetails(result)).toEqual({ success: true, messageId: "m-ambient" });
});
it("keeps explicit threadId over ambient delivery defaults", async () => {
mockSendMessage.mockResolvedValueOnce({ ok: true, messageId: "m-explicit" } as never);
const tool = createZalouserTool({
deliveryContext: {
channel: "zalouser",
to: "zalouser:g-ambient",
},
});
await tool.execute("tool-1", {
action: "send",
threadId: "u-explicit",
message: "hello",
isGroup: false,
});
expect(mockSendMessage).toHaveBeenCalledWith("u-explicit", "hello", {
profile: undefined,
isGroup: false,
});
});
it("does not route send actions from foreign ambient thread defaults", async () => {
const tool = createZalouserTool({
deliveryContext: {
channel: "slack",
to: "channel:C123",
threadId: "1710000000.000100",
},
});
const result = await tool.execute("tool-1", {
action: "send",
message: "hello",
});
expect(mockSendMessage).not.toHaveBeenCalled();
expect(extractDetails(result)).toEqual({
error: "threadId and message required for send action",
});
});
it("returns tool error when send action fails", async () => {
mockSendMessage.mockResolvedValueOnce({ ok: false, error: "blocked" } as never);
const result = await executeZalouserTool("tool-1", {
action: "send",
threadId: "t-1",
message: "hello",
});
expect(extractDetails(result)).toEqual({ error: "blocked" });
});
it("routes image and link actions to correct helpers", async () => {
mockSendImage.mockResolvedValueOnce({ ok: true, messageId: "img-1" } as never);
const imageResult = await executeZalouserTool("tool-1", {
action: "image",
threadId: "g-1",
url: "https://example.com/image.jpg",
message: "caption",
isGroup: true,
});
expect(mockSendImage).toHaveBeenCalledWith("g-1", "https://example.com/image.jpg", {
profile: undefined,
caption: "caption",
isGroup: true,
});
expect(extractDetails(imageResult)).toEqual({ success: true, messageId: "img-1" });
mockSendLink.mockResolvedValueOnce({ ok: true, messageId: "lnk-1" } as never);
const linkResult = await executeZalouserTool("tool-1", {
action: "link",
threadId: "t-2",
url: "https://openclaw.ai",
message: "read this",
});
expect(mockSendLink).toHaveBeenCalledWith("t-2", "https://openclaw.ai", {
profile: undefined,
caption: "read this",
isGroup: undefined,
});
expect(extractDetails(linkResult)).toEqual({ success: true, messageId: "lnk-1" });
});
it("returns friends/groups lists", async () => {
mockListFriends.mockResolvedValueOnce([{ userId: "1", displayName: "Alice" }]);
mockListGroups.mockResolvedValueOnce([{ groupId: "2", name: "Work" }]);
const friends = await executeZalouserTool("tool-1", {
action: "friends",
profile: "work",
query: "ali",
});
expect(mockListFriends).toHaveBeenCalledWith("work", "ali");
expect(extractDetails(friends)).toEqual([{ userId: "1", displayName: "Alice" }]);
const groups = await executeZalouserTool("tool-1", {
action: "groups",
profile: "work",
query: "wrk",
});
expect(mockListGroups).toHaveBeenCalledWith("work", "wrk");
expect(extractDetails(groups)).toEqual([{ groupId: "2", name: "Work" }]);
});
it("reports me + status actions", async () => {
mockGetUserInfo.mockResolvedValueOnce({ userId: "7", displayName: "Me" });
mockCheckAuth.mockResolvedValueOnce(true);
const me = await executeZalouserTool("tool-1", { action: "me", profile: "work" });
expect(mockGetUserInfo).toHaveBeenCalledWith("work");
expect(extractDetails(me)).toEqual({ userId: "7", displayName: "Me" });
const status = await executeZalouserTool("tool-1", { action: "status", profile: "work" });
expect(mockCheckAuth).toHaveBeenCalledWith("work");
expect(extractDetails(status)).toEqual({
authenticated: true,
output: "authenticated",
});
});
});

View File

@@ -0,0 +1,190 @@
// Zalouser plugin module implements tool behavior.
import { stringEnum } from "openclaw/plugin-sdk/channel-actions";
import type { AnyAgentTool, OpenClawPluginToolContext } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { jsonResult as json, type AgentToolResult } from "openclaw/plugin-sdk/tool-results";
import { Type } from "typebox";
import { sendImageZalouser, sendLinkZalouser, sendMessageZalouser } from "./send.js";
import { parseZalouserOutboundTarget } from "./session-route.js";
import {
checkZaloAuthenticated,
getZaloUserInfo,
listZaloFriendsMatching,
listZaloGroupsMatching,
} from "./zalo-js.js";
const ACTIONS = ["send", "image", "link", "friends", "groups", "me", "status"] as const;
const ZalouserToolSchema = Type.Object(
{
action: stringEnum(ACTIONS, { description: `Action to perform: ${ACTIONS.join(", ")}` }),
threadId: Type.Optional(Type.String({ description: "Thread ID for messaging" })),
message: Type.Optional(Type.String({ description: "Message text" })),
isGroup: Type.Optional(Type.Boolean({ description: "Is group chat" })),
profile: Type.Optional(Type.String({ description: "Profile name" })),
query: Type.Optional(Type.String({ description: "Search query" })),
url: Type.Optional(Type.String({ description: "URL for media/link" })),
},
{ additionalProperties: false },
);
type ToolParams = {
action: (typeof ACTIONS)[number];
threadId?: string;
message?: string;
isGroup?: boolean;
profile?: string;
query?: string;
url?: string;
};
type ZalouserToolContext = Pick<OpenClawPluginToolContext, "deliveryContext">;
function resolveAmbientZalouserTarget(context?: ZalouserToolContext): {
threadId?: string;
isGroup?: boolean;
} {
const deliveryContext = context?.deliveryContext;
const rawTarget = deliveryContext?.to;
if (
(deliveryContext?.channel === undefined || deliveryContext.channel === "zalouser") &&
typeof rawTarget === "string" &&
rawTarget.trim()
) {
try {
return parseZalouserOutboundTarget(rawTarget);
} catch {
// Ignore unrelated delivery targets; explicit tool params still win.
}
}
if (deliveryContext?.channel && deliveryContext.channel !== "zalouser") {
return {};
}
const ambientThreadId = deliveryContext?.threadId;
if (typeof ambientThreadId === "string" && ambientThreadId.trim()) {
return { threadId: ambientThreadId.trim() };
}
if (typeof ambientThreadId === "number" && Number.isFinite(ambientThreadId)) {
return { threadId: String(ambientThreadId) };
}
return {};
}
function resolveZalouserSendTarget(params: ToolParams, context?: ZalouserToolContext) {
const explicitThreadId = typeof params.threadId === "string" ? params.threadId.trim() : "";
const ambientTarget = resolveAmbientZalouserTarget(context);
return {
threadId: explicitThreadId || ambientTarget.threadId,
isGroup: typeof params.isGroup === "boolean" ? params.isGroup : ambientTarget.isGroup,
};
}
export async function executeZalouserTool(
_toolCallId: string,
params: ToolParams,
_signal?: AbortSignal,
_onUpdate?: unknown,
context?: ZalouserToolContext,
): Promise<AgentToolResult<unknown>> {
try {
switch (params.action) {
case "send": {
const target = resolveZalouserSendTarget(params, context);
if (!target.threadId || !params.message) {
throw new Error("threadId and message required for send action");
}
const result = await sendMessageZalouser(target.threadId, params.message, {
profile: params.profile,
isGroup: target.isGroup,
});
if (!result.ok) {
throw new Error(result.error || "Failed to send message");
}
return json({ success: true, messageId: result.messageId });
}
case "image": {
const target = resolveZalouserSendTarget(params, context);
if (!target.threadId) {
throw new Error("threadId required for image action");
}
if (!params.url) {
throw new Error("url required for image action");
}
const result = await sendImageZalouser(target.threadId, params.url, {
profile: params.profile,
caption: params.message,
isGroup: target.isGroup,
});
if (!result.ok) {
throw new Error(result.error || "Failed to send image");
}
return json({ success: true, messageId: result.messageId });
}
case "link": {
const target = resolveZalouserSendTarget(params, context);
if (!target.threadId || !params.url) {
throw new Error("threadId and url required for link action");
}
const result = await sendLinkZalouser(target.threadId, params.url, {
profile: params.profile,
caption: params.message,
isGroup: target.isGroup,
});
if (!result.ok) {
throw new Error(result.error || "Failed to send link");
}
return json({ success: true, messageId: result.messageId });
}
case "friends": {
const rows = await listZaloFriendsMatching(params.profile, params.query);
return json(rows);
}
case "groups": {
const rows = await listZaloGroupsMatching(params.profile, params.query);
return json(rows);
}
case "me": {
const info = await getZaloUserInfo(params.profile);
return json(info ?? { error: "Not authenticated" });
}
case "status": {
const authenticated = await checkZaloAuthenticated(params.profile);
return json({
authenticated,
output: authenticated ? "authenticated" : "not authenticated",
});
}
default: {
params.action satisfies never;
throw new Error(
`Unknown action: ${String(params.action)}. Valid actions: send, image, link, friends, groups, me, status`,
);
}
}
} catch (err) {
return json({
error: formatErrorMessage(err),
});
}
}
export function createZalouserTool(context?: ZalouserToolContext): AnyAgentTool {
return {
name: "zalouser",
label: "Zalo Personal",
description:
"Send messages and access data via Zalo personal account. " +
"Actions: send (text message), image (send image URL), link (send link), " +
"friends (list/search friends), groups (list groups), me (profile info), status (auth check).",
parameters: ZalouserToolSchema,
execute: async (toolCallId, params, signal, onUpdate) =>
await executeZalouserTool(toolCallId, params as ToolParams, signal, onUpdate, context),
} satisfies AnyAgentTool;
}

View File

@@ -0,0 +1,131 @@
// Zalouser type declarations define plugin contracts.
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
import type { Style } from "./zca-constants.js";
export type ZcaFriend = {
userId: string;
displayName: string;
avatar?: string;
};
export type ZaloGroup = {
groupId: string;
name: string;
memberCount?: number;
};
export type ZaloGroupMember = {
userId: string;
displayName: string;
avatar?: string;
};
export type ZaloEventMessage = {
msgId: string;
cliMsgId: string;
uidFrom: string;
idTo: string;
msgType: string;
st: number;
at: number;
cmd: number;
ts: string | number;
};
export type ZaloInboundMessage = {
threadId: string;
isGroup: boolean;
senderId: string;
senderName?: string;
groupName?: string;
content: string;
commandContent?: string;
timestampMs: number;
msgId?: string;
cliMsgId?: string;
hasAnyMention?: boolean;
wasExplicitlyMentioned?: boolean;
canResolveExplicitMention?: boolean;
implicitMention?: boolean;
quotedGlobalMsgId?: string;
quotedOwnerId?: string;
quotedBody?: string;
eventMessage?: ZaloEventMessage;
raw: unknown;
};
export type ZcaUserInfo = {
userId: string;
displayName: string;
avatar?: string;
};
export type ZaloSendOptions = {
profile?: string;
mediaUrl?: string;
caption?: string;
isGroup?: boolean;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
textMode?: "markdown" | "plain";
textChunkMode?: "length" | "newline";
textChunkLimit?: number;
textStyles?: Style[];
};
export type ZaloSendResult = {
ok: boolean;
messageId?: string;
receipt: MessageReceipt;
error?: string;
};
export type ZaloGroupContext = {
groupId: string;
name?: string;
members?: string[];
};
export type ZaloAuthStatus = {
connected: boolean;
message: string;
};
type ZalouserToolConfig = { allow?: string[]; deny?: string[] };
export type ZalouserGroupConfig = {
enabled?: boolean;
requireMention?: boolean;
tools?: ZalouserToolConfig;
};
type ZalouserSharedConfig = {
enabled?: boolean;
name?: string;
profile?: string;
dangerouslyAllowNameMatching?: boolean;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
historyLimit?: number;
groupAllowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled";
groups?: Record<string, ZalouserGroupConfig>;
messagePrefix?: string;
responsePrefix?: string;
};
export type ZalouserAccountConfig = ZalouserSharedConfig;
export type ZalouserConfig = ZalouserSharedConfig & {
defaultAccount?: string;
accounts?: Record<string, ZalouserAccountConfig>;
};
export type ResolvedZalouserAccount = {
accountId: string;
name?: string;
enabled: boolean;
profile: string;
authenticated: boolean;
config: ZalouserAccountConfig;
};

View File

@@ -0,0 +1,491 @@
// Zalouser tests cover zalo js.credentials plugin behavior.
import {
lstat,
mkdir,
mkdtemp,
readFile,
rm,
stat,
symlink,
utimes,
writeFile,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { withEnvAsync } from "openclaw/plugin-sdk/test-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { API, Credentials, LoginQRCallbackEvent } from "./zca-client.js";
import { LoginQRCallbackEventType } from "./zca-constants.js";
const createZaloMock = vi.hoisted(() => vi.fn());
const ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
vi.mock("./zca-client.js", () => ({
createZalo: createZaloMock,
TextStyle: { Indent: 9 },
}));
import {
checkZaloAuthenticated,
listZaloFriends,
sendZaloLink,
sendZaloReaction,
startZaloQrLogin,
waitForZaloQrLogin,
} from "./zalo-js.js";
type StoredCredentialFile = {
imei: string;
cookie: Credentials["cookie"];
userAgent: string;
language?: string;
createdAt?: string;
lastUsedAt?: string;
};
function credentialPath(stateDir: string, profile: string): string {
const trimmed = profile.trim().toLowerCase();
const filename =
!trimmed || trimmed === "default"
? "credentials.json"
: `credentials-${encodeURIComponent(trimmed)}.json`;
return path.join(stateDir, "credentials", "zalouser", filename);
}
async function readStoredCredentials(
stateDir: string,
profile: string,
): Promise<StoredCredentialFile> {
return JSON.parse(
await readFile(credentialPath(stateDir, profile), "utf8"),
) as StoredCredentialFile;
}
function createMockApi(params: {
imei: string;
userAgent: string;
language?: string;
cookies: unknown[] | (() => unknown[]);
getAllFriends?: API["getAllFriends"];
}): API {
return {
getContext: () => ({
imei: params.imei,
userAgent: params.userAgent,
language: params.language,
}),
getCookie: () => ({
toJSON: () => ({
cookies: typeof params.cookies === "function" ? params.cookies() : params.cookies,
}),
}),
fetchAccountInfo: async () => ({
userId: "user-1",
username: "user-1",
displayName: "Zalo User",
zaloName: "Zalo User",
avatar: "",
}),
getAllFriends: params.getAllFriends ?? vi.fn(async () => []),
listener: {
on: vi.fn(),
off: vi.fn(),
start: vi.fn(),
stop: vi.fn(),
},
} as unknown as API;
}
describe("zalouser credential persistence", () => {
beforeEach(() => {
createZaloMock.mockReset();
});
it("persists the final API cookie jar after QR login", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
const profile = "qr-refresh";
const callbackCookie = [{ key: "zpsid", value: "callback", domain: "chat.zalo.me" }];
const refreshedCookie = [{ key: "zpsid", value: "refreshed", domain: "chat.zalo.me" }];
const api = createMockApi({
imei: "api-imei",
userAgent: "api-user-agent",
language: "vi",
cookies: refreshedCookie,
});
createZaloMock.mockResolvedValueOnce({
loginQR: async (_options: unknown, callback?: (event: LoginQRCallbackEvent) => unknown) => {
callback?.({
type: LoginQRCallbackEventType.QRCodeGenerated,
data: {
code: "qr-code",
image: "data:image/png;base64,abc123",
},
actions: {
saveToFile: vi.fn(async () => undefined),
retry: vi.fn(),
abort: vi.fn(),
},
});
callback?.({
type: LoginQRCallbackEventType.GotLoginInfo,
data: {
cookie: callbackCookie,
imei: "callback-imei",
userAgent: "callback-user-agent",
},
actions: null,
});
return api;
},
});
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
await startZaloQrLogin({ profile, timeoutMs: 1000 });
const loginResult = await waitForZaloQrLogin({ profile, timeoutMs: 1000 });
expect(loginResult.connected).toBe(true);
const stored = await readStoredCredentials(stateDir, profile);
expect(stored.imei).toBe("api-imei");
expect(stored.userAgent).toBe("api-user-agent");
expect(stored.language).toBe("vi");
expect(stored.cookie).toEqual(refreshedCookie);
});
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
it("caps oversized QR start timeout before computing the polling deadline", async () => {
createZaloMock.mockResolvedValueOnce({
loginQR: async () => new Promise(() => {}),
});
const nowSpy = vi.spyOn(Date, "now");
nowSpy
.mockReturnValueOnce(0)
.mockReturnValueOnce(0)
.mockReturnValueOnce(MAX_TIMER_TIMEOUT_MS + 1);
try {
const result = await startZaloQrLogin({
profile: "qr-timeout-cap",
timeoutMs: Number.MAX_SAFE_INTEGER,
});
expect(result.message).toBe(
"Still preparing QR. Call wait to continue checking login status.",
);
expect(nowSpy).toHaveBeenCalledTimes(3);
} finally {
nowSpy.mockRestore();
}
});
it("rewrites restored sessions with cookies refreshed by zca-js login", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
const profile = "restore-refresh";
const storedCookie = [{ key: "zpsid", value: "stored", domain: "chat.zalo.me" }];
const refreshedCookie = [{ key: "zpsid", value: "refreshed", domain: "chat.zalo.me" }];
const filePath = credentialPath(stateDir, profile);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(
filePath,
JSON.stringify(
{
imei: "stored-imei",
cookie: storedCookie,
userAgent: "stored-user-agent",
createdAt: "2026-04-01T00:00:00.000Z",
},
null,
2,
),
);
const api = createMockApi({
imei: "stored-imei",
userAgent: "stored-user-agent",
language: "vi",
cookies: refreshedCookie,
});
const login = vi.fn(async () => api);
createZaloMock.mockResolvedValueOnce({ login });
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
await expect(checkZaloAuthenticated(profile)).resolves.toBe(true);
expect(login).toHaveBeenCalledWith({
imei: "stored-imei",
cookie: storedCookie,
userAgent: "stored-user-agent",
language: undefined,
});
const stored = await readStoredCredentials(stateDir, profile);
expect(stored.cookie).toEqual(refreshedCookie);
expect(stored.createdAt).toBe("2026-04-01T00:00:00.000Z");
expect(stored.lastUsedAt).toMatch(ISO_TIMESTAMP_RE);
});
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
it("persists cookie changes after a successful API call", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
const profile = "api-refresh";
const storedCookie: unknown[] = [{ key: "zpsid", value: "stored", domain: "chat.zalo.me" }];
const loginCookie: unknown[] = [{ key: "zpsid", value: "login", domain: "chat.zalo.me" }];
const refreshedCookie: unknown[] = [
{ key: "zpsid", value: "api-refreshed", domain: "chat.zalo.me" },
];
const filePath = credentialPath(stateDir, profile);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(
filePath,
JSON.stringify(
{
imei: "stored-imei",
cookie: storedCookie,
userAgent: "stored-user-agent",
createdAt: "2026-04-01T00:00:00.000Z",
},
null,
2,
),
);
let currentCookie = loginCookie;
const api = createMockApi({
imei: "stored-imei",
userAgent: "stored-user-agent",
language: "vi",
cookies: () => currentCookie,
getAllFriends: vi.fn(async () => {
currentCookie = refreshedCookie;
return [
{
userId: "friend-1",
username: "friend-1",
displayName: "Friend One",
zaloName: "Friend One",
avatar: "",
},
];
}),
});
createZaloMock.mockResolvedValueOnce({ login: vi.fn(async () => api) });
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
await expect(listZaloFriends(profile)).resolves.toEqual([
{
userId: "friend-1",
displayName: "Friend One",
avatar: undefined,
},
]);
const stored = await readStoredCredentials(stateDir, profile);
expect(stored.cookie).toEqual(refreshedCookie);
expect(stored.createdAt).toBe("2026-04-01T00:00:00.000Z");
expect(stored.lastUsedAt).toMatch(ISO_TIMESTAMP_RE);
});
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
it("does not rewrite credentials when the live cookie jar only reorders cookies", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
const profile = "api-stable";
const cookieA: unknown[] = [
{ key: "zpsid", value: "same", domain: "chat.zalo.me" },
{ key: "zpw", value: "same-secondary", domain: "chat.zalo.me" },
];
const cookieB = [...cookieA].toReversed();
const filePath = credentialPath(stateDir, profile);
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(
filePath,
JSON.stringify(
{
imei: "stored-imei",
cookie: cookieA,
userAgent: "stored-user-agent",
createdAt: "2026-04-01T00:00:00.000Z",
},
null,
2,
),
);
let currentCookie = cookieA;
const api = createMockApi({
imei: "stored-imei",
userAgent: "stored-user-agent",
language: "vi",
cookies: () => currentCookie,
getAllFriends: vi.fn(async () => []),
});
createZaloMock.mockResolvedValueOnce({ login: vi.fn(async () => api) });
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
await expect(listZaloFriends(profile)).resolves.toStrictEqual([]);
const firstRaw = await readFile(filePath, "utf8");
const stableMtime = new Date("2026-04-01T00:00:10.000Z");
await utimes(filePath, stableMtime, stableMtime);
const firstMtimeMs = (await stat(filePath)).mtimeMs;
currentCookie = cookieB;
await expect(listZaloFriends(profile)).resolves.toStrictEqual([]);
expect(await readFile(filePath, "utf8")).toBe(firstRaw);
expect((await stat(filePath)).mtimeMs).toBe(firstMtimeMs);
});
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
function expectMissingSessionResult(result: { ok: boolean; error?: string }) {
expect(result.ok).toBe(false);
expect(result.error).toContain("No saved Zalo session");
}
it("keeps reaction sends non-throwing when session restore fails", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
const result = await sendZaloReaction({
profile: "missing-session",
threadId: "thread-1",
msgId: "msg-1",
cliMsgId: "cli-1",
emoji: "like",
});
expectMissingSessionResult(result);
});
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
it("keeps link sends non-throwing when session restore fails", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
const result = await sendZaloLink("thread-1", "https://example.com", {
profile: "missing-session",
});
expectMissingSessionResult(result);
});
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
it.skipIf(process.platform === "win32")(
"writes credentials with private permissions",
async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
const profile = "private-mode";
const api = createMockApi({
imei: "api-imei",
userAgent: "api-user-agent",
cookies: [{ key: "zpsid", value: "private", domain: "chat.zalo.me" }],
});
createZaloMock.mockResolvedValueOnce({
loginQR: async (_options: unknown, callback?: (event: LoginQRCallbackEvent) => unknown) => {
callback?.({
type: LoginQRCallbackEventType.QRCodeGenerated,
data: {
code: "qr-code",
image: "data:image/png;base64,abc123",
},
actions: {
saveToFile: vi.fn(async () => undefined),
retry: vi.fn(),
abort: vi.fn(),
},
});
return api;
},
});
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
await startZaloQrLogin({ profile, timeoutMs: 1000 });
const loginResult = await waitForZaloQrLogin({ profile, timeoutMs: 1000 });
expect(loginResult.connected).toBe(true);
const filePath = credentialPath(stateDir, profile);
const dirMode = (await stat(path.dirname(filePath))).mode & 0o777;
const fileMode = (await stat(filePath)).mode & 0o777;
expect(dirMode).toBe(0o700);
expect(fileMode).toBe(0o600);
});
} finally {
await rm(stateDir, { recursive: true, force: true });
}
},
);
it.skipIf(process.platform === "win32")(
"refuses to write credentials through a symlinked file",
async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-credentials-"));
const profile = "symlink-target";
const filePath = credentialPath(stateDir, profile);
const targetPath = path.join(stateDir, "outside.json");
const api = createMockApi({
imei: "api-imei",
userAgent: "api-user-agent",
cookies: [{ key: "zpsid", value: "symlink", domain: "chat.zalo.me" }],
});
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(targetPath, "sentinel", "utf8");
await symlink(targetPath, filePath);
createZaloMock.mockResolvedValueOnce({
loginQR: async (_options: unknown, callback?: (event: LoginQRCallbackEvent) => unknown) => {
callback?.({
type: LoginQRCallbackEventType.QRCodeGenerated,
data: {
code: "qr-code",
image: "data:image/png;base64,abc123",
},
actions: {
saveToFile: vi.fn(async () => undefined),
retry: vi.fn(),
abort: vi.fn(),
},
});
return api;
},
});
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
const started = await startZaloQrLogin({ profile, timeoutMs: 1000 });
const waited = await waitForZaloQrLogin({ profile, timeoutMs: 1000 });
expect(`${started.message} ${waited.message}`).toMatch(
/Refusing to write Zalo credentials to symlinked path|private store target must be a regular file/,
);
});
expect(await readFile(targetPath, "utf8")).toBe("sentinel");
expect((await lstat(filePath)).isSymbolicLink()).toBe(true);
} finally {
await rm(stateDir, { recursive: true, force: true });
}
},
);
});

View File

@@ -0,0 +1,83 @@
// Zalouser plugin module implements zalo js mocks behavior.
import { vi, type Mock } from "vitest";
type ZaloJsModule = typeof import("./zalo-js.js");
type ZaloJsMocks = {
checkZaloAuthenticatedMock: Mock<ZaloJsModule["checkZaloAuthenticated"]>;
getZaloUserInfoMock: Mock<ZaloJsModule["getZaloUserInfo"]>;
listZaloFriendsMock: Mock<ZaloJsModule["listZaloFriends"]>;
listZaloFriendsMatchingMock: Mock<ZaloJsModule["listZaloFriendsMatching"]>;
listZaloGroupMembersMock: Mock<ZaloJsModule["listZaloGroupMembers"]>;
listZaloGroupsMock: Mock<ZaloJsModule["listZaloGroups"]>;
listZaloGroupsMatchingMock: Mock<ZaloJsModule["listZaloGroupsMatching"]>;
logoutZaloProfileMock: Mock<ZaloJsModule["logoutZaloProfile"]>;
resolveZaloAllowFromEntriesMock: Mock<ZaloJsModule["resolveZaloAllowFromEntries"]>;
resolveZaloGroupContextMock: Mock<ZaloJsModule["resolveZaloGroupContext"]>;
resolveZaloGroupsByEntriesMock: Mock<ZaloJsModule["resolveZaloGroupsByEntries"]>;
startZaloListenerMock: Mock<ZaloJsModule["startZaloListener"]>;
startZaloQrLoginMock: Mock<ZaloJsModule["startZaloQrLogin"]>;
waitForZaloQrLoginMock: Mock<ZaloJsModule["waitForZaloQrLogin"]>;
};
const zaloJsMocks = vi.hoisted(
(): ZaloJsMocks => ({
checkZaloAuthenticatedMock: vi.fn(async () => false),
getZaloUserInfoMock: vi.fn(async () => null),
listZaloFriendsMock: vi.fn(async () => []),
listZaloFriendsMatchingMock: vi.fn(async () => []),
listZaloGroupMembersMock: vi.fn(async () => []),
listZaloGroupsMock: vi.fn(async () => []),
listZaloGroupsMatchingMock: vi.fn(async () => []),
logoutZaloProfileMock: vi.fn(async () => ({
cleared: true,
loggedOut: true,
message: "Logged out and cleared local session.",
})),
resolveZaloAllowFromEntriesMock: vi.fn(async ({ entries }: { entries: string[] }) =>
entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })),
),
resolveZaloGroupContextMock: vi.fn(async (_profile, groupId) => ({
groupId,
name: undefined,
members: [],
})),
resolveZaloGroupsByEntriesMock: vi.fn(async ({ entries }: { entries: string[] }) =>
entries.map((entry) => ({ input: entry, resolved: true, id: entry, note: undefined })),
),
startZaloListenerMock: vi.fn(async () => ({ stop: vi.fn() })),
startZaloQrLoginMock: vi.fn(async () => ({
message: "qr pending",
qrDataUrl: undefined,
})),
waitForZaloQrLoginMock: vi.fn(async () => ({
connected: false,
message: "login pending",
})),
}),
);
export const listZaloFriendsMock = zaloJsMocks.listZaloFriendsMock;
export const listZaloFriendsMatchingMock = zaloJsMocks.listZaloFriendsMatchingMock;
export const listZaloGroupMembersMock = zaloJsMocks.listZaloGroupMembersMock;
export const listZaloGroupsMock = zaloJsMocks.listZaloGroupsMock;
export const startZaloListenerMock: Mock<ZaloJsModule["startZaloListener"]> =
zaloJsMocks.startZaloListenerMock;
export const startZaloQrLoginMock = zaloJsMocks.startZaloQrLoginMock;
export const waitForZaloQrLoginMock = zaloJsMocks.waitForZaloQrLoginMock;
vi.mock("./zalo-js.js", () => ({
checkZaloAuthenticated: zaloJsMocks.checkZaloAuthenticatedMock,
getZaloUserInfo: zaloJsMocks.getZaloUserInfoMock,
listZaloFriends: listZaloFriendsMock,
listZaloFriendsMatching: listZaloFriendsMatchingMock,
listZaloGroupMembers: listZaloGroupMembersMock,
listZaloGroups: listZaloGroupsMock,
listZaloGroupsMatching: zaloJsMocks.listZaloGroupsMatchingMock,
logoutZaloProfile: zaloJsMocks.logoutZaloProfileMock,
resolveZaloAllowFromEntries: zaloJsMocks.resolveZaloAllowFromEntriesMock,
resolveZaloGroupContext: zaloJsMocks.resolveZaloGroupContextMock,
resolveZaloGroupsByEntries: zaloJsMocks.resolveZaloGroupsByEntriesMock,
startZaloListener: startZaloListenerMock,
startZaloQrLogin: startZaloQrLoginMock,
waitForZaloQrLogin: waitForZaloQrLoginMock,
}));

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,106 @@
// Zalouser tests cover zalo quote metadata plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { __testing as zaloTesting } from "./zalo-js.js";
afterEach(() => {
vi.useRealTimers();
});
describe("Zalo quote metadata extraction (#86851)", () => {
it("extracts quote id, owner, and body from zca-js message data", () => {
const message = zaloTesting.toInboundMessage(
{
type: 0,
data: {
uidFrom: "123456789",
idTo: "987654321",
content: "ok",
ts: 1_764_000_000_000,
quote: {
globalMsgId: 987654321234,
ownerId: "555444333_2",
msg: "Previous bot message content",
},
},
} as unknown as Parameters<typeof zaloTesting.toInboundMessage>[0],
"555444333",
);
expect(message?.quotedGlobalMsgId).toBe("987654321234");
expect(message?.quotedOwnerId).toBe("555444333");
expect(message?.quotedBody).toBe("Previous bot message content");
expect(message?.implicitMention).toBe(true);
});
it("omits quote metadata when the zca-js quote object is absent", () => {
const message = zaloTesting.toInboundMessage({
type: 0,
data: {
uidFrom: "123456789",
idTo: "987654321",
content: "plain message",
ts: 1_764_000_000_000,
},
} as unknown as Parameters<typeof zaloTesting.toInboundMessage>[0]);
expect(message?.quotedGlobalMsgId).toBeUndefined();
expect(message?.quotedOwnerId).toBeUndefined();
expect(message?.quotedBody).toBeUndefined();
});
});
describe("Zalo inbound timestamp normalization", () => {
function inboundTimestamp(ts: unknown): number | undefined {
return zaloTesting.toInboundMessage({
type: 0,
data: {
uidFrom: "123456789",
idTo: "987654321",
content: "plain message",
ts,
},
} as unknown as Parameters<typeof zaloTesting.toInboundMessage>[0])?.timestampMs;
}
it("normalizes second and millisecond timestamps", () => {
expect(inboundTimestamp(1_764_000_000)).toBe(1_764_000_000_000);
expect(inboundTimestamp("1764000000.5")).toBe(1_764_000_000_500);
expect(inboundTimestamp(1_764_000_000_000)).toBe(1_764_000_000_000);
});
it("falls back for partial or unsafe timestamps", () => {
vi.useFakeTimers();
vi.setSystemTime(1_700_000_000_000);
expect(inboundTimestamp("1764000000abc")).toBe(1_700_000_000_000);
expect(inboundTimestamp("9007199254740993")).toBe(1_700_000_000_000);
expect(inboundTimestamp(8_640_000_000_000_001)).toBe(1_700_000_000_000);
});
});
describe("Zalo group context cache", () => {
afterEach(() => {
zaloTesting.clearCachedGroupContext("cache-profile");
});
it("drops cached group context when the current clock is invalid", () => {
zaloTesting.writeCachedGroupContext("cache-profile", {
groupId: "group-invalid-clock",
name: "Cached",
});
vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
expect(zaloTesting.readCachedGroupContext("cache-profile", "group-invalid-clock")).toBeNull();
});
it("does not cache group context when ttl expiry exceeds the Date range", () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
zaloTesting.writeCachedGroupContext("cache-profile", {
groupId: "group-overflow",
name: "Overflow",
});
expect(zaloTesting.readCachedGroupContext("cache-profile", "group-overflow")).toBeNull();
});
});

View File

@@ -0,0 +1,28 @@
// Zalouser tests cover zca client plugin behavior.
import { describe, expect, it, vi } from "vitest";
describe("zca-client runtime loading", () => {
it("does not import zca-js until a session is created", async () => {
vi.clearAllMocks();
let constructedOptions: { logging?: boolean; selfListen?: boolean } | undefined;
function MockZalo(options?: { logging?: boolean; selfListen?: boolean }) {
constructedOptions = options;
}
const runtimeFactory = vi.fn(() => ({
Zalo: MockZalo,
}));
vi.doMock("zca-js", runtimeFactory);
const zcaClient = await import("./zca-client.js");
expect(runtimeFactory).not.toHaveBeenCalled();
await zcaClient.createZalo({ logging: false, selfListen: true });
expect(runtimeFactory).toHaveBeenCalledTimes(1);
expect(constructedOptions).toEqual({
logging: false,
selfListen: true,
});
});
});

View File

@@ -0,0 +1,259 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
// Zalouser plugin module implements zca client behavior.
import {
LoginQRCallbackEventType,
Reactions,
TextStyle,
ThreadType,
type Style,
} from "./zca-constants.js";
type ZcaJsRuntime = {
Zalo: unknown;
};
// Keep zca-js behind a runtime boundary so bundled metadata/contracts can load
// without resolving its optional WebSocket dependency tree.
const loadZcaJsRuntime = createLazyRuntimeModule(() =>
import("zca-js").then((mod) => mod as unknown as ZcaJsRuntime),
);
export { LoginQRCallbackEventType, Reactions, TextStyle, ThreadType };
export type { Style };
export type Credentials = {
imei: string;
cookie: unknown;
userAgent: string;
language?: string;
};
export type User = {
userId: string;
username: string;
displayName: string;
zaloName: string;
avatar: string;
};
export type GroupInfo = {
groupId: string;
name: string;
totalMember?: number;
memberIds?: unknown[];
currentMems?: Array<{
id?: unknown;
dName?: string;
zaloName?: string;
avatar?: string;
}>;
};
export type Message = {
type: number;
threadId: string;
isSelf: boolean;
data: Record<string, unknown>;
};
export type LoginQRCallbackEvent =
| {
type: 0;
data: {
code: string;
image: string;
};
actions: {
saveToFile: (qrPath?: string) => Promise<unknown>;
retry: () => unknown;
abort: () => unknown;
};
}
| {
type: 1;
data: null;
actions: {
retry: () => unknown;
abort: () => unknown;
};
}
| {
type: 2;
data: {
avatar: string;
display_name: string;
};
actions: {
retry: () => unknown;
abort: () => unknown;
};
}
| {
type: 3;
data: {
code: string;
};
actions: {
retry: () => unknown;
abort: () => unknown;
};
}
| {
type: 4;
data: {
cookie: unknown;
imei: string;
userAgent: string;
};
actions: null;
};
export type Listener = {
on(event: "message", callback: (message: Message) => void): void;
on(event: "error", callback: (error: unknown) => void): void;
on(event: "closed", callback: (code: number, reason: string) => void): void;
off(event: "message", callback: (message: Message) => void): void;
off(event: "error", callback: (error: unknown) => void): void;
off(event: "closed", callback: (code: number, reason: string) => void): void;
start(opts?: { retryOnClose?: boolean }): void;
stop(): void;
};
type DeliveryEventMessage = {
msgId: string;
cliMsgId: string;
uidFrom: string;
idTo: string;
msgType: string;
st: number;
at: number;
cmd: number;
ts: string | number;
};
type DeliveryEventMessages = DeliveryEventMessage | DeliveryEventMessage[];
export type API = {
listener: Listener;
getContext(): {
imei: string;
userAgent: string;
language?: string;
};
getCookie(): {
toJSON(): {
cookies: unknown[];
};
};
fetchAccountInfo(): Promise<User | { profile: User }>;
getAllFriends(): Promise<User[]>;
getOwnId(): string;
getAllGroups(): Promise<{
gridVerMap: Record<string, string>;
}>;
getGroupInfo(groupId: string | string[]): Promise<{
gridInfoMap: Record<string, GroupInfo & { memVerList?: unknown }>;
}>;
getGroupMembersInfo(memberId: string | string[]): Promise<{
profiles: Record<
string,
{
id?: string;
displayName?: string;
zaloName?: string;
avatar?: string;
}
>;
}>;
sendMessage(
message: string | Record<string, unknown>,
threadId: string,
type?: number,
): Promise<{
msgId?: string | number;
message?: { msgId?: string | number } | null;
attachment?: Array<{ msgId?: string | number }>;
}>;
uploadAttachment(
sources:
| string
| {
data: Buffer;
filename: `${string}.${string}`;
metadata: {
totalSize: number;
width?: number;
height?: number;
};
}
| Array<
| string
| {
data: Buffer;
filename: `${string}.${string}`;
metadata: {
totalSize: number;
width?: number;
height?: number;
};
}
>,
threadId: string,
type?: number,
): Promise<
Array<{
fileType: "image" | "video" | "others";
fileUrl?: string;
msgId?: string | number;
fileId?: string;
fileName?: string;
}>
>;
sendVoice(
options: {
voiceUrl: string;
ttl?: number;
},
threadId: string,
type?: number,
): Promise<{ msgId?: string | number }>;
sendLink(
payload: { link: string; msg?: string },
threadId: string,
type?: number,
): Promise<{ msgId?: string | number }>;
sendTypingEvent(threadId: string, type?: number, destType?: number): Promise<{ status: number }>;
addReaction(
icon: string | { rType: number; source: number; icon: string },
dest: {
data: {
msgId: string;
cliMsgId: string;
};
threadId: string;
type: number;
},
): Promise<unknown>;
sendDeliveredEvent(
isSeen: boolean,
messages: DeliveryEventMessages,
type?: number,
): Promise<unknown>;
sendSeenEvent(messages: DeliveryEventMessages, type?: number): Promise<unknown>;
};
type ZaloCtor = new (options?: { logging?: boolean; selfListen?: boolean }) => {
login(credentials: Credentials): Promise<API>;
loginQR(
options?: { userAgent?: string; language?: string; qrPath?: string },
callback?: (event: LoginQRCallbackEvent) => unknown,
): Promise<API>;
};
export async function createZalo(
options?: ConstructorParameters<ZaloCtor>[0],
): Promise<InstanceType<ZaloCtor>> {
const zcaJs = await loadZcaJsRuntime();
const Zalo = zcaJs.Zalo as ZaloCtor;
return new Zalo(options);
}

View File

@@ -0,0 +1,56 @@
// Zalouser plugin module implements zca constants behavior.
export const ThreadType = {
User: 0,
Group: 1,
} as const;
export const LoginQRCallbackEventType = {
QRCodeGenerated: 0,
QRCodeExpired: 1,
QRCodeScanned: 2,
QRCodeDeclined: 3,
GotLoginInfo: 4,
} as const;
export const Reactions = {
HEART: "/-heart",
LIKE: "/-strong",
HAHA: ":>",
WOW: ":o",
CRY: ":-((",
ANGRY: ":-h",
NONE: "",
} as const;
// Mirror zca-js sendMessage style constants locally because the package root
// typing surface does not consistently expose TextStyle/Style to tsgo.
export const TextStyle = {
Bold: "b",
Italic: "i",
Underline: "u",
StrikeThrough: "s",
Red: "c_db342e",
Orange: "c_f27806",
Yellow: "c_f7b503",
Green: "c_15a85f",
Small: "f_13",
Big: "f_18",
UnorderedList: "lst_1",
OrderedList: "lst_2",
Indent: "ind_$",
} as const;
type TextStyleValue = (typeof TextStyle)[keyof typeof TextStyle];
export type Style =
| {
start: number;
len: number;
st: Exclude<TextStyleValue, typeof TextStyle.Indent>;
}
| {
start: number;
len: number;
st: typeof TextStyle.Indent;
indentSize?: number;
};

View File

@@ -0,0 +1,23 @@
// Zalouser type declarations define plugin contracts.
declare module "zca-js" {
export const ThreadType: {
User: number;
Group: number;
};
export const LoginQRCallbackEventType: {
QRCodeGenerated: number;
QRCodeExpired: number;
QRCodeScanned: number;
QRCodeDeclined: number;
GotLoginInfo: number;
};
export const Reactions: Record<string, string>;
export class Zalo {
constructor(options?: { logging?: boolean; selfListen?: boolean });
login(credentials: unknown): Promise<unknown>;
loginQR(options?: unknown, callback?: (event: unknown) => unknown): Promise<unknown>;
}
}