fix(zalouser): normalize send and onboarding flows

This commit is contained in:
Peter Steinberger
2026-02-22 11:28:34 +00:00
parent 5c7ab8eae3
commit 49648daec0
4 changed files with 263 additions and 204 deletions

View File

@@ -23,6 +23,45 @@ import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from ".
const channel = "zalouser" as const; const channel = "zalouser" as const;
function setZalouserAccountScopedConfig(
cfg: OpenClawConfig,
accountId: string,
defaultPatch: Record<string, unknown>,
accountPatch: Record<string, unknown> = defaultPatch,
): OpenClawConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
...defaultPatch,
},
},
} as OpenClawConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...cfg.channels?.zalouser?.accounts,
[accountId]: {
...cfg.channels?.zalouser?.accounts?.[accountId],
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
...accountPatch,
},
},
},
},
} as OpenClawConfig;
}
function setZalouserDmPolicy( function setZalouserDmPolicy(
cfg: OpenClawConfig, cfg: OpenClawConfig,
dmPolicy: "pairing" | "allowlist" | "open" | "disabled", dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
@@ -123,40 +162,10 @@ async function promptZalouserAllowFrom(params: {
continue; continue;
} }
const unique = mergeAllowFromEntries(existingAllowFrom, results.filter(Boolean) as string[]); const unique = mergeAllowFromEntries(existingAllowFrom, results.filter(Boolean) as string[]);
if (accountId === DEFAULT_ACCOUNT_ID) { return setZalouserAccountScopedConfig(cfg, accountId, {
return { dmPolicy: "allowlist",
...cfg, allowFrom: unique,
channels: { });
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
} as OpenClawConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...cfg.channels?.zalouser?.accounts,
[accountId]: {
...cfg.channels?.zalouser?.accounts?.[accountId],
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
} as OpenClawConfig;
} }
} }
@@ -165,37 +174,9 @@ function setZalouserGroupPolicy(
accountId: string, accountId: string,
groupPolicy: "open" | "allowlist" | "disabled", groupPolicy: "open" | "allowlist" | "disabled",
): OpenClawConfig { ): OpenClawConfig {
if (accountId === DEFAULT_ACCOUNT_ID) { return setZalouserAccountScopedConfig(cfg, accountId, {
return { groupPolicy,
...cfg, });
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
groupPolicy,
},
},
} as OpenClawConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...cfg.channels?.zalouser?.accounts,
[accountId]: {
...cfg.channels?.zalouser?.accounts?.[accountId],
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
groupPolicy,
},
},
},
},
} as OpenClawConfig;
} }
function setZalouserGroupAllowlist( function setZalouserGroupAllowlist(
@@ -204,37 +185,9 @@ function setZalouserGroupAllowlist(
groupKeys: string[], groupKeys: string[],
): OpenClawConfig { ): OpenClawConfig {
const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }])); const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }]));
if (accountId === DEFAULT_ACCOUNT_ID) { return setZalouserAccountScopedConfig(cfg, accountId, {
return { groups,
...cfg, });
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
groups,
},
},
} as OpenClawConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled: true,
accounts: {
...cfg.channels?.zalouser?.accounts,
[accountId]: {
...cfg.channels?.zalouser?.accounts?.[accountId],
enabled: cfg.channels?.zalouser?.accounts?.[accountId]?.enabled ?? true,
groups,
},
},
},
},
} as OpenClawConfig;
} }
async function resolveZalouserGroups(params: { async function resolveZalouserGroups(params: {
@@ -403,38 +356,12 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
} }
// Enable the channel // Enable the channel
if (accountId === DEFAULT_ACCOUNT_ID) { next = setZalouserAccountScopedConfig(
next = { next,
...next, accountId,
channels: { { profile: account.profile !== "default" ? account.profile : undefined },
...next.channels, { profile: account.profile, enabled: true },
zalouser: { );
...next.channels?.zalouser,
enabled: true,
profile: account.profile !== "default" ? account.profile : undefined,
},
},
} as OpenClawConfig;
} else {
next = {
...next,
channels: {
...next.channels,
zalouser: {
...next.channels?.zalouser,
enabled: true,
accounts: {
...next.channels?.zalouser?.accounts,
[accountId]: {
...next.channels?.zalouser?.accounts?.[accountId],
enabled: true,
profile: account.profile,
},
},
},
},
} as OpenClawConfig;
}
if (forceAllowFrom) { if (forceAllowFrom) {
next = await promptZalouserAllowFrom({ next = await promptZalouserAllowFrom({

View File

@@ -0,0 +1,156 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
sendImageZalouser,
sendLinkZalouser,
sendMessageZalouser,
type ZalouserSendResult,
} from "./send.js";
import { runZca } from "./zca.js";
vi.mock("./zca.js", () => ({
runZca: vi.fn(),
}));
const mockRunZca = vi.mocked(runZca);
const originalZcaProfile = process.env.ZCA_PROFILE;
function okResult(stdout = "message_id: msg-1") {
return {
ok: true,
stdout,
stderr: "",
exitCode: 0,
};
}
function failResult(stderr = "") {
return {
ok: false,
stdout: "",
stderr,
exitCode: 1,
};
}
describe("zalouser send helpers", () => {
beforeEach(() => {
mockRunZca.mockReset();
delete process.env.ZCA_PROFILE;
});
afterEach(() => {
if (originalZcaProfile) {
process.env.ZCA_PROFILE = originalZcaProfile;
return;
}
delete process.env.ZCA_PROFILE;
});
it("returns validation error when thread id is missing", async () => {
const result = await sendMessageZalouser("", "hello");
expect(result).toEqual({
ok: false,
error: "No threadId provided",
} satisfies ZalouserSendResult);
expect(mockRunZca).not.toHaveBeenCalled();
});
it("builds text send command with truncation and group flag", async () => {
mockRunZca.mockResolvedValueOnce(okResult("message id: mid-123"));
const result = await sendMessageZalouser(" thread-1 ", "x".repeat(2200), {
profile: "profile-a",
isGroup: true,
});
expect(mockRunZca).toHaveBeenCalledWith(["msg", "send", "thread-1", "x".repeat(2000), "-g"], {
profile: "profile-a",
});
expect(result).toEqual({ ok: true, messageId: "mid-123" });
});
it("routes media sends from sendMessage and keeps text as caption", async () => {
mockRunZca.mockResolvedValueOnce(okResult());
await sendMessageZalouser("thread-2", "media caption", {
profile: "profile-b",
mediaUrl: "https://cdn.example.com/video.mp4",
isGroup: true,
});
expect(mockRunZca).toHaveBeenCalledWith(
[
"msg",
"video",
"thread-2",
"-u",
"https://cdn.example.com/video.mp4",
"-m",
"media caption",
"-g",
],
{ profile: "profile-b" },
);
});
it("maps audio media to voice command", async () => {
mockRunZca.mockResolvedValueOnce(okResult());
await sendMessageZalouser("thread-3", "", {
profile: "profile-c",
mediaUrl: "https://cdn.example.com/clip.mp3",
});
expect(mockRunZca).toHaveBeenCalledWith(
["msg", "voice", "thread-3", "-u", "https://cdn.example.com/clip.mp3"],
{ profile: "profile-c" },
);
});
it("builds image command with caption and returns fallback error", async () => {
mockRunZca.mockResolvedValueOnce(failResult(""));
const result = await sendImageZalouser("thread-4", " https://cdn.example.com/img.png ", {
profile: "profile-d",
caption: "caption text",
isGroup: true,
});
expect(mockRunZca).toHaveBeenCalledWith(
[
"msg",
"image",
"thread-4",
"-u",
"https://cdn.example.com/img.png",
"-m",
"caption text",
"-g",
],
{ profile: "profile-d" },
);
expect(result).toEqual({ ok: false, error: "Failed to send image" });
});
it("uses env profile fallback and builds link command", async () => {
process.env.ZCA_PROFILE = "env-profile";
mockRunZca.mockResolvedValueOnce(okResult("abc123"));
const result = await sendLinkZalouser("thread-5", " https://openclaw.ai ", { isGroup: true });
expect(mockRunZca).toHaveBeenCalledWith(
["msg", "link", "thread-5", "https://openclaw.ai", "-g"],
{ profile: "env-profile" },
);
expect(result).toEqual({ ok: true, messageId: "abc123" });
});
it("returns caught command errors", async () => {
mockRunZca.mockRejectedValueOnce(new Error("zca unavailable"));
await expect(sendLinkZalouser("thread-6", "https://openclaw.ai")).resolves.toEqual({
ok: false,
error: "zca unavailable",
});
});
});

View File

@@ -13,12 +13,41 @@ export type ZalouserSendResult = {
error?: string; error?: string;
}; };
function resolveProfile(options: ZalouserSendOptions): string {
return options.profile || process.env.ZCA_PROFILE || "default";
}
function appendCaptionAndGroupFlags(args: string[], options: ZalouserSendOptions): void {
if (options.caption) {
args.push("-m", options.caption.slice(0, 2000));
}
if (options.isGroup) {
args.push("-g");
}
}
async function runSendCommand(
args: string[],
profile: string,
fallbackError: string,
): Promise<ZalouserSendResult> {
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || fallbackError };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function sendMessageZalouser( export async function sendMessageZalouser(
threadId: string, threadId: string,
text: string, text: string,
options: ZalouserSendOptions = {}, options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> { ): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default"; const profile = resolveProfile(options);
if (!threadId?.trim()) { if (!threadId?.trim()) {
return { ok: false, error: "No threadId provided" }; return { ok: false, error: "No threadId provided" };
@@ -38,17 +67,7 @@ export async function sendMessageZalouser(
args.push("-g"); args.push("-g");
} }
try { return runSendCommand(args, profile, "Failed to send message");
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || "Failed to send message" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
} }
async function sendMediaZalouser( async function sendMediaZalouser(
@@ -56,7 +75,7 @@ async function sendMediaZalouser(
mediaUrl: string, mediaUrl: string,
options: ZalouserSendOptions = {}, options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> { ): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default"; const profile = resolveProfile(options);
if (!threadId?.trim()) { if (!threadId?.trim()) {
return { ok: false, error: "No threadId provided" }; return { ok: false, error: "No threadId provided" };
@@ -78,24 +97,8 @@ async function sendMediaZalouser(
} }
const args = ["msg", command, threadId.trim(), "-u", mediaUrl.trim()]; const args = ["msg", command, threadId.trim(), "-u", mediaUrl.trim()];
if (options.caption) { appendCaptionAndGroupFlags(args, options);
args.push("-m", options.caption.slice(0, 2000)); return runSendCommand(args, profile, `Failed to send ${command}`);
}
if (options.isGroup) {
args.push("-g");
}
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || `Failed to send ${command}` };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
} }
export async function sendImageZalouser( export async function sendImageZalouser(
@@ -103,24 +106,10 @@ export async function sendImageZalouser(
imageUrl: string, imageUrl: string,
options: ZalouserSendOptions = {}, options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> { ): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default"; const profile = resolveProfile(options);
const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()]; const args = ["msg", "image", threadId.trim(), "-u", imageUrl.trim()];
if (options.caption) { appendCaptionAndGroupFlags(args, options);
args.push("-m", options.caption.slice(0, 2000)); return runSendCommand(args, profile, "Failed to send image");
}
if (options.isGroup) {
args.push("-g");
}
try {
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || "Failed to send image" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
} }
export async function sendLinkZalouser( export async function sendLinkZalouser(
@@ -128,21 +117,13 @@ export async function sendLinkZalouser(
url: string, url: string,
options: ZalouserSendOptions = {}, options: ZalouserSendOptions = {},
): Promise<ZalouserSendResult> { ): Promise<ZalouserSendResult> {
const profile = options.profile || process.env.ZCA_PROFILE || "default"; const profile = resolveProfile(options);
const args = ["msg", "link", threadId.trim(), url.trim()]; const args = ["msg", "link", threadId.trim(), url.trim()];
if (options.isGroup) { if (options.isGroup) {
args.push("-g"); args.push("-g");
} }
try { return runSendCommand(args, profile, "Failed to send link");
const result = await runZca(args, { profile });
if (result.ok) {
return { ok: true, messageId: extractMessageId(result.stdout) };
}
return { ok: false, error: result.stderr || "Failed to send link" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
} }
function extractMessageId(stdout: string): string | undefined { function extractMessageId(stdout: string): string | undefined {

View File

@@ -68,35 +68,30 @@ export type ListenOptions = CommonOptions & {
prefix?: string; prefix?: string;
}; };
export type ZalouserAccountConfig = { type ZalouserToolConfig = { allow?: string[]; deny?: string[] };
type ZalouserGroupConfig = {
allow?: boolean;
enabled?: boolean;
tools?: ZalouserToolConfig;
};
type ZalouserSharedConfig = {
enabled?: boolean; enabled?: boolean;
name?: string; name?: string;
profile?: string; profile?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled"; dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>; allowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled"; groupPolicy?: "open" | "allowlist" | "disabled";
groups?: Record< groups?: Record<string, ZalouserGroupConfig>;
string,
{ allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }
>;
messagePrefix?: string; messagePrefix?: string;
responsePrefix?: string; responsePrefix?: string;
}; };
export type ZalouserConfig = { export type ZalouserAccountConfig = ZalouserSharedConfig;
enabled?: boolean;
name?: string; export type ZalouserConfig = ZalouserSharedConfig & {
profile?: string;
defaultAccount?: string; defaultAccount?: string;
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
groupPolicy?: "open" | "allowlist" | "disabled";
groups?: Record<
string,
{ allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }
>;
messagePrefix?: string;
responsePrefix?: string;
accounts?: Record<string, ZalouserAccountConfig>; accounts?: Record<string, ZalouserAccountConfig>;
}; };