fix(zalouser): fix setup-only onboarding flow (#49219)

* zalouser: extract shared plugin base to reduce duplication

* fix(zalouser): bump zca-js to 2.1.2 and fix state dir resolution

* fix(zalouser): allow empty allowlist during onboarding and add quickstart DM policy prompt

* fix minor review

* fix(zalouser): restore forceAllowFrom setup flow

* fix(zalouser): default group access to allowlist
This commit is contained in:
darkamenosa 2026-03-18 03:33:22 +07:00 committed by GitHub
parent 5a2a4abc12
commit b31b681088
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 584 additions and 111 deletions

View File

@ -5,7 +5,7 @@
"type": "module",
"dependencies": {
"@sinclair/typebox": "0.34.48",
"zca-js": "2.1.1",
"zca-js": "2.1.2",
"zod": "^4.3.6"
},
"openclaw": {
@ -24,7 +24,7 @@
"zlu"
],
"order": 85,
"quickstartAllowFrom": true
"quickstartAllowFrom": false
},
"install": {
"npmSpec": "@openclaw/zalouser",

View File

@ -1,4 +1,6 @@
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
import { zalouserPlugin } from "./src/channel.js";
import { zalouserSetupPlugin } from "./src/channel.setup.js";
export default defineSetupPluginEntry(zalouserPlugin);
export { zalouserSetupPlugin } from "./src/channel.setup.js";
export default defineSetupPluginEntry(zalouserSetupPlugin);

View File

@ -124,6 +124,19 @@ describe("zalouser account resolution", () => {
expect(resolved.config.allowFrom).toEqual(["123"]);
});
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: {

View File

@ -24,7 +24,12 @@ function mergeZalouserAccountConfig(cfg: OpenClawConfig, accountId: string): Zal
const raw = (cfg.channels?.zalouser ?? {}) as ZalouserConfig;
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
const merged = { ...base, ...account };
return {
...merged,
// Match Telegram's safe default: groups stay allowlisted unless explicitly opened.
groupPolicy: merged.groupPolicy ?? "allowlist",
};
}
function resolveProfile(config: ZalouserAccountConfig, accountId: string): string {

View File

@ -0,0 +1,35 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
import { withEnvAsync } from "../../../test/helpers/extensions/env.js";
import { zalouserSetupPlugin } from "./channel.setup.js";
const zalouserSetupAdapter = buildChannelSetupWizardAdapterFromSetupWizard({
plugin: zalouserSetupPlugin,
wizard: zalouserSetupPlugin.setupWizard!,
});
describe("zalouser setup plugin", () => {
it("builds setup status without an initialized runtime", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zalouser-setup-"));
try {
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
await expect(
zalouserSetupAdapter.getStatus({
cfg: {},
accountOverrides: {},
}),
).resolves.toMatchObject({
channel: "zalouser",
configured: false,
statusLines: ["Zalo Personal: needs QR login"],
});
});
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
});

View File

@ -0,0 +1,12 @@
import type { ChannelPlugin } from "openclaw/plugin-sdk/zalouser";
import type { ResolvedZalouserAccount } from "./accounts.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

@ -1,4 +1,3 @@
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { buildAccountScopedDmSecurityPolicy } from "openclaw/plugin-sdk/channel-policy";
import type {
@ -13,15 +12,11 @@ import type {
import {
buildChannelSendResult,
buildBaseAccountStatusSnapshot,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatAllowFromLowercase,
isDangerousNameMatchingEnabled,
isNumericTargetId,
normalizeAccountId,
sendPayloadWithChunkedTextAndMedia,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk/zalouser";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import {
@ -32,7 +27,6 @@ import {
checkZcaAuthenticated,
type ResolvedZalouserAccount,
} from "./accounts.js";
import { ZalouserConfigSchema } from "./config-schema.js";
import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js";
import { resolveZalouserReactionMessageIds } from "./message-sid.js";
import { probeZalouser } from "./probe.js";
@ -41,6 +35,7 @@ import { getZalouserRuntime } from "./runtime.js";
import { sendMessageZalouser, sendReactionZalouser } from "./send.js";
import { zalouserSetupAdapter } from "./setup-core.js";
import { zalouserSetupWizard } from "./setup-surface.js";
import { createZalouserPluginBase } from "./shared.js";
import { collectZalouserStatusIssues } from "./status-issues.js";
import {
listZaloFriendsMatching,
@ -52,18 +47,6 @@ import {
getZaloUserInfo,
} from "./zalo-js.js";
const 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: true,
};
const ZALOUSER_TEXT_CHUNK_LIMIT = 2000;
function stripZalouserTargetPrefix(raw: string): string {
@ -304,62 +287,10 @@ const zalouserMessageActions: ChannelMessageActionAdapter = {
};
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
id: "zalouser",
meta,
setup: zalouserSetupAdapter,
setupWizard: zalouserSetupWizard,
capabilities: {
chatTypes: ["direct", "group"],
media: true,
reactions: true,
threads: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.zalouser"] },
configSchema: buildChannelConfigSchema(ZalouserConfigSchema),
config: {
listAccountIds: (cfg) => listZalouserAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveZalouserAccountSync({ cfg: cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg,
sectionKey: "zalouser",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg,
sectionKey: "zalouser",
accountId,
clearBaseFields: [
"profile",
"name",
"dmPolicy",
"allowFrom",
"historyLimit",
"groupAllowFrom",
"groupPolicy",
"groups",
"messagePrefix",
],
}),
isConfigured: async (account) => await checkZcaAuthenticated(account.profile),
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: undefined,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
mapAllowFromEntries(resolveZalouserAccountSync({ cfg: cfg, accountId }).config.allowFrom),
formatAllowFrom: ({ allowFrom }) =>
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
},
...createZalouserPluginBase({
setupWizard: zalouserSetupWizard,
setup: zalouserSetupAdapter,
}),
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
return buildAccountScopedDmSecurityPolicy({

View File

@ -24,7 +24,7 @@ const zalouserAccountSchema = z.object({
allowFrom: AllowFromListSchema,
historyLimit: z.number().int().min(0).optional(),
groupAllowFrom: AllowFromListSchema,
groupPolicy: GroupPolicySchema.optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
groups: z.object({}).catchall(groupConfigSchema).optional(),
messagePrefix: z.string().optional(),
responsePrefix: z.string().optional(),

View File

@ -1,6 +1,7 @@
import type { OpenClawConfig, PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/zalouser";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./monitor.send-mocks.js";
import { resolveZalouserAccountSync } from "./accounts.js";
import { __testing } from "./monitor.js";
import {
sendDeliveredZalouserMock,
@ -376,6 +377,34 @@ describe("zalouser monitor group mention gating", () => {
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,

View File

@ -61,5 +61,243 @@ describe("zalouser setup wizard", () => {
expect(result.accountId).toBe("default");
expect(result.cfg.channels?.zalouser?.enabled).toBe(true);
expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true);
});
it("prompts DM policy before group access in quickstart", async () => {
const runtime = createRuntimeEnv();
const seen: string[] = [];
const prompter = createTestWizardPrompter({
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;
}),
select: vi.fn(
async ({ message, options }: { message: string; options: Array<{ value: string }> }) => {
const first = options[0];
if (!first) {
throw new Error("no options");
}
seen.push(message);
if (message === "Zalo Personal DM policy") {
return "pairing";
}
return first.value;
},
) as ReturnType<typeof createTestWizardPrompter>["select"],
});
const result = await zalouserConfigureAdapter.configure({
cfg: {} as OpenClawConfig,
runtime,
prompter,
options: { quickstartDefaults: true },
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.accountId).toBe("default");
expect(result.cfg.channels?.zalouser?.enabled).toBe(true);
expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true);
expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("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 runtime = createRuntimeEnv();
const note = vi.fn(async (_message: string, _title?: string) => {});
const prompter = createTestWizardPrompter({
note,
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;
}),
select: vi.fn(
async ({ message, options }: { message: string; options: Array<{ value: string }> }) => {
const first = options[0];
if (!first) {
throw new Error("no options");
}
if (message === "Zalo Personal DM policy") {
return "allowlist";
}
return first.value;
},
) as ReturnType<typeof createTestWizardPrompter>["select"],
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Zalouser allowFrom (name or user id)") {
return "";
}
return "";
}) as ReturnType<typeof createTestWizardPrompter>["text"],
});
const result = await zalouserConfigureAdapter.configure({
cfg: {} as OpenClawConfig,
runtime,
prompter,
options: { quickstartDefaults: true },
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.accountId).toBe("default");
expect(result.cfg.channels?.zalouser?.enabled).toBe(true);
expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true);
expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("allowlist");
expect(result.cfg.channels?.zalouser?.allowFrom).toEqual([]);
expect(
note.mock.calls.some(([message]) =>
String(message).includes("No DM allowlist entries added yet."),
),
).toBe(true);
});
it("allows an empty group allowlist with a warning", async () => {
const runtime = createRuntimeEnv();
const note = vi.fn(async (_message: string, _title?: string) => {});
const prompter = createTestWizardPrompter({
note,
confirm: vi.fn(async ({ message }: { message: string }) => {
if (message === "Login via QR code now?") {
return false;
}
if (message === "Configure Zalo groups access?") {
return true;
}
return false;
}),
select: vi.fn(
async ({ message, options }: { message: string; options: Array<{ value: string }> }) => {
const first = options[0];
if (!first) {
throw new Error("no options");
}
if (message === "Zalo groups access") {
return "allowlist";
}
return first.value;
},
) as ReturnType<typeof createTestWizardPrompter>["select"],
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "Zalo groups allowlist (comma-separated)") {
return "";
}
return "";
}) as ReturnType<typeof createTestWizardPrompter>["text"],
});
const result = await zalouserConfigureAdapter.configure({
cfg: {} as OpenClawConfig,
runtime,
prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.cfg.channels?.zalouser?.groupPolicy).toBe("allowlist");
expect(result.cfg.channels?.zalouser?.groups).toEqual({});
expect(
note.mock.calls.some(([message]) =>
String(message).includes("No group allowlist entries added yet."),
),
).toBe(true);
});
it("preserves non-quickstart forceAllowFrom behavior", async () => {
const runtime = createRuntimeEnv();
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 zalouserConfigureAdapter.configure({
cfg: {} as OpenClawConfig,
runtime,
prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: true,
});
expect(result.cfg.channels?.zalouser?.dmPolicy).toBe("allowlist");
expect(result.cfg.channels?.zalouser?.allowFrom).toEqual([]);
expect(seen).not.toContain("Zalo Personal DM policy");
expect(seen).toContain("Zalouser allowFrom (name or user id)");
expect(
note.mock.calls.some(([message]) =>
String(message).includes("No DM allowlist entries added yet."),
),
).toBe(true);
});
it("allowlists the plugin when a plugin allowlist already exists", async () => {
const runtime = createRuntimeEnv();
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 zalouserConfigureAdapter.configure({
cfg: {
plugins: {
allow: ["telegram"],
},
} as OpenClawConfig,
runtime,
prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.cfg.plugins?.entries?.zalouser?.enabled).toBe(true);
expect(result.cfg.plugins?.allow).toEqual(["telegram", "zalouser"]);
});
});

View File

@ -1,5 +1,6 @@
import {
DEFAULT_ACCOUNT_ID,
formatCliCommand,
formatDocsLink,
formatResolvedUnresolvedNote,
mergeAllowFromEntries,
@ -8,6 +9,7 @@ import {
setTopLevelChannelDmPolicyWithAllowFrom,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
type DmPolicy,
type OpenClawConfig,
} from "openclaw/plugin-sdk/setup";
import {
@ -27,6 +29,18 @@ import {
} from "./zalo-js.js";
const channel = "zalouser" as const;
const ZALOUSER_ALLOW_FROM_PLACEHOLDER = "Alice, 123456789, or leave empty to configure later";
const ZALOUSER_GROUPS_PLACEHOLDER = "Family, Work, 123456789, or leave empty for now";
const ZALOUSER_DM_ACCESS_TITLE = "Zalo Personal DM access";
const ZALOUSER_ALLOWLIST_TITLE = "Zalo Personal allowlist";
const ZALOUSER_GROUPS_TITLE = "Zalo groups";
function parseZalouserEntries(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
function setZalouserAccountScopedConfig(
cfg: OpenClawConfig,
@ -43,10 +57,7 @@ function setZalouserAccountScopedConfig(
}) as OpenClawConfig;
}
function setZalouserDmPolicy(
cfg: OpenClawConfig,
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
): OpenClawConfig {
function setZalouserDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
return setTopLevelChannelDmPolicyWithAllowFrom({
cfg,
channel,
@ -69,12 +80,41 @@ function setZalouserGroupAllowlist(
accountId: string,
groupKeys: string[],
): OpenClawConfig {
const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }]));
const groups = Object.fromEntries(
groupKeys.map((key) => [key, { allow: 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> {
@ -98,20 +138,28 @@ async function promptZalouserAllowFrom(params: {
const { cfg, prompter, accountId } = params;
const resolved = resolveZalouserAccountSync({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
const parseInput = (raw: string) =>
raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
while (true) {
const entry = await prompter.text({
message: "Zalouser allowFrom (name or user id)",
placeholder: "Alice, 123456789",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
placeholder: ZALOUSER_ALLOW_FROM_PLACEHOLDER,
initialValue: existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : undefined,
});
const parts = parseInput(String(entry));
const parts = parseZalouserEntries(String(entry));
if (parts.length === 0) {
await prompter.note(
[
"No DM allowlist entries added yet.",
"Direct chats will stay blocked until you add people later.",
`Tip: use \`${formatCliCommand("openclaw directory peers list --channel zalouser")}\` to look up people after onboarding.`,
].join("\n"),
ZALOUSER_ALLOWLIST_TITLE,
);
return setZalouserAccountScopedConfig(cfg, accountId, {
dmPolicy: "allowlist",
allowFrom: [],
});
}
const resolvedEntries = await resolveZaloAllowFromEntries({
profile: resolved.profile,
entries: parts,
@ -121,7 +169,7 @@ async function promptZalouserAllowFrom(params: {
if (unresolved.length > 0) {
await prompter.note(
`Could not resolve: ${unresolved.join(", ")}. Use numeric user ids or exact friend names.`,
"Zalo Personal allowlist",
ZALOUSER_ALLOWLIST_TITLE,
);
continue;
}
@ -135,7 +183,7 @@ async function promptZalouserAllowFrom(params: {
.filter((item) => item.note)
.map((item) => `${item.input} -> ${item.id} (${item.note})`);
if (notes.length > 0) {
await prompter.note(notes.join("\n"), "Zalo Personal allowlist");
await prompter.note(notes.join("\n"), ZALOUSER_ALLOWLIST_TITLE);
}
return setZalouserAccountScopedConfig(cfg, accountId, {
@ -150,7 +198,7 @@ const zalouserDmPolicy: ChannelSetupDmPolicy = {
channel,
policyKey: "channels.zalouser.dmPolicy",
allowFromKey: "channels.zalouser.allowFrom",
getCurrent: (cfg) => (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
getCurrent: (cfg) => (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as DmPolicy,
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as OpenClawConfig, policy),
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
const id =
@ -165,6 +213,52 @@ const zalouserDmPolicy: ChannelSetupDmPolicy = {
},
};
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 = (cfg.channels?.zalouser?.dmPolicy ?? "pairing") as DmPolicy;
const existingAllowFrom = resolved.config.allowFrom ?? [];
const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
await prompter.note(
[
"Direct chats are configured separately from group chats.",
"- pairing (default): unknown people get a pairing code",
"- allowlist: only listed people can DM",
"- open: anyone can DM",
"- disabled: ignore DMs",
"",
`Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`,
"If you choose allowlist now, you can leave it empty and add people later.",
].join("\n"),
ZALOUSER_DM_ACCESS_TITLE,
);
const policy = (await prompter.select({
message: "Zalo Personal DM policy",
options: [
{ value: "pairing", label: "Pairing (recommended)" },
{ value: "allowlist", label: "Allowlist (specific users only)" },
{ value: "open", label: "Open (public inbound DMs)" },
{ value: "disabled", label: "Disabled (ignore DMs)" },
],
initialValue: existingPolicy,
})) as DmPolicy;
if (policy === "allowlist") {
return await promptZalouserAllowFrom({
cfg,
prompter,
accountId,
});
}
return setZalouserDmPolicy(cfg, policy);
}
export { zalouserSetupAdapter } from "./setup-core.js";
export const zalouserSetupWizard: ChannelSetupWizard = {
@ -191,7 +285,7 @@ export const zalouserSetupWizard: ChannelSetupWizard = {
return [`Zalo Personal: ${configured ? "logged in" : "needs QR login"}`];
},
},
prepare: async ({ cfg, accountId, prompter }) => {
prepare: async ({ cfg, accountId, prompter, options }) => {
let next = cfg;
const account = resolveZalouserAccountSync({ cfg: next, accountId });
const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
@ -265,12 +359,20 @@ export const zalouserSetupWizard: ChannelSetupWizard = {
{ profile: account.profile, enabled: true },
);
if (options?.quickstartDefaults) {
next = await promptZalouserQuickstartDmPolicy({
cfg: next,
prompter,
accountId,
});
}
return { cfg: next };
},
credentials: [],
groupAccess: {
label: "Zalo groups",
placeholder: "Family, Work, 123456789",
placeholder: ZALOUSER_GROUPS_PLACEHOLDER,
currentPolicy: ({ cfg, accountId }) =>
resolveZalouserAccountSync({ cfg, accountId }).config.groupPolicy ?? "allowlist",
currentEntries: ({ cfg, accountId }) =>
@ -281,6 +383,15 @@ export const zalouserSetupWizard: ChannelSetupWizard = {
setZalouserGroupPolicy(cfg as OpenClawConfig, accountId, policy),
resolveAllowlist: async ({ cfg, accountId, entries, prompter }) => {
if (entries.length === 0) {
await prompter.note(
[
"No group allowlist entries added yet.",
"Group chats will stay blocked until you add groups later.",
`Tip: use \`${formatCliCommand("openclaw directory groups list --channel zalouser")}\` after onboarding to find group IDs.`,
"Mention requirement stays on by default for groups you allow later.",
].join("\n"),
ZALOUSER_GROUPS_TITLE,
);
return [];
}
const updatedAccount = resolveZalouserAccountSync({ cfg: cfg as OpenClawConfig, accountId });
@ -299,13 +410,13 @@ export const zalouserSetupWizard: ChannelSetupWizard = {
unresolved,
});
if (resolution) {
await prompter.note(resolution, "Zalo groups");
await prompter.note(resolution, ZALOUSER_GROUPS_TITLE);
}
return keys;
} catch (err) {
await prompter.note(
`Group lookup failed; keeping entries as typed. ${String(err)}`,
"Zalo groups",
ZALOUSER_GROUPS_TITLE,
);
return entries.map((entry) => entry.trim()).filter(Boolean);
}
@ -313,16 +424,16 @@ export const zalouserSetupWizard: ChannelSetupWizard = {
applyAllowlist: ({ cfg, accountId, resolved }) =>
setZalouserGroupAllowlist(cfg as OpenClawConfig, accountId, resolved as string[]),
},
finalize: async ({ cfg, accountId, forceAllowFrom, prompter }) => {
finalize: async ({ cfg, accountId, forceAllowFrom, options, prompter }) => {
let next = cfg;
if (forceAllowFrom) {
if (forceAllowFrom && !options?.quickstartDefaults) {
next = await promptZalouserAllowFrom({
cfg: next,
prompter,
accountId,
});
}
return { cfg: next };
return { cfg: ensureZalouserPluginEnabled(next) };
},
dmPolicy: zalouserDmPolicy,
};

View File

@ -0,0 +1,95 @@
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
import type { ChannelPlugin } from "openclaw/plugin-sdk/zalouser";
import {
buildChannelConfigSchema,
deleteAccountFromConfigSection,
formatAllowFromLowercase,
setAccountEnabledInConfigSection,
} from "openclaw/plugin-sdk/zalouser";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
checkZcaAuthenticated,
type ResolvedZalouserAccount,
} from "./accounts.js";
import { ZalouserConfigSchema } from "./config-schema.js";
export const zalouserMeta = {
id: "zalouser",
label: "Zalo Personal",
selectionLabel: "Zalo (Personal Account)",
docsPath: "/channels/zalouser",
docsLabel: "zalouser",
blurb: "Zalo personal account via QR code login.",
aliases: ["zlu"],
order: 85,
quickstartAllowFrom: false,
} satisfies ChannelPlugin<ResolvedZalouserAccount>["meta"];
export function createZalouserPluginBase(params: {
setupWizard: NonNullable<ChannelPlugin<ResolvedZalouserAccount>["setupWizard"]>;
setup: NonNullable<ChannelPlugin<ResolvedZalouserAccount>["setup"]>;
}): Pick<
ChannelPlugin<ResolvedZalouserAccount>,
"id" | "meta" | "setupWizard" | "capabilities" | "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,
},
reload: { configPrefixes: ["channels.zalouser"] },
configSchema: buildChannelConfigSchema(ZalouserConfigSchema),
config: {
listAccountIds: (cfg) => listZalouserAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveZalouserAccountSync({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "zalouser",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "zalouser",
accountId,
clearBaseFields: [
"profile",
"name",
"dmPolicy",
"allowFrom",
"historyLimit",
"groupAllowFrom",
"groupPolicy",
"groups",
"messagePrefix",
],
}),
isConfigured: async (account) => await checkZcaAuthenticated(account.profile),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: undefined,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
mapAllowFromEntries(resolveZalouserAccountSync({ cfg, accountId }).config.allowFrom),
formatAllowFrom: ({ allowFrom }) =>
formatAllowFromLowercase({ allowFrom, stripPrefixRe: /^(zalouser|zlu):/i }),
},
setup: params.setup,
};
}

View File

@ -3,9 +3,9 @@ import fs from "node:fs";
import fsp from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { resolveStateDir as resolvePluginStateDir } from "openclaw/plugin-sdk/state-paths";
import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/zalouser";
import { normalizeZaloReactionIcon } from "./reaction.js";
import { getZalouserRuntime } from "./runtime.js";
import type {
ZaloAuthStatus,
ZaloEventMessage,
@ -85,7 +85,7 @@ type StoredZaloCredentials = {
};
function resolveStateDir(env: NodeJS.ProcessEnv = process.env): string {
return getZalouserRuntime().state.resolveStateDir(env, os.homedir);
return resolvePluginStateDir(env, os.homedir);
}
function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string {

12
pnpm-lock.yaml generated
View File

@ -288,6 +288,8 @@ importers:
extensions/byteplus: {}
extensions/chutes: {}
extensions/cloudflare-ai-gateway: {}
extensions/copilot-proxy: {}
@ -601,8 +603,8 @@ importers:
specifier: 0.34.48
version: 0.34.48
zca-js:
specifier: 2.1.1
version: 2.1.1
specifier: 2.1.2
version: 2.1.2
zod:
specifier: ^4.3.6
version: 4.3.6
@ -6879,8 +6881,8 @@ packages:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
zca-js@2.1.1:
resolution: {integrity: sha512-6zCmaIIWg/1eYlvCvO4rVsFt6SQ8MRodro3dCzMkk+LNgB3MyaEMBywBJfsw44WhODmOh8iMlPv4xDTNTMWDWA==}
zca-js@2.1.2:
resolution: {integrity: sha512-82+zCqoIXnXEF6C9YuN3Kf7WKlyyujY/6Ejl2n8PkwazYkBK0k7kiPd8S7nHvC5Wl7vjwGRhDYeAM8zTHyoRxQ==}
engines: {node: '>=18.0.0'}
zod-to-json-schema@3.25.1:
@ -14349,7 +14351,7 @@ snapshots:
yoctocolors@2.1.2: {}
zca-js@2.1.1:
zca-js@2.1.2:
dependencies:
crypto-js: 4.2.0
form-data: 2.5.4