Add ez-assistant and kerberos service folders
This commit is contained in:
55
docker-compose/ez-assistant/extensions/zalo/CHANGELOG.md
Normal file
55
docker-compose/ez-assistant/extensions/zalo/CHANGELOG.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.1.23
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.21
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.17-1
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.17
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.16
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 2026.1.14
|
||||
|
||||
### Changes
|
||||
- Version alignment with core Moltbot release numbers.
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Features
|
||||
- Zalo Bot API channel plugin with token-based auth (env/config/file).
|
||||
- Direct message support (DMs only) with pairing/allowlist/open/disabled policies.
|
||||
- Polling and webhook delivery modes.
|
||||
- Text + image messaging with 2000-char chunking and media size caps.
|
||||
- Multi-account support with per-account config.
|
||||
50
docker-compose/ez-assistant/extensions/zalo/README.md
Normal file
50
docker-compose/ez-assistant/extensions/zalo/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# @clawdbot/zalo
|
||||
|
||||
Zalo channel plugin for Clawdbot (Bot API).
|
||||
|
||||
## Install (local checkout)
|
||||
|
||||
```bash
|
||||
clawdbot plugins install ./extensions/zalo
|
||||
```
|
||||
|
||||
## Install (npm)
|
||||
|
||||
```bash
|
||||
clawdbot plugins install @clawdbot/zalo
|
||||
```
|
||||
|
||||
Onboarding: select Zalo and confirm the install prompt to fetch the plugin automatically.
|
||||
|
||||
## Config
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing",
|
||||
proxy: "http://proxy.local:8080"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Webhook mode
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
zalo: {
|
||||
webhookUrl: "https://example.com/zalo-webhook",
|
||||
webhookSecret: "your-secret-8-plus-chars",
|
||||
webhookPath: "/zalo-webhook"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If `webhookPath` is omitted, the plugin uses the webhook URL path.
|
||||
|
||||
Restart the gateway after config changes.
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "zalo",
|
||||
"channels": [
|
||||
"zalo"
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
20
docker-compose/ez-assistant/extensions/zalo/index.ts
Normal file
20
docker-compose/ez-assistant/extensions/zalo/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { MoltbotPluginApi } from "clawdbot/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { zaloDock, zaloPlugin } from "./src/channel.js";
|
||||
import { handleZaloWebhookRequest } from "./src/monitor.js";
|
||||
import { setZaloRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "zalo",
|
||||
name: "Zalo",
|
||||
description: "Zalo channel plugin (Bot API)",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: MoltbotPluginApi) {
|
||||
setZaloRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
|
||||
api.registerHttpHandler(handleZaloWebhookRequest);
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
33
docker-compose/ez-assistant/extensions/zalo/package.json
Normal file
33
docker-compose/ez-assistant/extensions/zalo/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@moltbot/zalo",
|
||||
"version": "2026.1.26",
|
||||
"type": "module",
|
||||
"description": "Moltbot Zalo channel plugin",
|
||||
"moltbot": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"channel": {
|
||||
"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
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@moltbot/zalo",
|
||||
"localPath": "extensions/zalo",
|
||||
"defaultChoice": "npm"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"moltbot": "workspace:*",
|
||||
"undici": "7.19.0"
|
||||
}
|
||||
}
|
||||
71
docker-compose/ez-assistant/extensions/zalo/src/accounts.ts
Normal file
71
docker-compose/ez-assistant/extensions/zalo/src/accounts.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
|
||||
|
||||
import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
|
||||
import { resolveZaloToken } from "./token.js";
|
||||
|
||||
function listConfiguredAccountIds(cfg: MoltbotConfig): 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: MoltbotConfig): 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: MoltbotConfig): 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: MoltbotConfig,
|
||||
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: MoltbotConfig, 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: MoltbotConfig;
|
||||
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: MoltbotConfig): ResolvedZaloAccount[] {
|
||||
return listZaloAccountIds(cfg)
|
||||
.map((accountId) => resolveZaloAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
62
docker-compose/ez-assistant/extensions/zalo/src/actions.ts
Normal file
62
docker-compose/ez-assistant/extensions/zalo/src/actions.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
MoltbotConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { jsonResult, readStringParam } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { listEnabledZaloAccounts } from "./accounts.js";
|
||||
import { sendMessageZalo } from "./send.js";
|
||||
|
||||
const providerId = "zalo";
|
||||
|
||||
function listEnabledAccounts(cfg: MoltbotConfig) {
|
||||
return listEnabledZaloAccounts(cfg).filter(
|
||||
(account) => account.enabled && account.tokenSource !== "none",
|
||||
);
|
||||
}
|
||||
|
||||
export const zaloMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
const accounts = listEnabledAccounts(cfg as MoltbotConfig);
|
||||
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 MoltbotConfig,
|
||||
});
|
||||
|
||||
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
docker-compose/ez-assistant/extensions/zalo/src/api.ts
Normal file
206
docker-compose/ez-assistant/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 });
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { zaloPlugin } from "./channel.js";
|
||||
|
||||
describe("zalo directory", () => {
|
||||
it("lists peers from allowFrom", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
zalo: {
|
||||
allowFrom: ["zalo:123", "zl:234", "345"],
|
||||
},
|
||||
},
|
||||
} as unknown as MoltbotConfig;
|
||||
|
||||
expect(zaloPlugin.directory).toBeTruthy();
|
||||
expect(zaloPlugin.directory?.listPeers).toBeTruthy();
|
||||
expect(zaloPlugin.directory?.listGroups).toBeTruthy();
|
||||
|
||||
await expect(
|
||||
zaloPlugin.directory!.listPeers({ cfg, accountId: undefined, query: undefined, limit: undefined }),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "123" },
|
||||
{ kind: "user", id: "234" },
|
||||
{ kind: "user", id: "345" },
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(zaloPlugin.directory!.listGroups({ cfg, accountId: undefined, query: undefined, limit: undefined })).resolves.toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
394
docker-compose/ez-assistant/extensions/zalo/src/channel.ts
Normal file
394
docker-compose/ez-assistant/extensions/zalo/src/channel.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDock,
|
||||
ChannelPlugin,
|
||||
MoltbotConfig,
|
||||
} 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 { zaloOnboardingAdapter } from "./onboarding.js";
|
||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||
import { probeZalo } from "./probe.js";
|
||||
import { sendMessageZalo } from "./send.js";
|
||||
import { collectZaloStatusIssues } from "./status-issues.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 MoltbotConfig, 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"] },
|
||||
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listZaloAccountIds(cfg as MoltbotConfig),
|
||||
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as MoltbotConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg as MoltbotConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
sectionKey: "zalo",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
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 MoltbotConfig, 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 MoltbotConfig).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,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return false;
|
||||
return /^\d{3,}$/.test(trimmed);
|
||||
},
|
||||
hint: "<chatId>",
|
||||
},
|
||||
},
|
||||
directory: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveZaloAccount({ cfg: cfg as MoltbotConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() || "";
|
||||
const peers = Array.from(
|
||||
new Set(
|
||||
(account.config.allowFrom ?? [])
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter((entry) => Boolean(entry) && entry !== "*")
|
||||
.map((entry) => entry.replace(/^(zalo|zl):/i, "")),
|
||||
),
|
||||
)
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }) as const);
|
||||
return peers;
|
||||
},
|
||||
listGroups: async () => [],
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
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 MoltbotConfig,
|
||||
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 MoltbotConfig;
|
||||
}
|
||||
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 MoltbotConfig;
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
idLabel: "zaloUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const account = resolveZaloAccount({ cfg: cfg as MoltbotConfig });
|
||||
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;
|
||||
},
|
||||
chunkerMode: "text",
|
||||
textChunkLimit: 2000,
|
||||
sendText: async ({ to, text, accountId, cfg }) => {
|
||||
const result = await sendMessageZalo(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
cfg: cfg as MoltbotConfig,
|
||||
});
|
||||
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 MoltbotConfig,
|
||||
});
|
||||
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 MoltbotConfig,
|
||||
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 }),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
|
||||
const zaloAccountSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
botToken: z.string().optional(),
|
||||
tokenFile: z.string().optional(),
|
||||
webhookUrl: z.string().optional(),
|
||||
webhookSecret: z.string().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
|
||||
allowFrom: z.array(allowFromEntry).optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
proxy: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZaloConfigSchema = zaloAccountSchema.extend({
|
||||
accounts: z.object({}).catchall(zaloAccountSchema).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
});
|
||||
760
docker-compose/ez-assistant/extensions/zalo/src/monitor.ts
Normal file
760
docker-compose/ez-assistant/extensions/zalo/src/monitor.ts
Normal file
@@ -0,0 +1,760 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import type { MoltbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk";
|
||||
|
||||
import type { ResolvedZaloAccount } from "./accounts.js";
|
||||
import {
|
||||
ZaloApiError,
|
||||
deleteWebhook,
|
||||
getUpdates,
|
||||
sendMessage,
|
||||
sendPhoto,
|
||||
setWebhook,
|
||||
type ZaloFetch,
|
||||
type ZaloMessage,
|
||||
type ZaloUpdate,
|
||||
} from "./api.js";
|
||||
import { resolveZaloProxyFetch } from "./proxy.js";
|
||||
import { getZaloRuntime } from "./runtime.js";
|
||||
|
||||
export type ZaloRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
error?: (message: string) => void;
|
||||
};
|
||||
|
||||
export type ZaloMonitorOptions = {
|
||||
token: string;
|
||||
account: ResolvedZaloAccount;
|
||||
config: MoltbotConfig;
|
||||
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;
|
||||
|
||||
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
|
||||
|
||||
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
runtime.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: MoltbotConfig;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
core: ZaloCoreRuntime;
|
||||
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;
|
||||
}
|
||||
|
||||
// Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result }
|
||||
const raw = body.value;
|
||||
const record =
|
||||
raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
||||
const update: ZaloUpdate | undefined =
|
||||
record && record.ok === true && record.result
|
||||
? (record.result as ZaloUpdate)
|
||||
: (record as ZaloUpdate | null) ?? undefined;
|
||||
|
||||
if (!update?.event_name) {
|
||||
res.statusCode = 400;
|
||||
res.end("invalid payload");
|
||||
return true;
|
||||
}
|
||||
|
||||
target.statusSink?.({ lastInboundAt: Date.now() });
|
||||
processUpdate(
|
||||
update,
|
||||
target.token,
|
||||
target.account,
|
||||
target.config,
|
||||
target.runtime,
|
||||
target.core,
|
||||
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: MoltbotConfig;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
core: ZaloCoreRuntime;
|
||||
abortSignal: AbortSignal;
|
||||
isStopped: () => boolean;
|
||||
mediaMaxMb: number;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
}) {
|
||||
const {
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
core,
|
||||
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,
|
||||
core,
|
||||
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: MoltbotConfig,
|
||||
runtime: ZaloRuntimeEnv,
|
||||
core: ZaloCoreRuntime,
|
||||
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,
|
||||
core,
|
||||
statusSink,
|
||||
fetcher,
|
||||
);
|
||||
break;
|
||||
case "message.image.received":
|
||||
await handleImageMessage(
|
||||
message,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
core,
|
||||
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: MoltbotConfig,
|
||||
runtime: ZaloRuntimeEnv,
|
||||
core: ZaloCoreRuntime,
|
||||
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,
|
||||
core,
|
||||
text,
|
||||
mediaPath: undefined,
|
||||
mediaType: undefined,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleImageMessage(
|
||||
message: ZaloMessage,
|
||||
token: string,
|
||||
account: ResolvedZaloAccount,
|
||||
config: MoltbotConfig,
|
||||
runtime: ZaloRuntimeEnv,
|
||||
core: ZaloCoreRuntime,
|
||||
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 core.channel.media.fetchRemoteMedia({ url: photo });
|
||||
const saved = await core.channel.media.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,
|
||||
core,
|
||||
text: caption,
|
||||
mediaPath,
|
||||
mediaType,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
}
|
||||
|
||||
async function processMessageWithPipeline(params: {
|
||||
message: ZaloMessage;
|
||||
token: string;
|
||||
account: ResolvedZaloAccount;
|
||||
config: MoltbotConfig;
|
||||
runtime: ZaloRuntimeEnv;
|
||||
core: ZaloCoreRuntime;
|
||||
text?: string;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
message,
|
||||
token,
|
||||
account,
|
||||
config,
|
||||
runtime,
|
||||
core,
|
||||
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));
|
||||
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
|
||||
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(
|
||||
rawBody,
|
||||
config,
|
||||
);
|
||||
const storeAllowFrom =
|
||||
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
|
||||
? await core.channel.pairing.readAllowFromStore("zalo").catch(() => [])
|
||||
: [];
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = isSenderAllowed(senderId, effectiveAllowFrom);
|
||||
const commandAuthorized = shouldComputeAuth
|
||||
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers: [{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!isGroup) {
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open") {
|
||||
const allowed = senderAllowedForCommands;
|
||||
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "zalo",
|
||||
id: senderId,
|
||||
meta: { name: senderName ?? undefined },
|
||||
});
|
||||
|
||||
if (created) {
|
||||
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
|
||||
try {
|
||||
await sendMessage(
|
||||
token,
|
||||
{
|
||||
chat_id: chatId,
|
||||
text: core.channel.pairing.buildPairingReply({
|
||||
channel: "zalo",
|
||||
idLine: `Your Zalo user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
},
|
||||
fetcher,
|
||||
);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`zalo pairing reply failed for ${senderId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: config,
|
||||
channel: "zalo",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "dm",
|
||||
id: chatId,
|
||||
},
|
||||
});
|
||||
|
||||
if (
|
||||
isGroup &&
|
||||
core.channel.commands.isControlCommandMessage(rawBody, config) &&
|
||||
commandAuthorized !== true
|
||||
) {
|
||||
logVerbose(core, runtime, `zalo: drop control command from unauthorized sender ${senderId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "Zalo",
|
||||
from: fromLabel,
|
||||
timestamp: date ? date * 1000 : undefined,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: isGroup ? `zalo:group:${chatId}` : `zalo:${senderId}`,
|
||||
To: `zalo:${chatId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: senderName || undefined,
|
||||
SenderId: senderId,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
Provider: "zalo",
|
||||
Surface: "zalo",
|
||||
MessageSid: message_id,
|
||||
MediaPath: mediaPath,
|
||||
MediaType: mediaType,
|
||||
MediaUrl: mediaPath,
|
||||
OriginatingChannel: "zalo",
|
||||
OriginatingTo: `zalo:${chatId}`,
|
||||
});
|
||||
|
||||
await core.channel.session.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
onRecordError: (err) => {
|
||||
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: config,
|
||||
channel: "zalo",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
deliver: async (payload) => {
|
||||
await deliverZaloReply({
|
||||
payload,
|
||||
token,
|
||||
chatId,
|
||||
runtime,
|
||||
core,
|
||||
config,
|
||||
accountId: account.accountId,
|
||||
statusSink,
|
||||
fetcher,
|
||||
tableMode,
|
||||
});
|
||||
},
|
||||
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;
|
||||
core: ZaloCoreRuntime;
|
||||
config: MoltbotConfig;
|
||||
accountId?: string;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
fetcher?: ZaloFetch;
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): Promise<void> {
|
||||
const { payload, token, chatId, runtime, core, config, accountId, statusSink, fetcher } = params;
|
||||
const tableMode = params.tableMode ?? "code";
|
||||
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
|
||||
|
||||
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 ? 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 (text) {
|
||||
const chunkMode = core.channel.text.resolveChunkMode(config, "zalo", accountId);
|
||||
const chunks = core.channel.text.chunkMarkdownTextWithMode(
|
||||
text,
|
||||
ZALO_TEXT_LIMIT,
|
||||
chunkMode,
|
||||
);
|
||||
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 core = getZaloRuntime();
|
||||
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,
|
||||
core,
|
||||
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,
|
||||
core,
|
||||
abortSignal,
|
||||
isStopped: () => stopped,
|
||||
mediaMaxMb: effectiveMediaMaxMb,
|
||||
statusSink,
|
||||
fetcher,
|
||||
});
|
||||
|
||||
return { stop };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createServer } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { MoltbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
|
||||
import type { ResolvedZaloAccount } from "./types.js";
|
||||
import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
|
||||
|
||||
async function withServer(
|
||||
handler: Parameters<typeof createServer>[0],
|
||||
fn: (baseUrl: string) => Promise<void>,
|
||||
) {
|
||||
const server = createServer(handler);
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const address = server.address() as AddressInfo | null;
|
||||
if (!address) throw new Error("missing server address");
|
||||
try {
|
||||
await fn(`http://127.0.0.1:${address.port}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
describe("handleZaloWebhookRequest", () => {
|
||||
it("returns 400 for non-object payloads", async () => {
|
||||
const core = {} as PluginRuntime;
|
||||
const account: ResolvedZaloAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
token: "tok",
|
||||
tokenSource: "config",
|
||||
config: {},
|
||||
};
|
||||
const unregister = registerZaloWebhookTarget({
|
||||
token: "tok",
|
||||
account,
|
||||
config: {} as MoltbotConfig,
|
||||
runtime: {},
|
||||
core,
|
||||
secret: "secret",
|
||||
path: "/hook",
|
||||
mediaMaxMb: 5,
|
||||
});
|
||||
|
||||
try {
|
||||
await withServer(async (req, res) => {
|
||||
const handled = await handleZaloWebhookRequest(req, res);
|
||||
if (!handled) {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
}
|
||||
}, async (baseUrl) => {
|
||||
const response = await fetch(`${baseUrl}/hook`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-bot-api-secret-token": "secret",
|
||||
},
|
||||
body: "null",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
});
|
||||
405
docker-compose/ez-assistant/extensions/zalo/src/onboarding.ts
Normal file
405
docker-compose/ez-assistant/extensions/zalo/src/onboarding.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
MoltbotConfig,
|
||||
WizardPrompter,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
promptAccountId,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
import {
|
||||
listZaloAccountIds,
|
||||
resolveDefaultZaloAccountId,
|
||||
resolveZaloAccount,
|
||||
} from "./accounts.js";
|
||||
|
||||
const channel = "zalo" as const;
|
||||
|
||||
type UpdateMode = "polling" | "webhook";
|
||||
|
||||
function setZaloDmPolicy(
|
||||
cfg: MoltbotConfig,
|
||||
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 MoltbotConfig;
|
||||
}
|
||||
|
||||
function setZaloUpdateMode(
|
||||
cfg: MoltbotConfig,
|
||||
accountId: string,
|
||||
mode: UpdateMode,
|
||||
webhookUrl?: string,
|
||||
webhookSecret?: string,
|
||||
webhookPath?: string,
|
||||
): MoltbotConfig {
|
||||
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 MoltbotConfig;
|
||||
}
|
||||
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 MoltbotConfig;
|
||||
}
|
||||
|
||||
if (isDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
zalo: {
|
||||
...cfg.channels?.zalo,
|
||||
webhookUrl,
|
||||
webhookSecret,
|
||||
webhookPath,
|
||||
},
|
||||
},
|
||||
} as MoltbotConfig;
|
||||
}
|
||||
|
||||
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 MoltbotConfig;
|
||||
}
|
||||
|
||||
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.molt.bot/channels/zalo",
|
||||
].join("\n"),
|
||||
"Zalo bot token",
|
||||
);
|
||||
}
|
||||
|
||||
async function promptZaloAllowFrom(params: {
|
||||
cfg: MoltbotConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<MoltbotConfig> {
|
||||
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 MoltbotConfig;
|
||||
}
|
||||
|
||||
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 MoltbotConfig;
|
||||
}
|
||||
|
||||
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 MoltbotConfig, policy),
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const id =
|
||||
accountId && normalizeAccountId(accountId)
|
||||
? normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID
|
||||
: resolveDefaultZaloAccountId(cfg as MoltbotConfig);
|
||||
return promptZaloAllowFrom({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
prompter,
|
||||
accountId: id,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
dmPolicy,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listZaloAccountIds(cfg as MoltbotConfig).some((accountId) =>
|
||||
Boolean(resolveZaloAccount({ cfg: cfg as MoltbotConfig, 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 MoltbotConfig);
|
||||
let zaloAccountId = zaloOverride
|
||||
? normalizeAccountId(zaloOverride)
|
||||
: defaultZaloAccountId;
|
||||
if (shouldPromptAccountIds && !zaloOverride) {
|
||||
zaloAccountId = await promptAccountId({
|
||||
cfg: cfg as MoltbotConfig,
|
||||
prompter,
|
||||
label: "Zalo",
|
||||
currentId: zaloAccountId,
|
||||
listAccountIds: listZaloAccountIds,
|
||||
defaultAccountId: defaultZaloAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
let next = cfg as MoltbotConfig;
|
||||
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 MoltbotConfig;
|
||||
} 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 MoltbotConfig;
|
||||
} 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 MoltbotConfig;
|
||||
}
|
||||
}
|
||||
|
||||
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
docker-compose/ez-assistant/extensions/zalo/src/probe.ts
Normal file
46
docker-compose/ez-assistant/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
docker-compose/ez-assistant/extensions/zalo/src/proxy.ts
Normal file
18
docker-compose/ez-assistant/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;
|
||||
}
|
||||
14
docker-compose/ez-assistant/extensions/zalo/src/runtime.ts
Normal file
14
docker-compose/ez-assistant/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;
|
||||
}
|
||||
117
docker-compose/ez-assistant/extensions/zalo/src/send.ts
Normal file
117
docker-compose/ez-assistant/extensions/zalo/src/send.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
|
||||
|
||||
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?: MoltbotConfig;
|
||||
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, 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) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "clawdbot/plugin-sdk";
|
||||
|
||||
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;
|
||||
}
|
||||
55
docker-compose/ez-assistant/extensions/zalo/src/token.ts
Normal file
55
docker-compose/ez-assistant/extensions/zalo/src/token.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
|
||||
|
||||
import type { ZaloConfig } from "./types.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" };
|
||||
}
|
||||
42
docker-compose/ez-assistant/extensions/zalo/src/types.ts
Normal file
42
docker-compose/ez-assistant/extensions/zalo/src/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user