zalo-agent-cli 1.1.0 → 1.3.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,239 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ZaloNotifier } from "./notifier.js";
4
+
5
+ /** Create a spy api.sendMessage that records calls */
6
+ function makeSpy() {
7
+ const calls = [];
8
+ const api = {
9
+ sendMessage: async (text, thread, type) => {
10
+ calls.push({ text, thread, type });
11
+ },
12
+ };
13
+ return { api, calls };
14
+ }
15
+
16
+ /** Minimal enabled config for ZaloNotifier */
17
+ function enabledConfig(overrides = {}) {
18
+ return {
19
+ notify: {
20
+ enabled: true,
21
+ thread: "notify_group_123",
22
+ on: ["dm"],
23
+ cooldown: "10ms", // very short for tests
24
+ ...overrides,
25
+ },
26
+ };
27
+ }
28
+
29
+ /** Disabled config */
30
+ function disabledConfig() {
31
+ return {
32
+ notify: {
33
+ enabled: false,
34
+ thread: "notify_group_123",
35
+ on: ["dm"],
36
+ cooldown: "10ms",
37
+ },
38
+ };
39
+ }
40
+
41
+ /** A normalised DM message */
42
+ function dmMsg(text = "hello", overrides = {}) {
43
+ return { threadType: "dm", senderId: "user_1", senderName: "Alice", text, ...overrides };
44
+ }
45
+
46
+ /** Wait for ms milliseconds */
47
+ function wait(ms) {
48
+ return new Promise((r) => setTimeout(r, ms));
49
+ }
50
+
51
+ describe("ZaloNotifier constructor", () => {
52
+ it("is disabled by default when config.notify.enabled=false", () => {
53
+ const { api } = makeSpy();
54
+ const n = new ZaloNotifier(api, disabledConfig());
55
+ assert.equal(n._enabled, false);
56
+ });
57
+
58
+ it("is enabled when config.notify.enabled=true", () => {
59
+ const { api } = makeSpy();
60
+ const n = new ZaloNotifier(api, enabledConfig());
61
+ assert.equal(n._enabled, true);
62
+ });
63
+
64
+ it("stores notify thread and onTypes from config", () => {
65
+ const { api } = makeSpy();
66
+ const n = new ZaloNotifier(api, enabledConfig({ thread: "grp_99", on: ["dm", "group"] }));
67
+ assert.equal(n._notifyThread, "grp_99");
68
+ assert.ok(n._onTypes.has("dm"));
69
+ assert.ok(n._onTypes.has("group"));
70
+ });
71
+
72
+ it("agentConnected starts as false", () => {
73
+ const { api } = makeSpy();
74
+ const n = new ZaloNotifier(api, enabledConfig());
75
+ assert.equal(n._agentConnected, false);
76
+ });
77
+ });
78
+
79
+ describe("ZaloNotifier onMessage - disabled", () => {
80
+ it("does not queue messages when disabled", async () => {
81
+ const { api, calls } = makeSpy();
82
+ const n = new ZaloNotifier(api, disabledConfig());
83
+ n.onMessage(dmMsg());
84
+ await wait(30);
85
+ assert.equal(calls.length, 0);
86
+ assert.equal(n._pending.length, 0);
87
+ n.destroy();
88
+ });
89
+ });
90
+
91
+ describe("ZaloNotifier onMessage - agent connected", () => {
92
+ it("does not notify when agent is connected", async () => {
93
+ const { api, calls } = makeSpy();
94
+ const n = new ZaloNotifier(api, enabledConfig());
95
+ n.setAgentConnected(true);
96
+ n.onMessage(dmMsg());
97
+ await wait(30);
98
+ assert.equal(calls.length, 0);
99
+ n.destroy();
100
+ });
101
+
102
+ it("notifies again after agent disconnects", async () => {
103
+ const { api, calls } = makeSpy();
104
+ const n = new ZaloNotifier(api, enabledConfig());
105
+ n.setAgentConnected(true);
106
+ n.onMessage(dmMsg("ignored"));
107
+ n.setAgentConnected(false);
108
+ n.onMessage(dmMsg("should notify"));
109
+ await wait(30);
110
+ assert.equal(calls.length, 1);
111
+ n.destroy();
112
+ });
113
+ });
114
+
115
+ describe("ZaloNotifier onMessage - type filtering", () => {
116
+ it("queues messages matching onTypes (dm)", async () => {
117
+ const { api, calls } = makeSpy();
118
+ const n = new ZaloNotifier(api, enabledConfig({ on: ["dm"] }));
119
+ n.onMessage(dmMsg("queued"));
120
+ await wait(30);
121
+ assert.equal(calls.length, 1);
122
+ });
123
+
124
+ it("ignores messages not matching onTypes", async () => {
125
+ const { api, calls } = makeSpy();
126
+ const n = new ZaloNotifier(api, enabledConfig({ on: ["dm"] }));
127
+ n.onMessage({ threadType: "group", senderId: "u1", senderName: "Bob", text: "hi" });
128
+ await wait(30);
129
+ assert.equal(calls.length, 0);
130
+ });
131
+
132
+ it("notifies for group type when configured", async () => {
133
+ const { api, calls } = makeSpy();
134
+ const n = new ZaloNotifier(api, enabledConfig({ on: ["group"] }));
135
+ n.onMessage({ threadType: "group", senderId: "u1", senderName: "Bob", text: "hi" });
136
+ await wait(30);
137
+ assert.equal(calls.length, 1);
138
+ });
139
+ });
140
+
141
+ describe("ZaloNotifier _flush notification format", () => {
142
+ it("sends to the configured notify thread", async () => {
143
+ const { api, calls } = makeSpy();
144
+ const n = new ZaloNotifier(api, enabledConfig({ thread: "grp_notify" }));
145
+ n.onMessage(dmMsg("test message"));
146
+ await wait(30);
147
+ assert.equal(calls[0].thread, "grp_notify");
148
+ assert.equal(calls[0].type, 1); // Group conversation type
149
+ });
150
+
151
+ it("notification text includes sender name and message preview", async () => {
152
+ const { api, calls } = makeSpy();
153
+ const n = new ZaloNotifier(api, enabledConfig());
154
+ n.onMessage(dmMsg("hello world", { senderName: "Alice" }));
155
+ await wait(30);
156
+ assert.ok(calls[0].text.includes("Alice"));
157
+ assert.ok(calls[0].text.includes("hello world"));
158
+ });
159
+
160
+ it("shows up to 3 message previews in a single flush", async () => {
161
+ const { api, calls } = makeSpy();
162
+ const n = new ZaloNotifier(api, enabledConfig());
163
+ n.onMessage(dmMsg("msg1", { senderName: "A" }));
164
+ n.onMessage(dmMsg("msg2", { senderName: "B" }));
165
+ n.onMessage(dmMsg("msg3", { senderName: "C" }));
166
+ await wait(30);
167
+ // Only one batched send
168
+ assert.equal(calls.length, 1);
169
+ assert.ok(calls[0].text.includes("msg1"));
170
+ assert.ok(calls[0].text.includes("msg2"));
171
+ assert.ok(calls[0].text.includes("msg3"));
172
+ });
173
+
174
+ it("includes overflow count when more than 3 messages", async () => {
175
+ const { api, calls } = makeSpy();
176
+ const n = new ZaloNotifier(api, enabledConfig());
177
+ for (let i = 1; i <= 5; i++) {
178
+ n.onMessage(dmMsg(`msg${i}`, { senderName: `User${i}` }));
179
+ }
180
+ await wait(30);
181
+ assert.equal(calls.length, 1);
182
+ // Should mention the extra 2 messages
183
+ assert.ok(calls[0].text.includes("2"));
184
+ });
185
+
186
+ it("sends total message count in notification text", async () => {
187
+ const { api, calls } = makeSpy();
188
+ const n = new ZaloNotifier(api, enabledConfig());
189
+ n.onMessage(dmMsg("one"));
190
+ n.onMessage(dmMsg("two"));
191
+ await wait(30);
192
+ assert.ok(calls[0].text.includes("2"));
193
+ });
194
+ });
195
+
196
+ describe("ZaloNotifier cooldown batching", () => {
197
+ it("batches multiple messages within cooldown into a single send", async () => {
198
+ const { api, calls } = makeSpy();
199
+ const n = new ZaloNotifier(api, enabledConfig({ cooldown: "20ms" }));
200
+ n.onMessage(dmMsg("first"));
201
+ n.onMessage(dmMsg("second"));
202
+ n.onMessage(dmMsg("third"));
203
+ await wait(50); // wait for cooldown to fire
204
+ assert.equal(calls.length, 1);
205
+ });
206
+
207
+ it("clears pending after flush so next batch is independent", async () => {
208
+ const { api, calls } = makeSpy();
209
+ const n = new ZaloNotifier(api, enabledConfig({ cooldown: "20ms" }));
210
+ n.onMessage(dmMsg("batch1"));
211
+ await wait(40); // first flush
212
+
213
+ n.onMessage(dmMsg("batch2"));
214
+ await wait(40); // second flush
215
+
216
+ assert.equal(calls.length, 2);
217
+ });
218
+ });
219
+
220
+ describe("ZaloNotifier destroy", () => {
221
+ it("flushes pending messages on destroy", async () => {
222
+ const { api, calls } = makeSpy();
223
+ // Long cooldown so timer wouldn't fire naturally
224
+ const n = new ZaloNotifier(api, enabledConfig({ cooldown: "60000ms" }));
225
+ n.onMessage(dmMsg("urgent"));
226
+ n.destroy(); // should flush immediately
227
+ // _flush is async, give it a tick
228
+ await wait(10);
229
+ assert.equal(calls.length, 1);
230
+ });
231
+
232
+ it("destroy with no pending messages does not send", async () => {
233
+ const { api, calls } = makeSpy();
234
+ const n = new ZaloNotifier(api, enabledConfig());
235
+ n.destroy(); // nothing pending, no timer
236
+ await wait(10);
237
+ assert.equal(calls.length, 0);
238
+ });
239
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Filter which threads to watch and which messages to keep.
3
+ * Supports glob-like patterns for thread matching and noise reduction.
4
+ */
5
+
6
+ /** System message types to drop (join, leave, pin, etc.) */
7
+ const SYSTEM_MSG_TYPES = new Set(["system", "join", "leave", "pin", "unpin", "rename"]);
8
+
9
+ export class ThreadFilter {
10
+ /**
11
+ * @param {object} config - MCP config object
12
+ * @param {string[]} config.watchThreads - Glob patterns like ["dm:*", "group:support"]
13
+ * @param {string[]} [config.triggerKeywords] - Keywords that trigger attention
14
+ */
15
+ constructor(config) {
16
+ this._watchPatterns = config.watchThreads || ["dm:*", "group:*"];
17
+ this._triggerKeywords = (config.triggerKeywords || []).map((k) => k.toLowerCase());
18
+ }
19
+
20
+ /**
21
+ * Check if a thread should be watched based on patterns.
22
+ * Pattern format: "dm:*", "group:*", "group:exact_id", "dm:exact_id"
23
+ * @param {string} threadId
24
+ * @param {"group"|"dm"} threadType
25
+ * @returns {boolean}
26
+ */
27
+ shouldWatch(threadId, threadType) {
28
+ const key = `${threadType}:${threadId}`;
29
+ for (const pattern of this._watchPatterns) {
30
+ if (this._matchPattern(pattern, key, threadType)) return true;
31
+ }
32
+ return false;
33
+ }
34
+
35
+ /**
36
+ * Check if a message passes noise filter (drop stickers, short emoji, system msgs).
37
+ * @param {object} message - Normalized message
38
+ * @returns {boolean}
39
+ */
40
+ shouldKeep(message) {
41
+ // Drop system messages
42
+ if (message.type && SYSTEM_MSG_TYPES.has(message.type)) return false;
43
+ // Drop sticker-only messages
44
+ if (message.type === "sticker") return false;
45
+ // Drop very short emoji-only messages (< 3 chars, all emoji/whitespace)
46
+ if (message.text && message.text.length < 3 && /^[\s\p{Emoji}]*$/u.test(message.text)) {
47
+ return false;
48
+ }
49
+ // Keep everything else (including images, files, links)
50
+ return true;
51
+ }
52
+
53
+ /**
54
+ * Check if a message is a trigger (contains keyword or @mention).
55
+ * @param {object} message - Normalized message
56
+ * @returns {boolean}
57
+ */
58
+ isTrigger(message) {
59
+ if (!message.text || this._triggerKeywords.length === 0) return false;
60
+ const lower = message.text.toLowerCase();
61
+ return this._triggerKeywords.some((kw) => lower.includes(kw));
62
+ }
63
+
64
+ /**
65
+ * Match a pattern against a thread key.
66
+ * @param {string} pattern - e.g. "dm:*", "group:support_123"
67
+ * @param {string} key - e.g. "dm:user_456", "group:support_123"
68
+ * @param {string} threadType - "dm" or "group"
69
+ * @returns {boolean}
70
+ */
71
+ _matchPattern(pattern, key, threadType) {
72
+ // Wildcard: "dm:*" matches all DMs, "group:*" matches all groups
73
+ if (pattern === `${threadType}:*`) return true;
74
+ // Catch-all patterns
75
+ if (pattern === "*" || pattern === "*:*") return true;
76
+ // Exact match
77
+ return pattern === key;
78
+ }
79
+ }
@@ -0,0 +1,206 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ThreadFilter } from "./thread-filter.js";
4
+
5
+ /** Helper: create a ThreadFilter with given patterns and keywords */
6
+ function makeFilter(watchThreads = ["dm:*", "group:*"], triggerKeywords = []) {
7
+ return new ThreadFilter({ watchThreads, triggerKeywords });
8
+ }
9
+
10
+ describe("ThreadFilter shouldWatch - glob patterns", () => {
11
+ it("dm:* matches any DM thread", () => {
12
+ const f = makeFilter(["dm:*"]);
13
+ assert.equal(f.shouldWatch("user_123", "dm"), true);
14
+ assert.equal(f.shouldWatch("abc", "dm"), true);
15
+ });
16
+
17
+ it("group:* matches any group thread", () => {
18
+ const f = makeFilter(["group:*"]);
19
+ assert.equal(f.shouldWatch("support_456", "group"), true);
20
+ assert.equal(f.shouldWatch("anything", "group"), true);
21
+ });
22
+
23
+ it("dm:* does not match group threads", () => {
24
+ const f = makeFilter(["dm:*"]);
25
+ assert.equal(f.shouldWatch("some_group", "group"), false);
26
+ });
27
+
28
+ it("group:* does not match dm threads", () => {
29
+ const f = makeFilter(["group:*"]);
30
+ assert.equal(f.shouldWatch("some_user", "dm"), false);
31
+ });
32
+ });
33
+
34
+ describe("ThreadFilter shouldWatch - exact match", () => {
35
+ it("exact group pattern matches only that thread id", () => {
36
+ const f = makeFilter(["group:support_123"]);
37
+ assert.equal(f.shouldWatch("support_123", "group"), true);
38
+ assert.equal(f.shouldWatch("support_456", "group"), false);
39
+ });
40
+
41
+ it("exact dm pattern matches only that thread id", () => {
42
+ const f = makeFilter(["dm:user_42"]);
43
+ assert.equal(f.shouldWatch("user_42", "dm"), true);
44
+ assert.equal(f.shouldWatch("user_99", "dm"), false);
45
+ });
46
+
47
+ it("exact match requires type prefix to align", () => {
48
+ const f = makeFilter(["group:support_123"]);
49
+ // same id but different type should not match
50
+ assert.equal(f.shouldWatch("support_123", "dm"), false);
51
+ });
52
+ });
53
+
54
+ describe("ThreadFilter shouldWatch - catch-all patterns", () => {
55
+ it("* matches any thread regardless of type", () => {
56
+ const f = makeFilter(["*"]);
57
+ assert.equal(f.shouldWatch("user_1", "dm"), true);
58
+ assert.equal(f.shouldWatch("group_1", "group"), true);
59
+ });
60
+
61
+ it("*:* matches any thread regardless of type", () => {
62
+ const f = makeFilter(["*:*"]);
63
+ assert.equal(f.shouldWatch("user_1", "dm"), true);
64
+ assert.equal(f.shouldWatch("group_1", "group"), true);
65
+ });
66
+ });
67
+
68
+ describe("ThreadFilter shouldWatch - unmatched patterns", () => {
69
+ it("returns false when no pattern matches", () => {
70
+ const f = makeFilter(["group:specific_only"]);
71
+ assert.equal(f.shouldWatch("other_group", "group"), false);
72
+ assert.equal(f.shouldWatch("any_user", "dm"), false);
73
+ });
74
+
75
+ it("empty watchThreads array never matches", () => {
76
+ const f = makeFilter([]);
77
+ assert.equal(f.shouldWatch("x", "dm"), false);
78
+ assert.equal(f.shouldWatch("y", "group"), false);
79
+ });
80
+ });
81
+
82
+ describe("ThreadFilter shouldKeep - system messages dropped", () => {
83
+ const SYSTEM_TYPES = ["system", "join", "leave", "pin", "unpin", "rename"];
84
+
85
+ for (const type of SYSTEM_TYPES) {
86
+ it(`drops message with type="${type}"`, () => {
87
+ const f = makeFilter();
88
+ assert.equal(f.shouldKeep({ type, text: "some text" }), false);
89
+ });
90
+ }
91
+ });
92
+
93
+ describe("ThreadFilter shouldKeep - sticker messages dropped", () => {
94
+ it("drops sticker-only messages", () => {
95
+ const f = makeFilter();
96
+ assert.equal(f.shouldKeep({ type: "sticker" }), false);
97
+ });
98
+ });
99
+
100
+ describe("ThreadFilter shouldKeep - short emoji-only messages dropped", () => {
101
+ it("drops single emoji (< 3 chars)", () => {
102
+ const f = makeFilter();
103
+ assert.equal(f.shouldKeep({ text: "👍" }), false);
104
+ });
105
+
106
+ it("drops two-char emoji message", () => {
107
+ const f = makeFilter();
108
+ assert.equal(f.shouldKeep({ text: "😂" }), false);
109
+ });
110
+
111
+ it("keeps messages with 3+ chars even if emoji", () => {
112
+ const f = makeFilter();
113
+ // Three emojis — length in JS may vary; use text that is clearly >= 3 code units
114
+ assert.equal(f.shouldKeep({ text: "hey" }), true);
115
+ });
116
+
117
+ it("drops whitespace-only short message", () => {
118
+ const f = makeFilter();
119
+ // 1-2 spaces are < 3 chars and match /^[\s\p{Emoji}]*$/u
120
+ assert.equal(f.shouldKeep({ text: " " }), false);
121
+ });
122
+ });
123
+
124
+ describe("ThreadFilter shouldKeep - kept messages", () => {
125
+ it("keeps normal text messages", () => {
126
+ const f = makeFilter();
127
+ assert.equal(f.shouldKeep({ type: "text", text: "Hello there!" }), true);
128
+ });
129
+
130
+ it("keeps image messages (no text)", () => {
131
+ const f = makeFilter();
132
+ assert.equal(f.shouldKeep({ type: "image" }), true);
133
+ });
134
+
135
+ it("keeps file messages", () => {
136
+ const f = makeFilter();
137
+ assert.equal(f.shouldKeep({ type: "file" }), true);
138
+ });
139
+
140
+ it("keeps link messages", () => {
141
+ const f = makeFilter();
142
+ assert.equal(f.shouldKeep({ type: "link", text: "https://example.com" }), true);
143
+ });
144
+
145
+ it("keeps message with no type field", () => {
146
+ const f = makeFilter();
147
+ assert.equal(f.shouldKeep({ text: "plain message" }), true);
148
+ });
149
+ });
150
+
151
+ describe("ThreadFilter isTrigger", () => {
152
+ it("detects keyword in message text (case-insensitive)", () => {
153
+ const f = makeFilter(["dm:*"], ["@bot"]);
154
+ assert.equal(f.isTrigger({ text: "Hey @BOT help me" }), true);
155
+ });
156
+
157
+ it("detects keyword at start of text", () => {
158
+ const f = makeFilter(["dm:*"], ["help"]);
159
+ assert.equal(f.isTrigger({ text: "help me please" }), true);
160
+ });
161
+
162
+ it("returns false when keyword not present", () => {
163
+ const f = makeFilter(["dm:*"], ["@bot"]);
164
+ assert.equal(f.isTrigger({ text: "just a normal message" }), false);
165
+ });
166
+
167
+ it("returns false when no keywords configured", () => {
168
+ const f = makeFilter(["dm:*"], []);
169
+ assert.equal(f.isTrigger({ text: "@bot trigger me" }), false);
170
+ });
171
+
172
+ it("returns false when text is empty string", () => {
173
+ const f = makeFilter(["dm:*"], ["@bot"]);
174
+ assert.equal(f.isTrigger({ text: "" }), false);
175
+ });
176
+
177
+ it("returns false when text property is missing", () => {
178
+ const f = makeFilter(["dm:*"], ["@bot"]);
179
+ assert.equal(f.isTrigger({}), false);
180
+ });
181
+
182
+ it("matches any of multiple keywords", () => {
183
+ const f = makeFilter(["dm:*"], ["urgent", "help", "broken"]);
184
+ assert.equal(f.isTrigger({ text: "system is broken" }), true);
185
+ assert.equal(f.isTrigger({ text: "need urgent attention" }), true);
186
+ assert.equal(f.isTrigger({ text: "everything is fine" }), false);
187
+ });
188
+ });
189
+
190
+ describe("ThreadFilter edge cases", () => {
191
+ it("shouldKeep handles empty message object", () => {
192
+ const f = makeFilter();
193
+ // No type, no text — should keep (default allow)
194
+ assert.equal(f.shouldKeep({}), true);
195
+ });
196
+
197
+ it("shouldKeep handles null text field gracefully", () => {
198
+ const f = makeFilter();
199
+ assert.equal(f.shouldKeep({ type: "text", text: null }), true);
200
+ });
201
+
202
+ it("isTrigger handles null text field", () => {
203
+ const f = makeFilter(["dm:*"], ["keyword"]);
204
+ assert.equal(f.isTrigger({ text: null }), false);
205
+ });
206
+ });
@@ -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
- // 1. Display Zalo's official QR PNG inline in terminal
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
- // 3. Also fire-and-forget the zca-js built-in save
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
- // 4. Base64 data URL (for coding agents / IDE preview)
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
- info("QR data URL (for IDE preview):");
71
- console.log(` data:image/png;base64,${imageB64.substring(0, 100)}...`);
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
+ });