feat: migrate zalo plugin to sdk

This commit is contained in:
Peter Steinberger
2026-01-18 03:34:02 +00:00
parent 5fa1a63978
commit b6d470a679
22 changed files with 182 additions and 654 deletions

View File

@@ -1,25 +1,22 @@
import type {
CoreConfig,
ResolvedZaloAccount,
ZaloAccountConfig,
ZaloConfig,
} from "./types.js";
import { resolveZaloToken } from "./token.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
import { resolveZaloToken } from "./token.js";
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listZaloAccountIds(cfg: CoreConfig): string[] {
export function listZaloAccountIds(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 resolveDefaultZaloAccountId(cfg: CoreConfig): string {
export function resolveDefaultZaloAccountId(cfg: ClawdbotConfig): string {
const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
if (zaloConfig?.defaultAccount?.trim()) return zaloConfig.defaultAccount.trim();
const ids = listZaloAccountIds(cfg);
@@ -28,7 +25,7 @@ export function resolveDefaultZaloAccountId(cfg: CoreConfig): string {
}
function resolveAccountConfig(
cfg: CoreConfig,
cfg: ClawdbotConfig,
accountId: string,
): ZaloAccountConfig | undefined {
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
@@ -36,7 +33,7 @@ function resolveAccountConfig(
return accounts[accountId] as ZaloAccountConfig | undefined;
}
function mergeZaloAccountConfig(cfg: CoreConfig, accountId: string): ZaloAccountConfig {
function mergeZaloAccountConfig(cfg: ClawdbotConfig, accountId: string): ZaloAccountConfig {
const raw = (cfg.channels?.zalo ?? {}) as ZaloConfig;
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
const account = resolveAccountConfig(cfg, accountId) ?? {};
@@ -44,7 +41,7 @@ function mergeZaloAccountConfig(cfg: CoreConfig, accountId: string): ZaloAccount
}
export function resolveZaloAccount(params: {
cfg: CoreConfig;
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedZaloAccount {
const accountId = normalizeAccountId(params.accountId);
@@ -67,7 +64,7 @@ export function resolveZaloAccount(params: {
};
}
export function listEnabledZaloAccounts(cfg: CoreConfig): ResolvedZaloAccount[] {
export function listEnabledZaloAccounts(cfg: ClawdbotConfig): ResolvedZaloAccount[] {
return listZaloAccountIds(cfg)
.map((accountId) => resolveZaloAccount({ cfg, accountId }))
.filter((account) => account.enabled);

View File

@@ -1,16 +1,16 @@
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import { jsonResult, readStringParam } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "./types.js";
import { listEnabledZaloAccounts } from "./accounts.js";
import { sendMessageZalo } from "./send.js";
import { jsonResult, readStringParam } from "./tool-helpers.js";
const providerId = "zalo";
function listEnabledAccounts(cfg: CoreConfig) {
function listEnabledAccounts(cfg: ClawdbotConfig) {
return listEnabledZaloAccounts(cfg).filter(
(account) => account.enabled && account.tokenSource !== "none",
);
@@ -18,7 +18,7 @@ function listEnabledAccounts(cfg: CoreConfig) {
export const zaloMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledAccounts(cfg as CoreConfig);
const accounts = listEnabledAccounts(cfg as ClawdbotConfig);
if (accounts.length === 0) return [];
const actions = new Set<ChannelMessageActionName>(["send"]);
return Array.from(actions);
@@ -44,7 +44,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = {
const result = await sendMessageZalo(to ?? "", content ?? "", {
accountId: accountId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
});
if (!result.ok) {

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { CoreConfig } from "./types.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { zaloPlugin } from "./channel.js";
@@ -12,7 +12,7 @@ describe("zalo directory", () => {
allowFrom: ["zalo:123", "zl:234", "345"],
},
},
} as unknown as CoreConfig;
} as unknown as ClawdbotConfig;
expect(zaloPlugin.directory).toBeTruthy();
expect(zaloPlugin.directory?.listPeers).toBeTruthy();

View File

@@ -1,25 +1,29 @@
import type { ChannelAccountSnapshot, ChannelDock, ChannelPlugin } from "clawdbot/plugin-sdk";
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
import type {
ChannelAccountSnapshot,
ChannelDock,
ChannelPlugin,
ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount } from "./accounts.js";
import { zaloMessageActions } from "./actions.js";
import { ZaloConfigSchema } from "./config-schema.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "./shared/channel-config.js";
import { zaloOnboardingAdapter } from "./onboarding.js";
import { formatPairingApproveHint, PAIRING_APPROVED_MESSAGE } from "./shared/pairing.js";
import { resolveZaloProxyFetch } from "./proxy.js";
import { probeZalo } from "./probe.js";
import { sendMessageZalo } from "./send.js";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "./shared/channel-setup.js";
import { collectZaloStatusIssues } from "./status-issues.js";
import type { CoreConfig } from "./types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
const meta = {
id: "zalo",
@@ -33,7 +37,6 @@ const meta = {
quickstartAllowFrom: true,
};
function normalizeZaloMessagingTarget(raw: string): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
@@ -50,7 +53,7 @@ export const zaloDock: ChannelDock = {
outbound: { textChunkLimit: 2000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
@@ -84,12 +87,12 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
reload: { configPrefixes: ["channels.zalo"] },
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
config: {
listAccountIds: (cfg) => listZaloAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg as CoreConfig),
listAccountIds: (cfg) => listZaloAccountIds(cfg as ClawdbotConfig),
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg as ClawdbotConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
sectionKey: "zalo",
accountId,
enabled,
@@ -97,7 +100,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
sectionKey: "zalo",
accountId,
clearBaseFields: ["botToken", "tokenFile", "name"],
@@ -111,7 +114,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
@@ -125,7 +128,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as CoreConfig).channels?.zalo?.accounts?.[resolvedAccountId],
(cfg as ClawdbotConfig).channels?.zalo?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.zalo.accounts.${resolvedAccountId}.`
@@ -161,7 +164,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveZaloAccount({ cfg: cfg as CoreConfig, accountId });
const account = resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const peers = Array.from(
new Set(
@@ -182,7 +185,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
channelKey: "zalo",
accountId,
name,
@@ -198,7 +201,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
channelKey: "zalo",
accountId,
name: input.name,
@@ -227,7 +230,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
: {}),
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
return {
...next,
@@ -250,14 +253,14 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
},
},
},
} as CoreConfig;
} as ClawdbotConfig;
},
},
pairing: {
idLabel: "zaloUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""),
notifyApproval: async ({ cfg, id }) => {
const account = resolveZaloAccount({ cfg: cfg as CoreConfig });
const account = resolveZaloAccount({ cfg: cfg as ClawdbotConfig });
if (!account.token) throw new Error("Zalo token not configured");
await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token });
},
@@ -289,7 +292,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {
accountId: accountId ?? undefined,
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
});
return {
channel: "zalo",
@@ -302,7 +305,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
const result = await sendMessageZalo(to, text, {
accountId: accountId ?? undefined,
mediaUrl,
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
});
return {
channel: "zalo",
@@ -375,7 +378,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
return monitorZaloProvider({
token,
account,
config: ctx.cfg as CoreConfig,
config: ctx.cfg as ClawdbotConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
useWebhook: Boolean(account.config.webhookUrl),

View File

@@ -1,176 +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 };
pairingAdapter?: {
idLabel: string;
normalizeAllowEntry?: (entry: string) => string;
notifyApproval?: (params: { cfg: unknown; id: string; runtime?: unknown }) => Promise<void>;
};
}) => 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,14 +1,17 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
finalizeInboundContext,
formatAgentEnvelope,
isControlCommandMessage,
recordSessionMetaFromInbound,
resolveCommandAuthorizedFromAuthorizers,
resolveStorePath,
shouldComputeCommandAuthorized,
type ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
ZaloApiError,
deleteWebhook,
@@ -20,10 +23,8 @@ import {
type ZaloMessage,
type ZaloUpdate,
} from "./api.js";
import { zaloPlugin } from "./channel.js";
import { loadCoreChannelDeps } from "./core-bridge.js";
import { resolveZaloProxyFetch } from "./proxy.js";
import type { CoreConfig } from "./types.js";
import { getZaloRuntime } from "./runtime.js";
export type ZaloRuntimeEnv = {
log?: (message: string) => void;
@@ -33,7 +34,7 @@ export type ZaloRuntimeEnv = {
export type ZaloMonitorOptions = {
token: string;
account: ResolvedZaloAccount;
config: CoreConfig;
config: ClawdbotConfig;
runtime: ZaloRuntimeEnv;
abortSignal: AbortSignal;
useWebhook?: boolean;
@@ -51,9 +52,11 @@ export type ZaloMonitorResult = {
const ZALO_TEXT_LIMIT = 2000;
const DEFAULT_MEDIA_MAX_MB = 5;
function logVerbose(deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>, message: string): void {
if (deps.shouldLogVerbose()) {
console.log(`[zalo] ${message}`);
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
if (core.logging.shouldLogVerbose()) {
runtime.log?.(`[zalo] ${message}`);
}
}
@@ -100,9 +103,9 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
type WebhookTarget = {
token: string;
account: ResolvedZaloAccount;
config: CoreConfig;
config: ClawdbotConfig;
runtime: ZaloRuntimeEnv;
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
core: ZaloCoreRuntime;
secret: string;
path: string;
mediaMaxMb: number;
@@ -207,7 +210,7 @@ export async function handleZaloWebhookRequest(
target.account,
target.config,
target.runtime,
target.deps,
target.core,
target.mediaMaxMb,
target.statusSink,
target.fetcher,
@@ -223,9 +226,9 @@ export async function handleZaloWebhookRequest(
function startPollingLoop(params: {
token: string;
account: ResolvedZaloAccount;
config: CoreConfig;
config: ClawdbotConfig;
runtime: ZaloRuntimeEnv;
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
core: ZaloCoreRuntime;
abortSignal: AbortSignal;
isStopped: () => boolean;
mediaMaxMb: number;
@@ -237,7 +240,7 @@ function startPollingLoop(params: {
account,
config,
runtime,
deps,
core,
abortSignal,
isStopped,
mediaMaxMb,
@@ -259,7 +262,7 @@ function startPollingLoop(params: {
account,
config,
runtime,
deps,
core,
mediaMaxMb,
statusSink,
fetcher,
@@ -286,9 +289,9 @@ async function processUpdate(
update: ZaloUpdate,
token: string,
account: ResolvedZaloAccount,
config: CoreConfig,
config: ClawdbotConfig,
runtime: ZaloRuntimeEnv,
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
core: ZaloCoreRuntime,
mediaMaxMb: number,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
fetcher?: ZaloFetch,
@@ -304,7 +307,7 @@ async function processUpdate(
account,
config,
runtime,
deps,
core,
statusSink,
fetcher,
);
@@ -316,7 +319,7 @@ async function processUpdate(
account,
config,
runtime,
deps,
core,
mediaMaxMb,
statusSink,
fetcher,
@@ -337,9 +340,9 @@ async function handleTextMessage(
message: ZaloMessage,
token: string,
account: ResolvedZaloAccount,
config: CoreConfig,
config: ClawdbotConfig,
runtime: ZaloRuntimeEnv,
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
core: ZaloCoreRuntime,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
fetcher?: ZaloFetch,
): Promise<void> {
@@ -352,7 +355,7 @@ async function handleTextMessage(
account,
config,
runtime,
deps,
core,
text,
mediaPath: undefined,
mediaType: undefined,
@@ -365,9 +368,9 @@ async function handleImageMessage(
message: ZaloMessage,
token: string,
account: ResolvedZaloAccount,
config: CoreConfig,
config: ClawdbotConfig,
runtime: ZaloRuntimeEnv,
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
core: ZaloCoreRuntime,
mediaMaxMb: number,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
fetcher?: ZaloFetch,
@@ -380,8 +383,8 @@ async function handleImageMessage(
if (photo) {
try {
const maxBytes = mediaMaxMb * 1024 * 1024;
const fetched = await deps.fetchRemoteMedia({ url: photo });
const saved = await deps.saveMediaBuffer(
const fetched = await core.channel.media.fetchRemoteMedia({ url: photo });
const saved = await core.channel.media.saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
@@ -400,7 +403,7 @@ async function handleImageMessage(
account,
config,
runtime,
deps,
core,
text: caption,
mediaPath,
mediaType,
@@ -413,9 +416,9 @@ async function processMessageWithPipeline(params: {
message: ZaloMessage;
token: string;
account: ResolvedZaloAccount;
config: CoreConfig;
config: ClawdbotConfig;
runtime: ZaloRuntimeEnv;
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
core: ZaloCoreRuntime;
text?: string;
mediaPath?: string;
mediaType?: string;
@@ -428,7 +431,7 @@ async function processMessageWithPipeline(params: {
account,
config,
runtime,
deps,
core,
text,
mediaPath,
mediaType,
@@ -448,7 +451,7 @@ async function processMessageWithPipeline(params: {
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await deps.readChannelAllowFromStore("zalo").catch(() => [])
? await core.channel.pairing.readAllowFromStore("zalo").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const useAccessGroups = config.commands?.useAccessGroups !== false;
@@ -462,7 +465,7 @@ async function processMessageWithPipeline(params: {
if (!isGroup) {
if (dmPolicy === "disabled") {
logVerbose(deps, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
return;
}
@@ -471,21 +474,20 @@ async function processMessageWithPipeline(params: {
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await deps.upsertChannelPairingRequest({
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "zalo",
id: senderId,
meta: { name: senderName ?? undefined },
pairingAdapter: zaloPlugin.pairing,
});
if (created) {
logVerbose(deps, `zalo pairing request sender=${senderId}`);
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
try {
await sendMessage(
token,
{
chat_id: chatId,
text: deps.buildPairingReply({
text: core.channel.pairing.buildPairingReply({
channel: "zalo",
idLine: `Your Zalo user id: ${senderId}`,
code,
@@ -495,18 +497,26 @@ async function processMessageWithPipeline(params: {
);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(deps, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
logVerbose(
core,
runtime,
`zalo pairing reply failed for ${senderId}: ${String(err)}`,
);
}
}
} else {
logVerbose(deps, `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`);
logVerbose(
core,
runtime,
`Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
}
const route = deps.resolveAgentRoute({
const route = core.channel.routing.resolveAgentRoute({
cfg: config,
channel: "zalo",
accountId: account.accountId,
@@ -517,16 +527,14 @@ async function processMessageWithPipeline(params: {
});
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
logVerbose(deps, `zalo: drop control command from unauthorized sender ${senderId}`);
logVerbose(core, runtime, `zalo: drop control command from unauthorized sender ${senderId}`);
return;
}
const fromLabel = isGroup
? `group:${chatId}`
: senderName || `user:${senderId}`;
const body = deps.formatAgentEnvelope({
channel: "Zalo",
from: fromLabel,
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const body = formatAgentEnvelope({
channel: "Zalo",
from: fromLabel,
timestamp: date ? date * 1000 : undefined,
body: rawBody,
});
@@ -565,7 +573,7 @@ async function processMessageWithPipeline(params: {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
});
await deps.dispatchReplyWithBufferedBlockDispatcher({
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
@@ -575,7 +583,7 @@ async function processMessageWithPipeline(params: {
token,
chatId,
runtime,
deps,
core,
statusSink,
fetcher,
});
@@ -592,11 +600,11 @@ async function deliverZaloReply(params: {
token: string;
chatId: string;
runtime: ZaloRuntimeEnv;
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
core: ZaloCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
}): Promise<void> {
const { payload, token, chatId, runtime, deps, statusSink, fetcher } = params;
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
@@ -620,7 +628,7 @@ async function deliverZaloReply(params: {
}
if (payload.text) {
const chunks = deps.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
for (const chunk of chunks) {
try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
@@ -649,7 +657,7 @@ export async function monitorZaloProvider(
fetcher: fetcherOverride,
} = options;
const deps = await loadCoreChannelDeps();
const core = getZaloRuntime();
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
@@ -686,7 +694,7 @@ export async function monitorZaloProvider(
account,
config,
runtime,
deps,
core,
path,
secret: webhookSecret,
statusSink: (patch) => statusSink?.(patch),
@@ -715,7 +723,7 @@ export async function monitorZaloProvider(
account,
config,
runtime,
deps,
core,
abortSignal,
isStopped: () => stopped,
mediaMaxMb: effectiveMediaMaxMb,

View File

@@ -3,8 +3,8 @@ import type { AddressInfo } from "node:net";
import { describe, expect, it } from "vitest";
import type { CoreConfig, ResolvedZaloAccount } from "./types.js";
import type { loadCoreChannelDeps } from "./core-bridge.js";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import type { ResolvedZaloAccount } from "./types.js";
import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
async function withServer(
@@ -26,7 +26,7 @@ async function withServer(
describe("handleZaloWebhookRequest", () => {
it("returns 400 for non-object payloads", async () => {
const deps = {} as Awaited<ReturnType<typeof loadCoreChannelDeps>>;
const core = {} as PluginRuntime;
const account: ResolvedZaloAccount = {
accountId: "default",
enabled: true,
@@ -37,9 +37,9 @@ describe("handleZaloWebhookRequest", () => {
const unregister = registerZaloWebhookTarget({
token: "tok",
account,
config: {} as CoreConfig,
config: {} as ClawdbotConfig,
runtime: {},
deps,
core,
secret: "secret",
path: "/hook",
mediaMaxMb: 5,

View File

@@ -1,23 +1,30 @@
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
ClawdbotConfig,
WizardPrompter,
} from "clawdbot/plugin-sdk";
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
promptAccountId,
} from "clawdbot/plugin-sdk";
import { addWildcardAllowFrom, promptAccountId } from "./shared/onboarding.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
import {
listZaloAccountIds,
resolveDefaultZaloAccountId,
resolveZaloAccount,
} from "./accounts.js";
import type { CoreConfig } from "./types.js";
const channel = "zalo" as const;
type UpdateMode = "polling" | "webhook";
function setZaloDmPolicy(cfg: CoreConfig, dmPolicy: "pairing" | "allowlist" | "open" | "disabled") {
function setZaloDmPolicy(
cfg: ClawdbotConfig,
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
) {
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.zalo?.allowFrom) : undefined;
return {
...cfg,
@@ -29,17 +36,17 @@ function setZaloDmPolicy(cfg: CoreConfig, dmPolicy: "pairing" | "allowlist" | "o
...(allowFrom ? { allowFrom } : {}),
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
function setZaloUpdateMode(
cfg: CoreConfig,
cfg: ClawdbotConfig,
accountId: string,
mode: UpdateMode,
webhookUrl?: string,
webhookSecret?: string,
webhookPath?: string,
): CoreConfig {
): ClawdbotConfig {
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
if (mode === "polling") {
if (isDefault) {
@@ -55,7 +62,7 @@ function setZaloUpdateMode(
...cfg.channels,
zalo: rest,
},
} as CoreConfig;
} as ClawdbotConfig;
}
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
string,
@@ -78,7 +85,7 @@ function setZaloUpdateMode(
accounts,
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
if (isDefault) {
@@ -93,7 +100,7 @@ function setZaloUpdateMode(
webhookPath,
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
@@ -115,7 +122,7 @@ function setZaloUpdateMode(
accounts,
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
async function noteZaloTokenHelp(prompter: WizardPrompter): Promise<void> {
@@ -132,10 +139,10 @@ async function noteZaloTokenHelp(prompter: WizardPrompter): Promise<void> {
}
async function promptZaloAllowFrom(params: {
cfg: CoreConfig;
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
}): Promise<ClawdbotConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveZaloAccount({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
@@ -169,7 +176,7 @@ async function promptZaloAllowFrom(params: {
allowFrom: unique,
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
return {
@@ -190,7 +197,7 @@ async function promptZaloAllowFrom(params: {
},
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
const dmPolicy: ChannelOnboardingDmPolicy = {
@@ -199,15 +206,15 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
policyKey: "channels.zalo.dmPolicy",
allowFromKey: "channels.zalo.allowFrom",
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as CoreConfig, policy),
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as ClawdbotConfig, policy),
};
export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const configured = listZaloAccountIds(cfg as CoreConfig).some((accountId) =>
Boolean(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).token),
const configured = listZaloAccountIds(cfg as ClawdbotConfig).some((accountId) =>
Boolean(resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }).token),
);
return {
channel,
@@ -219,13 +226,13 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds, forceAllowFrom }) => {
const zaloOverride = accountOverrides.zalo?.trim();
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg as CoreConfig);
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg as ClawdbotConfig);
let zaloAccountId = zaloOverride
? normalizeAccountId(zaloOverride)
: defaultZaloAccountId;
if (shouldPromptAccountIds && !zaloOverride) {
zaloAccountId = await promptAccountId({
cfg: cfg as CoreConfig,
cfg: cfg as ClawdbotConfig,
prompter,
label: "Zalo",
currentId: zaloAccountId,
@@ -234,7 +241,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
});
}
let next = cfg as CoreConfig;
let next = cfg as ClawdbotConfig;
const resolvedAccount = resolveZaloAccount({ cfg: next, accountId: zaloAccountId });
const accountConfigured = Boolean(resolvedAccount.token);
const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
@@ -262,7 +269,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
enabled: true,
},
},
} as CoreConfig;
} as ClawdbotConfig;
} else {
token = String(
await prompter.text({
@@ -305,7 +312,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
botToken: token,
},
},
} as CoreConfig;
} as ClawdbotConfig;
} else {
next = {
...next,
@@ -324,7 +331,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
},
},
},
} as CoreConfig;
} as ClawdbotConfig;
}
}

View File

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

View File

@@ -1,4 +1,5 @@
import type { CoreConfig } from "./types.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { ZaloFetch } from "./api.js";
import { sendMessage, sendPhoto } from "./api.js";
import { resolveZaloAccount } from "./accounts.js";
@@ -8,7 +9,7 @@ import { resolveZaloToken } from "./token.js";
export type ZaloSendOptions = {
token?: string;
accountId?: string;
cfg?: CoreConfig;
cfg?: ClawdbotConfig;
mediaUrl?: string;
caption?: string;
verbose?: boolean;

View File

@@ -1,15 +0,0 @@
export const DEFAULT_ACCOUNT_ID = "default";
export function normalizeAccountId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_ACCOUNT_ID;
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
return (
trimmed
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
.slice(0, 64) || DEFAULT_ACCOUNT_ID
);
}

View File

@@ -1,112 +0,0 @@
import { DEFAULT_ACCOUNT_ID } from "./account-ids.js";
type ChannelSection = {
accounts?: Record<string, Record<string, unknown>>;
enabled?: boolean;
};
type ConfigWithChannels = {
channels?: Record<string, unknown>;
};
export function setAccountEnabledInConfigSection<T extends ConfigWithChannels>(params: {
cfg: T;
sectionKey: string;
accountId: string;
enabled: boolean;
allowTopLevel?: boolean;
}): T {
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
const channels = params.cfg.channels;
const base = (channels?.[params.sectionKey] as ChannelSection | undefined) ?? undefined;
const hasAccounts = Boolean(base?.accounts);
if (params.allowTopLevel && accountKey === DEFAULT_ACCOUNT_ID && !hasAccounts) {
return {
...params.cfg,
channels: {
...channels,
[params.sectionKey]: {
...base,
enabled: params.enabled,
},
},
} as T;
}
const baseAccounts = (base?.accounts ?? {}) as Record<string, Record<string, unknown>>;
const existing = baseAccounts[accountKey] ?? {};
return {
...params.cfg,
channels: {
...channels,
[params.sectionKey]: {
...base,
accounts: {
...baseAccounts,
[accountKey]: {
...existing,
enabled: params.enabled,
},
},
},
},
} as T;
}
export function deleteAccountFromConfigSection<T extends ConfigWithChannels>(params: {
cfg: T;
sectionKey: string;
accountId: string;
clearBaseFields?: string[];
}): T {
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const base = (channels?.[params.sectionKey] as ChannelSection | undefined) ?? undefined;
if (!base) return params.cfg;
const baseAccounts =
base.accounts && typeof base.accounts === "object" ? { ...base.accounts } : undefined;
if (accountKey !== DEFAULT_ACCOUNT_ID) {
const accounts = baseAccounts ? { ...baseAccounts } : {};
delete accounts[accountKey];
return {
...params.cfg,
channels: {
...channels,
[params.sectionKey]: {
...base,
accounts: Object.keys(accounts).length ? accounts : undefined,
},
},
} as T;
}
if (baseAccounts && Object.keys(baseAccounts).length > 0) {
delete baseAccounts[accountKey];
const baseRecord = { ...(base as Record<string, unknown>) };
for (const field of params.clearBaseFields ?? []) {
if (field in baseRecord) baseRecord[field] = undefined;
}
return {
...params.cfg,
channels: {
...channels,
[params.sectionKey]: {
...baseRecord,
accounts: Object.keys(baseAccounts).length ? baseAccounts : undefined,
},
},
} as T;
}
const nextChannels = { ...channels } as Record<string, unknown>;
delete nextChannels[params.sectionKey];
const nextCfg = { ...params.cfg } as T;
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels as T["channels"];
} else {
delete nextCfg.channels;
}
return nextCfg;
}

View File

@@ -1,114 +0,0 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./account-ids.js";
type ConfigWithChannels = {
channels?: Record<string, unknown>;
};
type ChannelSectionBase = {
name?: string;
accounts?: Record<string, Record<string, unknown>>;
};
function channelHasAccounts(cfg: ConfigWithChannels, channelKey: string): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[channelKey] as ChannelSectionBase | undefined;
return Boolean(base?.accounts && Object.keys(base.accounts).length > 0);
}
function shouldStoreNameInAccounts(params: {
cfg: ConfigWithChannels;
channelKey: string;
accountId: string;
alwaysUseAccounts?: boolean;
}): boolean {
if (params.alwaysUseAccounts) return true;
if (params.accountId !== DEFAULT_ACCOUNT_ID) return true;
return channelHasAccounts(params.cfg, params.channelKey);
}
export function applyAccountNameToChannelSection<T extends ConfigWithChannels>(params: {
cfg: T;
channelKey: string;
accountId: string;
name?: string;
alwaysUseAccounts?: boolean;
}): T {
const trimmed = params.name?.trim();
if (!trimmed) return params.cfg;
const accountId = normalizeAccountId(params.accountId);
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const baseConfig = channels?.[params.channelKey];
const base =
typeof baseConfig === "object" && baseConfig ? (baseConfig as ChannelSectionBase) : undefined;
const useAccounts = shouldStoreNameInAccounts({
cfg: params.cfg,
channelKey: params.channelKey,
accountId,
alwaysUseAccounts: params.alwaysUseAccounts,
});
if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) {
const safeBase = base ?? {};
return {
...params.cfg,
channels: {
...channels,
[params.channelKey]: {
...safeBase,
name: trimmed,
},
},
} as T;
}
const baseAccounts: Record<string, Record<string, unknown>> = base?.accounts ?? {};
const existingAccount = baseAccounts[accountId] ?? {};
const baseWithoutName =
accountId === DEFAULT_ACCOUNT_ID
? (({ name: _ignored, ...rest }) => rest)(base ?? {})
: (base ?? {});
return {
...params.cfg,
channels: {
...channels,
[params.channelKey]: {
...baseWithoutName,
accounts: {
...baseAccounts,
[accountId]: {
...existingAccount,
name: trimmed,
},
},
},
},
} as T;
}
export function migrateBaseNameToDefaultAccount<T extends ConfigWithChannels>(params: {
cfg: T;
channelKey: string;
alwaysUseAccounts?: boolean;
}): T {
if (params.alwaysUseAccounts) return params.cfg;
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[params.channelKey] as ChannelSectionBase | undefined;
const baseName = base?.name?.trim();
if (!baseName) return params.cfg;
const accounts: Record<string, Record<string, unknown>> = {
...base?.accounts,
};
const defaultAccount = accounts[DEFAULT_ACCOUNT_ID] ?? {};
if (!defaultAccount.name) {
accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName };
}
const { name: _ignored, ...rest } = base ?? {};
return {
...params.cfg,
channels: {
...channels,
[params.channelKey]: {
...rest,
accounts,
},
},
} as T;
}

View File

@@ -1,53 +0,0 @@
import type { WizardPrompter } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./account-ids.js";
export type PromptAccountIdParams<TConfig> = {
cfg: TConfig;
prompter: WizardPrompter;
label: string;
currentId?: string;
listAccountIds: (cfg: TConfig) => string[];
defaultAccountId: string;
};
export async function promptAccountId<TConfig>(
params: PromptAccountIdParams<TConfig>,
): Promise<string> {
const existingIds = params.listAccountIds(params.cfg);
const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
const choice = (await params.prompter.select({
message: `${params.label} account`,
options: [
...existingIds.map((id) => ({
value: id,
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
})),
{ value: "__new__", label: "Add a new account" },
],
initialValue: initial,
})) as string;
if (choice !== "__new__") return normalizeAccountId(choice);
const entered = await params.prompter.text({
message: `New ${params.label} account id`,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const normalized = normalizeAccountId(String(entered));
if (String(entered).trim() !== normalized) {
await params.prompter.note(
`Normalized account id to "${normalized}".`,
`${params.label} account`,
);
}
return normalized;
}
export function addWildcardAllowFrom(
allowFrom?: Array<string | number> | null,
): Array<string | number> {
const next = (allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean);
if (!next.includes("*")) next.push("*");
return next;
}

View File

@@ -1,6 +0,0 @@
export const PAIRING_APPROVED_MESSAGE =
"\u2705 Clawdbot access approved. Send a message to start chatting.";
export function formatPairingApproveHint(channelId: string): string {
return `Approve via: clawdbot pairing list ${channelId} / clawdbot pairing approve ${channelId} <code>`;
}

View File

@@ -1,7 +1,8 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
import type { ZaloConfig } from "./types.js";
import { DEFAULT_ACCOUNT_ID } from "./shared/account-ids.js";
export type ZaloTokenResolution = {
token: string;

View File

@@ -1,30 +0,0 @@
export function readStringParam(
params: Record<string, unknown>,
key: string,
opts?: { required?: boolean; allowEmpty?: boolean; trim?: boolean },
): string | undefined {
const raw = params[key];
if (raw === undefined || raw === null) {
if (opts?.required) throw new Error(`${key} is required`);
return undefined;
}
const value = String(raw);
const trimmed = opts?.trim === false ? value : value.trim();
if (!opts?.allowEmpty && !trimmed) {
if (opts?.required) throw new Error(`${key} is required`);
return undefined;
}
return trimmed;
}
export function jsonResult(payload: unknown) {
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
details: payload,
};
}

View File

@@ -40,10 +40,3 @@ export type ResolvedZaloAccount = {
tokenSource: ZaloTokenSource;
config: ZaloAccountConfig;
};
export type CoreConfig = {
channels?: {
zalo?: ZaloConfig;
};
[key: string]: unknown;
};