diff --git a/extensions/zalouser/CHANGELOG.md b/extensions/zalouser/CHANGELOG.md new file mode 100644 index 000000000..bd70b5054 --- /dev/null +++ b/extensions/zalouser/CHANGELOG.md @@ -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 diff --git a/extensions/zalouser/index.ts b/extensions/zalouser/index.ts index 03aa05a17..d4eb53672 100644 --- a/extensions/zalouser/index.ts +++ b/extensions/zalouser/index.ts @@ -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({ diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index da53d54c9..8e87591b0 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -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 = { id: "zalouser", meta, @@ -86,6 +121,7 @@ export const zalouserPlugin: ChannelPlugin = { 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 = { 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 = { lastStopAt: null, lastError: null, }, + collectStatusIssues: collectZalouserStatusIssues, buildChannelSummary: ({ snapshot }) => ({ configured: snapshot.configured ?? false, running: snapshot.running ?? false, @@ -433,22 +525,12 @@ export const zalouserPlugin: ChannelPlugin = { 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 = { 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", diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts new file mode 100644 index 000000000..ca36c1c72 --- /dev/null +++ b/extensions/zalouser/src/config-schema.ts @@ -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(), +}); diff --git a/extensions/zalouser/src/probe.ts b/extensions/zalouser/src/probe.ts new file mode 100644 index 000000000..9ef290133 --- /dev/null +++ b/extensions/zalouser/src/probe.ts @@ -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 { + 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(result.stdout); + if (!user) { + return { ok: false, error: "Failed to parse user info" }; + } + return { ok: true, user }; +} diff --git a/extensions/zalouser/src/status-issues.test.ts b/extensions/zalouser/src/status-issues.test.ts new file mode 100644 index 000000000..8e592c59b --- /dev/null +++ b/extensions/zalouser/src/status-issues.test.ts @@ -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); + }); +}); diff --git a/extensions/zalouser/src/status-issues.ts b/extensions/zalouser/src/status-issues.ts new file mode 100644 index 000000000..0c12d1e30 --- /dev/null +++ b/extensions/zalouser/src/status-issues.ts @@ -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 => + 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; +}