From 5a8f77aa6a6d78cb89d8cf59092b3a115b1b6bf0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 22 Mar 2026 22:17:12 +0000 Subject: [PATCH] refactor: adopt chat plugin builder in zalouser --- extensions/zalouser/src/channel.ts | 587 ++++++++++++----------- src/plugins/conversation-binding.test.ts | 6 +- 2 files changed, 299 insertions(+), 294 deletions(-) diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 90cdeea5d49..d762627b288 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -9,6 +9,7 @@ import { createRawChannelSendResultAdapter, } from "openclaw/plugin-sdk/channel-send-result"; import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime"; +import { createChatChannelPlugin } from "openclaw/plugin-sdk/core"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import type { ChannelAccountSnapshot, @@ -37,7 +38,7 @@ import { } from "./accounts.js"; import { buildZalouserGroupCandidates, findZalouserGroupEntry } from "./group-policy.js"; import { resolveZalouserReactionMessageIds } from "./message-sid.js"; -import { probeZalouser } from "./probe.js"; +import { probeZalouser, type ZalouserProbeResult } from "./probe.js"; import { writeQrDataUrlToTempFile } from "./qr-temp-file.js"; import { getZalouserRuntime } from "./runtime.js"; import { sendMessageZalouser, sendReactionZalouser } from "./send.js"; @@ -62,6 +63,33 @@ import { } from "./zalo-js.js"; const ZALOUSER_TEXT_CHUNK_LIMIT = 2000; +const zalouserRawSendResultAdapter = createRawChannelSendResultAdapter({ + channel: "zalouser", + sendText: async ({ to, text, accountId, cfg }) => { + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const target = parseZalouserOutboundTarget(to); + return await sendMessageZalouser(target.threadId, text, { + profile: account.profile, + isGroup: target.isGroup, + textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), + textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), + }); + }, + sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => { + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const target = parseZalouserOutboundTarget(to); + return await sendMessageZalouser(target.threadId, text, { + profile: account.profile, + isGroup: target.isGroup, + mediaUrl, + mediaLocalRoots, + textMode: "markdown", + textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), + textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), + }); + }, +}); function resolveZalouserQrProfile(accountId?: string | null): string { const normalized = normalizeAccountId(accountId); @@ -217,310 +245,287 @@ const zalouserMessageActions: ChannelMessageActionAdapter = { }, }; -export const zalouserPlugin: ChannelPlugin = { - ...createZalouserPluginBase({ - setupWizard: zalouserSetupWizard, - setup: zalouserSetupAdapter, - }), - security: { - resolveDmPolicy: resolveZalouserDmPolicy, - }, - groups: { - resolveRequireMention: resolveZalouserRequireMention, - resolveToolPolicy: resolveZalouserGroupToolPolicy, - }, - threading: { - resolveReplyToMode: createStaticReplyToModeResolver("off"), - }, - actions: zalouserMessageActions, - messaging: { - normalizeTarget: (raw) => normalizeZalouserTarget(raw), - resolveOutboundSessionRoute: (params) => resolveZalouserOutboundSessionRoute(params), - targetResolver: { - looksLikeId: (raw) => { - const normalized = normalizeZalouserTarget(raw); - if (!normalized) { - return false; - } - if (/^group:[^\s]+$/i.test(normalized) || /^user:[^\s]+$/i.test(normalized)) { - return true; - } - return isNumericTargetId(normalized); +export const zalouserPlugin: ChannelPlugin = + createChatChannelPlugin({ + base: { + ...createZalouserPluginBase({ + setupWizard: zalouserSetupWizard, + setup: zalouserSetupAdapter, + }), + groups: { + resolveRequireMention: resolveZalouserRequireMention, + resolveToolPolicy: resolveZalouserGroupToolPolicy, }, - hint: "", - }, - }, - directory: { - self: async ({ cfg, accountId }) => { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const parsed = await getZaloUserInfo(account.profile); - if (!parsed?.userId) { - return null; - } - return mapUser({ - id: String(parsed.userId), - name: parsed.displayName ?? null, - avatarUrl: parsed.avatar ?? null, - raw: parsed, - }); - }, - listPeers: async ({ cfg, accountId, query, limit }) => { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const friends = await listZaloFriendsMatching(account.profile, query); - const rows = friends.map((friend) => - mapUser({ - id: String(friend.userId), - name: friend.displayName ?? null, - avatarUrl: friend.avatar ?? null, - raw: friend, - }), - ); - return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows; - }, - listGroups: async ({ cfg, accountId, query, limit }) => { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const groups = await listZaloGroupsMatching(account.profile, query); - const rows = groups.map((group) => - mapGroup({ - id: `group:${String(group.groupId)}`, - name: group.name ?? null, - raw: group, - }), - ); - return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows; - }, - listGroupMembers: async ({ cfg, accountId, groupId, limit }) => { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const normalizedGroupId = parseZalouserDirectoryGroupId(groupId); - const members = await listZaloGroupMembers(account.profile, normalizedGroupId); - const rows = members.map((member) => - mapUser({ - id: member.userId, - name: member.displayName, - avatarUrl: member.avatar ?? null, - raw: member, - }), - ); - return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows; - }, - }, - resolver: { - resolveTargets: async ({ cfg, accountId, inputs, kind, runtime }) => { - const results = []; - for (const input of inputs) { - const trimmed = input.trim(); - if (!trimmed) { - results.push({ input, resolved: false, note: "empty input" }); - continue; - } - if (/^\d+$/.test(trimmed)) { - results.push({ input, resolved: true, id: trimmed }); - continue; - } - try { + actions: zalouserMessageActions, + messaging: { + normalizeTarget: (raw) => normalizeZalouserTarget(raw), + resolveOutboundSessionRoute: (params) => resolveZalouserOutboundSessionRoute(params), + targetResolver: { + looksLikeId: (raw) => { + const normalized = normalizeZalouserTarget(raw); + if (!normalized) { + return false; + } + if (/^group:[^\s]+$/i.test(normalized) || /^user:[^\s]+$/i.test(normalized)) { + return true; + } + return isNumericTargetId(normalized); + }, + hint: "", + }, + }, + directory: { + self: async ({ cfg, accountId }) => { + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const parsed = await getZaloUserInfo(account.profile); + if (!parsed?.userId) { + return null; + } + return mapUser({ + id: String(parsed.userId), + name: parsed.displayName ?? null, + avatarUrl: parsed.avatar ?? null, + raw: parsed, + }); + }, + listPeers: async ({ cfg, accountId, query, limit }) => { + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const friends = await listZaloFriendsMatching(account.profile, query); + const rows = friends.map((friend) => + mapUser({ + id: String(friend.userId), + name: friend.displayName ?? null, + avatarUrl: friend.avatar ?? null, + raw: friend, + }), + ); + return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows; + }, + listGroups: async ({ cfg, accountId, query, limit }) => { + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const groups = await listZaloGroupsMatching(account.profile, query); + const rows = groups.map((group) => + mapGroup({ + id: `group:${String(group.groupId)}`, + name: group.name ?? null, + raw: group, + }), + ); + return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows; + }, + listGroupMembers: async ({ cfg, accountId, groupId, limit }) => { + const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); + const normalizedGroupId = parseZalouserDirectoryGroupId(groupId); + const members = await listZaloGroupMembers(account.profile, normalizedGroupId); + const rows = members.map((member) => + mapUser({ + id: member.userId, + name: member.displayName, + avatarUrl: member.avatar ?? null, + raw: member, + }), + ); + return typeof limit === "number" && limit > 0 ? rows.slice(0, limit) : rows; + }, + }, + resolver: { + resolveTargets: async ({ cfg, accountId, inputs, kind, runtime }) => { + const results = []; + for (const input of inputs) { + const trimmed = input.trim(); + if (!trimmed) { + results.push({ input, resolved: false, note: "empty input" }); + continue; + } + if (/^\d+$/.test(trimmed)) { + results.push({ input, resolved: true, id: trimmed }); + continue; + } + try { + const account = resolveZalouserAccountSync({ + cfg: cfg, + accountId: accountId ?? DEFAULT_ACCOUNT_ID, + }); + if (kind === "user") { + const friends = await listZaloFriendsMatching(account.profile, trimmed); + const best = friends[0]; + results.push({ + input, + resolved: Boolean(best?.userId), + id: best?.userId, + name: best?.displayName, + note: friends.length > 1 ? "multiple matches; chose first" : undefined, + }); + } else { + const groups = await listZaloGroupsMatching(account.profile, trimmed); + const best = + groups.find((group) => group.name.toLowerCase() === trimmed.toLowerCase()) ?? + groups[0]; + results.push({ + input, + resolved: Boolean(best?.groupId), + id: best?.groupId, + name: best?.name, + note: groups.length > 1 ? "multiple matches; chose first" : undefined, + }); + } + } catch (err) { + runtime.error?.(`zalouser resolve failed: ${String(err)}`); + results.push({ input, resolved: false, note: "lookup failed" }); + } + } + return results; + }, + }, + auth: { + login: async ({ cfg, accountId, runtime }) => { const account = resolveZalouserAccountSync({ cfg: cfg, accountId: accountId ?? DEFAULT_ACCOUNT_ID, }); - if (kind === "user") { - const friends = await listZaloFriendsMatching(account.profile, trimmed); - const best = friends[0]; - results.push({ - input, - resolved: Boolean(best?.userId), - id: best?.userId, - name: best?.displayName, - note: friends.length > 1 ? "multiple matches; chose first" : undefined, - }); - } else { - const groups = await listZaloGroupsMatching(account.profile, trimmed); - const best = - groups.find((group) => group.name.toLowerCase() === trimmed.toLowerCase()) ?? - groups[0]; - results.push({ - input, - resolved: Boolean(best?.groupId), - id: best?.groupId, - name: best?.name, - note: groups.length > 1 ? "multiple matches; chose first" : undefined, - }); + + runtime.log( + `Generating QR login for Zalo Personal (account: ${account.accountId}, profile: ${account.profile})...`, + ); + + const started = await startZaloQrLogin({ + profile: account.profile, + timeoutMs: 35_000, + }); + if (!started.qrDataUrl) { + throw new Error(started.message || "Failed to start QR login"); } - } catch (err) { - runtime.error?.(`zalouser resolve failed: ${String(err)}`); - results.push({ input, resolved: false, note: "lookup failed" }); - } - } - return results; - }, - }, - pairing: createTextPairingAdapter({ - idLabel: "zalouserUserId", - message: "Your pairing request has been approved.", - normalizeAllowEntry: createPairingPrefixStripper(/^(zalouser|zlu):/i), - notify: async ({ cfg, id, message }) => { - const account = resolveZalouserAccountSync({ cfg: cfg }); - const authenticated = await checkZcaAuthenticated(account.profile); - if (!authenticated) { - throw new Error("Zalouser not authenticated"); - } - await sendMessageZalouser(id, message, { - profile: account.profile, - }); - }, - }), - auth: { - login: async ({ cfg, accountId, runtime }) => { - const account = resolveZalouserAccountSync({ - cfg: cfg, - accountId: accountId ?? DEFAULT_ACCOUNT_ID, - }); - runtime.log( - `Generating QR login for Zalo Personal (account: ${account.accountId}, profile: ${account.profile})...`, - ); + const qrPath = await writeQrDataUrlToTempFile(started.qrDataUrl, account.profile); + if (qrPath) { + runtime.log(`Scan QR image: ${qrPath}`); + } else { + runtime.log("QR generated but could not be written to a temp file."); + } - const started = await startZaloQrLogin({ - profile: account.profile, - timeoutMs: 35_000, - }); - if (!started.qrDataUrl) { - throw new Error(started.message || "Failed to start QR login"); - } + const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 180_000 }); + if (!waited.connected) { + throw new Error(waited.message || "Zalouser login failed"); + } - const qrPath = await writeQrDataUrlToTempFile(started.qrDataUrl, account.profile); - if (qrPath) { - runtime.log(`Scan QR image: ${qrPath}`); - } else { - runtime.log("QR generated but could not be written to a temp file."); - } - - const waited = await waitForZaloQrLogin({ profile: account.profile, timeoutMs: 180_000 }); - if (!waited.connected) { - throw new Error(waited.message || "Zalouser login failed"); - } - - runtime.log(waited.message); - }, - }, - outbound: { - deliveryMode: "direct", - chunker: (text, limit) => getZalouserRuntime().channel.text.chunkMarkdownText(text, limit), - chunkerMode: "markdown", - sendPayload: async (ctx) => - await sendPayloadWithChunkedTextAndMedia({ - ctx, - sendText: (nextCtx) => zalouserPlugin.outbound!.sendText!(nextCtx), - sendMedia: (nextCtx) => zalouserPlugin.outbound!.sendMedia!(nextCtx), - emptyResult: createEmptyChannelResult("zalouser"), - }), - ...createRawChannelSendResultAdapter({ - channel: "zalouser", - sendText: async ({ to, text, accountId, cfg }) => { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const target = parseZalouserOutboundTarget(to); - return await sendMessageZalouser(target.threadId, text, { - profile: account.profile, - isGroup: target.isGroup, - textMode: "markdown", - textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), - textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), - }); + runtime.log(waited.message); + }, }, - sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => { - const account = resolveZalouserAccountSync({ cfg: cfg, accountId }); - const target = parseZalouserOutboundTarget(to); - return await sendMessageZalouser(target.threadId, text, { + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + collectStatusIssues: collectZalouserStatusIssues, + buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot), + probeAccount: async ({ account, timeoutMs }) => probeZalouser(account.profile, timeoutMs), + buildAccountSnapshot: async ({ account, runtime }) => { + const configured = await checkZcaAuthenticated(account.profile); + const configError = "not authenticated"; + return buildBaseAccountStatusSnapshot( + { + account: { + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured, + }, + runtime: configured + ? runtime + : { ...runtime, lastError: runtime?.lastError ?? configError }, + }, + { + dmPolicy: account.config.dmPolicy ?? "pairing", + }, + ); + }, + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + let userLabel = ""; + try { + const userInfo = await getZcaUserInfo(account.profile); + if (userInfo?.displayName) { + userLabel = ` (${userInfo.displayName})`; + } + ctx.setStatus({ + accountId: account.accountId, + profile: userInfo, + }); + } catch { + // ignore probe errors + } + const statusSink = createAccountStatusSink({ + accountId: ctx.accountId, + setStatus: ctx.setStatus, + }); + ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`); + const { monitorZalouserProvider } = await import("./monitor.js"); + return monitorZalouserProvider({ + account, + config: ctx.cfg, + runtime: ctx.runtime, + abortSignal: ctx.abortSignal, + statusSink, + }); + }, + loginWithQrStart: async (params) => { + const profile = resolveZalouserQrProfile(params.accountId); + return await startZaloQrLogin({ + profile, + force: params.force, + timeoutMs: params.timeoutMs, + }); + }, + loginWithQrWait: async (params) => { + const profile = resolveZalouserQrProfile(params.accountId); + return await waitForZaloQrLogin({ + profile, + timeoutMs: params.timeoutMs, + }); + }, + logoutAccount: async (ctx) => + await logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)), + }, + }, + security: { + resolveDmPolicy: resolveZalouserDmPolicy, + }, + threading: { + resolveReplyToMode: createStaticReplyToModeResolver("off"), + }, + pairing: createTextPairingAdapter({ + idLabel: "zalouserUserId", + message: "Your pairing request has been approved.", + normalizeAllowEntry: createPairingPrefixStripper(/^(zalouser|zlu):/i), + notify: async ({ cfg, id, message }) => { + const account = resolveZalouserAccountSync({ cfg: cfg }); + const authenticated = await checkZcaAuthenticated(account.profile); + if (!authenticated) { + throw new Error("Zalouser not authenticated"); + } + await sendMessageZalouser(id, message, { profile: account.profile, - isGroup: target.isGroup, - mediaUrl, - mediaLocalRoots, - textMode: "markdown", - textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId), - textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId), }); }, }), - }, - status: { - defaultRuntime: { - accountId: DEFAULT_ACCOUNT_ID, - running: false, - lastStartAt: null, - lastStopAt: null, - lastError: null, + outbound: { + deliveryMode: "direct", + chunker: (text, limit) => getZalouserRuntime().channel.text.chunkMarkdownText(text, limit), + chunkerMode: "markdown", + sendPayload: async (ctx) => + await sendPayloadWithChunkedTextAndMedia({ + ctx, + sendText: (nextCtx) => zalouserRawSendResultAdapter.sendText!(nextCtx), + sendMedia: (nextCtx) => zalouserRawSendResultAdapter.sendMedia!(nextCtx), + emptyResult: createEmptyChannelResult("zalouser"), + }), + ...zalouserRawSendResultAdapter, }, - collectStatusIssues: collectZalouserStatusIssues, - buildChannelSummary: ({ snapshot }) => buildPassiveProbedChannelStatusSummary(snapshot), - probeAccount: async ({ account, timeoutMs }) => probeZalouser(account.profile, timeoutMs), - buildAccountSnapshot: async ({ account, runtime }) => { - const configured = await checkZcaAuthenticated(account.profile); - const configError = "not authenticated"; - return buildBaseAccountStatusSnapshot( - { - account: { - accountId: account.accountId, - name: account.name, - enabled: account.enabled, - configured, - }, - runtime: configured - ? runtime - : { ...runtime, lastError: runtime?.lastError ?? configError }, - }, - { - dmPolicy: account.config.dmPolicy ?? "pairing", - }, - ); - }, - }, - gateway: { - startAccount: async (ctx) => { - const account = ctx.account; - let userLabel = ""; - try { - const userInfo = await getZcaUserInfo(account.profile); - if (userInfo?.displayName) { - userLabel = ` (${userInfo.displayName})`; - } - ctx.setStatus({ - accountId: account.accountId, - profile: userInfo, - }); - } catch { - // ignore probe errors - } - const statusSink = createAccountStatusSink({ - accountId: ctx.accountId, - setStatus: ctx.setStatus, - }); - ctx.log?.info(`[${account.accountId}] starting zalouser provider${userLabel}`); - const { monitorZalouserProvider } = await import("./monitor.js"); - return monitorZalouserProvider({ - account, - config: ctx.cfg, - runtime: ctx.runtime, - abortSignal: ctx.abortSignal, - statusSink, - }); - }, - loginWithQrStart: async (params) => { - const profile = resolveZalouserQrProfile(params.accountId); - return await startZaloQrLogin({ - profile, - force: params.force, - timeoutMs: params.timeoutMs, - }); - }, - loginWithQrWait: async (params) => { - const profile = resolveZalouserQrProfile(params.accountId); - return await waitForZaloQrLogin({ - profile, - timeoutMs: params.timeoutMs, - }); - }, - logoutAccount: async (ctx) => - await logoutZaloProfile(ctx.account.profile || resolveZalouserQrProfile(ctx.accountId)), - }, -}; + }); export type { ResolvedZalouserAccount }; diff --git a/src/plugins/conversation-binding.test.ts b/src/plugins/conversation-binding.test.ts index 4d80cc09a68..4bede10d67e 100644 --- a/src/plugins/conversation-binding.test.ts +++ b/src/plugins/conversation-binding.test.ts @@ -12,7 +12,7 @@ import type { PluginRegistry } from "./registry.js"; const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-binding-")); const approvalsPath = path.join(tempRoot, "plugin-binding-approvals.json"); -function createPluginRegistryStub(): PluginRegistry { +function createEmptyPluginRegistry(): PluginRegistry { return { plugins: [], tools: [], @@ -32,7 +32,7 @@ function createPluginRegistryStub(): PluginRegistry { commands: [], conversationBindingResolvedHandlers: [], diagnostics: [], - }; + } as unknown as PluginRegistry; } const sessionBindingState = vi.hoisted(() => { const records = new Map(); @@ -105,7 +105,7 @@ const sessionBindingState = vi.hoisted(() => { }); const pluginRuntimeState = vi.hoisted(() => ({ - registry: createPluginRegistryStub(), + registry: createEmptyPluginRegistry(), })); vi.mock("../infra/home-dir.js", async (importOriginal) => {