zalo-agent-cli 1.0.31 → 1.2.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.
- package/README.md +148 -738
- package/package.json +6 -3
- package/src/cli.test.js +1 -0
- package/src/commands/listen.js +20 -3
- package/src/commands/login.js +15 -4
- package/src/commands/msg.js +9 -3
- package/src/commands/oa-init.js +503 -0
- package/src/commands/oa-listen.js +215 -0
- package/src/commands/oa.js +657 -0
- package/src/core/oa-client.js +391 -0
- package/src/index.js +7 -4
- package/src/utils/qr-display.js +27 -16
- package/src/utils/qr-display.test.js +98 -0
- package/src/utils/qr-http-server.js +19 -5
|
@@ -0,0 +1,391 @@
|
|
|
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
|
+
/** Get message delivery status. */
|
|
194
|
+
export async function getMessageStatus(messageId, oaId = "default") {
|
|
195
|
+
const token = getToken(oaId);
|
|
196
|
+
return oaFetch(`${V3_BASE}/message/status?message_id=${messageId}`, { token });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── User / Follower Management ──────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
/** Get OA profile info. */
|
|
202
|
+
export async function getOAProfile(oaId = "default") {
|
|
203
|
+
const token = getToken(oaId);
|
|
204
|
+
// v2 fallback — v3 docs incomplete for getoa
|
|
205
|
+
return oaFetch(`${V2_BASE}/getoa`, { token });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Get follower info by user_id. */
|
|
209
|
+
export async function getFollowerInfo(userId, oaId = "default") {
|
|
210
|
+
const token = getToken(oaId);
|
|
211
|
+
return oaFetch(`${V3_BASE}/user/detail?user_id=${userId}`, { token });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Get followers list (paginated). */
|
|
215
|
+
export async function getFollowers(offset = 0, count = 50, oaId = "default") {
|
|
216
|
+
const token = getToken(oaId);
|
|
217
|
+
return oaFetch(`${V3_BASE}/user/getlist?offset=${offset}&count=${count}`, { token });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Update follower info (name, phone, address, etc.). */
|
|
221
|
+
export async function updateFollowerInfo(userId, updates, oaId = "default") {
|
|
222
|
+
const token = getToken(oaId);
|
|
223
|
+
return oaFetch(`${V3_BASE}/user/update`, {
|
|
224
|
+
method: "POST",
|
|
225
|
+
token,
|
|
226
|
+
body: { user_id: userId, ...updates },
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── Tags ────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/** Get all tags. */
|
|
233
|
+
export async function getTags(oaId = "default") {
|
|
234
|
+
const token = getToken(oaId);
|
|
235
|
+
// v2 endpoint — v3 not documented
|
|
236
|
+
return oaFetch(`${V2_BASE}/tag/gettagsofoa`, { token });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Assign tag to follower. */
|
|
240
|
+
export async function assignTag(userId, tagName, oaId = "default") {
|
|
241
|
+
const token = getToken(oaId);
|
|
242
|
+
return oaFetch(`${V3_BASE}/tag/taguser`, {
|
|
243
|
+
method: "POST",
|
|
244
|
+
token,
|
|
245
|
+
body: { user_id: userId, tag_name: tagName },
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Remove tag. */
|
|
250
|
+
export async function removeTag(tagName, oaId = "default") {
|
|
251
|
+
const token = getToken(oaId);
|
|
252
|
+
return oaFetch(`${V3_BASE}/tag/rmtag`, {
|
|
253
|
+
method: "POST",
|
|
254
|
+
token,
|
|
255
|
+
body: { tag_name: tagName },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Remove follower from tag. */
|
|
260
|
+
export async function removeFollowerFromTag(userId, tagName, oaId = "default") {
|
|
261
|
+
const token = getToken(oaId);
|
|
262
|
+
return oaFetch(`${V3_BASE}/tag/rmfollowerfromtag`, {
|
|
263
|
+
method: "POST",
|
|
264
|
+
token,
|
|
265
|
+
body: { user_id: userId, tag_name: tagName },
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ─── Media Upload ────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/** Upload image to OA (returns attachment_id). */
|
|
272
|
+
export async function uploadImage(filePath, oaId = "default") {
|
|
273
|
+
const token = getToken(oaId);
|
|
274
|
+
const { default: FormData } = await import("node-fetch");
|
|
275
|
+
// Use native FormData-like via node-fetch's Blob
|
|
276
|
+
const fileData = fs.readFileSync(filePath);
|
|
277
|
+
const blob = new (await import("node:buffer")).Blob([fileData]);
|
|
278
|
+
const form = new globalThis.FormData();
|
|
279
|
+
form.append("file", blob, filePath.split("/").pop());
|
|
280
|
+
|
|
281
|
+
return oaFetch(`${V3_BASE}/upload/image`, {
|
|
282
|
+
method: "POST",
|
|
283
|
+
token,
|
|
284
|
+
body: form,
|
|
285
|
+
isFormData: true,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Upload file to OA (returns token/attachment_id). */
|
|
290
|
+
export async function uploadFile(filePath, oaId = "default") {
|
|
291
|
+
const token = getToken(oaId);
|
|
292
|
+
const fileData = fs.readFileSync(filePath);
|
|
293
|
+
const blob = new (await import("node:buffer")).Blob([fileData]);
|
|
294
|
+
const form = new globalThis.FormData();
|
|
295
|
+
form.append("file", blob, filePath.split("/").pop());
|
|
296
|
+
|
|
297
|
+
return oaFetch(`${V3_BASE}/upload/file`, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
token,
|
|
300
|
+
body: form,
|
|
301
|
+
isFormData: true,
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── Conversations ───────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
/** Get recent chat list (v2 API). */
|
|
308
|
+
export async function getRecentChat(offset = 0, count = 10, oaId = "default") {
|
|
309
|
+
const token = getToken(oaId);
|
|
310
|
+
return oaFetch(`${V2_BASE}/listrecentchat?offset=${offset}&count=${count}`, { token });
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Get conversation history with a user (v2 API). */
|
|
314
|
+
export async function getConversation(userId, offset = 0, count = 10, oaId = "default") {
|
|
315
|
+
const token = getToken(oaId);
|
|
316
|
+
return oaFetch(`${V2_BASE}/conversation?user_id=${userId}&offset=${offset}&count=${count}`, {
|
|
317
|
+
token,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Menu ────────────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
/** Update OA menu. */
|
|
324
|
+
export async function updateMenu(menuData, oaId = "default") {
|
|
325
|
+
const token = getToken(oaId);
|
|
326
|
+
return oaFetch(`${V3_BASE}/menu`, { method: "POST", token, body: menuData });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ─── Articles ────────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
/** Create article (broadcast). */
|
|
332
|
+
export async function createArticle(articleData, oaId = "default") {
|
|
333
|
+
const token = getToken(oaId);
|
|
334
|
+
return oaFetch(`${V3_BASE}/article/create`, { method: "POST", token, body: articleData });
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Get article list. */
|
|
338
|
+
export async function getArticleList(offset = 0, limit = 10, oaId = "default") {
|
|
339
|
+
const token = getToken(oaId);
|
|
340
|
+
return oaFetch(`${V3_BASE}/article/getlist?offset=${offset}&limit=${limit}`, { token });
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Get article detail. */
|
|
344
|
+
export async function getArticleDetail(articleId, oaId = "default") {
|
|
345
|
+
const token = getToken(oaId);
|
|
346
|
+
return oaFetch(`${V3_BASE}/article/getdetail?id=${articleId}`, { token });
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ─── Store ───────────────────────────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
/** Create product in OA store. */
|
|
352
|
+
export async function createProduct(productData, oaId = "default") {
|
|
353
|
+
const token = getToken(oaId);
|
|
354
|
+
return oaFetch(`${V3_BASE}/store/product/create`, { method: "POST", token, body: productData });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Get product list. */
|
|
358
|
+
export async function getProductList(offset = 0, limit = 10, oaId = "default") {
|
|
359
|
+
const token = getToken(oaId);
|
|
360
|
+
return oaFetch(`${V3_BASE}/store/product/getproductofoa?offset=${offset}&limit=${limit}`, {
|
|
361
|
+
token,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Get product detail. */
|
|
366
|
+
export async function getProductInfo(productId, oaId = "default") {
|
|
367
|
+
const token = getToken(oaId);
|
|
368
|
+
return oaFetch(`${V3_BASE}/store/product/getproduct?id=${productId}`, { token });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Create category in OA store. */
|
|
372
|
+
export async function createCategory(categoryData, oaId = "default") {
|
|
373
|
+
const token = getToken(oaId);
|
|
374
|
+
return oaFetch(`${V3_BASE}/store/category/create`, {
|
|
375
|
+
method: "POST",
|
|
376
|
+
token,
|
|
377
|
+
body: categoryData,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Get category list. */
|
|
382
|
+
export async function getCategoryList(oaId = "default") {
|
|
383
|
+
const token = getToken(oaId);
|
|
384
|
+
return oaFetch(`${V3_BASE}/store/category/getcategoryofoa`, { token });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Create order. */
|
|
388
|
+
export async function createOrder(orderData, oaId = "default") {
|
|
389
|
+
const token = getToken(oaId);
|
|
390
|
+
return oaFetch(`${V3_BASE}/store/order/create`, { method: "POST", token, body: orderData });
|
|
391
|
+
}
|
package/src/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { registerQuickMsgCommands } from "./commands/quick-msg.js";
|
|
|
26
26
|
import { registerLabelCommands } from "./commands/label.js";
|
|
27
27
|
import { registerCatalogCommands } from "./commands/catalog.js";
|
|
28
28
|
import { registerListenCommand } from "./commands/listen.js";
|
|
29
|
+
import { registerOACommands } from "./commands/oa.js";
|
|
29
30
|
import { autoLogin } from "./core/zalo-client.js";
|
|
30
31
|
import { checkForUpdates, selfUpdate } from "./utils/update-check.js";
|
|
31
32
|
import { success, error, warning } from "./utils/output.js";
|
|
@@ -41,16 +42,17 @@ program
|
|
|
41
42
|
.version(pkg.version)
|
|
42
43
|
.option("--json", "Output results as JSON (machine-readable)")
|
|
43
44
|
.hook("preAction", async (thisCommand) => {
|
|
45
|
+
const cmdName = thisCommand.args?.[0] || thisCommand.name();
|
|
44
46
|
// Suppress zca-js internal logs in JSON mode to keep stdout clean for piping
|
|
45
47
|
if (program.opts().json) {
|
|
46
48
|
process.env.ZALO_JSON_MODE = "1";
|
|
47
|
-
} else {
|
|
49
|
+
} else if (cmdName !== "oa") {
|
|
50
|
+
// OA commands use official Zalo API — no disclaimer needed
|
|
48
51
|
warning(DISCLAIMER);
|
|
49
52
|
console.log();
|
|
50
53
|
}
|
|
51
|
-
// Auto-login before any command that needs it (skip for login/account commands)
|
|
52
|
-
const
|
|
53
|
-
const skipAutoLogin = ["login", "account", "help", "version", "update"].includes(cmdName);
|
|
54
|
+
// Auto-login before any command that needs it (skip for login/account/oa commands)
|
|
55
|
+
const skipAutoLogin = ["login", "account", "help", "version", "update", "oa"].includes(cmdName);
|
|
54
56
|
if (!skipAutoLogin) {
|
|
55
57
|
await autoLogin(program.opts().json);
|
|
56
58
|
}
|
|
@@ -85,5 +87,6 @@ registerQuickMsgCommands(program);
|
|
|
85
87
|
registerLabelCommands(program);
|
|
86
88
|
registerCatalogCommands(program);
|
|
87
89
|
registerListenCommand(program);
|
|
90
|
+
registerOACommands(program);
|
|
88
91
|
|
|
89
92
|
program.parse();
|
package/src/utils/qr-display.js
CHANGED
|
@@ -36,39 +36,50 @@ function getOpenCommand() {
|
|
|
36
36
|
/**
|
|
37
37
|
* Display QR code from a zca-js login QR event.
|
|
38
38
|
* Synchronous — safe to call from zca-js callback.
|
|
39
|
+
* In JSON mode (--json), outputs structured event for AI agents.
|
|
39
40
|
* @param {object} event - zca-js QR callback event
|
|
40
41
|
*/
|
|
41
42
|
export function displayQR(event) {
|
|
42
43
|
const imageB64 = event.data?.image || "";
|
|
44
|
+
const jsonMode = process.env.ZALO_JSON_MODE === "1";
|
|
43
45
|
|
|
44
|
-
//
|
|
45
|
-
// Uses iTerm2 inline image protocol (also WezTerm, Hyper, Kitty)
|
|
46
|
-
// Terminals that don't support it simply ignore the escape sequence
|
|
47
|
-
if (imageB64) {
|
|
48
|
-
const b64ForTerm = Buffer.from(imageB64, "base64").toString("base64");
|
|
49
|
-
process.stdout.write(`\x1b]1337;File=inline=1;width=30;preserveAspectRatio=1:${b64ForTerm}\x07\n`);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// 2. Save PNG to config dir
|
|
46
|
+
// Always save PNG to config dir (needed by HTTP server and agents)
|
|
53
47
|
if (imageB64) {
|
|
54
48
|
try {
|
|
55
49
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
56
50
|
writeFileSync(QR_PATH, Buffer.from(imageB64, "base64"));
|
|
57
|
-
const openCmd = getOpenCommand();
|
|
58
|
-
info(`QR image saved: ${QR_PATH}`);
|
|
59
|
-
info(`To open: ${openCmd} "${QR_PATH}"`);
|
|
60
51
|
} catch {}
|
|
61
52
|
}
|
|
62
53
|
|
|
63
|
-
//
|
|
54
|
+
// Also fire-and-forget the zca-js built-in save
|
|
64
55
|
if (event.actions?.saveToFile) {
|
|
65
56
|
event.actions.saveToFile(QR_PATH).catch(() => {});
|
|
66
57
|
}
|
|
67
58
|
|
|
68
|
-
//
|
|
59
|
+
// JSON mode: structured output for AI agents — no terminal escapes, no noise
|
|
60
|
+
if (jsonMode) {
|
|
61
|
+
if (imageB64) {
|
|
62
|
+
console.log(JSON.stringify({
|
|
63
|
+
event: "qr",
|
|
64
|
+
image: imageB64,
|
|
65
|
+
file: QR_PATH,
|
|
66
|
+
dataUrl: `data:image/png;base64,${imageB64}`,
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Human mode: terminal inline image + hints
|
|
69
73
|
if (imageB64) {
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
// iTerm2/Kitty/WezTerm inline image protocol
|
|
75
|
+
const b64ForTerm = Buffer.from(imageB64, "base64").toString("base64");
|
|
76
|
+
process.stdout.write(`\x1b]1337;File=inline=1;width=30;preserveAspectRatio=1:${b64ForTerm}\x07\n`);
|
|
77
|
+
|
|
78
|
+
const openCmd = getOpenCommand();
|
|
79
|
+
info(`QR image saved: ${QR_PATH}`);
|
|
80
|
+
info(`To open: ${openCmd} "${QR_PATH}"`);
|
|
81
|
+
info("Copy this URL and paste in any browser to view QR:");
|
|
82
|
+
console.log(`data:image/png;base64,${imageB64}`);
|
|
72
83
|
}
|
|
73
84
|
}
|
|
74
85
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for QR display utility — JSON mode structured output for AI agents.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { displayQR, getQRPath } from "./qr-display.js";
|
|
8
|
+
import { existsSync, unlinkSync, mkdirSync } from "fs";
|
|
9
|
+
import { dirname } from "path";
|
|
10
|
+
|
|
11
|
+
// Tiny valid 1x1 white PNG as base64 (for testing without real QR)
|
|
12
|
+
const TINY_PNG_B64 =
|
|
13
|
+
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==";
|
|
14
|
+
|
|
15
|
+
describe("displayQR", () => {
|
|
16
|
+
let originalLog;
|
|
17
|
+
let captured;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
// Capture console.log output
|
|
21
|
+
originalLog = console.log;
|
|
22
|
+
captured = [];
|
|
23
|
+
console.log = (...args) => captured.push(args.join(" "));
|
|
24
|
+
|
|
25
|
+
// Ensure config dir exists for file save
|
|
26
|
+
const qrDir = dirname(getQRPath());
|
|
27
|
+
mkdirSync(qrDir, { recursive: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
console.log = originalLog;
|
|
32
|
+
delete process.env.ZALO_JSON_MODE;
|
|
33
|
+
|
|
34
|
+
// Clean up saved QR file
|
|
35
|
+
try {
|
|
36
|
+
unlinkSync(getQRPath());
|
|
37
|
+
} catch {}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("JSON mode outputs structured event with all fields", () => {
|
|
41
|
+
process.env.ZALO_JSON_MODE = "1";
|
|
42
|
+
displayQR({ data: { image: TINY_PNG_B64 } });
|
|
43
|
+
|
|
44
|
+
assert.equal(captured.length, 1, "should output exactly one JSON line");
|
|
45
|
+
const parsed = JSON.parse(captured[0]);
|
|
46
|
+
assert.equal(parsed.event, "qr");
|
|
47
|
+
assert.equal(parsed.image, TINY_PNG_B64);
|
|
48
|
+
assert.ok(parsed.file.endsWith("qr.png"), "file path should end with qr.png");
|
|
49
|
+
assert.ok(parsed.dataUrl.startsWith("data:image/png;base64,"), "dataUrl should be a data URL");
|
|
50
|
+
assert.ok(parsed.dataUrl.includes(TINY_PNG_B64), "dataUrl should contain full base64");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("JSON mode does not output terminal escape sequences", () => {
|
|
54
|
+
process.env.ZALO_JSON_MODE = "1";
|
|
55
|
+
|
|
56
|
+
// Also capture stdout.write
|
|
57
|
+
const stdoutWrites = [];
|
|
58
|
+
const originalWrite = process.stdout.write;
|
|
59
|
+
process.stdout.write = (data) => stdoutWrites.push(data);
|
|
60
|
+
|
|
61
|
+
displayQR({ data: { image: TINY_PNG_B64 } });
|
|
62
|
+
|
|
63
|
+
process.stdout.write = originalWrite;
|
|
64
|
+
|
|
65
|
+
// No iTerm2 escape sequences
|
|
66
|
+
const hasEscape = stdoutWrites.some((w) => typeof w === "string" && w.includes("\x1b]1337"));
|
|
67
|
+
assert.ok(!hasEscape, "JSON mode should not output terminal escape sequences");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("saves QR PNG file in both modes", () => {
|
|
71
|
+
process.env.ZALO_JSON_MODE = "1";
|
|
72
|
+
displayQR({ data: { image: TINY_PNG_B64 } });
|
|
73
|
+
assert.ok(existsSync(getQRPath()), "QR PNG should be saved to disk");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("handles empty image gracefully in JSON mode", () => {
|
|
77
|
+
process.env.ZALO_JSON_MODE = "1";
|
|
78
|
+
displayQR({ data: {} });
|
|
79
|
+
assert.equal(captured.length, 0, "should not output anything for empty image");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("human mode outputs data URL with full base64 (not truncated)", () => {
|
|
83
|
+
// No ZALO_JSON_MODE set = human mode
|
|
84
|
+
// Suppress stdout.write (terminal escapes)
|
|
85
|
+
const originalWrite = process.stdout.write;
|
|
86
|
+
process.stdout.write = () => true;
|
|
87
|
+
|
|
88
|
+
displayQR({ data: { image: TINY_PNG_B64 } });
|
|
89
|
+
|
|
90
|
+
process.stdout.write = originalWrite;
|
|
91
|
+
|
|
92
|
+
// Find the data URL line in captured output
|
|
93
|
+
const dataUrlLine = captured.find((line) => line.startsWith("data:image/png;base64,"));
|
|
94
|
+
assert.ok(dataUrlLine, "should output a data URL line");
|
|
95
|
+
assert.ok(dataUrlLine.includes(TINY_PNG_B64), "data URL should contain full base64, not truncated");
|
|
96
|
+
assert.ok(!dataUrlLine.includes("..."), "data URL should not be truncated with ...");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -124,14 +124,28 @@ setInterval(async()=>{
|
|
|
124
124
|
|
|
125
125
|
server.on("listening", async () => {
|
|
126
126
|
const actualPort = server.address().port;
|
|
127
|
-
|
|
127
|
+
const jsonMode = process.env.ZALO_JSON_MODE === "1";
|
|
128
|
+
let publicIp = null;
|
|
129
|
+
|
|
128
130
|
// Auto-detect public IP for VPS users
|
|
129
131
|
try {
|
|
130
132
|
const res = await nodefetch("https://api.ipify.org", { timeout: 3000 });
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
publicIp = (await res.text()).trim() || null;
|
|
134
|
+
} catch {}
|
|
135
|
+
|
|
136
|
+
if (jsonMode) {
|
|
137
|
+
console.log(JSON.stringify({
|
|
138
|
+
event: "qr_server",
|
|
139
|
+
port: actualPort,
|
|
140
|
+
localUrl: `http://localhost:${actualPort}/qr`,
|
|
141
|
+
publicUrl: publicIp ? `http://${publicIp}:${actualPort}/qr` : null,
|
|
142
|
+
}));
|
|
143
|
+
} else {
|
|
144
|
+
info(`QR available at: http://localhost:${actualPort}/qr`);
|
|
145
|
+
if (publicIp) info(`On VPS, open: http://${publicIp}:${actualPort}/qr`);
|
|
146
|
+
else info(`On VPS, open: http://<your-server-ip>:${actualPort}/qr`);
|
|
147
|
+
info(`If firewall blocks, run: sudo ufw allow ${actualPort}/tcp`);
|
|
148
|
+
info(`Or copy QR file: scp user@vps:~/.zalo-agent/qr.png ./qr.png`);
|
|
135
149
|
}
|
|
136
150
|
});
|
|
137
151
|
|