diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b13ed56b..f958d8d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/zalouser/index.ts b/extensions/zalouser/index.ts index 2271292d8..d4e1a7de0 100644 --- a/extensions/zalouser/index.ts +++ b/extensions/zalouser/index.ts @@ -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); diff --git a/extensions/zalouser/src/accounts.ts b/extensions/zalouser/src/accounts.ts index 31f487d05..2428dbfc5 100644 --- a/extensions/zalouser/src/accounts.ts +++ b/extensions/zalouser/src/accounts.ts @@ -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 { } export async function resolveZalouserAccount(params: { - cfg: CoreConfig; + cfg: ClawdbotConfig; accountId?: string | null; }): Promise { 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 { +export async function listEnabledZalouserAccounts( + cfg: ClawdbotConfig, +): Promise { const ids = listZalouserAccountIds(cfg); const accounts = await Promise.all( ids.map((accountId) => resolveZalouserAccount({ cfg, accountId })) diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 6fd4f690e..da53d54c9 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -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 = { id: "zalouser", meta, @@ -143,20 +87,24 @@ export const zalouserPlugin: ChannelPlugin = { }, 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { } 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 = { 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 = { 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 = { }, 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 = { }; }, 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 = { 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 }), diff --git a/extensions/zalouser/src/core-bridge.ts b/extensions/zalouser/src/core-bridge.ts deleted file mode 100644 index 46162412b..000000000 --- a/extensions/zalouser/src/core-bridge.ts +++ /dev/null @@ -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; - onError?: (err: unknown, info: { kind: string }) => void; - }; - }) => Promise; - 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; - 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 | 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(); - 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(relativePath: string): Promise { - 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 { - 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; -} diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index 9d18070a0..9f028722c 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -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( return index; } -function logVerbose(deps: CoreChannelDeps, runtime: RuntimeEnv, message: string): void { - if (deps.shouldLogVerbose()) { +type ZalouserCoreRuntime = ReturnType; + +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 { @@ -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 { - 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 | 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?.(); diff --git a/extensions/zalouser/src/onboarding.ts b/extensions/zalouser/src/onboarding.ts index e220c2765..1717b535b 100644 --- a/extensions/zalouser/src/onboarding.ts +++ b/extensions/zalouser/src/onboarding.ts @@ -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 { @@ -56,10 +60,10 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise { } async function promptZalouserAllowFrom(params: { - cfg: CoreConfig; + cfg: ClawdbotConfig; prompter: WizardPrompter; accountId: string; -}): Promise { +}): Promise { 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> { @@ -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 { - 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) { diff --git a/extensions/zalouser/src/runtime.ts b/extensions/zalouser/src/runtime.ts new file mode 100644 index 000000000..0f4fb8b6d --- /dev/null +++ b/extensions/zalouser/src/runtime.ts @@ -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; +} diff --git a/extensions/zalouser/src/types.ts b/extensions/zalouser/src/types.ts index 441d26144..fcdb81dcc 100644 --- a/extensions/zalouser/src/types.ts +++ b/extensions/zalouser/src/types.ts @@ -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; }; -export type CoreConfig = { - channels?: { - zalouser?: ZalouserConfig; - [key: string]: unknown; - }; - [key: string]: unknown; -}; - export type ResolvedZalouserAccount = { accountId: string; name?: string;