feat: migrate zalo plugin to sdk
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
extensions/zalo/src/runtime.ts
Normal file
14
extensions/zalo/src/runtime.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -40,10 +40,3 @@ export type ResolvedZaloAccount = {
|
||||
tokenSource: ZaloTokenSource;
|
||||
config: ZaloAccountConfig;
|
||||
};
|
||||
|
||||
export type CoreConfig = {
|
||||
channels?: {
|
||||
zalo?: ZaloConfig;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user