From 5d9a5b79582ffceb5d84f411b450f66bc97fc38d Mon Sep 17 00:00:00 2001 From: tsu Date: Mon, 19 Jan 2026 14:26:16 +0700 Subject: [PATCH 1/3] feat: implement zalouser channel plugin with configuration and status monitoring --- extensions/zalouser/CHANGELOG.md | 13 +++ extensions/zalouser/index.ts | 4 +- extensions/zalouser/src/channel.ts | 110 ++++++++++++++++++++--- extensions/zalouser/src/config-schema.ts | 23 +++++ extensions/zalouser/src/probe.ts | 29 ++++++ extensions/zalouser/src/status-issues.ts | 53 +++++++++++ 6 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 extensions/zalouser/CHANGELOG.md create mode 100644 extensions/zalouser/src/config-schema.ts create mode 100644 extensions/zalouser/src/probe.ts create mode 100644 extensions/zalouser/src/status-issues.ts 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..97f0822e8 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,63 @@ 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, + ...(input.profile ? { profile: input.profile } : {}), + }, + }, + } 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, + ...(input.profile ? { profile: input.profile } : {}), + }, + }, + }, + }, + } as ClawdbotConfig; + }, + }, messaging: { normalizeTarget: (raw) => { const trimmed = raw?.trim(); @@ -424,6 +517,7 @@ export const zalouserPlugin: ChannelPlugin = { lastStopAt: null, lastError: null, }, + collectStatusIssues: collectZalouserStatusIssues, buildChannelSummary: ({ snapshot }) => ({ configured: snapshot.configured ?? false, running: snapshot.running ?? false, @@ -433,20 +527,8 @@ 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); return { diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts new file mode 100644 index 000000000..69e180586 --- /dev/null +++ b/extensions/zalouser/src/config-schema.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +const groupConfigSchema = z.object({ + allow: 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..624d71d2d --- /dev/null +++ b/extensions/zalouser/src/probe.ts @@ -0,0 +1,29 @@ +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 ?? 10000, + }); + + if (!result.ok) { + return { ok: false, error: result.stderr || "Failed to probe" }; + } + + try { + const user = parseJsonOutput(result.stdout); + return { ok: true, user: user ?? undefined }; + } catch { + return { ok: false, error: "Failed to parse user info" }; + } +} diff --git a/extensions/zalouser/src/status-issues.ts b/extensions/zalouser/src/status-issues.ts new file mode 100644 index 000000000..baf1edfc4 --- /dev/null +++ b/extensions/zalouser/src/status-issues.ts @@ -0,0 +1,53 @@ +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { checkZcaInstalled } from "./zca.js"; +import { resolveZalouserAccountSync } from "./accounts.js"; + +export interface ZalouserStatusIssue { + level: "error" | "warn" | "info"; + code: string; + message: string; + hint?: string; +} + +export async function collectZalouserStatusIssues(params: { + cfg: unknown; + accountId?: string; +}): Promise { + const issues: ZalouserStatusIssue[] = []; + + // Check zca binary + const zcaInstalled = await checkZcaInstalled(); + if (!zcaInstalled) { + issues.push({ + level: "error", + code: "ZCA_NOT_FOUND", + message: "zca CLI not found in PATH", + hint: "Install zca from https://zca-cli.dev or ensure it's in your PATH", + }); + return issues; + } + + // Check account configuration + try { + const account = resolveZalouserAccountSync({ + cfg: params.cfg as ClawdbotConfig, + accountId: params.accountId, + }); + + if (!account.enabled) { + issues.push({ + level: "warn", + code: "ACCOUNT_DISABLED", + message: `Account ${account.accountId} is disabled`, + }); + } + } catch (err) { + issues.push({ + level: "error", + code: "ACCOUNT_RESOLVE_FAILED", + message: `Failed to resolve account: ${String(err)}`, + }); + } + + return issues; +} From cd8309cc31149fd804260e2021cae642e980a32e Mon Sep 17 00:00:00 2001 From: tsu Date: Mon, 19 Jan 2026 19:18:04 +0700 Subject: [PATCH 2/3] chore: simplify user parsing logic in probeZalouser function --- extensions/zalouser/src/probe.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/extensions/zalouser/src/probe.ts b/extensions/zalouser/src/probe.ts index 624d71d2d..737811211 100644 --- a/extensions/zalouser/src/probe.ts +++ b/extensions/zalouser/src/probe.ts @@ -20,10 +20,9 @@ export async function probeZalouser( return { ok: false, error: result.stderr || "Failed to probe" }; } - try { - const user = parseJsonOutput(result.stdout); - return { ok: true, user: user ?? undefined }; - } catch { + const user = parseJsonOutput(result.stdout); + if (!user) { return { ok: false, error: "Failed to parse user info" }; } + return { ok: true, user }; } From 0372bdf6fea20841869697e8ef22aa95e2433e75 Mon Sep 17 00:00:00 2001 From: tsu Date: Mon, 19 Jan 2026 20:25:17 +0700 Subject: [PATCH 3/3] fix: add enabled property to groupConfigSchema for improved configuration --- extensions/zalouser/src/config-schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index 69e180586..ca36c1c72 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -4,6 +4,7 @@ const allowFromEntry = z.union([z.string(), z.number()]); const groupConfigSchema = z.object({ allow: z.boolean().optional(), + enabled: z.boolean().optional(), }); const zalouserAccountSchema = z.object({