feat: add plugin HTTP hooks + Zalo plugin
This commit is contained in:
74
extensions/zalo/src/accounts.ts
Normal file
74
extensions/zalo/src/accounts.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type {
|
||||
CoreConfig,
|
||||
ResolvedZaloAccount,
|
||||
ZaloAccountConfig,
|
||||
ZaloConfig,
|
||||
} from "./types.js";
|
||||
import { resolveZaloToken } from "./token.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
|
||||
|
||||
function listConfiguredAccountIds(cfg: CoreConfig): 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[] {
|
||||
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 {
|
||||
const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
|
||||
if (zaloConfig?.defaultAccount?.trim()) return zaloConfig.defaultAccount.trim();
|
||||
const ids = listZaloAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
function resolveAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
): ZaloAccountConfig | undefined {
|
||||
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") return undefined;
|
||||
return accounts[accountId] as ZaloAccountConfig | undefined;
|
||||
}
|
||||
|
||||
function mergeZaloAccountConfig(cfg: CoreConfig, accountId: string): ZaloAccountConfig {
|
||||
const raw = (cfg.channels?.zalo ?? {}) as ZaloConfig;
|
||||
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
return { ...base, ...account };
|
||||
}
|
||||
|
||||
export function resolveZaloAccount(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedZaloAccount {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const baseEnabled = (params.cfg.channels?.zalo as ZaloConfig | undefined)?.enabled !== false;
|
||||
const merged = mergeZaloAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
const tokenResolution = resolveZaloToken(
|
||||
params.cfg.channels?.zalo as ZaloConfig | undefined,
|
||||
accountId,
|
||||
);
|
||||
|
||||
return {
|
||||
accountId,
|
||||
name: merged.name?.trim() || undefined,
|
||||
enabled,
|
||||
token: tokenResolution.token,
|
||||
tokenSource: tokenResolution.source,
|
||||
config: merged,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledZaloAccounts(cfg: CoreConfig): ResolvedZaloAccount[] {
|
||||
return listZaloAccountIds(cfg)
|
||||
.map((accountId) => resolveZaloAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
59
extensions/zalo/src/actions.ts
Normal file
59
extensions/zalo/src/actions.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../../src/channels/plugins/types.js";
|
||||
|
||||
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) {
|
||||
return listEnabledZaloAccounts(cfg).filter(
|
||||
(account) => account.enabled && account.tokenSource !== "none",
|
||||
);
|
||||
}
|
||||
|
||||
export const zaloMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const accounts = listEnabledAccounts(cfg as CoreConfig);
|
||||
if (accounts.length === 0) return [];
|
||||
const actions = new Set<ChannelMessageActionName>(["send"]);
|
||||
return Array.from(actions);
|
||||
},
|
||||
supportsButtons: () => false,
|
||||
extractToolSend: ({ args }) => {
|
||||
const action = typeof args.action === "string" ? args.action.trim() : "";
|
||||
if (action !== "sendMessage") return null;
|
||||
const to = typeof args.to === "string" ? args.to : undefined;
|
||||
if (!to) return null;
|
||||
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
|
||||
return { to, accountId };
|
||||
},
|
||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||
if (action === "send") {
|
||||
const to = readStringParam(params, "to", { required: true });
|
||||
const content = readStringParam(params, "message", {
|
||||
required: true,
|
||||
allowEmpty: true,
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "media", { trim: false });
|
||||
|
||||
const result = await sendMessageZalo(to ?? "", content ?? "", {
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
cfg: cfg as CoreConfig,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return jsonResult({
|
||||
ok: false,
|
||||
error: result.error ?? "Failed to send Zalo message",
|
||||
});
|
||||
}
|
||||
|
||||
return jsonResult({ ok: true, to, messageId: result.messageId });
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
|
||||
},
|
||||
};
|
||||
206
extensions/zalo/src/api.ts
Normal file
206
extensions/zalo/src/api.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Zalo Bot API client
|
||||
* @see https://bot.zaloplatforms.com/docs
|
||||
*/
|
||||
|
||||
const ZALO_API_BASE = "https://bot-api.zaloplatforms.com";
|
||||
|
||||
export type ZaloFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
export type ZaloApiResponse<T = unknown> = {
|
||||
ok: boolean;
|
||||
result?: T;
|
||||
error_code?: number;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type ZaloBotInfo = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export type ZaloMessage = {
|
||||
message_id: string;
|
||||
from: {
|
||||
id: string;
|
||||
name?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
chat: {
|
||||
id: string;
|
||||
chat_type: "PRIVATE" | "GROUP";
|
||||
};
|
||||
date: number;
|
||||
text?: string;
|
||||
photo?: string;
|
||||
caption?: string;
|
||||
sticker?: string;
|
||||
};
|
||||
|
||||
export type ZaloUpdate = {
|
||||
event_name:
|
||||
| "message.text.received"
|
||||
| "message.image.received"
|
||||
| "message.sticker.received"
|
||||
| "message.unsupported.received";
|
||||
message?: ZaloMessage;
|
||||
};
|
||||
|
||||
export type ZaloSendMessageParams = {
|
||||
chat_id: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type ZaloSendPhotoParams = {
|
||||
chat_id: string;
|
||||
photo: string;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export type ZaloSetWebhookParams = {
|
||||
url: string;
|
||||
secret_token: string;
|
||||
};
|
||||
|
||||
export type ZaloGetUpdatesParams = {
|
||||
/** Timeout in seconds (passed as string to API) */
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export class ZaloApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly errorCode?: number,
|
||||
public readonly description?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ZaloApiError";
|
||||
}
|
||||
|
||||
/** True if this is a long-polling timeout (no updates available) */
|
||||
get isPollingTimeout(): boolean {
|
||||
return this.errorCode === 408;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the Zalo Bot API
|
||||
*/
|
||||
export async function callZaloApi<T = unknown>(
|
||||
method: string,
|
||||
token: string,
|
||||
body?: Record<string, unknown>,
|
||||
options?: { timeoutMs?: number; fetch?: ZaloFetch },
|
||||
): Promise<ZaloApiResponse<T>> {
|
||||
const url = `${ZALO_API_BASE}/bot${token}/${method}`;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = options?.timeoutMs
|
||||
? setTimeout(() => controller.abort(), options.timeoutMs)
|
||||
: undefined;
|
||||
const fetcher = options?.fetch ?? fetch;
|
||||
|
||||
try {
|
||||
const response = await fetcher(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const data = (await response.json()) as ZaloApiResponse<T>;
|
||||
|
||||
if (!data.ok) {
|
||||
throw new ZaloApiError(
|
||||
data.description ?? `Zalo API error: ${method}`,
|
||||
data.error_code,
|
||||
data.description,
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate bot token and get bot info
|
||||
*/
|
||||
export async function getMe(
|
||||
token: string,
|
||||
timeoutMs?: number,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloBotInfo>> {
|
||||
return callZaloApi<ZaloBotInfo>("getMe", token, undefined, { timeoutMs, fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text message
|
||||
*/
|
||||
export async function sendMessage(
|
||||
token: string,
|
||||
params: ZaloSendMessageParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloMessage>> {
|
||||
return callZaloApi<ZaloMessage>("sendMessage", token, params, { fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a photo message
|
||||
*/
|
||||
export async function sendPhoto(
|
||||
token: string,
|
||||
params: ZaloSendPhotoParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloMessage>> {
|
||||
return callZaloApi<ZaloMessage>("sendPhoto", token, params, { fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get updates using long polling (dev/testing only)
|
||||
* Note: Zalo returns a single update per call, not an array like Telegram
|
||||
*/
|
||||
export async function getUpdates(
|
||||
token: string,
|
||||
params?: ZaloGetUpdatesParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<ZaloUpdate>> {
|
||||
const pollTimeoutSec = params?.timeout ?? 30;
|
||||
const timeoutMs = (pollTimeoutSec + 5) * 1000;
|
||||
const body = { timeout: String(pollTimeoutSec) };
|
||||
return callZaloApi<ZaloUpdate>("getUpdates", token, body, { timeoutMs, fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Set webhook URL for receiving updates
|
||||
*/
|
||||
export async function setWebhook(
|
||||
token: string,
|
||||
params: ZaloSetWebhookParams,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<boolean>> {
|
||||
return callZaloApi<boolean>("setWebhook", token, params, { fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete webhook configuration
|
||||
*/
|
||||
export async function deleteWebhook(
|
||||
token: string,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<boolean>> {
|
||||
return callZaloApi<boolean>("deleteWebhook", token, undefined, { fetch: fetcher });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current webhook info
|
||||
*/
|
||||
export async function getWebhookInfo(
|
||||
token: string,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloApiResponse<{ url?: string; has_custom_certificate?: boolean }>> {
|
||||
return callZaloApi("getWebhookInfo", token, undefined, { fetch: fetcher });
|
||||
}
|
||||
370
extensions/zalo/src/channel.ts
Normal file
370
extensions/zalo/src/channel.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import type { ChannelDock, ChannelPlugin } from "../../src/channels/plugins/types.js";
|
||||
import type { ChannelAccountSnapshot } from "../../src/channels/plugins/types.js";
|
||||
|
||||
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount } from "./accounts.js";
|
||||
import { zaloMessageActions } from "./actions.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",
|
||||
label: "Zalo",
|
||||
selectionLabel: "Zalo (Bot API)",
|
||||
docsPath: "/channels/zalo",
|
||||
docsLabel: "zalo",
|
||||
blurb: "Vietnam-focused messaging platform with Bot API.",
|
||||
aliases: ["zl"],
|
||||
order: 80,
|
||||
quickstartAllowFrom: true,
|
||||
};
|
||||
|
||||
|
||||
function normalizeZaloMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = raw?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.replace(/^(zalo|zl):/i, "");
|
||||
}
|
||||
|
||||
export const zaloDock: ChannelDock = {
|
||||
id: "zalo",
|
||||
capabilities: {
|
||||
chatTypes: ["direct"],
|
||||
media: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
outbound: { textChunkLimit: 2000 },
|
||||
config: {
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.replace(/^(zalo|zl):/i, ""))
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
},
|
||||
};
|
||||
|
||||
export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
|
||||
id: "zalo",
|
||||
meta,
|
||||
onboarding: zaloOnboardingAdapter,
|
||||
capabilities: {
|
||||
chatTypes: ["direct"],
|
||||
media: true,
|
||||
reactions: false,
|
||||
threads: false,
|
||||
polls: false,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.zalo"] },
|
||||
config: {
|
||||
listAccountIds: (cfg) => listZaloAccountIds(cfg as CoreConfig),
|
||||
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg as CoreConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
sectionKey: "zalo",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
sectionKey: "zalo",
|
||||
accountId,
|
||||
clearBaseFields: ["botToken", "tokenFile", "name"],
|
||||
}),
|
||||
isConfigured: (account) => Boolean(account.token?.trim()),
|
||||
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: Boolean(account.token?.trim()),
|
||||
tokenSource: account.tokenSource,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => entry.replace(/^(zalo|zl):/i, ""))
|
||||
.map((entry) => entry.toLowerCase()),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(
|
||||
(cfg as CoreConfig).channels?.zalo?.accounts?.[resolvedAccountId],
|
||||
);
|
||||
const basePath = useAccountPath
|
||||
? `channels.zalo.accounts.${resolvedAccountId}.`
|
||||
: "channels.zalo.";
|
||||
return {
|
||||
policy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPath: `${basePath}dmPolicy`,
|
||||
allowFromPath: basePath,
|
||||
approveHint: formatPairingApproveHint("zalo"),
|
||||
normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
|
||||
};
|
||||
},
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
},
|
||||
actions: zaloMessageActions,
|
||||
messaging: {
|
||||
normalizeTarget: normalizeZaloMessagingTarget,
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
channelKey: "zalo",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ accountId, input }) => {
|
||||
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "ZALO_BOT_TOKEN can only be used for the default account.";
|
||||
}
|
||||
if (!input.useEnv && !input.token && !input.tokenFile) {
|
||||
return "Zalo requires --token or --token-file (or --use-env).";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
channelKey: "zalo",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: "zalo",
|
||||
})
|
||||
: namedConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
...(input.useEnv
|
||||
? {}
|
||||
: input.tokenFile
|
||||
? { tokenFile: input.tokenFile }
|
||||
: input.token
|
||||
? { botToken: input.token }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(next.channels?.zalo?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(next.channels?.zalo?.accounts?.[accountId] ?? {}),
|
||||
enabled: true,
|
||||
...(input.tokenFile
|
||||
? { tokenFile: input.tokenFile }
|
||||
: input.token
|
||||
? { botToken: input.token }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
idLabel: "zaloUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveZaloAccount({ cfg: cfg as CoreConfig });
|
||||
if (!account.token) throw new Error("Zalo token not configured");
|
||||
await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token });
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => {
|
||||
if (!text) return [];
|
||||
if (limit <= 0 || text.length <= limit) return [text];
|
||||
const chunks: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > limit) {
|
||||
const window = remaining.slice(0, limit);
|
||||
const lastNewline = window.lastIndexOf("\n");
|
||||
const lastSpace = window.lastIndexOf(" ");
|
||||
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
|
||||
if (breakIdx <= 0) breakIdx = limit;
|
||||
const rawChunk = remaining.slice(0, breakIdx);
|
||||
const chunk = rawChunk.trimEnd();
|
||||
if (chunk.length > 0) chunks.push(chunk);
|
||||
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
|
||||
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
|
||||
remaining = remaining.slice(nextStart).trimStart();
|
||||
}
|
||||
if (remaining.length) chunks.push(remaining);
|
||||
return chunks;
|
||||
},
|
||||
textChunkLimit: 2000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error("Delivering to Zalo requires --to <chatId>"),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
sendText: async ({ to, text, accountId, cfg }) => {
|
||||
const result = await sendMessageZalo(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
cfg: cfg as CoreConfig,
|
||||
});
|
||||
return {
|
||||
channel: "zalo",
|
||||
ok: result.ok,
|
||||
messageId: result.messageId ?? "",
|
||||
error: result.error ? new Error(result.error) : undefined,
|
||||
};
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
|
||||
const result = await sendMessageZalo(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl,
|
||||
cfg: cfg as CoreConfig,
|
||||
});
|
||||
return {
|
||||
channel: "zalo",
|
||||
ok: result.ok,
|
||||
messageId: result.messageId ?? "",
|
||||
error: result.error ? new Error(result.error) : undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: collectZaloStatusIssues,
|
||||
buildChannelSummary: ({ snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
tokenSource: snapshot.tokenSource ?? "none",
|
||||
running: snapshot.running ?? false,
|
||||
mode: snapshot.mode ?? null,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
|
||||
buildAccountSnapshot: ({ account, runtime }) => {
|
||||
const configured = Boolean(account.token?.trim());
|
||||
return {
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured,
|
||||
tokenSource: account.tokenSource,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
mode: account.config.webhookUrl ? "webhook" : "polling",
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const token = account.token.trim();
|
||||
let zaloBotLabel = "";
|
||||
const fetcher = resolveZaloProxyFetch(account.config.proxy);
|
||||
try {
|
||||
const probe = await probeZalo(token, 2500, fetcher);
|
||||
const name = probe.ok ? probe.bot?.name?.trim() : null;
|
||||
if (name) zaloBotLabel = ` (${name})`;
|
||||
ctx.setStatus({
|
||||
accountId: account.accountId,
|
||||
bot: probe.bot,
|
||||
});
|
||||
} catch {
|
||||
// ignore probe errors
|
||||
}
|
||||
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`);
|
||||
const { monitorZaloProvider } = await import("./monitor.js");
|
||||
return monitorZaloProvider({
|
||||
token,
|
||||
account,
|
||||
config: ctx.cfg as CoreConfig,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
useWebhook: Boolean(account.config.webhookUrl),
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
webhookSecret: account.config.webhookSecret,
|
||||
webhookPath: account.config.webhookPath,
|
||||
fetcher,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
171
extensions/zalo/src/core-bridge.ts
Normal file
171
extensions/zalo/src/core-bridge.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
export type CoreChannelDeps = {
|
||||
chunkMarkdownText: (text: string, limit: number) => string[];
|
||||
formatAgentEnvelope: (params: {
|
||||
channel: string;
|
||||
from: string;
|
||||
timestamp?: number;
|
||||
body: string;
|
||||
}) => string;
|
||||
dispatchReplyWithBufferedBlockDispatcher: (params: {
|
||||
ctx: unknown;
|
||||
cfg: unknown;
|
||||
dispatcherOptions: {
|
||||
deliver: (payload: unknown) => Promise<void>;
|
||||
onError?: (err: unknown, info: { kind: string }) => void;
|
||||
};
|
||||
}) => Promise<void>;
|
||||
resolveAgentRoute: (params: {
|
||||
cfg: unknown;
|
||||
channel: string;
|
||||
accountId: string;
|
||||
peer: { kind: "dm" | "group" | "channel"; id: string };
|
||||
}) => { sessionKey: string; accountId: string };
|
||||
buildPairingReply: (params: { channel: string; idLine: string; code: string }) => string;
|
||||
readChannelAllowFromStore: (channel: string) => Promise<string[]>;
|
||||
upsertChannelPairingRequest: (params: {
|
||||
channel: string;
|
||||
id: string;
|
||||
meta?: { name?: string };
|
||||
}) => Promise<{ code: string; created: boolean }>;
|
||||
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
|
||||
saveMediaBuffer: (
|
||||
buffer: Buffer,
|
||||
contentType: string | undefined,
|
||||
type: "inbound" | "outbound",
|
||||
maxBytes: number,
|
||||
) => Promise<{ path: string; contentType: string }>;
|
||||
shouldLogVerbose: () => boolean;
|
||||
};
|
||||
|
||||
let coreRootCache: string | null = null;
|
||||
let coreDepsPromise: Promise<CoreChannelDeps> | null = null;
|
||||
|
||||
function findPackageRoot(startDir: string, name: string): string | null {
|
||||
let dir = startDir;
|
||||
for (;;) {
|
||||
const pkgPath = path.join(dir, "package.json");
|
||||
try {
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const raw = fs.readFileSync(pkgPath, "utf8");
|
||||
const pkg = JSON.parse(raw) as { name?: string };
|
||||
if (pkg.name === name) return dir;
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) return null;
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClawdbotRoot(): string {
|
||||
if (coreRootCache) return coreRootCache;
|
||||
const override = process.env.CLAWDBOT_ROOT?.trim();
|
||||
if (override) {
|
||||
coreRootCache = override;
|
||||
return override;
|
||||
}
|
||||
|
||||
const candidates = new Set<string>();
|
||||
if (process.argv[1]) {
|
||||
candidates.add(path.dirname(process.argv[1]));
|
||||
}
|
||||
candidates.add(process.cwd());
|
||||
try {
|
||||
const urlPath = fileURLToPath(import.meta.url);
|
||||
candidates.add(path.dirname(urlPath));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
for (const start of candidates) {
|
||||
const found = findPackageRoot(start, "clawdbot");
|
||||
if (found) {
|
||||
coreRootCache = found;
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.",
|
||||
);
|
||||
}
|
||||
|
||||
async function importCoreModule<T>(relativePath: string): Promise<T> {
|
||||
const root = resolveClawdbotRoot();
|
||||
const distPath = path.join(root, "dist", relativePath);
|
||||
if (!fs.existsSync(distPath)) {
|
||||
throw new Error(
|
||||
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
|
||||
);
|
||||
}
|
||||
return (await import(pathToFileURL(distPath).href)) as T;
|
||||
}
|
||||
|
||||
export async function loadCoreChannelDeps(): Promise<CoreChannelDeps> {
|
||||
if (coreDepsPromise) return coreDepsPromise;
|
||||
|
||||
coreDepsPromise = (async () => {
|
||||
const [
|
||||
chunk,
|
||||
envelope,
|
||||
dispatcher,
|
||||
routing,
|
||||
pairingMessages,
|
||||
pairingStore,
|
||||
mediaFetch,
|
||||
mediaStore,
|
||||
globals,
|
||||
] = await Promise.all([
|
||||
importCoreModule<{ chunkMarkdownText: CoreChannelDeps["chunkMarkdownText"] }>(
|
||||
"auto-reply/chunk.js",
|
||||
),
|
||||
importCoreModule<{ formatAgentEnvelope: CoreChannelDeps["formatAgentEnvelope"] }>(
|
||||
"auto-reply/envelope.js",
|
||||
),
|
||||
importCoreModule<{
|
||||
dispatchReplyWithBufferedBlockDispatcher: CoreChannelDeps["dispatchReplyWithBufferedBlockDispatcher"];
|
||||
}>("auto-reply/reply/provider-dispatcher.js"),
|
||||
importCoreModule<{ resolveAgentRoute: CoreChannelDeps["resolveAgentRoute"] }>(
|
||||
"routing/resolve-route.js",
|
||||
),
|
||||
importCoreModule<{ buildPairingReply: CoreChannelDeps["buildPairingReply"] }>(
|
||||
"pairing/pairing-messages.js",
|
||||
),
|
||||
importCoreModule<{
|
||||
readChannelAllowFromStore: CoreChannelDeps["readChannelAllowFromStore"];
|
||||
upsertChannelPairingRequest: CoreChannelDeps["upsertChannelPairingRequest"];
|
||||
}>("pairing/pairing-store.js"),
|
||||
importCoreModule<{ fetchRemoteMedia: CoreChannelDeps["fetchRemoteMedia"] }>(
|
||||
"media/fetch.js",
|
||||
),
|
||||
importCoreModule<{ saveMediaBuffer: CoreChannelDeps["saveMediaBuffer"] }>(
|
||||
"media/store.js",
|
||||
),
|
||||
importCoreModule<{ shouldLogVerbose: CoreChannelDeps["shouldLogVerbose"] }>(
|
||||
"globals.js",
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
chunkMarkdownText: chunk.chunkMarkdownText,
|
||||
formatAgentEnvelope: envelope.formatAgentEnvelope,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
dispatcher.dispatchReplyWithBufferedBlockDispatcher,
|
||||
resolveAgentRoute: routing.resolveAgentRoute,
|
||||
buildPairingReply: pairingMessages.buildPairingReply,
|
||||
readChannelAllowFromStore: pairingStore.readChannelAllowFromStore,
|
||||
upsertChannelPairingRequest: pairingStore.upsertChannelPairingRequest,
|
||||
fetchRemoteMedia: mediaFetch.fetchRemoteMedia,
|
||||
saveMediaBuffer: mediaStore.saveMediaBuffer,
|
||||
shouldLogVerbose: globals.shouldLogVerbose,
|
||||
};
|
||||
})();
|
||||
|
||||
return coreDepsPromise;
|
||||
}
|
||||
679
extensions/zalo/src/monitor.ts
Normal file
679
extensions/zalo/src/monitor.ts
Normal file
@@ -0,0 +1,679 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
import {
|
||||
ZaloApiError,
|
||||
deleteWebhook,
|
||||
getUpdates,
|
||||
sendMessage,
|
||||
sendPhoto,
|
||||
setWebhook,
|
||||
type ZaloFetch,
|
||||
type ZaloMessage,
|
||||
type ZaloUpdate,
|
||||
} from "./api.js";
|
||||
import { loadCoreChannelDeps } from "./core-bridge.js";
|
||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
export type ZaloRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
|
||||
export type ZaloMonitorOptions = {
|
||||
token: string;
|
||||
account: ResolvedZaloAccount;
|
||||
config: CoreConfig;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
useWebhook?: boolean;
|
||||
webhookUrl?: string;
|
||||
webhookSecret?: string;
|
||||
webhookPath?: string;
|
||||
fetcher?: ZaloFetch;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
};
|
||||
|
||||
export type ZaloMonitorResult = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
|
||||
if (allowFrom.includes("*")) return true;
|
||||
const normalizedSenderId = senderId.toLowerCase();
|
||||
return allowFrom.some((entry) => {
|
||||
const normalized = entry.toLowerCase().replace(/^(zalo|zl):/i, "");
|
||||
return normalized === normalizedSenderId;
|
||||
});
|
||||
}
|
||||
|
||||
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
resolve({ ok: false, error: "payload too large" });
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if (!raw.trim()) {
|
||||
resolve({ ok: false, error: "empty payload" });
|
||||
return;
|
||||
}
|
||||
resolve({ ok: true, value: JSON.parse(raw) as unknown });
|
||||
} catch (err) {
|
||||
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
type WebhookTarget = {
|
||||
token: string;
|
||||
account: ResolvedZaloAccount;
|
||||
config: CoreConfig;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
||||
secret: string;
|
||||
path: string;
|
||||
mediaMaxMb: number;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
};
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
|
||||
function normalizeWebhookPath(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return "/";
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
if (withSlash.length > 1 && withSlash.endsWith("/")) {
|
||||
return withSlash.slice(0, -1);
|
||||
}
|
||||
return withSlash;
|
||||
}
|
||||
|
||||
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
|
||||
const trimmedPath = webhookPath?.trim();
|
||||
if (trimmedPath) return normalizeWebhookPath(trimmedPath);
|
||||
if (webhookUrl?.trim()) {
|
||||
try {
|
||||
const parsed = new URL(webhookUrl);
|
||||
return normalizeWebhookPath(parsed.pathname || "/");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
|
||||
const key = normalizeWebhookPath(target.path);
|
||||
const normalizedTarget = { ...target, path: key };
|
||||
const existing = webhookTargets.get(key) ?? [];
|
||||
const next = [...existing, normalizedTarget];
|
||||
webhookTargets.set(key, next);
|
||||
return () => {
|
||||
const updated = (webhookTargets.get(key) ?? []).filter(
|
||||
(entry) => entry !== normalizedTarget,
|
||||
);
|
||||
if (updated.length > 0) {
|
||||
webhookTargets.set(key, updated);
|
||||
} else {
|
||||
webhookTargets.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleZaloWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const path = normalizeWebhookPath(url.pathname);
|
||||
const targets = webhookTargets.get(path);
|
||||
if (!targets || targets.length === 0) return false;
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "POST");
|
||||
res.end("Method Not Allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
|
||||
const target = targets.find((entry) => entry.secret === headerToken);
|
||||
if (!target) {
|
||||
res.statusCode = 401;
|
||||
res.end("unauthorized");
|
||||
return true;
|
||||
}
|
||||
|
||||
const body = await readJsonBody(req, 1024 * 1024);
|
||||
if (!body.ok) {
|
||||
res.statusCode = body.error === "payload too large" ? 413 : 400;
|
||||
res.end(body.error ?? "invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
const payload = body.value as { ok?: boolean; result?: ZaloUpdate };
|
||||
if (!payload?.ok || !payload.result) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
target.statusSink?.({ lastInboundAt: Date.now() });
|
||||
processUpdate(
|
||||
payload.result,
|
||||
target.token,
|
||||
target.account,
|
||||
target.config,
|
||||
target.runtime,
|
||||
target.deps,
|
||||
target.mediaMaxMb,
|
||||
target.statusSink,
|
||||
target.fetcher,
|
||||
).catch((err) => {
|
||||
target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
res.statusCode = 200;
|
||||
res.end("ok");
|
||||
return true;
|
||||
}
|
||||
|
||||
function startPollingLoop(params: {
|
||||
token: string;
|
||||
account: ResolvedZaloAccount;
|
||||
config: CoreConfig;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
||||
abortSignal: AbortSignal;
|
||||
isStopped: () => boolean;
|
||||
mediaMaxMb: number;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
}) {
|
||||
const {
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
abortSignal,
|
||||
isStopped,
|
||||
mediaMaxMb,
|
||||
statusSink,
|
||||
fetcher,
|
||||
} = params;
|
||||
const pollTimeout = 30;
|
||||
|
||||
const poll = async () => {
|
||||
if (isStopped() || abortSignal.aborted) return;
|
||||
|
||||
try {
|
||||
const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
|
||||
if (response.ok && response.result) {
|
||||
statusSink?.({ lastInboundAt: Date.now() });
|
||||
await processUpdate(
|
||||
response.result,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
mediaMaxMb,
|
||||
statusSink,
|
||||
fetcher,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof ZaloApiError && err.isPollingTimeout) {
|
||||
// no updates
|
||||
} else if (!isStopped() && !abortSignal.aborted) {
|
||||
console.error(`[${account.accountId}] Zalo polling error:`, err);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
|
||||
if (!isStopped() && !abortSignal.aborted) {
|
||||
setImmediate(poll);
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
}
|
||||
|
||||
async function processUpdate(
|
||||
update: ZaloUpdate,
|
||||
token: string,
|
||||
account: ResolvedZaloAccount,
|
||||
config: CoreConfig,
|
||||
runtime: ZaloRuntimeEnv,
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
|
||||
mediaMaxMb: number,
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<void> {
|
||||
const { event_name, message } = update;
|
||||
if (!message) return;
|
||||
|
||||
switch (event_name) {
|
||||
case "message.text.received":
|
||||
await handleTextMessage(
|
||||
message,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
statusSink,
|
||||
fetcher,
|
||||
);
|
||||
break;
|
||||
case "message.image.received":
|
||||
await handleImageMessage(
|
||||
message,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
mediaMaxMb,
|
||||
statusSink,
|
||||
fetcher,
|
||||
);
|
||||
break;
|
||||
case "message.sticker.received":
|
||||
console.log(`[${account.accountId}] Received sticker from ${message.from.id}`);
|
||||
break;
|
||||
case "message.unsupported.received":
|
||||
console.log(
|
||||
`[${account.accountId}] Received unsupported message type from ${message.from.id}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTextMessage(
|
||||
message: ZaloMessage,
|
||||
token: string,
|
||||
account: ResolvedZaloAccount,
|
||||
config: CoreConfig,
|
||||
runtime: ZaloRuntimeEnv,
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<void> {
|
||||
const { text } = message;
|
||||
if (!text?.trim()) return;
|
||||
|
||||
await processMessageWithPipeline({
|
||||
message,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
text,
|
||||
mediaPath: undefined,
|
||||
mediaType: undefined,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleImageMessage(
|
||||
message: ZaloMessage,
|
||||
token: string,
|
||||
account: ResolvedZaloAccount,
|
||||
config: CoreConfig,
|
||||
runtime: ZaloRuntimeEnv,
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
|
||||
mediaMaxMb: number,
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<void> {
|
||||
const { photo, caption } = message;
|
||||
|
||||
let mediaPath: string | undefined;
|
||||
let mediaType: string | undefined;
|
||||
|
||||
if (photo) {
|
||||
try {
|
||||
const maxBytes = mediaMaxMb * 1024 * 1024;
|
||||
const fetched = await deps.fetchRemoteMedia({ url: photo });
|
||||
const saved = await deps.saveMediaBuffer(
|
||||
fetched.buffer,
|
||||
fetched.contentType,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
);
|
||||
mediaPath = saved.path;
|
||||
mediaType = saved.contentType;
|
||||
} catch (err) {
|
||||
console.error(`[${account.accountId}] Failed to download Zalo image:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
await processMessageWithPipeline({
|
||||
message,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
text: caption,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
async function processMessageWithPipeline(params: {
|
||||
message: ZaloMessage;
|
||||
token: string;
|
||||
account: ResolvedZaloAccount;
|
||||
config: CoreConfig;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
||||
text?: string;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
message,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
text,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
statusSink,
|
||||
fetcher,
|
||||
} = params;
|
||||
const { from, chat, message_id, date } = message;
|
||||
|
||||
const isGroup = chat.chat_type === "GROUP";
|
||||
const chatId = chat.id;
|
||||
const senderId = from.id;
|
||||
const senderName = from.name;
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose(deps, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const storeAllowFrom = await deps.readChannelAllowFromStore("zalo").catch(() => []);
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await deps.upsertChannelPairingRequest({
|
||||
channel: "zalo",
|
||||
id: senderId,
|
||||
meta: { name: senderName ?? undefined },
|
||||
});
|
||||
|
||||
if (created) {
|
||||
logVerbose(deps, `zalo pairing request sender=${senderId}`);
|
||||
try {
|
||||
await sendMessage(
|
||||
token,
|
||||
{
|
||||
chat_id: chatId,
|
||||
text: deps.buildPairingReply({
|
||||
channel: "zalo",
|
||||
idLine: `Your Zalo user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerbose(deps, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(deps, `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const route = deps.resolveAgentRoute({
|
||||
cfg: config,
|
||||
channel: "zalo",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: chatId,
|
||||
},
|
||||
});
|
||||
|
||||
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
||||
const fromLabel = isGroup
|
||||
? `group:${chatId} from ${senderName || senderId}`
|
||||
: senderName || `user:${senderId}`;
|
||||
const body = deps.formatAgentEnvelope({
|
||||
channel: "Zalo",
|
||||
from: fromLabel,
|
||||
timestamp: date ? date * 1000 : undefined,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const ctxPayload = {
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: isGroup ? `group:${chatId}` : `zalo:${senderId}`,
|
||||
To: `zalo:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
Provider: "zalo",
|
||||
Surface: "zalo",
|
||||
MessageSid: message_id,
|
||||
MediaPath: mediaPath,
|
||||
MediaType: mediaType,
|
||||
MediaUrl: mediaPath,
|
||||
OriginatingChannel: "zalo",
|
||||
OriginatingTo: `zalo:${chatId}`,
|
||||
};
|
||||
|
||||
await deps.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
await deliverZaloReply({
|
||||
payload,
|
||||
token,
|
||||
chatId,
|
||||
runtime,
|
||||
deps,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function deliverZaloReply(params: {
|
||||
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
|
||||
token: string;
|
||||
chatId: string;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
}): Promise<void> {
|
||||
const { payload, token, chatId, runtime, deps, statusSink, fetcher } = params;
|
||||
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
|
||||
if (mediaList.length > 0) {
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? payload.text : undefined;
|
||||
first = false;
|
||||
try {
|
||||
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`Zalo photo send failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload.text) {
|
||||
const chunks = deps.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
|
||||
for (const chunk of chunks) {
|
||||
try {
|
||||
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
runtime.error?.(`Zalo message send failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function monitorZaloProvider(
|
||||
options: ZaloMonitorOptions,
|
||||
): Promise<ZaloMonitorResult> {
|
||||
const {
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
abortSignal,
|
||||
useWebhook,
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
webhookPath,
|
||||
statusSink,
|
||||
fetcher: fetcherOverride,
|
||||
} = options;
|
||||
|
||||
const deps = await loadCoreChannelDeps();
|
||||
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
|
||||
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
|
||||
|
||||
let stopped = false;
|
||||
const stopHandlers: Array<() => void> = [];
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
for (const handler of stopHandlers) {
|
||||
handler();
|
||||
}
|
||||
};
|
||||
|
||||
if (useWebhook) {
|
||||
if (!webhookUrl || !webhookSecret) {
|
||||
throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
|
||||
}
|
||||
if (!webhookUrl.startsWith("https://")) {
|
||||
throw new Error("Zalo webhook URL must use HTTPS");
|
||||
}
|
||||
if (webhookSecret.length < 8 || webhookSecret.length > 256) {
|
||||
throw new Error("Zalo webhook secret must be 8-256 characters");
|
||||
}
|
||||
|
||||
const path = resolveWebhookPath(webhookPath, webhookUrl);
|
||||
if (!path) {
|
||||
throw new Error("Zalo webhookPath could not be derived");
|
||||
}
|
||||
|
||||
await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
|
||||
|
||||
const unregister = registerZaloWebhookTarget({
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
path,
|
||||
secret: webhookSecret,
|
||||
statusSink: (patch) => statusSink?.(patch),
|
||||
mediaMaxMb: effectiveMediaMaxMb,
|
||||
fetcher,
|
||||
});
|
||||
stopHandlers.push(unregister);
|
||||
abortSignal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
void deleteWebhook(token, fetcher).catch(() => {});
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
return { stop };
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteWebhook(token, fetcher);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
startPollingLoop({
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
deps,
|
||||
abortSignal,
|
||||
isStopped: () => stopped,
|
||||
mediaMaxMb: effectiveMediaMaxMb,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
|
||||
return { stop };
|
||||
}
|
||||
384
extensions/zalo/src/onboarding.ts
Normal file
384
extensions/zalo/src/onboarding.ts
Normal file
@@ -0,0 +1,384 @@
|
||||
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../../src/channels/plugins/onboarding-types.js";
|
||||
import type { WizardPrompter } from "../../src/wizard/prompts.js";
|
||||
|
||||
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") {
|
||||
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.zalo?.allowFrom) : undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
function setZaloUpdateMode(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
mode: UpdateMode,
|
||||
webhookUrl?: string,
|
||||
webhookSecret?: string,
|
||||
webhookPath?: string,
|
||||
): CoreConfig {
|
||||
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
|
||||
if (mode === "polling") {
|
||||
if (isDefault) {
|
||||
const {
|
||||
webhookUrl: _url,
|
||||
webhookSecret: _secret,
|
||||
webhookPath: _path,
|
||||
...rest
|
||||
} = cfg.channels?.zalo ?? {};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: rest,
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
const existing = accounts[accountId] ?? {};
|
||||
const {
|
||||
webhookUrl: _url,
|
||||
webhookSecret: _secret,
|
||||
webhookPath: _path,
|
||||
...rest
|
||||
} = existing;
|
||||
accounts[accountId] = rest;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
accounts,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
if (isDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
webhookPath,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
accounts[accountId] = {
|
||||
...(accounts[accountId] ?? {}),
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
webhookPath,
|
||||
};
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
accounts,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
async function noteZaloTokenHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Open Zalo Bot Platform: https://bot.zaloplatforms.com",
|
||||
"2) Create a bot and get the token",
|
||||
"3) Token looks like 12345689:abc-xyz",
|
||||
"Tip: you can also set ZALO_BOT_TOKEN in your env.",
|
||||
"Docs: https://docs.clawd.bot/channels/zalo",
|
||||
].join("\n"),
|
||||
"Zalo bot token",
|
||||
);
|
||||
}
|
||||
|
||||
async function promptZaloAllowFrom(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<CoreConfig> {
|
||||
const { cfg, prompter, accountId } = params;
|
||||
const resolved = resolveZaloAccount({ cfg, accountId });
|
||||
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
||||
const entry = await prompter.text({
|
||||
message: "Zalo allowFrom (user id)",
|
||||
placeholder: "123456789",
|
||||
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) return "Required";
|
||||
if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id";
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const normalized = String(entry).trim();
|
||||
const merged = [
|
||||
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
|
||||
normalized,
|
||||
];
|
||||
const unique = [...new Set(merged)];
|
||||
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
enabled: true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(cfg.channels?.zalo?.accounts ?? {}),
|
||||
[accountId]: {
|
||||
...(cfg.channels?.zalo?.accounts?.[accountId] ?? {}),
|
||||
enabled: cfg.channels?.zalo?.accounts?.[accountId]?.enabled ?? true,
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: unique,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Zalo",
|
||||
channel,
|
||||
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),
|
||||
};
|
||||
|
||||
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),
|
||||
);
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Zalo: ${configured ? "configured" : "needs token"}`],
|
||||
selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly",
|
||||
quickstartScore: configured ? 1 : 10,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds, forceAllowFrom }) => {
|
||||
const zaloOverride = accountOverrides.zalo?.trim();
|
||||
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg as CoreConfig);
|
||||
let zaloAccountId = zaloOverride
|
||||
? normalizeAccountId(zaloOverride)
|
||||
: defaultZaloAccountId;
|
||||
if (shouldPromptAccountIds && !zaloOverride) {
|
||||
zaloAccountId = await promptAccountId({
|
||||
cfg: cfg as CoreConfig,
|
||||
prompter,
|
||||
label: "Zalo",
|
||||
currentId: zaloAccountId,
|
||||
listAccountIds: listZaloAccountIds,
|
||||
defaultAccountId: defaultZaloAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg as CoreConfig;
|
||||
const resolvedAccount = resolveZaloAccount({ cfg: next, accountId: zaloAccountId });
|
||||
const accountConfigured = Boolean(resolvedAccount.token);
|
||||
const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const canUseEnv = allowEnv && Boolean(process.env.ZALO_BOT_TOKEN?.trim());
|
||||
const hasConfigToken = Boolean(
|
||||
resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
|
||||
);
|
||||
|
||||
let token: string | null = null;
|
||||
if (!accountConfigured) {
|
||||
await noteZaloTokenHelp(prompter);
|
||||
}
|
||||
if (canUseEnv && !resolvedAccount.config.botToken) {
|
||||
const keepEnv = await prompter.confirm({
|
||||
message: "ZALO_BOT_TOKEN detected. Use env var?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (keepEnv) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
} else {
|
||||
token = String(
|
||||
await prompter.text({
|
||||
message: "Enter Zalo bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else if (hasConfigToken) {
|
||||
const keep = await prompter.confirm({
|
||||
message: "Zalo token already configured. Keep it?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keep) {
|
||||
token = String(
|
||||
await prompter.text({
|
||||
message: "Enter Zalo bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
} else {
|
||||
token = String(
|
||||
await prompter.text({
|
||||
message: "Enter Zalo bot token",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
}
|
||||
|
||||
if (token) {
|
||||
if (zaloAccountId === DEFAULT_ACCOUNT_ID) {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
botToken: token,
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
} else {
|
||||
next = {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
zalo: {
|
||||
...next.channels?.zalo,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...(next.channels?.zalo?.accounts ?? {}),
|
||||
[zaloAccountId]: {
|
||||
...(next.channels?.zalo?.accounts?.[zaloAccountId] ?? {}),
|
||||
enabled: true,
|
||||
botToken: token,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig;
|
||||
}
|
||||
}
|
||||
|
||||
const wantsWebhook = await prompter.confirm({
|
||||
message: "Use webhook mode for Zalo?",
|
||||
initialValue: false,
|
||||
});
|
||||
if (wantsWebhook) {
|
||||
const webhookUrl = String(
|
||||
await prompter.text({
|
||||
message: "Webhook URL (https://...) ",
|
||||
validate: (value) => (value?.trim()?.startsWith("https://") ? undefined : "HTTPS URL required"),
|
||||
}),
|
||||
).trim();
|
||||
const defaultPath = (() => {
|
||||
try {
|
||||
return new URL(webhookUrl).pathname || "/zalo-webhook";
|
||||
} catch {
|
||||
return "/zalo-webhook";
|
||||
}
|
||||
})();
|
||||
const webhookSecret = String(
|
||||
await prompter.text({
|
||||
message: "Webhook secret (8-256 chars)",
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "");
|
||||
if (raw.length < 8 || raw.length > 256) return "8-256 chars";
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
).trim();
|
||||
const webhookPath = String(
|
||||
await prompter.text({
|
||||
message: "Webhook path (optional)",
|
||||
initialValue: defaultPath,
|
||||
}),
|
||||
).trim();
|
||||
next = setZaloUpdateMode(
|
||||
next,
|
||||
zaloAccountId,
|
||||
"webhook",
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
webhookPath || undefined,
|
||||
);
|
||||
} else {
|
||||
next = setZaloUpdateMode(next, zaloAccountId, "polling");
|
||||
}
|
||||
|
||||
if (forceAllowFrom) {
|
||||
next = await promptZaloAllowFrom({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId: zaloAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
return { cfg: next, accountId: zaloAccountId };
|
||||
},
|
||||
};
|
||||
46
extensions/zalo/src/probe.ts
Normal file
46
extensions/zalo/src/probe.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js";
|
||||
|
||||
export type ZaloProbeResult = {
|
||||
ok: boolean;
|
||||
bot?: ZaloBotInfo;
|
||||
error?: string;
|
||||
elapsedMs: number;
|
||||
};
|
||||
|
||||
export async function probeZalo(
|
||||
token: string,
|
||||
timeoutMs = 5000,
|
||||
fetcher?: ZaloFetch,
|
||||
): Promise<ZaloProbeResult> {
|
||||
if (!token?.trim()) {
|
||||
return { ok: false, error: "No token provided", elapsedMs: 0 };
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await getMe(token.trim(), timeoutMs, fetcher);
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
|
||||
if (response.ok && response.result) {
|
||||
return { ok: true, bot: response.result, elapsedMs };
|
||||
}
|
||||
|
||||
return { ok: false, error: "Invalid response from Zalo API", elapsedMs };
|
||||
} catch (err) {
|
||||
const elapsedMs = Date.now() - startTime;
|
||||
|
||||
if (err instanceof ZaloApiError) {
|
||||
return { ok: false, error: err.description ?? err.message, elapsedMs };
|
||||
}
|
||||
|
||||
if (err instanceof Error) {
|
||||
if (err.name === "AbortError") {
|
||||
return { ok: false, error: `Request timed out after ${timeoutMs}ms`, elapsedMs };
|
||||
}
|
||||
return { ok: false, error: err.message, elapsedMs };
|
||||
}
|
||||
|
||||
return { ok: false, error: String(err), elapsedMs };
|
||||
}
|
||||
}
|
||||
18
extensions/zalo/src/proxy.ts
Normal file
18
extensions/zalo/src/proxy.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ProxyAgent, fetch as undiciFetch } from "undici";
|
||||
import type { Dispatcher } from "undici";
|
||||
|
||||
import type { ZaloFetch } from "./api.js";
|
||||
|
||||
const proxyCache = new Map<string, ZaloFetch>();
|
||||
|
||||
export function resolveZaloProxyFetch(proxyUrl?: string | null): ZaloFetch | undefined {
|
||||
const trimmed = proxyUrl?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const cached = proxyCache.get(trimmed);
|
||||
if (cached) return cached;
|
||||
const agent = new ProxyAgent(trimmed);
|
||||
const fetcher: ZaloFetch = (input, init) =>
|
||||
undiciFetch(input, { ...(init ?? {}), dispatcher: agent as Dispatcher });
|
||||
proxyCache.set(trimmed, fetcher);
|
||||
return fetcher;
|
||||
}
|
||||
116
extensions/zalo/src/send.ts
Normal file
116
extensions/zalo/src/send.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import type { ZaloFetch } from "./api.js";
|
||||
import { sendMessage, sendPhoto } from "./api.js";
|
||||
import { resolveZaloAccount } from "./accounts.js";
|
||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||
import { resolveZaloToken } from "./token.js";
|
||||
|
||||
export type ZaloSendOptions = {
|
||||
token?: string;
|
||||
accountId?: string;
|
||||
cfg?: CoreConfig;
|
||||
mediaUrl?: string;
|
||||
caption?: string;
|
||||
verbose?: boolean;
|
||||
proxy?: string;
|
||||
};
|
||||
|
||||
export type ZaloSendResult = {
|
||||
ok: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function resolveSendContext(options: ZaloSendOptions): {
|
||||
token: string;
|
||||
fetcher?: ZaloFetch;
|
||||
} {
|
||||
if (options.cfg) {
|
||||
const account = resolveZaloAccount({
|
||||
cfg: options.cfg,
|
||||
accountId: options.accountId,
|
||||
});
|
||||
const token = options.token || account.token;
|
||||
const proxy = options.proxy ?? account.config.proxy;
|
||||
return { token, fetcher: resolveZaloProxyFetch(proxy) };
|
||||
}
|
||||
|
||||
const token = options.token ?? resolveZaloToken(undefined, options.accountId).token;
|
||||
const proxy = options.proxy;
|
||||
return { token: token || process.env.ZALO_BOT_TOKEN?.trim() || "", fetcher: resolveZaloProxyFetch(proxy) };
|
||||
}
|
||||
|
||||
export async function sendMessageZalo(
|
||||
chatId: string,
|
||||
text: string,
|
||||
options: ZaloSendOptions = {},
|
||||
): Promise<ZaloSendResult> {
|
||||
const { token, fetcher } = resolveSendContext(options);
|
||||
|
||||
if (!token) {
|
||||
return { ok: false, error: "No Zalo bot token configured" };
|
||||
}
|
||||
|
||||
if (!chatId?.trim()) {
|
||||
return { ok: false, error: "No chat_id provided" };
|
||||
}
|
||||
|
||||
if (options.mediaUrl) {
|
||||
return sendPhotoZalo(chatId, options.mediaUrl, {
|
||||
...options,
|
||||
token,
|
||||
caption: text || options.caption,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendMessage(token, {
|
||||
chat_id: chatId.trim(),
|
||||
text: text.slice(0, 2000),
|
||||
}, fetcher);
|
||||
|
||||
if (response.ok && response.result) {
|
||||
return { ok: true, messageId: response.result.message_id };
|
||||
}
|
||||
|
||||
return { ok: false, error: "Failed to send message" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPhotoZalo(
|
||||
chatId: string,
|
||||
photoUrl: string,
|
||||
options: ZaloSendOptions = {},
|
||||
): Promise<ZaloSendResult> {
|
||||
const { token, fetcher } = resolveSendContext(options);
|
||||
|
||||
if (!token) {
|
||||
return { ok: false, error: "No Zalo bot token configured" };
|
||||
}
|
||||
|
||||
if (!chatId?.trim()) {
|
||||
return { ok: false, error: "No chat_id provided" };
|
||||
}
|
||||
|
||||
if (!photoUrl?.trim()) {
|
||||
return { ok: false, error: "No photo URL provided" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendPhoto(token, {
|
||||
chat_id: chatId.trim(),
|
||||
photo: photoUrl.trim(),
|
||||
caption: options.caption?.slice(0, 2000),
|
||||
}, fetcher);
|
||||
|
||||
if (response.ok && response.result) {
|
||||
return { ok: true, messageId: response.result.message_id };
|
||||
}
|
||||
|
||||
return { ok: false, error: "Failed to send photo" };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
15
extensions/zalo/src/shared/account-ids.ts
Normal file
15
extensions/zalo/src/shared/account-ids.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
112
extensions/zalo/src/shared/channel-config.ts
Normal file
112
extensions/zalo/src/shared/channel-config.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
}
|
||||
114
extensions/zalo/src/shared/channel-setup.ts
Normal file
114
extensions/zalo/src/shared/channel-setup.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
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;
|
||||
}
|
||||
53
extensions/zalo/src/shared/onboarding.ts
Normal file
53
extensions/zalo/src/shared/onboarding.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
|
||||
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;
|
||||
}
|
||||
6
extensions/zalo/src/shared/pairing.ts
Normal file
6
extensions/zalo/src/shared/pairing.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
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>`;
|
||||
}
|
||||
50
extensions/zalo/src/status-issues.ts
Normal file
50
extensions/zalo/src/status-issues.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../../src/channels/plugins/types.js";
|
||||
|
||||
type ZaloAccountStatus = {
|
||||
accountId?: unknown;
|
||||
enabled?: unknown;
|
||||
configured?: unknown;
|
||||
dmPolicy?: unknown;
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === "object");
|
||||
|
||||
const asString = (value: unknown): string | undefined =>
|
||||
typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
|
||||
|
||||
function readZaloAccountStatus(value: ChannelAccountSnapshot): ZaloAccountStatus | null {
|
||||
if (!isRecord(value)) return null;
|
||||
return {
|
||||
accountId: value.accountId,
|
||||
enabled: value.enabled,
|
||||
configured: value.configured,
|
||||
dmPolicy: value.dmPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectZaloStatusIssues(
|
||||
accounts: ChannelAccountSnapshot[],
|
||||
): ChannelStatusIssue[] {
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
for (const entry of accounts) {
|
||||
const account = readZaloAccountStatus(entry);
|
||||
if (!account) continue;
|
||||
const accountId = asString(account.accountId) ?? "default";
|
||||
const enabled = account.enabled !== false;
|
||||
const configured = account.configured === true;
|
||||
if (!enabled || !configured) continue;
|
||||
|
||||
if (account.dmPolicy === "open") {
|
||||
issues.push({
|
||||
channel: "zalo",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message:
|
||||
'Zalo dmPolicy is "open", allowing any user to message the bot without pairing.',
|
||||
fix: 'Set channels.zalo.dmPolicy to "pairing" or "allowlist" to restrict access.',
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
54
extensions/zalo/src/token.ts
Normal file
54
extensions/zalo/src/token.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import type { ZaloConfig } from "./types.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "./shared/account-ids.js";
|
||||
|
||||
export type ZaloTokenResolution = {
|
||||
token: string;
|
||||
source: "env" | "config" | "configFile" | "none";
|
||||
};
|
||||
|
||||
export function resolveZaloToken(
|
||||
config: ZaloConfig | undefined,
|
||||
accountId?: string | null,
|
||||
): ZaloTokenResolution {
|
||||
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const isDefaultAccount = resolvedAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const baseConfig = config;
|
||||
const accountConfig =
|
||||
resolvedAccountId !== DEFAULT_ACCOUNT_ID
|
||||
? (baseConfig?.accounts?.[resolvedAccountId] as ZaloConfig | undefined)
|
||||
: undefined;
|
||||
|
||||
if (accountConfig) {
|
||||
const token = accountConfig.botToken?.trim();
|
||||
if (token) return { token, source: "config" };
|
||||
const tokenFile = accountConfig.tokenFile?.trim();
|
||||
if (tokenFile) {
|
||||
try {
|
||||
const fileToken = readFileSync(tokenFile, "utf8").trim();
|
||||
if (fileToken) return { token: fileToken, source: "configFile" };
|
||||
} catch {
|
||||
// ignore read failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDefaultAccount) {
|
||||
const token = baseConfig?.botToken?.trim();
|
||||
if (token) return { token, source: "config" };
|
||||
const tokenFile = baseConfig?.tokenFile?.trim();
|
||||
if (tokenFile) {
|
||||
try {
|
||||
const fileToken = readFileSync(tokenFile, "utf8").trim();
|
||||
if (fileToken) return { token: fileToken, source: "configFile" };
|
||||
} catch {
|
||||
// ignore read failures
|
||||
}
|
||||
}
|
||||
const envToken = process.env.ZALO_BOT_TOKEN?.trim();
|
||||
if (envToken) return { token: envToken, source: "env" };
|
||||
}
|
||||
|
||||
return { token: "", source: "none" };
|
||||
}
|
||||
30
extensions/zalo/src/tool-helpers.ts
Normal file
30
extensions/zalo/src/tool-helpers.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
49
extensions/zalo/src/types.ts
Normal file
49
extensions/zalo/src/types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export type ZaloAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
/** If false, do not start this Zalo account. Default: true. */
|
||||
enabled?: boolean;
|
||||
/** Bot token from Zalo Bot Creator. */
|
||||
botToken?: string;
|
||||
/** Path to file containing the bot token. */
|
||||
tokenFile?: string;
|
||||
/** Webhook URL for receiving updates (HTTPS required). */
|
||||
webhookUrl?: string;
|
||||
/** Webhook secret token (8-256 chars) for request verification. */
|
||||
webhookSecret?: string;
|
||||
/** Webhook path for the gateway HTTP server (defaults to webhook URL path). */
|
||||
webhookPath?: string;
|
||||
/** Direct message access policy (default: pairing). */
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
/** Allowlist for DM senders (Zalo user IDs). */
|
||||
allowFrom?: Array<string | number>;
|
||||
/** Max inbound media size in MB. */
|
||||
mediaMaxMb?: number;
|
||||
/** Proxy URL for API requests. */
|
||||
proxy?: string;
|
||||
};
|
||||
|
||||
export type ZaloConfig = {
|
||||
/** Optional per-account Zalo configuration (multi-account). */
|
||||
accounts?: Record<string, ZaloAccountConfig>;
|
||||
/** Default account ID when multiple accounts are configured. */
|
||||
defaultAccount?: string;
|
||||
} & ZaloAccountConfig;
|
||||
|
||||
export type ZaloTokenSource = "env" | "config" | "configFile" | "none";
|
||||
|
||||
export type ResolvedZaloAccount = {
|
||||
accountId: string;
|
||||
name?: string;
|
||||
enabled: boolean;
|
||||
token: string;
|
||||
tokenSource: ZaloTokenSource;
|
||||
config: ZaloAccountConfig;
|
||||
};
|
||||
|
||||
export type CoreConfig = {
|
||||
channels?: {
|
||||
zalo?: ZaloConfig;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
Reference in New Issue
Block a user