fix: align zalouser status + schema

This commit is contained in:
Peter Steinberger
2026-01-20 13:29:19 +00:00
parent fa51294f65
commit f067ea25b4
7 changed files with 304 additions and 18 deletions

View File

@@ -0,0 +1,13 @@
# Changelog
## 2026.1.17-1
- Initial version with full channel plugin support
- QR code login via zca-cli
- Multi-account support
- Agent tool for sending messages
- Group and DM policy support
- ChannelDock for lightweight shared metadata
- Zod-based config schema validation
- Setup adapter for programmatic configuration
- Dedicated probe and status issues modules

View File

@@ -1,7 +1,7 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { zalouserPlugin } from "./src/channel.js";
import { zalouserDock, zalouserPlugin } from "./src/channel.js";
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
import { setZalouserRuntime } from "./src/runtime.js";
@@ -13,7 +13,7 @@ const plugin = {
register(api: ClawdbotPluginApi) {
setZalouserRuntime(api.runtime);
// Register channel plugin (for onboarding & gateway)
api.registerChannel(zalouserPlugin);
api.registerChannel({ plugin: zalouserPlugin, dock: zalouserDock });
// Register agent tool
api.registerTool({

View File

@@ -1,13 +1,17 @@
import type {
ChannelAccountSnapshot,
ChannelDirectoryEntry,
ChannelDock,
ChannelPlugin,
ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
@@ -23,6 +27,9 @@ import { zalouserOnboardingAdapter } from "./onboarding.js";
import { sendMessageZalouser } from "./send.js";
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
import type { ZcaFriend, ZcaGroup, ZcaUserInfo } from "./types.js";
import { ZalouserConfigSchema } from "./config-schema.js";
import { collectZalouserStatusIssues } from "./status-issues.js";
import { probeZalouser } from "./probe.js";
const meta = {
id: "zalouser",
@@ -72,6 +79,34 @@ function mapGroup(params: {
};
}
export const zalouserDock: ChannelDock = {
id: "zalouser",
capabilities: {
chatTypes: ["direct", "group"],
media: true,
blockStreaming: true,
},
outbound: { textChunkLimit: 2000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(zalouser|zlu):/i, ""))
.map((entry) => entry.toLowerCase()),
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
};
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
id: "zalouser",
meta,
@@ -86,6 +121,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
blockStreaming: true,
},
reload: { configPrefixes: ["channels.zalouser"] },
configSchema: buildChannelConfigSchema(ZalouserConfigSchema),
config: {
listAccountIds: (cfg) => listZalouserAccountIds(cfg as ClawdbotConfig),
resolveAccount: (cfg, accountId) =>
@@ -156,6 +192,61 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
threading: {
resolveReplyToMode: () => "off",
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "zalouser",
accountId,
name,
}),
validateInput: () => null,
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "zalouser",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "zalouser",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
zalouser: {
...next.channels?.zalouser,
enabled: true,
},
},
} as ClawdbotConfig;
}
return {
...next,
channels: {
...next.channels,
zalouser: {
...next.channels?.zalouser,
enabled: true,
accounts: {
...(next.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(next.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled: true,
},
},
},
},
} as ClawdbotConfig;
},
},
messaging: {
normalizeTarget: (raw) => {
const trimmed = raw?.trim();
@@ -424,6 +515,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
lastStopAt: null,
lastError: null,
},
collectStatusIssues: collectZalouserStatusIssues,
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
@@ -433,22 +525,12 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) => {
const result = await runZca(["me", "info", "-j"], {
profile: account.profile,
timeout: timeoutMs,
});
if (!result.ok) {
return { ok: false, error: result.stderr };
}
try {
return { ok: true, user: JSON.parse(result.stdout) };
} catch {
return { ok: false, error: "Failed to parse user info" };
}
},
probeAccount: async ({ account, timeoutMs }) =>
probeZalouser(account.profile, timeoutMs),
buildAccountSnapshot: async ({ account, runtime }) => {
const configured = await checkZcaAuthenticated(account.profile);
const zcaInstalled = await checkZcaInstalled();
const configured = zcaInstalled ? await checkZcaAuthenticated(account.profile) : false;
const configError = zcaInstalled ? "not authenticated" : "zca CLI not found in PATH";
return {
accountId: account.accountId,
name: account.name,
@@ -457,7 +539,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: configured ? (runtime?.lastError ?? null) : "not configured",
lastError: configured ? (runtime?.lastError ?? null) : runtime?.lastError ?? configError,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
dmPolicy: account.config.dmPolicy ?? "pairing",

View File

@@ -0,0 +1,24 @@
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const groupConfigSchema = z.object({
allow: z.boolean().optional(),
enabled: z.boolean().optional(),
});
const zalouserAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
profile: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
groupPolicy: z.enum(["disabled", "allowlist", "open"]).optional(),
groups: z.object({}).catchall(groupConfigSchema).optional(),
messagePrefix: z.string().optional(),
});
export const ZalouserConfigSchema = zalouserAccountSchema.extend({
accounts: z.object({}).catchall(zalouserAccountSchema).optional(),
defaultAccount: z.string().optional(),
});

View File

@@ -0,0 +1,28 @@
import { runZca, parseJsonOutput } from "./zca.js";
import type { ZcaUserInfo } from "./types.js";
export interface ZalouserProbeResult {
ok: boolean;
user?: ZcaUserInfo;
error?: string;
}
export async function probeZalouser(
profile: string,
timeoutMs?: number,
): Promise<ZalouserProbeResult> {
const result = await runZca(["me", "info", "-j"], {
profile,
timeout: timeoutMs,
});
if (!result.ok) {
return { ok: false, error: result.stderr || "Failed to probe" };
}
const user = parseJsonOutput<ZcaUserInfo>(result.stdout);
if (!user) {
return { ok: false, error: "Failed to parse user info" };
}
return { ok: true, user };
}

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { collectZalouserStatusIssues } from "./status-issues.js";
describe("collectZalouserStatusIssues", () => {
it("flags missing zca when configured is false", () => {
const issues = collectZalouserStatusIssues([
{
accountId: "default",
enabled: true,
configured: false,
lastError: "zca CLI not found in PATH",
},
]);
expect(issues).toHaveLength(1);
expect(issues[0]?.kind).toBe("runtime");
expect(issues[0]?.message).toMatch(/zca CLI not found/i);
});
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", () => {
const issues = collectZalouserStatusIssues([
{
accountId: "default",
enabled: true,
configured: true,
dmPolicy: "open",
},
]);
expect(issues).toHaveLength(1);
expect(issues[0]?.kind).toBe("config");
});
it("skips disabled accounts", () => {
const issues = collectZalouserStatusIssues([
{
accountId: "default",
enabled: false,
configured: false,
lastError: "zca CLI not found in PATH",
},
]);
expect(issues).toHaveLength(0);
});
});

View File

@@ -0,0 +1,81 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "clawdbot/plugin-sdk";
type ZalouserAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
dmPolicy?: unknown;
lastError?: unknown;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
const asString = (value: unknown): string | undefined =>
typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
function readZalouserAccountStatus(value: ChannelAccountSnapshot): ZalouserAccountStatus | null {
if (!isRecord(value)) return null;
return {
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
dmPolicy: value.dmPolicy,
lastError: value.lastError,
};
}
function isMissingZca(lastError?: string): boolean {
if (!lastError) return false;
const lower = lastError.toLowerCase();
return lower.includes("zca") && (lower.includes("not found") || lower.includes("enoent"));
}
export function collectZalouserStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) {
const account = readZalouserAccountStatus(entry);
if (!account) continue;
const accountId = asString(account.accountId) ?? "default";
const enabled = account.enabled !== false;
if (!enabled) continue;
const configured = account.configured === true;
const lastError = asString(account.lastError)?.trim();
if (!configured) {
if (isMissingZca(lastError)) {
issues.push({
channel: "zalouser",
accountId,
kind: "runtime",
message: "zca CLI not found in PATH.",
fix: "Install zca-cli and ensure it is on PATH for the Gateway process.",
});
} else {
issues.push({
channel: "zalouser",
accountId,
kind: "auth",
message: "Not authenticated (no zca session).",
fix: "Run: clawdbot 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;
}