zalo_bot 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ name: Publish to npm
2
+
3
+ on:
4
+ push:
5
+ branches: [master]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: 22
16
+ registry-url: https://registry.npmjs.org
17
+
18
+ - run: npm install
19
+
20
+ - run: npm publish --access public
21
+ env:
22
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @openclaw/zalo-bot
2
+
3
+ OpenClaw Zalo channel plugin (Bot API) — patched fork.
4
+
5
+ ## AI Install Metadata
6
+ - Plugin id: zalo_bot
7
+ - Channel id: zalo_bot
8
+ - Package name: @openclaw/zalo-bot
9
+
10
+ ## Fixes
11
+
12
+ - **`photo_url` field mapping**: Correctly parses the Zalo Bot API's `photo_url` field for image messages
13
+ - **Image + Caption handling**: When a user sends both an image and text caption, both are now correctly forwarded to the AI agent
14
+
15
+ ## Install (local checkout)
16
+
17
+ ```bash
18
+ # Clone the repo
19
+ git clone https://github.com/vuminhtuanhvtc/openclaw-zalo-bot.git
20
+ cd openclaw-zalo-bot
21
+
22
+ # Install dependencies
23
+ npm install
24
+
25
+ # Register with OpenClaw
26
+ openclaw plugins install .
27
+ ```
28
+
29
+ Restart Gateway after installation.
30
+
31
+ ## Quick Start
32
+
33
+ Add channel config to `openclaw.json`:
34
+
35
+ ```json
36
+ {
37
+ "channels": {
38
+ "zalo_bot": {
39
+ "enabled": true,
40
+ "token": "YOUR_ZALO_BOT_TOKEN",
41
+ "webhookUrl": "https://your-domain.com/zalo_bot-webhook",
42
+ "webhookSecret": "your-webhook-secret",
43
+ "dmPolicy": "open"
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ Then restart:
50
+
51
+ ```bash
52
+ openclaw gateway restart
53
+ ```
54
+
55
+ ## License
56
+
57
+ MIT
package/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
3
+ import { zaloDock, zaloPlugin } from "./src/channel.js";
4
+ import { handleZaloWebhookRequest } from "./src/monitor.js";
5
+ import { setZaloRuntime } from "./src/runtime.js";
6
+
7
+ const plugin = {
8
+ id: "zalo_bot",
9
+ name: "Zalo Bot",
10
+ description: "Zalo channel plugin (Bot API) — patched fork",
11
+ configSchema: emptyPluginConfigSchema(),
12
+ register(api: OpenClawPluginApi) {
13
+ setZaloRuntime(api.runtime);
14
+ api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
15
+ api.registerHttpHandler(handleZaloWebhookRequest);
16
+ },
17
+ };
18
+
19
+ export default plugin;
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "zalo_bot",
3
+ "channels": ["zalo_bot"],
4
+ "configSchema": {
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {}
8
+ }
9
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "zalo_bot",
3
+ "version": "1.0.0",
4
+ "description": "OpenClaw Zalo channel plugin (Bot API) — patched fork with photo_url and image+caption fixes",
5
+ "type": "module",
6
+ "dependencies": {
7
+ "undici": "7.22.0",
8
+ "zod": "^4.3.6"
9
+ },
10
+ "openclaw": {
11
+ "extensions": [
12
+ "./index.ts"
13
+ ],
14
+ "channel": {
15
+ "id": "zalo_bot",
16
+ "label": "Zalo Bot",
17
+ "selectionLabel": "Zalo Bot (Bot API)",
18
+ "docsPath": "/channels/zalo",
19
+ "docsLabel": "zalo_bot",
20
+ "blurb": "Vietnam-focused messaging platform with Bot API (patched).",
21
+ "aliases": [
22
+ "zb"
23
+ ],
24
+ "order": 81,
25
+ "quickstartAllowFrom": true
26
+ },
27
+ "install": {
28
+ "npmSpec": "zalo_bot",
29
+ "localPath": "extensions/zalo_bot",
30
+ "defaultChoice": "npm"
31
+ }
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/vuminhtuanhvtc/openclaw-zalo-bot.git"
36
+ },
37
+ "license": "MIT"
38
+ }
@@ -0,0 +1,82 @@
1
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
2
+ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
3
+ import { resolveZaloToken } from "./token.js";
4
+ import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
5
+
6
+ export type { ResolvedZaloAccount };
7
+
8
+ function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
9
+ const accounts = (cfg.channels?.zalo_bot as ZaloConfig | undefined)?.accounts;
10
+ if (!accounts || typeof accounts !== "object") {
11
+ return [];
12
+ }
13
+ return Object.keys(accounts).filter(Boolean);
14
+ }
15
+
16
+ export function listZaloAccountIds(cfg: OpenClawConfig): string[] {
17
+ const ids = listConfiguredAccountIds(cfg);
18
+ if (ids.length === 0) {
19
+ return [DEFAULT_ACCOUNT_ID];
20
+ }
21
+ return ids.toSorted((a, b) => a.localeCompare(b));
22
+ }
23
+
24
+ export function resolveDefaultZaloAccountId(cfg: OpenClawConfig): string {
25
+ const zaloConfig = cfg.channels?.zalo_bot as ZaloConfig | undefined;
26
+ if (zaloConfig?.defaultAccount?.trim()) {
27
+ return zaloConfig.defaultAccount.trim();
28
+ }
29
+ const ids = listZaloAccountIds(cfg);
30
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) {
31
+ return DEFAULT_ACCOUNT_ID;
32
+ }
33
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
34
+ }
35
+
36
+ function resolveAccountConfig(
37
+ cfg: OpenClawConfig,
38
+ accountId: string,
39
+ ): ZaloAccountConfig | undefined {
40
+ const accounts = (cfg.channels?.zalo_bot as ZaloConfig | undefined)?.accounts;
41
+ if (!accounts || typeof accounts !== "object") {
42
+ return undefined;
43
+ }
44
+ return accounts[accountId] as ZaloAccountConfig | undefined;
45
+ }
46
+
47
+ function mergeZaloAccountConfig(cfg: OpenClawConfig, accountId: string): ZaloAccountConfig {
48
+ const raw = (cfg.channels?.zalo_bot ?? {}) as ZaloConfig;
49
+ const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
50
+ const account = resolveAccountConfig(cfg, accountId) ?? {};
51
+ return { ...base, ...account };
52
+ }
53
+
54
+ export function resolveZaloAccount(params: {
55
+ cfg: OpenClawConfig;
56
+ accountId?: string | null;
57
+ }): ResolvedZaloAccount {
58
+ const accountId = normalizeAccountId(params.accountId);
59
+ const baseEnabled = (params.cfg.channels?.zalo_bot as ZaloConfig | undefined)?.enabled !== false;
60
+ const merged = mergeZaloAccountConfig(params.cfg, accountId);
61
+ const accountEnabled = merged.enabled !== false;
62
+ const enabled = baseEnabled && accountEnabled;
63
+ const tokenResolution = resolveZaloToken(
64
+ params.cfg.channels?.zalo_bot as ZaloConfig | undefined,
65
+ accountId,
66
+ );
67
+
68
+ return {
69
+ accountId,
70
+ name: merged.name?.trim() || undefined,
71
+ enabled,
72
+ token: tokenResolution.token,
73
+ tokenSource: tokenResolution.source,
74
+ config: merged,
75
+ };
76
+ }
77
+
78
+ export function listEnabledZaloAccounts(cfg: OpenClawConfig): ResolvedZaloAccount[] {
79
+ return listZaloAccountIds(cfg)
80
+ .map((accountId) => resolveZaloAccount({ cfg, accountId }))
81
+ .filter((account) => account.enabled);
82
+ }
package/src/actions.ts ADDED
@@ -0,0 +1,56 @@
1
+ import type {
2
+ ChannelMessageActionAdapter,
3
+ ChannelMessageActionName,
4
+ OpenClawConfig,
5
+ } from "openclaw/plugin-sdk";
6
+ import { extractToolSend, jsonResult, readStringParam } from "openclaw/plugin-sdk";
7
+ import { listEnabledZaloAccounts } from "./accounts.js";
8
+ import { sendMessageZalo } from "./send.js";
9
+
10
+ const providerId = "zalo";
11
+
12
+ function listEnabledAccounts(cfg: OpenClawConfig) {
13
+ return listEnabledZaloAccounts(cfg).filter(
14
+ (account) => account.enabled && account.tokenSource !== "none",
15
+ );
16
+ }
17
+
18
+ export const zaloMessageActions: ChannelMessageActionAdapter = {
19
+ listActions: ({ cfg }) => {
20
+ const accounts = listEnabledAccounts(cfg);
21
+ if (accounts.length === 0) {
22
+ return [];
23
+ }
24
+ const actions = new Set<ChannelMessageActionName>(["send"]);
25
+ return Array.from(actions);
26
+ },
27
+ supportsButtons: () => false,
28
+ extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
29
+ handleAction: async ({ action, params, cfg, accountId }) => {
30
+ if (action === "send") {
31
+ const to = readStringParam(params, "to", { required: true });
32
+ const content = readStringParam(params, "message", {
33
+ required: true,
34
+ allowEmpty: true,
35
+ });
36
+ const mediaUrl = readStringParam(params, "media", { trim: false });
37
+
38
+ const result = await sendMessageZalo(to ?? "", content ?? "", {
39
+ accountId: accountId ?? undefined,
40
+ mediaUrl: mediaUrl ?? undefined,
41
+ cfg: cfg,
42
+ });
43
+
44
+ if (!result.ok) {
45
+ return jsonResult({
46
+ ok: false,
47
+ error: result.error ?? "Failed to send Zalo message",
48
+ });
49
+ }
50
+
51
+ return jsonResult({ ok: true, to, messageId: result.messageId });
52
+ }
53
+
54
+ throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
55
+ },
56
+ };
package/src/api.ts ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Zalo Bot API client
3
+ * @see https://bot.zaloplatforms.com/docs
4
+ */
5
+
6
+ const ZALO_API_BASE = "https://bot-api.zaloplatforms.com";
7
+
8
+ export type ZaloFetch = (input: string, init?: RequestInit) => Promise<Response>;
9
+
10
+ export type ZaloApiResponse<T = unknown> = {
11
+ ok: boolean;
12
+ result?: T;
13
+ error_code?: number;
14
+ description?: string;
15
+ };
16
+
17
+ export type ZaloBotInfo = {
18
+ id: string;
19
+ name: string;
20
+ avatar?: string;
21
+ };
22
+
23
+ export type ZaloMessage = {
24
+ message_id: string;
25
+ from: {
26
+ id: string;
27
+ name?: string;
28
+ avatar?: string;
29
+ };
30
+ chat: {
31
+ id: string;
32
+ chat_type: "PRIVATE" | "GROUP";
33
+ };
34
+ date: number;
35
+ text?: string;
36
+ photo_url?: string;
37
+ caption?: string;
38
+ sticker?: string;
39
+ };
40
+
41
+ export type ZaloUpdate = {
42
+ event_name:
43
+ | "message.text.received"
44
+ | "message.image.received"
45
+ | "message.sticker.received"
46
+ | "message.unsupported.received";
47
+ message?: ZaloMessage;
48
+ };
49
+
50
+ export type ZaloSendMessageParams = {
51
+ chat_id: string;
52
+ text: string;
53
+ };
54
+
55
+ export type ZaloSendPhotoParams = {
56
+ chat_id: string;
57
+ photo: string;
58
+ caption?: string;
59
+ };
60
+
61
+ export type ZaloSetWebhookParams = {
62
+ url: string;
63
+ secret_token: string;
64
+ };
65
+
66
+ export type ZaloGetUpdatesParams = {
67
+ /** Timeout in seconds (passed as string to API) */
68
+ timeout?: number;
69
+ };
70
+
71
+ export class ZaloApiError extends Error {
72
+ constructor(
73
+ message: string,
74
+ public readonly errorCode?: number,
75
+ public readonly description?: string,
76
+ ) {
77
+ super(message);
78
+ this.name = "ZaloApiError";
79
+ }
80
+
81
+ /** True if this is a long-polling timeout (no updates available) */
82
+ get isPollingTimeout(): boolean {
83
+ return this.errorCode === 408;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Call the Zalo Bot API
89
+ */
90
+ export async function callZaloApi<T = unknown>(
91
+ method: string,
92
+ token: string,
93
+ body?: Record<string, unknown>,
94
+ options?: { timeoutMs?: number; fetch?: ZaloFetch },
95
+ ): Promise<ZaloApiResponse<T>> {
96
+ const url = `${ZALO_API_BASE}/bot${token}/${method}`;
97
+ const controller = new AbortController();
98
+ const timeoutId = options?.timeoutMs
99
+ ? setTimeout(() => controller.abort(), options.timeoutMs)
100
+ : undefined;
101
+ const fetcher = options?.fetch ?? fetch;
102
+
103
+ try {
104
+ const response = await fetcher(url, {
105
+ method: "POST",
106
+ headers: {
107
+ "Content-Type": "application/json",
108
+ },
109
+ body: body ? JSON.stringify(body) : undefined,
110
+ signal: controller.signal,
111
+ });
112
+
113
+ const data = (await response.json()) as ZaloApiResponse<T>;
114
+
115
+ if (!data.ok) {
116
+ throw new ZaloApiError(
117
+ data.description ?? `Zalo API error: ${method}`,
118
+ data.error_code,
119
+ data.description,
120
+ );
121
+ }
122
+
123
+ return data;
124
+ } finally {
125
+ if (timeoutId) {
126
+ clearTimeout(timeoutId);
127
+ }
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Validate bot token and get bot info
133
+ */
134
+ export async function getMe(
135
+ token: string,
136
+ timeoutMs?: number,
137
+ fetcher?: ZaloFetch,
138
+ ): Promise<ZaloApiResponse<ZaloBotInfo>> {
139
+ return callZaloApi<ZaloBotInfo>("getMe", token, undefined, { timeoutMs, fetch: fetcher });
140
+ }
141
+
142
+ /**
143
+ * Send a text message
144
+ */
145
+ export async function sendMessage(
146
+ token: string,
147
+ params: ZaloSendMessageParams,
148
+ fetcher?: ZaloFetch,
149
+ ): Promise<ZaloApiResponse<ZaloMessage>> {
150
+ return callZaloApi<ZaloMessage>("sendMessage", token, params, { fetch: fetcher });
151
+ }
152
+
153
+ /**
154
+ * Send a photo message
155
+ */
156
+ export async function sendPhoto(
157
+ token: string,
158
+ params: ZaloSendPhotoParams,
159
+ fetcher?: ZaloFetch,
160
+ ): Promise<ZaloApiResponse<ZaloMessage>> {
161
+ return callZaloApi<ZaloMessage>("sendPhoto", token, params, { fetch: fetcher });
162
+ }
163
+
164
+ /**
165
+ * Get updates using long polling (dev/testing only)
166
+ * Note: Zalo returns a single update per call, not an array like Telegram
167
+ */
168
+ export async function getUpdates(
169
+ token: string,
170
+ params?: ZaloGetUpdatesParams,
171
+ fetcher?: ZaloFetch,
172
+ ): Promise<ZaloApiResponse<ZaloUpdate>> {
173
+ const pollTimeoutSec = params?.timeout ?? 30;
174
+ const timeoutMs = (pollTimeoutSec + 5) * 1000;
175
+ const body = { timeout: String(pollTimeoutSec) };
176
+ return callZaloApi<ZaloUpdate>("getUpdates", token, body, { timeoutMs, fetch: fetcher });
177
+ }
178
+
179
+ /**
180
+ * Set webhook URL for receiving updates
181
+ */
182
+ export async function setWebhook(
183
+ token: string,
184
+ params: ZaloSetWebhookParams,
185
+ fetcher?: ZaloFetch,
186
+ ): Promise<ZaloApiResponse<boolean>> {
187
+ return callZaloApi<boolean>("setWebhook", token, params, { fetch: fetcher });
188
+ }
189
+
190
+ /**
191
+ * Delete webhook configuration
192
+ */
193
+ export async function deleteWebhook(
194
+ token: string,
195
+ fetcher?: ZaloFetch,
196
+ ): Promise<ZaloApiResponse<boolean>> {
197
+ return callZaloApi<boolean>("deleteWebhook", token, undefined, { fetch: fetcher });
198
+ }
199
+
200
+ /**
201
+ * Get current webhook info
202
+ */
203
+ export async function getWebhookInfo(
204
+ token: string,
205
+ fetcher?: ZaloFetch,
206
+ ): Promise<ZaloApiResponse<{ url?: string; has_custom_certificate?: boolean }>> {
207
+ return callZaloApi("getWebhookInfo", token, undefined, { fetch: fetcher });
208
+ }