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:
parent
5a2a4abc12
commit
b31b681088
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 {
|
||||
|
||||
35
extensions/zalouser/src/channel.setup.test.ts
Normal file
35
extensions/zalouser/src/channel.setup.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
12
extensions/zalouser/src/channel.setup.ts
Normal file
12
extensions/zalouser/src/channel.setup.ts
Normal 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,
|
||||
}),
|
||||
};
|
||||
@ -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({
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
95
extensions/zalouser/src/shared.ts
Normal file
95
extensions/zalouser/src/shared.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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
12
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user