@ardennguyen/zalo-agent-cli 1.0.7

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.
Files changed (52) hide show
  1. package/DISCLAIMER.md +27 -0
  2. package/LICENSE +21 -0
  3. package/README.md +205 -0
  4. package/package.json +64 -0
  5. package/src/cli.test.js +98 -0
  6. package/src/commands/account.js +178 -0
  7. package/src/commands/auto-reply.js +83 -0
  8. package/src/commands/catalog.js +143 -0
  9. package/src/commands/conv.js +264 -0
  10. package/src/commands/friend.js +384 -0
  11. package/src/commands/group.js +606 -0
  12. package/src/commands/label.js +39 -0
  13. package/src/commands/listen.js +293 -0
  14. package/src/commands/login.js +165 -0
  15. package/src/commands/mcp.js +219 -0
  16. package/src/commands/msg.js +608 -0
  17. package/src/commands/oa-init.js +503 -0
  18. package/src/commands/oa-listen.js +215 -0
  19. package/src/commands/oa.js +657 -0
  20. package/src/commands/poll.js +159 -0
  21. package/src/commands/profile.js +274 -0
  22. package/src/commands/quick-msg.js +55 -0
  23. package/src/commands/reminder.js +195 -0
  24. package/src/core/accounts.js +93 -0
  25. package/src/core/credentials.js +63 -0
  26. package/src/core/oa-client.js +407 -0
  27. package/src/core/zalo-client.js +201 -0
  28. package/src/index.js +95 -0
  29. package/src/mcp/mcp-config.js +81 -0
  30. package/src/mcp/mcp-http-transport.js +84 -0
  31. package/src/mcp/mcp-server.js +40 -0
  32. package/src/mcp/mcp-tools.js +353 -0
  33. package/src/mcp/media-downloader.js +191 -0
  34. package/src/mcp/media-downloader.test.js +45 -0
  35. package/src/mcp/message-buffer.js +127 -0
  36. package/src/mcp/message-buffer.test.js +308 -0
  37. package/src/mcp/notifier.js +141 -0
  38. package/src/mcp/notifier.test.js +269 -0
  39. package/src/mcp/thread-filter.js +79 -0
  40. package/src/mcp/thread-filter.test.js +206 -0
  41. package/src/mcp/thread-name-cache.js +162 -0
  42. package/src/mcp/thread-name-cache.test.js +139 -0
  43. package/src/utils/bank-helpers.js +233 -0
  44. package/src/utils/bank-helpers.test.js +66 -0
  45. package/src/utils/extract-message-text.js +21 -0
  46. package/src/utils/output.js +21 -0
  47. package/src/utils/proxy-helpers.js +14 -0
  48. package/src/utils/proxy-helpers.test.js +36 -0
  49. package/src/utils/qr-display.js +91 -0
  50. package/src/utils/qr-display.test.js +97 -0
  51. package/src/utils/qr-http-server.js +190 -0
  52. package/src/utils/update-check.js +56 -0
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Multi-account registry at ~/.zalo-agent-cli/accounts.json
3
+ * Maps each account to its own proxy (1:1) and tracks active account.
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "fs";
7
+ import { CONFIG_DIR, deleteCredentials } from "./credentials.js";
8
+
9
+ const ACCOUNTS_FILE = `${CONFIG_DIR}/accounts.json`;
10
+
11
+ function ensureDir() {
12
+ mkdirSync(CONFIG_DIR, { recursive: true });
13
+ }
14
+
15
+ function load() {
16
+ if (!existsSync(ACCOUNTS_FILE)) return [];
17
+ try {
18
+ return JSON.parse(readFileSync(ACCOUNTS_FILE, "utf-8"));
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
23
+
24
+ function save(accounts) {
25
+ ensureDir();
26
+ writeFileSync(ACCOUNTS_FILE, JSON.stringify(accounts, null, 2), "utf-8");
27
+ chmodSync(ACCOUNTS_FILE, 0o600);
28
+ }
29
+
30
+ /** List all registered accounts. */
31
+ export function listAccounts() {
32
+ return load();
33
+ }
34
+
35
+ /** Get currently active account or null. */
36
+ export function getActive() {
37
+ return load().find((a) => a.active) || null;
38
+ }
39
+
40
+ /** Set an account as active (deactivates others). Returns false if not found. */
41
+ export function setActive(ownId) {
42
+ const accounts = load();
43
+ let found = false;
44
+ for (const a of accounts) {
45
+ if (a.ownId === ownId) {
46
+ a.active = true;
47
+ found = true;
48
+ } else {
49
+ a.active = false;
50
+ }
51
+ }
52
+ if (found) save(accounts);
53
+ return found;
54
+ }
55
+
56
+ /** Register a new account or update existing. New account becomes active. */
57
+ export function addAccount(ownId, name = "", proxy = null) {
58
+ const accounts = load();
59
+ const existing = accounts.find((a) => a.ownId === ownId);
60
+ if (existing) {
61
+ existing.name = name || existing.name || "";
62
+ existing.proxy = proxy;
63
+ } else {
64
+ for (const a of accounts) a.active = false;
65
+ accounts.push({ ownId, name, proxy, active: true });
66
+ }
67
+ save(accounts);
68
+ }
69
+
70
+ /** Remove account from registry and delete its credentials. */
71
+ export function removeAccount(ownId) {
72
+ const accounts = load();
73
+ const filtered = accounts.filter((a) => a.ownId !== ownId);
74
+ if (filtered.length === accounts.length) return false;
75
+ // If removed was active, activate first remaining
76
+ if (filtered.length && !filtered.some((a) => a.active)) {
77
+ filtered[0].active = true;
78
+ }
79
+ save(filtered);
80
+ deleteCredentials(ownId);
81
+ return true;
82
+ }
83
+
84
+ /** Get account by ownId. */
85
+ export function getAccount(ownId) {
86
+ return load().find((a) => a.ownId === ownId) || null;
87
+ }
88
+
89
+ /** Get proxy URL for a specific account. */
90
+ export function getProxyFor(ownId) {
91
+ const acc = getAccount(ownId);
92
+ return acc?.proxy || null;
93
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Per-account credential storage at ~/.zalo-agent-cli/credentials/
3
+ * All credential files use 0600 permissions (owner read/write only).
4
+ */
5
+
6
+ import { readFileSync, writeFileSync, unlinkSync, existsSync, mkdirSync, chmodSync } from "fs";
7
+ import { join } from "path";
8
+ import { homedir } from "os";
9
+
10
+ export const CONFIG_DIR = join(homedir(), ".zalo-agent-cli");
11
+ export const CREDENTIALS_DIR = join(CONFIG_DIR, "credentials");
12
+
13
+ /** Ensure config directories exist. */
14
+ function ensureDirs() {
15
+ mkdirSync(CREDENTIALS_DIR, { recursive: true });
16
+ }
17
+
18
+ function credPath(ownId) {
19
+ return join(CREDENTIALS_DIR, `cred_${ownId}.json`);
20
+ }
21
+
22
+ /**
23
+ * Save credentials for a specific account.
24
+ * @param {string} ownId
25
+ * @param {object} creds - {imei, cookie, userAgent, language?}
26
+ * @returns {string} File path
27
+ */
28
+ export function saveCredentials(ownId, creds) {
29
+ ensureDirs();
30
+ const target = credPath(ownId);
31
+ writeFileSync(target, JSON.stringify(creds, null, 2), "utf-8");
32
+ chmodSync(target, 0o600);
33
+ return target;
34
+ }
35
+
36
+ /**
37
+ * Load credentials for a specific account.
38
+ * @param {string} ownId
39
+ * @returns {object|null}
40
+ */
41
+ export function loadCredentials(ownId) {
42
+ const target = credPath(ownId);
43
+ if (!existsSync(target)) return null;
44
+ try {
45
+ return JSON.parse(readFileSync(target, "utf-8"));
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Delete credentials for a specific account.
53
+ * @param {string} ownId
54
+ * @returns {boolean}
55
+ */
56
+ export function deleteCredentials(ownId) {
57
+ const target = credPath(ownId);
58
+ if (existsSync(target)) {
59
+ unlinkSync(target);
60
+ return true;
61
+ }
62
+ return false;
63
+ }
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Zalo Official Account API v3.0 client.
3
+ * Wraps OA REST endpoints with node-fetch. No dependency on zca-js.
4
+ * Credentials stored in ~/.zalo-agent/oa-credentials.json.
5
+ */
6
+
7
+ import fs from "node:fs";
8
+ import { join } from "node:path";
9
+ import { homedir } from "node:os";
10
+ import nodefetch from "node-fetch";
11
+
12
+ const V3_BASE = "https://openapi.zalo.me/v3.0/oa";
13
+ const V2_BASE = "https://openapi.zalo.me/v2.0/oa";
14
+ const DATA_DIR = join(homedir(), ".zalo-agent");
15
+ const OA_CREDS_FILE = join(DATA_DIR, "oa-credentials.json");
16
+
17
+ /** Ensure data directory exists. */
18
+ function ensureDir() {
19
+ if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
20
+ }
21
+
22
+ const OAUTH_URL = "https://oauth.zaloapp.com/v4/oa/permission";
23
+ const TOKEN_URL = "https://oauth.zaloapp.com/v4/oa/access_token";
24
+
25
+ /** Save full OA credentials to disk with restricted file permissions. */
26
+ export function saveOACreds(data, oaId = "default") {
27
+ ensureDir();
28
+ let creds = {};
29
+ if (fs.existsSync(OA_CREDS_FILE)) {
30
+ creds = JSON.parse(fs.readFileSync(OA_CREDS_FILE, "utf8"));
31
+ }
32
+ creds[oaId] = { ...creds[oaId], ...data, updatedAt: new Date().toISOString() };
33
+ fs.writeFileSync(OA_CREDS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 });
34
+ }
35
+
36
+ /** Save OA access token to disk (backward compat). */
37
+ export function saveOAToken(accessToken, oaId = "default") {
38
+ saveOACreds({ accessToken }, oaId);
39
+ }
40
+
41
+ /** Load full OA credentials from disk. */
42
+ export function loadOACreds(oaId = "default") {
43
+ if (!fs.existsSync(OA_CREDS_FILE)) return null;
44
+ const creds = JSON.parse(fs.readFileSync(OA_CREDS_FILE, "utf8"));
45
+ return creds[oaId] || null;
46
+ }
47
+
48
+ /** Load OA access token from disk. */
49
+ export function loadOAToken(oaId = "default") {
50
+ return loadOACreds(oaId)?.accessToken || null;
51
+ }
52
+
53
+ /** Get token or throw. */
54
+ function getToken(oaId) {
55
+ const creds = loadOACreds(oaId);
56
+ if (!creds?.accessToken) {
57
+ throw new Error("OA not configured. Run: zalo-agent oa login --app-id <id> --secret <key>");
58
+ }
59
+ return creds.accessToken;
60
+ }
61
+
62
+ /** Build OAuth authorization URL. */
63
+ export function getOAuthUrl(appId, redirectUri = "http://localhost:3456/callback") {
64
+ const params = new URLSearchParams({
65
+ app_id: appId,
66
+ redirect_uri: redirectUri,
67
+ });
68
+ return `${OAUTH_URL}?${params}`;
69
+ }
70
+
71
+ /** Exchange authorization code for access + refresh tokens. */
72
+ export async function exchangeCode(code, appId, secretKey, redirectUri = "http://localhost:3456/callback") {
73
+ const res = await nodefetch(TOKEN_URL, {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/x-www-form-urlencoded", secret_key: secretKey },
76
+ body: new URLSearchParams({
77
+ code,
78
+ app_id: appId,
79
+ grant_type: "authorization_code",
80
+ redirect_uri: redirectUri,
81
+ }),
82
+ });
83
+ return res.json();
84
+ }
85
+
86
+ /** Refresh access token using refresh token. */
87
+ export async function refreshAccessToken(refreshToken, appId, secretKey) {
88
+ const res = await nodefetch(TOKEN_URL, {
89
+ method: "POST",
90
+ headers: { "Content-Type": "application/x-www-form-urlencoded", secret_key: secretKey },
91
+ body: new URLSearchParams({
92
+ refresh_token: refreshToken,
93
+ app_id: appId,
94
+ grant_type: "refresh_token",
95
+ }),
96
+ });
97
+ return res.json();
98
+ }
99
+
100
+ /** Make authenticated request to Zalo OA API. */
101
+ async function oaFetch(url, { method = "GET", body, token, isFormData = false } = {}) {
102
+ const headers = { access_token: token };
103
+ if (!isFormData) headers["Content-Type"] = "application/json";
104
+
105
+ const opts = { method, headers };
106
+ if (body && !isFormData) opts.body = JSON.stringify(body);
107
+ if (body && isFormData) opts.body = body;
108
+
109
+ const res = await nodefetch(url, opts);
110
+ const data = await res.json();
111
+ if (data.error && data.error !== 0) {
112
+ throw new Error(`OA API error ${data.error}: ${data.message || JSON.stringify(data)}`);
113
+ }
114
+ return data;
115
+ }
116
+
117
+ // ─── Messaging (v3.0) ────────────────────────────────────────────────
118
+
119
+ const VALID_MSG_TYPES = ["cs", "transaction", "promotion"];
120
+
121
+ /** Validate messageType to prevent path injection. */
122
+ function validateMsgType(messageType) {
123
+ if (!VALID_MSG_TYPES.includes(messageType)) {
124
+ throw new Error(`Invalid message type "${messageType}". Must be: ${VALID_MSG_TYPES.join(", ")}`);
125
+ }
126
+ return messageType;
127
+ }
128
+
129
+ /** Send text message via OA. messageType: cs | transaction | promotion */
130
+ export async function sendText(userId, text, messageType = "cs", oaId = "default") {
131
+ const token = getToken(oaId);
132
+ return oaFetch(`${V3_BASE}/message/${validateMsgType(messageType)}`, {
133
+ method: "POST",
134
+ token,
135
+ body: { recipient: { user_id: userId }, message: { text } },
136
+ });
137
+ }
138
+
139
+ /** Send image message via OA (by URL or attachment_id). */
140
+ export async function sendImage(userId, { imageUrl, imageId }, messageType = "cs", oaId = "default") {
141
+ const token = getToken(oaId);
142
+ const element = { media_type: "image" };
143
+ if (imageUrl) element.url = imageUrl;
144
+ else if (imageId) element.attachment_id = imageId;
145
+ else throw new Error("Provide --image-url or --image-id");
146
+
147
+ return oaFetch(`${V3_BASE}/message/${validateMsgType(messageType)}`, {
148
+ method: "POST",
149
+ token,
150
+ body: {
151
+ recipient: { user_id: userId },
152
+ message: {
153
+ attachment: {
154
+ type: "template",
155
+ payload: { template_type: "media", elements: [element] },
156
+ },
157
+ },
158
+ },
159
+ });
160
+ }
161
+
162
+ /** Send file message via OA (requires pre-uploaded file attachment_id). */
163
+ export async function sendFile(userId, fileId, messageType = "cs", oaId = "default") {
164
+ const token = getToken(oaId);
165
+ return oaFetch(`${V3_BASE}/message/${validateMsgType(messageType)}`, {
166
+ method: "POST",
167
+ token,
168
+ body: {
169
+ recipient: { user_id: userId },
170
+ message: { attachment: { type: "file", payload: { token: fileId } } },
171
+ },
172
+ });
173
+ }
174
+
175
+ /** Send list message via OA. elements: [{ title, subtitle, image_url, default_action }] */
176
+ export async function sendList(userId, elements, messageType = "cs", oaId = "default") {
177
+ const token = getToken(oaId);
178
+ return oaFetch(`${V3_BASE}/message/${validateMsgType(messageType)}`, {
179
+ method: "POST",
180
+ token,
181
+ body: {
182
+ recipient: { user_id: userId },
183
+ message: {
184
+ attachment: {
185
+ type: "template",
186
+ payload: { template_type: "list", elements },
187
+ },
188
+ },
189
+ },
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Get ZNS message delivery status.
195
+ * NOTE: This endpoint only works for ZNS (template/notification) message IDs,
196
+ * NOT for regular chat message IDs from conv recent/history.
197
+ * Regular chat messages have no status API — use conv history instead.
198
+ */
199
+ export async function getMessageStatus(messageId, oaId = "default") {
200
+ const token = getToken(oaId);
201
+ // Correct host: business.openapi.zalo.me (different from openapi.zalo.me)
202
+ return oaFetch(`https://business.openapi.zalo.me/message/status?message_id=${messageId}`, { token });
203
+ }
204
+
205
+ // ─── User / Follower Management ──────────────────────────────────────
206
+
207
+ /** Get OA profile info. */
208
+ export async function getOAProfile(oaId = "default") {
209
+ const token = getToken(oaId);
210
+ // v2 fallback — v3 docs incomplete for getoa
211
+ return oaFetch(`${V2_BASE}/getoa`, { token });
212
+ }
213
+
214
+ /** Get follower info by user_id. */
215
+ export async function getFollowerInfo(userId, oaId = "default") {
216
+ const token = getToken(oaId);
217
+ // Zalo v3 requires data={} JSON param format
218
+ const data = encodeURIComponent(JSON.stringify({ user_id: userId }));
219
+ return oaFetch(`${V3_BASE}/user/detail?data=${data}`, { token });
220
+ }
221
+
222
+ /** Get followers list (paginated). */
223
+ export async function getFollowers(offset = 0, count = 50, oaId = "default") {
224
+ const token = getToken(oaId);
225
+ // Zalo v3 requires params as data={"offset":N,"count":N} JSON query param, NOT plain ?offset=N&count=N
226
+ const data = encodeURIComponent(JSON.stringify({ offset, count }));
227
+ return oaFetch(`${V3_BASE}/user/getlist?data=${data}`, { token });
228
+ }
229
+
230
+ /** Update follower info (name, phone, address, etc.). */
231
+ export async function updateFollowerInfo(userId, updates, oaId = "default") {
232
+ const token = getToken(oaId);
233
+ return oaFetch(`${V3_BASE}/user/update`, {
234
+ method: "POST",
235
+ token,
236
+ body: { user_id: userId, ...updates },
237
+ });
238
+ }
239
+
240
+ // ─── Tags ────────────────────────────────────────────────────────────
241
+
242
+ /** Get all tags. */
243
+ export async function getTags(oaId = "default") {
244
+ const token = getToken(oaId);
245
+ // v2 endpoint — v3 not documented
246
+ return oaFetch(`${V2_BASE}/tag/gettagsofoa`, { token });
247
+ }
248
+
249
+ /** Assign tag to follower. */
250
+ export async function assignTag(userId, tagName, oaId = "default") {
251
+ const token = getToken(oaId);
252
+ return oaFetch(`${V3_BASE}/tag/taguser`, {
253
+ method: "POST",
254
+ token,
255
+ body: { user_id: userId, tag_name: tagName },
256
+ });
257
+ }
258
+
259
+ /** Remove tag. */
260
+ export async function removeTag(tagName, oaId = "default") {
261
+ const token = getToken(oaId);
262
+ return oaFetch(`${V3_BASE}/tag/rmtag`, {
263
+ method: "POST",
264
+ token,
265
+ body: { tag_name: tagName },
266
+ });
267
+ }
268
+
269
+ /** Remove follower from tag. */
270
+ export async function removeFollowerFromTag(userId, tagName, oaId = "default") {
271
+ const token = getToken(oaId);
272
+ return oaFetch(`${V3_BASE}/tag/rmfollowerfromtag`, {
273
+ method: "POST",
274
+ token,
275
+ body: { user_id: userId, tag_name: tagName },
276
+ });
277
+ }
278
+
279
+ // ─── Media Upload ────────────────────────────────────────────────────
280
+
281
+ /** Upload image to OA (returns attachment_id). */
282
+ export async function uploadImage(filePath, oaId = "default") {
283
+ const token = getToken(oaId);
284
+ // Use native FormData-like via node-fetch's Blob
285
+ const fileData = fs.readFileSync(filePath);
286
+ const blob = new (await import("node:buffer")).Blob([fileData]);
287
+ const form = new globalThis.FormData();
288
+ form.append("file", blob, filePath.split("/").pop());
289
+
290
+ return oaFetch(`${V3_BASE}/upload/image`, {
291
+ method: "POST",
292
+ token,
293
+ body: form,
294
+ isFormData: true,
295
+ });
296
+ }
297
+
298
+ /** Upload file to OA (returns token/attachment_id). */
299
+ export async function uploadFile(filePath, oaId = "default") {
300
+ const token = getToken(oaId);
301
+ const fileData = fs.readFileSync(filePath);
302
+ const blob = new (await import("node:buffer")).Blob([fileData]);
303
+ const form = new globalThis.FormData();
304
+ form.append("file", blob, filePath.split("/").pop());
305
+
306
+ return oaFetch(`${V3_BASE}/upload/file`, {
307
+ method: "POST",
308
+ token,
309
+ body: form,
310
+ isFormData: true,
311
+ });
312
+ }
313
+
314
+ // ─── Conversations ───────────────────────────────────────────────────
315
+
316
+ /** Get recent chat list (v2 API). */
317
+ export async function getRecentChat(offset = 0, count = 10, oaId = "default") {
318
+ const token = getToken(oaId);
319
+ // Zalo v2 requires params as data={"offset":N,"count":N} JSON query param
320
+ const data = encodeURIComponent(JSON.stringify({ offset, count }));
321
+ return oaFetch(`${V2_BASE}/listrecentchat?data=${data}`, { token });
322
+ }
323
+
324
+ /** Get conversation history with a user (v2 API). */
325
+ export async function getConversation(userId, offset = 0, count = 10, oaId = "default") {
326
+ const token = getToken(oaId);
327
+ // Zalo v2 requires params as data={"user_id":"...","offset":N,"count":N} JSON query param
328
+ const data = encodeURIComponent(JSON.stringify({ user_id: userId, offset, count }));
329
+ return oaFetch(`${V2_BASE}/conversation?data=${data}`, { token });
330
+ }
331
+
332
+ // ─── Menu ────────────────────────────────────────────────────────────
333
+
334
+ /** Update OA menu. */
335
+ export async function updateMenu(menuData, oaId = "default") {
336
+ const token = getToken(oaId);
337
+ return oaFetch(`${V3_BASE}/menu`, { method: "POST", token, body: menuData });
338
+ }
339
+
340
+ // ─── Articles ────────────────────────────────────────────────────────
341
+
342
+ /** Create article (broadcast). */
343
+ export async function createArticle(articleData, oaId = "default") {
344
+ const token = getToken(oaId);
345
+ return oaFetch(`${V3_BASE}/article/create`, { method: "POST", token, body: articleData });
346
+ }
347
+
348
+ /** Get article list. */
349
+ export async function getArticleList(offset = 0, limit = 10, oaId = "default", type = "normal") {
350
+ const token = getToken(oaId);
351
+ // Correct endpoint: v2.0/article/getslice (NOT v3.0/oa/article/getlist) with flat query params
352
+ const V2_ART_BASE = "https://openapi.zalo.me/v2.0/article";
353
+ return oaFetch(`${V2_ART_BASE}/getslice?offset=${offset}&limit=${limit}&type=${type}`, { token });
354
+ }
355
+
356
+ /** Get article detail. */
357
+ export async function getArticleDetail(articleId, oaId = "default") {
358
+ const token = getToken(oaId);
359
+ const V2_ART_BASE = "https://openapi.zalo.me/v2.0/article";
360
+ return oaFetch(`${V2_ART_BASE}/getdetail?id=${articleId}`, { token });
361
+ }
362
+
363
+ // ─── Store ───────────────────────────────────────────────────────────
364
+
365
+ /** Create product in OA store. */
366
+ export async function createProduct(productData, oaId = "default") {
367
+ const token = getToken(oaId);
368
+ return oaFetch(`${V3_BASE}/store/product/create`, { method: "POST", token, body: productData });
369
+ }
370
+
371
+ /** Get product list. */
372
+ export async function getProductList(offset = 0, limit = 10, oaId = "default") {
373
+ const token = getToken(oaId);
374
+ // Correct endpoint: v2.0/mstore (NOT v3.0/store) with flat query params
375
+ const V2_STORE = "https://openapi.zalo.me/v2.0/mstore";
376
+ return oaFetch(`${V2_STORE}/product/getproductofoa?offset=${offset}&limit=${limit}`, { token });
377
+ }
378
+
379
+ /** Get product detail. */
380
+ export async function getProductInfo(productId, oaId = "default") {
381
+ const token = getToken(oaId);
382
+ const V2_STORE = "https://openapi.zalo.me/v2.0/mstore";
383
+ return oaFetch(`${V2_STORE}/product/getproduct?id=${productId}`, { token });
384
+ }
385
+
386
+ /** Create category in OA store. */
387
+ export async function createCategory(categoryData, oaId = "default") {
388
+ const token = getToken(oaId);
389
+ return oaFetch(`${V3_BASE}/store/category/create`, {
390
+ method: "POST",
391
+ token,
392
+ body: categoryData,
393
+ });
394
+ }
395
+
396
+ /** Get category list. */
397
+ export async function getCategoryList(oaId = "default") {
398
+ const token = getToken(oaId);
399
+ const V2_STORE = "https://openapi.zalo.me/v2.0/mstore";
400
+ return oaFetch(`${V2_STORE}/category/getcategoryofoa`, { token });
401
+ }
402
+
403
+ /** Create order. */
404
+ export async function createOrder(orderData, oaId = "default") {
405
+ const token = getToken(oaId);
406
+ return oaFetch(`${V3_BASE}/store/order/create`, { method: "POST", token, body: orderData });
407
+ }