feat: migrate zalouser plugin to sdk

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Peter Steinberger
2026-01-18 04:23:25 +00:00
parent b105745299
commit 89c5185f1c
9 changed files with 152 additions and 397 deletions

View File

@@ -15,6 +15,19 @@ Docs: https://docs.clawd.bot
## 2026.1.18-2
## 2026.1.17-6
### Changes
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
- Docs: document plugin slots and memory plugin behavior.
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
## 2026.1.17-5
### Changes
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.

View File

@@ -2,12 +2,14 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { zalouserPlugin } from "./src/channel.js";
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
import { setZalouserRuntime } from "./src/runtime.js";
const plugin = {
id: "zalouser",
name: "Zalo Personal",
description: "Zalo personal account messaging via zca-cli",
register(api: ClawdbotPluginApi) {
setZalouserRuntime(api.runtime);
// Register channel plugin (for onboarding & gateway)
api.registerChannel(zalouserPlugin);

View File

@@ -1,25 +1,22 @@
import { runZca, parseJsonOutput } from "./zca.js";
import {
DEFAULT_ACCOUNT_ID,
type CoreConfig,
type ResolvedZalouserAccount,
type ZalouserAccountConfig,
type ZalouserConfig,
} from "./types.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
import { runZca, parseJsonOutput } from "./zca.js";
import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js";
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listZalouserAccountIds(cfg: CoreConfig): string[] {
export function listZalouserAccountIds(cfg: ClawdbotConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultZalouserAccountId(cfg: CoreConfig): string {
export function resolveDefaultZalouserAccountId(cfg: ClawdbotConfig): string {
const zalouserConfig = cfg.channels?.zalouser as ZalouserConfig | undefined;
if (zalouserConfig?.defaultAccount?.trim()) return zalouserConfig.defaultAccount.trim();
const ids = listZalouserAccountIds(cfg);
@@ -27,14 +24,8 @@ export function resolveDefaultZalouserAccountId(cfg: CoreConfig): string {
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
export function normalizeAccountId(accountId?: string | null): string {
const trimmed = accountId?.trim();
if (!trimmed) return DEFAULT_ACCOUNT_ID;
return trimmed.toLowerCase();
}
function resolveAccountConfig(
cfg: CoreConfig,
cfg: ClawdbotConfig,
accountId: string,
): ZalouserAccountConfig | undefined {
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
@@ -42,7 +33,10 @@ function resolveAccountConfig(
return accounts[accountId] as ZalouserAccountConfig | undefined;
}
function mergeZalouserAccountConfig(cfg: CoreConfig, accountId: string): ZalouserAccountConfig {
function mergeZalouserAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): ZalouserAccountConfig {
const raw = (cfg.channels?.zalouser ?? {}) as ZalouserConfig;
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
const account = resolveAccountConfig(cfg, accountId) ?? {};
@@ -62,7 +56,7 @@ export async function checkZcaAuthenticated(profile: string): Promise<boolean> {
}
export async function resolveZalouserAccount(params: {
cfg: CoreConfig;
cfg: ClawdbotConfig;
accountId?: string | null;
}): Promise<ResolvedZalouserAccount> {
const accountId = normalizeAccountId(params.accountId);
@@ -84,7 +78,7 @@ export async function resolveZalouserAccount(params: {
}
export function resolveZalouserAccountSync(params: {
cfg: CoreConfig;
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedZalouserAccount {
const accountId = normalizeAccountId(params.accountId);
@@ -104,7 +98,9 @@ export function resolveZalouserAccountSync(params: {
};
}
export async function listEnabledZalouserAccounts(cfg: CoreConfig): Promise<ResolvedZalouserAccount[]> {
export async function listEnabledZalouserAccounts(
cfg: ClawdbotConfig,
): Promise<ResolvedZalouserAccount[]> {
const ids = listZalouserAccountIds(cfg);
const accounts = await Promise.all(
ids.map((accountId) => resolveZalouserAccount({ cfg, accountId }))

View File

@@ -1,6 +1,16 @@
import type { ChannelAccountSnapshot, ChannelDirectoryEntry, ChannelPlugin } from "clawdbot/plugin-sdk";
import { formatPairingApproveHint } from "clawdbot/plugin-sdk";
import type {
ChannelAccountSnapshot,
ChannelDirectoryEntry,
ChannelPlugin,
ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import {
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
normalizeAccountId,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
@@ -12,14 +22,7 @@ import {
import { zalouserOnboardingAdapter } from "./onboarding.js";
import { sendMessageZalouser } from "./send.js";
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
import {
DEFAULT_ACCOUNT_ID,
type CoreConfig,
type ZalouserConfig,
type ZcaFriend,
type ZcaGroup,
type ZcaUserInfo,
} from "./types.js";
import type { ZcaFriend, ZcaGroup, ZcaUserInfo } from "./types.js";
const meta = {
id: "zalouser",
@@ -34,7 +37,7 @@ const meta = {
};
function resolveZalouserQrProfile(accountId?: string | null): string {
const normalized = String(accountId ?? "").trim();
const normalized = normalizeAccountId(accountId);
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
return process.env.ZCA_PROFILE?.trim() || "default";
}
@@ -69,65 +72,6 @@ function mapGroup(params: {
};
}
function deleteAccountFromConfigSection(params: {
cfg: CoreConfig;
accountId: string;
}): CoreConfig {
const { cfg, accountId } = params;
if (accountId === DEFAULT_ACCOUNT_ID) {
const { zalouser: _removed, ...restChannels } = cfg.channels ?? {};
return { ...cfg, channels: restChannels };
}
const accounts = { ...(cfg.channels?.zalouser?.accounts ?? {}) };
delete accounts[accountId];
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
accounts,
},
},
};
}
function setAccountEnabledInConfigSection(params: {
cfg: CoreConfig;
accountId: string;
enabled: boolean;
}): CoreConfig {
const { cfg, accountId, enabled } = params;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
accounts: {
...(cfg.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled,
},
},
},
},
};
}
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
id: "zalouser",
meta,
@@ -143,20 +87,24 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
},
reload: { configPrefixes: ["channels.zalouser"] },
config: {
listAccountIds: (cfg) => listZalouserAccountIds(cfg as CoreConfig),
listAccountIds: (cfg) => listZalouserAccountIds(cfg as ClawdbotConfig),
resolveAccount: (cfg, accountId) =>
resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as CoreConfig),
resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as ClawdbotConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
sectionKey: "zalouser",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
sectionKey: "zalouser",
accountId,
clearBaseFields: ["profile", "name", "dmPolicy", "allowFrom", "groupPolicy", "groups", "messagePrefix"],
}),
isConfigured: async (account) => {
// Check if zca auth status is OK for this profile
@@ -173,7 +121,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
configured: undefined,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
@@ -187,7 +135,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as CoreConfig).channels?.zalouser?.accounts?.[resolvedAccountId],
(cfg as ClawdbotConfig).channels?.zalouser?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.zalouser.accounts.${resolvedAccountId}.`
@@ -227,7 +175,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
self: async ({ cfg, accountId, runtime }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const result = await runZca(["me", "info", "-j"], { profile: account.profile, timeout: 10000 });
if (!result.ok) {
runtime.error(result.stderr || "Failed to fetch profile");
@@ -245,7 +193,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
listPeers: async ({ cfg, accountId, query, limit }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const args = query?.trim()
? ["friend", "find", query.trim()]
: ["friend", "list", "-j"];
@@ -269,7 +217,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
listGroups: async ({ cfg, accountId, query, limit }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 });
if (!result.ok) {
throw new Error(result.stderr || "Failed to list groups");
@@ -293,7 +241,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const result = await runZca(["group", "members", groupId, "-j"], {
profile: account.profile,
timeout: 20000,
@@ -335,7 +283,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
}
try {
const account = resolveZalouserAccountSync({
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
});
const args =
@@ -391,7 +339,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
idLabel: "zalouserUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
notifyApproval: async ({ cfg, id }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig });
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig });
const authenticated = await checkZcaAuthenticated(account.profile);
if (!authenticated) throw new Error("Zalouser not authenticated");
await sendMessageZalouser(id, "Your pairing request has been approved.", {
@@ -402,7 +350,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
auth: {
login: async ({ cfg, accountId, runtime }) => {
const account = resolveZalouserAccountSync({
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
});
const ok = await checkZcaInstalled();
@@ -445,7 +393,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
},
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const result = await sendMessageZalouser(to, text, { profile: account.profile });
return {
channel: "zalouser",
@@ -455,7 +403,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
};
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const result = await sendMessageZalouser(to, text, {
profile: account.profile,
mediaUrl,
@@ -534,7 +482,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
const { monitorZalouserProvider } = await import("./monitor.js");
return monitorZalouserProvider({
account,
config: ctx.cfg as CoreConfig,
config: ctx.cfg as ClawdbotConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),

View File

@@ -1,171 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
export type CoreChannelDeps = {
chunkMarkdownText: (text: string, limit: number) => string[];
formatAgentEnvelope: (params: {
channel: string;
from: string;
timestamp?: number;
body: string;
}) => string;
dispatchReplyWithBufferedBlockDispatcher: (params: {
ctx: unknown;
cfg: unknown;
dispatcherOptions: {
deliver: (payload: unknown) => Promise<void>;
onError?: (err: unknown, info: { kind: string }) => void;
};
}) => Promise<void>;
resolveAgentRoute: (params: {
cfg: unknown;
channel: string;
accountId: string;
peer: { kind: "dm" | "group" | "channel"; id: string };
}) => { sessionKey: string; accountId: string };
buildPairingReply: (params: { channel: string; idLine: string; code: string }) => string;
readChannelAllowFromStore: (channel: string) => Promise<string[]>;
upsertChannelPairingRequest: (params: {
channel: string;
id: string;
meta?: { name?: string };
}) => Promise<{ code: string; created: boolean }>;
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
saveMediaBuffer: (
buffer: Buffer,
contentType: string | undefined,
type: "inbound" | "outbound",
maxBytes: number,
) => Promise<{ path: string; contentType: string }>;
shouldLogVerbose: () => boolean;
};
let coreRootCache: string | null = null;
let coreDepsPromise: Promise<CoreChannelDeps> | null = null;
function findPackageRoot(startDir: string, name: string): string | null {
let dir = startDir;
for (;;) {
const pkgPath = path.join(dir, "package.json");
try {
if (fs.existsSync(pkgPath)) {
const raw = fs.readFileSync(pkgPath, "utf8");
const pkg = JSON.parse(raw) as { name?: string };
if (pkg.name === name) return dir;
}
} catch {
// ignore parse errors
}
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function resolveClawdbotRoot(): string {
if (coreRootCache) return coreRootCache;
const override = process.env.CLAWDBOT_ROOT?.trim();
if (override) {
coreRootCache = override;
return override;
}
const candidates = new Set<string>();
if (process.argv[1]) {
candidates.add(path.dirname(process.argv[1]));
}
candidates.add(process.cwd());
try {
const urlPath = fileURLToPath(import.meta.url);
candidates.add(path.dirname(urlPath));
} catch {
// ignore
}
for (const start of candidates) {
const found = findPackageRoot(start, "clawdbot");
if (found) {
coreRootCache = found;
return found;
}
}
throw new Error(
"Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.",
);
}
async function importCoreModule<T>(relativePath: string): Promise<T> {
const root = resolveClawdbotRoot();
const distPath = path.join(root, "dist", relativePath);
if (!fs.existsSync(distPath)) {
throw new Error(
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
);
}
return (await import(pathToFileURL(distPath).href)) as T;
}
export async function loadCoreChannelDeps(): Promise<CoreChannelDeps> {
if (coreDepsPromise) return coreDepsPromise;
coreDepsPromise = (async () => {
const [
chunk,
envelope,
dispatcher,
routing,
pairingMessages,
pairingStore,
mediaFetch,
mediaStore,
globals,
] = await Promise.all([
importCoreModule<{ chunkMarkdownText: CoreChannelDeps["chunkMarkdownText"] }>(
"auto-reply/chunk.js",
),
importCoreModule<{ formatAgentEnvelope: CoreChannelDeps["formatAgentEnvelope"] }>(
"auto-reply/envelope.js",
),
importCoreModule<{
dispatchReplyWithBufferedBlockDispatcher: CoreChannelDeps["dispatchReplyWithBufferedBlockDispatcher"];
}>("auto-reply/reply/provider-dispatcher.js"),
importCoreModule<{ resolveAgentRoute: CoreChannelDeps["resolveAgentRoute"] }>(
"routing/resolve-route.js",
),
importCoreModule<{ buildPairingReply: CoreChannelDeps["buildPairingReply"] }>(
"pairing/pairing-messages.js",
),
importCoreModule<{
readChannelAllowFromStore: CoreChannelDeps["readChannelAllowFromStore"];
upsertChannelPairingRequest: CoreChannelDeps["upsertChannelPairingRequest"];
}>("pairing/pairing-store.js"),
importCoreModule<{ fetchRemoteMedia: CoreChannelDeps["fetchRemoteMedia"] }>(
"media/fetch.js",
),
importCoreModule<{ saveMediaBuffer: CoreChannelDeps["saveMediaBuffer"] }>(
"media/store.js",
),
importCoreModule<{ shouldLogVerbose: CoreChannelDeps["shouldLogVerbose"] }>(
"globals.js",
),
]);
return {
chunkMarkdownText: chunk.chunkMarkdownText,
formatAgentEnvelope: envelope.formatAgentEnvelope,
dispatchReplyWithBufferedBlockDispatcher:
dispatcher.dispatchReplyWithBufferedBlockDispatcher,
resolveAgentRoute: routing.resolveAgentRoute,
buildPairingReply: pairingMessages.buildPairingReply,
readChannelAllowFromStore: pairingStore.readChannelAllowFromStore,
upsertChannelPairingRequest: pairingStore.upsertChannelPairingRequest,
fetchRemoteMedia: mediaFetch.fetchRemoteMedia,
saveMediaBuffer: mediaStore.saveMediaBuffer,
shouldLogVerbose: globals.shouldLogVerbose,
};
})();
return coreDepsPromise;
}

View File

@@ -1,8 +1,9 @@
import type { ChildProcess } from "node:child_process";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import {
finalizeInboundContext,
formatAgentEnvelope,
isControlCommandMessage,
mergeAllowlist,
recordSessionMetaFromInbound,
@@ -11,20 +12,19 @@ import {
shouldComputeCommandAuthorized,
summarizeMapping,
} from "clawdbot/plugin-sdk";
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
import { sendMessageZalouser } from "./send.js";
import type {
CoreConfig,
ResolvedZalouserAccount,
ZcaFriend,
ZcaGroup,
ZcaMessage,
} from "./types.js";
import { getZalouserRuntime } from "./runtime.js";
import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js";
export type ZalouserMonitorOptions = {
account: ResolvedZalouserAccount;
config: CoreConfig;
config: ClawdbotConfig;
runtime: RuntimeEnv;
abortSignal: AbortSignal;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
@@ -55,8 +55,10 @@ function buildNameIndex<T>(
return index;
}
function logVerbose(deps: CoreChannelDeps, runtime: RuntimeEnv, message: string): void {
if (deps.shouldLogVerbose()) {
type ZalouserCoreRuntime = ReturnType<typeof getZalouserRuntime>;
function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: string): void {
if (core.logging.shouldLogVerbose()) {
runtime.log(`[zalouser] ${message}`);
}
}
@@ -157,8 +159,8 @@ function startZcaListener(
async function processMessage(
message: ZcaMessage,
account: ResolvedZalouserAccount,
config: CoreConfig,
deps: CoreChannelDeps,
config: ClawdbotConfig,
core: ZalouserCoreRuntime,
runtime: RuntimeEnv,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
): Promise<void> {
@@ -176,13 +178,13 @@ async function processMessage(
const groups = account.config.groups ?? {};
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose(deps, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
logVerbose(core, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
return;
}
if (groupPolicy === "allowlist") {
const allowed = isGroupAllowed({ groupId: chatId, groupName, groups });
if (!allowed) {
logVerbose(deps, runtime, `zalouser: drop group ${chatId} (not allowlisted)`);
logVerbose(core, runtime, `zalouser: drop group ${chatId} (not allowlisted)`);
return;
}
}
@@ -194,7 +196,7 @@ async function processMessage(
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await deps.readChannelAllowFromStore("zalouser").catch(() => [])
? await core.channel.pairing.readAllowFromStore("zalouser").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const useAccessGroups = config.commands?.useAccessGroups !== false;
@@ -208,7 +210,7 @@ async function processMessage(
if (!isGroup) {
if (dmPolicy === "disabled") {
logVerbose(deps, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
logVerbose(core, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
return;
}
@@ -217,18 +219,18 @@ async function processMessage(
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await deps.upsertChannelPairingRequest({
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "zalouser",
id: senderId,
meta: { name: senderName || undefined },
});
if (created) {
logVerbose(deps, runtime, `zalouser pairing request sender=${senderId}`);
logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`);
try {
await sendMessageZalouser(
chatId,
deps.buildPairingReply({
core.channel.pairing.buildPairingReply({
channel: "zalouser",
idLine: `Your Zalo user id: ${senderId}`,
code,
@@ -238,7 +240,7 @@ async function processMessage(
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(
deps,
core,
runtime,
`zalouser pairing reply failed for ${senderId}: ${String(err)}`,
);
@@ -246,7 +248,7 @@ async function processMessage(
}
} else {
logVerbose(
deps,
core,
runtime,
`Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
);
@@ -257,13 +259,13 @@ async function processMessage(
}
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
logVerbose(deps, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
logVerbose(core, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
return;
}
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
const route = deps.resolveAgentRoute({
const route = core.channel.routing.resolveAgentRoute({
cfg: config,
channel: "zalouser",
accountId: account.accountId,
@@ -275,7 +277,7 @@ async function processMessage(
});
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const body = deps.formatAgentEnvelope({
const body = formatAgentEnvelope({
channel: "Zalo Personal",
from: fromLabel,
timestamp: timestamp ? timestamp * 1000 : undefined,
@@ -313,7 +315,7 @@ async function processMessage(
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
});
await deps.dispatchReplyWithBufferedBlockDispatcher({
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
@@ -324,7 +326,7 @@ async function processMessage(
chatId,
isGroup,
runtime,
deps,
core,
statusSink,
});
},
@@ -343,10 +345,10 @@ async function deliverZalouserReply(params: {
chatId: string;
isGroup: boolean;
runtime: RuntimeEnv;
deps: CoreChannelDeps;
core: ZalouserCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): Promise<void> {
const { payload, profile, chatId, isGroup, runtime, deps, statusSink } = params;
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
@@ -360,7 +362,7 @@ async function deliverZalouserReply(params: {
const caption = first ? payload.text : undefined;
first = false;
try {
logVerbose(deps, runtime, `Sending media to ${chatId}`);
logVerbose(core, runtime, `Sending media to ${chatId}`);
await sendMessageZalouser(chatId, caption ?? "", {
profile,
mediaUrl,
@@ -375,8 +377,8 @@ async function deliverZalouserReply(params: {
}
if (payload.text) {
const chunks = deps.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
logVerbose(deps, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) {
try {
await sendMessageZalouser(chatId, chunk, { profile, isGroup });
@@ -394,7 +396,7 @@ export async function monitorZalouserProvider(
let { account, config } = options;
const { abortSignal, statusSink, runtime } = options;
const deps = await loadCoreChannelDeps();
const core = getZalouserRuntime();
let stopped = false;
let proc: ChildProcess | null = null;
let restartTimer: ReturnType<typeof setTimeout> | null = null;
@@ -506,7 +508,7 @@ export async function monitorZalouserProvider(
}
logVerbose(
deps,
core,
runtime,
`[${account.accountId}] starting zca listener (profile=${account.profile})`,
);
@@ -515,16 +517,16 @@ export async function monitorZalouserProvider(
runtime,
account.profile,
(msg) => {
logVerbose(deps, runtime, `[${account.accountId}] inbound message`);
logVerbose(core, runtime, `[${account.accountId}] inbound message`);
statusSink?.({ lastInboundAt: Date.now() });
processMessage(msg, account, config, deps, runtime, statusSink).catch((err) => {
processMessage(msg, account, config, core, runtime, statusSink).catch((err) => {
runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
});
},
(err) => {
runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
if (!stopped && !abortSignal.aborted) {
logVerbose(deps, runtime, `[${account.accountId}] restarting listener in 5s...`);
logVerbose(core, runtime, `[${account.accountId}] restarting listener in 5s...`);
restartTimer = setTimeout(startListener, 5000);
} else {
resolveRunning?.();

View File

@@ -1,31 +1,35 @@
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
ClawdbotConfig,
WizardPrompter,
} from "clawdbot/plugin-sdk";
import { promptChannelAccessConfig } from "clawdbot/plugin-sdk";
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
promptAccountId,
promptChannelAccessConfig,
} from "clawdbot/plugin-sdk";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
normalizeAccountId,
checkZcaAuthenticated,
} from "./accounts.js";
import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
import { DEFAULT_ACCOUNT_ID, type CoreConfig, type ZcaGroup } from "./types.js";
import type { ZcaGroup } from "./types.js";
const channel = "zalouser" as const;
function setZalouserDmPolicy(
cfg: CoreConfig,
cfg: ClawdbotConfig,
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
): CoreConfig {
): ClawdbotConfig {
const allowFrom =
dmPolicy === "open"
? [...(cfg.channels?.zalouser?.allowFrom ?? []), "*"].filter(
(v, i, a) => a.indexOf(v) === i,
)
? addWildcardAllowFrom(cfg.channels?.zalouser?.allowFrom)
: undefined;
return {
...cfg,
@@ -37,7 +41,7 @@ function setZalouserDmPolicy(
...(allowFrom ? { allowFrom } : {}),
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
@@ -56,10 +60,10 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
}
async function promptZalouserAllowFrom(params: {
cfg: CoreConfig;
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
}): Promise<ClawdbotConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveZalouserAccountSync({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
@@ -93,7 +97,7 @@ async function promptZalouserAllowFrom(params: {
allowFrom: unique,
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
return {
@@ -114,14 +118,14 @@ async function promptZalouserAllowFrom(params: {
},
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
function setZalouserGroupPolicy(
cfg: CoreConfig,
cfg: ClawdbotConfig,
accountId: string,
groupPolicy: "open" | "allowlist" | "disabled",
): CoreConfig {
): ClawdbotConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
@@ -133,7 +137,7 @@ function setZalouserGroupPolicy(
groupPolicy,
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
return {
...cfg,
@@ -152,14 +156,14 @@ function setZalouserGroupPolicy(
},
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
function setZalouserGroupAllowlist(
cfg: CoreConfig,
cfg: ClawdbotConfig,
accountId: string,
groupKeys: string[],
): CoreConfig {
): ClawdbotConfig {
const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }]));
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
@@ -172,7 +176,7 @@ function setZalouserGroupAllowlist(
groups,
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
return {
...cfg,
@@ -191,11 +195,11 @@ function setZalouserGroupAllowlist(
},
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
async function resolveZalouserGroups(params: {
cfg: CoreConfig;
cfg: ClawdbotConfig;
accountId: string;
entries: string[];
}): Promise<Array<{ input: string; resolved: boolean; id?: string }>> {
@@ -226,65 +230,23 @@ async function resolveZalouserGroups(params: {
});
}
async function promptAccountId(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
label: string;
currentId: string;
listAccountIds: (cfg: CoreConfig) => string[];
defaultAccountId: string;
}): Promise<string> {
const { cfg, prompter, label, currentId, listAccountIds, defaultAccountId } = params;
const existingIds = listAccountIds(cfg);
const options = [
...existingIds.map((id) => ({
value: id,
label: id === defaultAccountId ? `${id} (default)` : id,
})),
{ value: "__new__", label: "Create new account" },
];
const selected = await prompter.select({
message: `${label} account`,
options,
initialValue: currentId,
});
if (selected === "__new__") {
const newId = await prompter.text({
message: "New account ID",
placeholder: "work",
validate: (value) => {
const raw = String(value ?? "").trim().toLowerCase();
if (!raw) return "Required";
if (!/^[a-z0-9_-]+$/.test(raw)) return "Use lowercase alphanumeric, dash, or underscore";
if (existingIds.includes(raw)) return "Account already exists";
return undefined;
},
});
return String(newId).trim().toLowerCase();
}
return selected as string;
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Zalo Personal",
channel,
policyKey: "channels.zalouser.dmPolicy",
allowFromKey: "channels.zalouser.allowFrom",
getCurrent: (cfg) => ((cfg as CoreConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as CoreConfig, policy),
getCurrent: (cfg) => ((cfg as ClawdbotConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as ClawdbotConfig, policy),
};
export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const ids = listZalouserAccountIds(cfg as CoreConfig);
const ids = listZalouserAccountIds(cfg as ClawdbotConfig);
let configured = false;
for (const accountId of ids) {
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const isAuth = await checkZcaAuthenticated(account.profile);
if (isAuth) {
configured = true;
@@ -316,14 +278,14 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
}
const zalouserOverride = accountOverrides.zalouser?.trim();
const defaultAccountId = resolveDefaultZalouserAccountId(cfg as CoreConfig);
const defaultAccountId = resolveDefaultZalouserAccountId(cfg as ClawdbotConfig);
let accountId = zalouserOverride
? normalizeAccountId(zalouserOverride)
: defaultAccountId;
if (shouldPromptAccountIds && !zalouserOverride) {
accountId = await promptAccountId({
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
prompter,
label: "Zalo Personal",
currentId: accountId,
@@ -332,7 +294,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
});
}
let next = cfg as CoreConfig;
let next = cfg as ClawdbotConfig;
const account = resolveZalouserAccountSync({ cfg: next, accountId });
const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
@@ -390,7 +352,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
profile: account.profile !== "default" ? account.profile : undefined,
},
},
} as CoreConfig;
} as ClawdbotConfig;
} else {
next = {
...next,
@@ -409,7 +371,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
},
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
if (forceAllowFrom) {

View File

@@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setZalouserRuntime(next: PluginRuntime): void {
runtime = next;
}
export function getZalouserRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Zalouser runtime not initialized");
}
return runtime;
}

View File

@@ -68,9 +68,6 @@ export type ListenOptions = CommonOptions & {
prefix?: string;
};
// Channel plugin config types
export const DEFAULT_ACCOUNT_ID = "default";
export type ZalouserAccountConfig = {
enabled?: boolean;
name?: string;
@@ -95,14 +92,6 @@ export type ZalouserConfig = {
accounts?: Record<string, ZalouserAccountConfig>;
};
export type CoreConfig = {
channels?: {
zalouser?: ZalouserConfig;
[key: string]: unknown;
};
[key: string]: unknown;
};
export type ResolvedZalouserAccount = {
accountId: string;
name?: string;