fix: align zalouser status + schema
This commit is contained in:
13
extensions/zalouser/CHANGELOG.md
Normal file
13
extensions/zalouser/CHANGELOG.md
Normal 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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
|
||||||
import { emptyPluginConfigSchema } 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 { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
|
||||||
import { setZalouserRuntime } from "./src/runtime.js";
|
import { setZalouserRuntime } from "./src/runtime.js";
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ const plugin = {
|
|||||||
register(api: ClawdbotPluginApi) {
|
register(api: ClawdbotPluginApi) {
|
||||||
setZalouserRuntime(api.runtime);
|
setZalouserRuntime(api.runtime);
|
||||||
// Register channel plugin (for onboarding & gateway)
|
// Register channel plugin (for onboarding & gateway)
|
||||||
api.registerChannel(zalouserPlugin);
|
api.registerChannel({ plugin: zalouserPlugin, dock: zalouserDock });
|
||||||
|
|
||||||
// Register agent tool
|
// Register agent tool
|
||||||
api.registerTool({
|
api.registerTool({
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import type {
|
import type {
|
||||||
ChannelAccountSnapshot,
|
ChannelAccountSnapshot,
|
||||||
ChannelDirectoryEntry,
|
ChannelDirectoryEntry,
|
||||||
|
ChannelDock,
|
||||||
ChannelPlugin,
|
ChannelPlugin,
|
||||||
ClawdbotConfig,
|
ClawdbotConfig,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
import {
|
import {
|
||||||
|
applyAccountNameToChannelSection,
|
||||||
|
buildChannelConfigSchema,
|
||||||
DEFAULT_ACCOUNT_ID,
|
DEFAULT_ACCOUNT_ID,
|
||||||
deleteAccountFromConfigSection,
|
deleteAccountFromConfigSection,
|
||||||
formatPairingApproveHint,
|
formatPairingApproveHint,
|
||||||
|
migrateBaseNameToDefaultAccount,
|
||||||
normalizeAccountId,
|
normalizeAccountId,
|
||||||
setAccountEnabledInConfigSection,
|
setAccountEnabledInConfigSection,
|
||||||
} from "clawdbot/plugin-sdk";
|
} from "clawdbot/plugin-sdk";
|
||||||
@@ -23,6 +27,9 @@ import { zalouserOnboardingAdapter } from "./onboarding.js";
|
|||||||
import { sendMessageZalouser } from "./send.js";
|
import { sendMessageZalouser } from "./send.js";
|
||||||
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
|
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
|
||||||
import type { ZcaFriend, ZcaGroup, ZcaUserInfo } from "./types.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 = {
|
const meta = {
|
||||||
id: "zalouser",
|
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> = {
|
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||||
id: "zalouser",
|
id: "zalouser",
|
||||||
meta,
|
meta,
|
||||||
@@ -86,6 +121,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
blockStreaming: true,
|
blockStreaming: true,
|
||||||
},
|
},
|
||||||
reload: { configPrefixes: ["channels.zalouser"] },
|
reload: { configPrefixes: ["channels.zalouser"] },
|
||||||
|
configSchema: buildChannelConfigSchema(ZalouserConfigSchema),
|
||||||
config: {
|
config: {
|
||||||
listAccountIds: (cfg) => listZalouserAccountIds(cfg as ClawdbotConfig),
|
listAccountIds: (cfg) => listZalouserAccountIds(cfg as ClawdbotConfig),
|
||||||
resolveAccount: (cfg, accountId) =>
|
resolveAccount: (cfg, accountId) =>
|
||||||
@@ -156,6 +192,61 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
threading: {
|
threading: {
|
||||||
resolveReplyToMode: () => "off",
|
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: {
|
messaging: {
|
||||||
normalizeTarget: (raw) => {
|
normalizeTarget: (raw) => {
|
||||||
const trimmed = raw?.trim();
|
const trimmed = raw?.trim();
|
||||||
@@ -424,6 +515,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
lastStopAt: null,
|
lastStopAt: null,
|
||||||
lastError: null,
|
lastError: null,
|
||||||
},
|
},
|
||||||
|
collectStatusIssues: collectZalouserStatusIssues,
|
||||||
buildChannelSummary: ({ snapshot }) => ({
|
buildChannelSummary: ({ snapshot }) => ({
|
||||||
configured: snapshot.configured ?? false,
|
configured: snapshot.configured ?? false,
|
||||||
running: snapshot.running ?? false,
|
running: snapshot.running ?? false,
|
||||||
@@ -433,22 +525,12 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
probe: snapshot.probe,
|
probe: snapshot.probe,
|
||||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||||
}),
|
}),
|
||||||
probeAccount: async ({ account, timeoutMs }) => {
|
probeAccount: async ({ account, timeoutMs }) =>
|
||||||
const result = await runZca(["me", "info", "-j"], {
|
probeZalouser(account.profile, timeoutMs),
|
||||||
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" };
|
|
||||||
}
|
|
||||||
},
|
|
||||||
buildAccountSnapshot: async ({ account, runtime }) => {
|
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 {
|
return {
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
@@ -457,7 +539,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
|||||||
running: runtime?.running ?? false,
|
running: runtime?.running ?? false,
|
||||||
lastStartAt: runtime?.lastStartAt ?? null,
|
lastStartAt: runtime?.lastStartAt ?? null,
|
||||||
lastStopAt: runtime?.lastStopAt ?? null,
|
lastStopAt: runtime?.lastStopAt ?? null,
|
||||||
lastError: configured ? (runtime?.lastError ?? null) : "not configured",
|
lastError: configured ? (runtime?.lastError ?? null) : runtime?.lastError ?? configError,
|
||||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||||
|
|||||||
24
extensions/zalouser/src/config-schema.ts
Normal file
24
extensions/zalouser/src/config-schema.ts
Normal 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(),
|
||||||
|
});
|
||||||
28
extensions/zalouser/src/probe.ts
Normal file
28
extensions/zalouser/src/probe.ts
Normal 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 };
|
||||||
|
}
|
||||||
58
extensions/zalouser/src/status-issues.test.ts
Normal file
58
extensions/zalouser/src/status-issues.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
extensions/zalouser/src/status-issues.ts
Normal file
81
extensions/zalouser/src/status-issues.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user