feat: implement zalouser channel plugin with configuration and status monitoring

This commit is contained in:
tsu
2026-01-19 14:26:16 +07:00
parent 0c8ba6599b
commit 5d9a5b7958
6 changed files with 216 additions and 16 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,63 @@ 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,
...(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<ResolvedZalouserAccount> = {
lastStopAt: null,
lastError: null,
},
collectStatusIssues: collectZalouserStatusIssues,
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
running: snapshot.running ?? false,
@@ -433,20 +527,8 @@ 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);
return {

View File

@@ -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(),
});

View File

@@ -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<ZalouserProbeResult> {
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<ZcaUserInfo>(result.stdout);
return { ok: true, user: user ?? undefined };
} catch {
return { ok: false, error: "Failed to parse user info" };
}
}

View File

@@ -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<ZalouserStatusIssue[]> {
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;
}