@ardennguyen/zalo-agent-cli 2.5.4 → 2.5.6

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,403 @@
1
+ /**
2
+ * What a running daemon can say about itself: is the socket up, when did anything last arrive, how
3
+ * far behind is the webhook, is it short of memory or blocked.
4
+ *
5
+ * `mcp start` and `listen` hold one account's one socket. Until this module the only outside view
6
+ * was `GET /health`, which answered `ok` with an uptime and a thread count whether the socket had
7
+ * been down for a minute or a week, so a fleet could not tell which accounts had gone quiet. Both
8
+ * entry points now build one of these (AGENTS.md section 13: they must not differ) and expose it:
9
+ *
10
+ * - `mcp start --http`: `GET /health` (liveness, unchanged shape plus the socket), `GET /ready`
11
+ * (200 only while the socket is up), and, behind the bearer token, `GET /status` (everything)
12
+ * and `GET /metrics` (Prometheus text).
13
+ * - both: `POST /status` on the loopback channel, which `listen --status` / `mcp start --status`
14
+ * print, so a `listen` daemon with no HTTP port is just as visible.
15
+ *
16
+ * Sources that only exist later in start-up (the webhook outbox, the message buffer, the stage
17
+ * lock) are plugged in with `setSource`, so the HTTP server can take the health object before they
18
+ * are built.
19
+ */
20
+
21
+ import { monitorEventLoopDelay } from "node:perf_hooks";
22
+
23
+ /** The listener events whose arrival means the account is alive; their counts are exported. */
24
+ export const COUNTED_EVENTS = Object.freeze([
25
+ "message",
26
+ "reaction",
27
+ "undo",
28
+ "group_event",
29
+ "friend_event",
30
+ "seen_messages",
31
+ "delivered_messages",
32
+ ]);
33
+
34
+ /** A webhook backlog older than this is reported as a warning. */
35
+ const WEBHOOK_AGE_WARN_MS = 5 * 60 * 1000;
36
+ /** A socket that has been down this long is reported, beyond the state itself. */
37
+ const SOCKET_DOWN_WARN_MS = 2 * 60 * 1000;
38
+ /** The event-loop histogram is cleared this often so it describes recent behavior, not all of history. */
39
+ const LAG_WINDOW_MS = 5 * 60 * 1000;
40
+ /** A stall at least this long is a warning. */
41
+ const LAG_WARN_MS = 3000;
42
+
43
+ const iso = (ms) => (typeof ms === "number" ? new Date(ms).toISOString() : null);
44
+ const ageSec = (since, now) => (typeof since === "number" ? Math.max(0, Math.round((now - since) / 1000)) : null);
45
+
46
+ /**
47
+ * @param {object} options
48
+ * @param {"mcp"|"listen"} options.kind - Which entry point this is
49
+ * @param {string} options.accountId
50
+ * @param {string} options.version - Package version
51
+ * @param {() => number} [options.now]
52
+ * @returns {{
53
+ * attach: (listener: object) => void,
54
+ * noteUp: () => void,
55
+ * noteDown: (code?: number|string|null) => void,
56
+ * noteStopping: () => void,
57
+ * noteEvent: (kind: string, info?: {self?: boolean}) => void,
58
+ * setSource: (name: 'webhook'|'buffer'|'gaps'|'stage', fn: () => any) => void,
59
+ * snapshot: () => object,
60
+ * ready: () => {ready: boolean, socket: string},
61
+ * stop: () => void,
62
+ * }}
63
+ */
64
+ export function createDaemonHealth({ kind, accountId, version, now = Date.now }) {
65
+ const startedAt = now();
66
+ let socketState = "starting";
67
+ let socketSince = startedAt;
68
+ let everUp = false;
69
+ let reconnects = 0;
70
+ let lastClose = null;
71
+ let lastEventAt = null;
72
+ let lastMessageAt = null;
73
+ const counts = Object.fromEntries(COUNTED_EVENTS.map((k) => [k, 0]));
74
+ let incoming = 0;
75
+ const sources = {};
76
+
77
+ const lag = monitorEventLoopDelay({ resolution: 20 });
78
+ lag.enable();
79
+ const lagReset = setInterval(() => lag.reset(), LAG_WINDOW_MS);
80
+ lagReset.unref?.();
81
+
82
+ /** A source that throws must not take the status route down with it. */
83
+ const read = (name) => {
84
+ try {
85
+ return sources[name]?.() ?? null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ };
90
+
91
+ const setState = (state) => {
92
+ if (socketState === state) return;
93
+ socketState = state;
94
+ socketSince = now();
95
+ };
96
+
97
+ const api = {
98
+ noteUp() {
99
+ if (socketState === "stopping") return;
100
+ if (everUp) reconnects++;
101
+ everUp = true;
102
+ setState("up");
103
+ },
104
+ noteDown(code = null) {
105
+ if (socketState === "stopping") return;
106
+ lastClose = { code: code ?? null, at: now() };
107
+ setState("down");
108
+ },
109
+ noteStopping() {
110
+ setState("stopping");
111
+ },
112
+ noteEvent(eventKind, { self = false } = {}) {
113
+ lastEventAt = now();
114
+ if (eventKind in counts) counts[eventKind]++;
115
+ if (eventKind === "message") {
116
+ lastMessageAt = lastEventAt;
117
+ if (!self) incoming++;
118
+ }
119
+ },
120
+ setSource(name, fn) {
121
+ sources[name] = fn;
122
+ },
123
+
124
+ /**
125
+ * Subscribe to a listener. Call again with the new listener after a re-login.
126
+ * Never subscribes to `cipher_key`, which carries the session's key material.
127
+ *
128
+ * @param {object} listener - zca-js listener
129
+ */
130
+ attach(listener) {
131
+ listener.on("connected", () => api.noteUp());
132
+ listener.on("disconnected", (code) => api.noteDown(code));
133
+ listener.on("closed", (code) => api.noteDown(code));
134
+ for (const k of COUNTED_EVENTS) {
135
+ listener.on(k, (payload) => api.noteEvent(k, { self: k === "message" && Boolean(payload?.isSelf) }));
136
+ }
137
+ },
138
+
139
+ /** @returns {{ready: boolean, socket: string}} */
140
+ ready() {
141
+ return { ready: socketState === "up", socket: socketState };
142
+ },
143
+
144
+ /** @returns {object} Everything, JSON-safe */
145
+ snapshot() {
146
+ const t = now();
147
+ const rawWebhook = read("webhook");
148
+ const webhook = rawWebhook && {
149
+ ...rawWebhook,
150
+ lastSuccessAgeSec: ageSec(rawWebhook.lastSuccessAt, t),
151
+ };
152
+ const buffer = read("buffer");
153
+ const gaps = read("gaps");
154
+ const stage = read("stage");
155
+ const mem = process.memoryUsage();
156
+ const p99Ms = lag.count ? lag.percentile(99) / 1e6 : 0;
157
+ const maxMs = lag.count ? lag.max / 1e6 : 0;
158
+
159
+ const warnings = [];
160
+ if (socketState === "down" && t - socketSince >= SOCKET_DOWN_WARN_MS) {
161
+ warnings.push(
162
+ `socket down for ${ageSec(socketSince, t)}s (last close code ${lastClose?.code ?? "unknown"})`,
163
+ );
164
+ }
165
+ if (webhook && webhook.pending && webhook.oldestPendingAgeMs >= WEBHOOK_AGE_WARN_MS) {
166
+ warnings.push(
167
+ `webhook backlog: ${webhook.pending} queued, oldest ${Math.round(webhook.oldestPendingAgeMs / 1000)}s` +
168
+ (webhook.lastError ? ` (${webhook.lastError})` : ""),
169
+ );
170
+ }
171
+ if (webhook?.dead) warnings.push(`webhook: ${webhook.dead} dead-lettered event(s)`);
172
+ if (gaps) warnings.push(`coverage: ${gaps} gap(s) not yet recovered`);
173
+ if (maxMs >= LAG_WARN_MS)
174
+ warnings.push(`event loop was blocked for ${(maxMs / 1000).toFixed(1)}s recently`);
175
+
176
+ return {
177
+ kind,
178
+ accountId,
179
+ version,
180
+ pid: process.pid,
181
+ startedAt: iso(startedAt),
182
+ uptimeSec: Math.round((t - startedAt) / 1000),
183
+ status: warnings.length ? "degraded" : "ok",
184
+ ready: socketState === "up",
185
+ warnings,
186
+ socket: {
187
+ state: socketState,
188
+ since: iso(socketSince),
189
+ forSec: ageSec(socketSince, t),
190
+ reconnects,
191
+ lastClose: lastClose && {
192
+ code: lastClose.code,
193
+ at: iso(lastClose.at),
194
+ ageSec: ageSec(lastClose.at, t),
195
+ },
196
+ },
197
+ events: {
198
+ lastEventAt: iso(lastEventAt),
199
+ lastEventAgeSec: ageSec(lastEventAt, t),
200
+ lastMessageAt: iso(lastMessageAt),
201
+ lastMessageAgeSec: ageSec(lastMessageAt, t),
202
+ incomingMessages: incoming,
203
+ byKind: { ...counts },
204
+ },
205
+ coverage: { pendingGaps: gaps ?? 0 },
206
+ stage: stage ? { running: stage.stage, forSec: ageSec(stage.startedAt, t) } : null,
207
+ webhook,
208
+ buffer,
209
+ process: {
210
+ rssBytes: mem.rss,
211
+ heapUsedBytes: mem.heapUsed,
212
+ eventLoopLagP99Ms: Math.round(p99Ms * 10) / 10,
213
+ eventLoopLagMaxMs: Math.round(maxMs * 10) / 10,
214
+ },
215
+ };
216
+ },
217
+
218
+ stop() {
219
+ lag.disable();
220
+ clearInterval(lagReset);
221
+ },
222
+ };
223
+ return api;
224
+ }
225
+
226
+ /** Escape a Prometheus label value. */
227
+ const esc = (v) => String(v).replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/"/g, '\\"');
228
+
229
+ /**
230
+ * A snapshot in the Prometheus text exposition format (version 0.0.4).
231
+ *
232
+ * A sample that has no value yet (no event has arrived, no delivery has happened) is left out
233
+ * rather than reported as 0, because 0 would read as "just now" for an age.
234
+ *
235
+ * @param {object} s - From `snapshot()`
236
+ * @returns {string}
237
+ */
238
+ export function renderPrometheus(s) {
239
+ const lines = [];
240
+ const account = `account="${esc(s.accountId)}"`;
241
+ const metric = (name, type, help, samples) => {
242
+ const present = samples.filter(([, v]) => v !== null && v !== undefined && Number.isFinite(v));
243
+ if (!present.length) return;
244
+ lines.push(`# HELP ${name} ${help}`, `# TYPE ${name} ${type}`);
245
+ for (const [labels, v] of present) lines.push(`${name}{${account}${labels ? `,${labels}` : ""}} ${v}`);
246
+ };
247
+ const one = (v) => [["", v]];
248
+
249
+ metric("zalo_info", "gauge", "Daemon build and kind; the value is always 1.", [
250
+ [`kind="${esc(s.kind)}",version="${esc(s.version)}"`, 1],
251
+ ]);
252
+ metric("zalo_up", "gauge", "1 while the Zalo socket is connected, else 0.", one(s.ready ? 1 : 0));
253
+ metric(
254
+ "zalo_socket_state_age_seconds",
255
+ "gauge",
256
+ "Seconds the socket has been in its current state (see the state label).",
257
+ [[`state="${esc(s.socket.state)}"`, s.socket.forSec]],
258
+ );
259
+ metric(
260
+ "zalo_socket_reconnects_total",
261
+ "counter",
262
+ "Reconnections since the daemon started.",
263
+ one(s.socket.reconnects),
264
+ );
265
+ metric("zalo_last_close_age_seconds", "gauge", "Seconds since the socket last closed.", [
266
+ [`code="${esc(s.socket.lastClose?.code ?? "")}"`, s.socket.lastClose?.ageSec ?? null],
267
+ ]);
268
+ metric(
269
+ "zalo_last_event_age_seconds",
270
+ "gauge",
271
+ "Seconds since any event arrived on the socket.",
272
+ one(s.events.lastEventAgeSec),
273
+ );
274
+ metric(
275
+ "zalo_last_message_age_seconds",
276
+ "gauge",
277
+ "Seconds since a message (sent or received) arrived on the socket.",
278
+ one(s.events.lastMessageAgeSec),
279
+ );
280
+ metric(
281
+ "zalo_incoming_messages_total",
282
+ "counter",
283
+ "Messages from other people since the daemon started.",
284
+ one(s.events.incomingMessages),
285
+ );
286
+ metric(
287
+ "zalo_events_total",
288
+ "counter",
289
+ "Listener events since the daemon started, by kind.",
290
+ Object.entries(s.events.byKind).map(([k, v]) => [`kind="${esc(k)}"`, v]),
291
+ );
292
+ metric(
293
+ "zalo_coverage_gaps_pending",
294
+ "gauge",
295
+ "Time windows whose messages are not yet recovered.",
296
+ one(s.coverage.pendingGaps),
297
+ );
298
+ metric(
299
+ "zalo_stage_running",
300
+ "gauge",
301
+ "1 while a sync stage runs on the socket (see the stage label).",
302
+ s.stage ? [[`stage="${esc(s.stage.running)}"`, 1]] : one(0),
303
+ );
304
+
305
+ const w = s.webhook;
306
+ metric("zalo_webhook_enabled", "gauge", "1 when a webhook receiver is configured.", one(w ? 1 : 0));
307
+ if (w) {
308
+ metric("zalo_webhook_pending", "gauge", "Events queued for the webhook receiver.", one(w.pending));
309
+ metric(
310
+ "zalo_webhook_dead",
311
+ "gauge",
312
+ "Events dead-lettered after the receiver refused them or they expired.",
313
+ one(w.dead),
314
+ );
315
+ metric(
316
+ "zalo_webhook_oldest_pending_age_seconds",
317
+ "gauge",
318
+ "How long the oldest queued webhook event has waited.",
319
+ one(Math.round((w.oldestPendingAgeMs ?? 0) / 1000)),
320
+ );
321
+ metric(
322
+ "zalo_webhook_delivered_total",
323
+ "counter",
324
+ "Webhook events delivered since the outbox was created.",
325
+ one(w.deliveredTotal),
326
+ );
327
+ metric(
328
+ "zalo_webhook_consecutive_failures",
329
+ "gauge",
330
+ "Failed delivery attempts in a row.",
331
+ one(w.consecutiveFailures),
332
+ );
333
+ metric(
334
+ "zalo_webhook_last_success_age_seconds",
335
+ "gauge",
336
+ "Seconds since a webhook delivery last succeeded.",
337
+ one(w.lastSuccessAgeSec ?? null),
338
+ );
339
+ }
340
+
341
+ if (s.buffer) {
342
+ metric(
343
+ "zalo_buffer_threads",
344
+ "gauge",
345
+ "Conversations with messages in the live buffer.",
346
+ one(s.buffer.threads),
347
+ );
348
+ metric("zalo_buffer_messages", "gauge", "Messages in the live buffer.", one(s.buffer.messages));
349
+ }
350
+
351
+ metric("zalo_process_resident_memory_bytes", "gauge", "Resident set size.", one(s.process.rssBytes));
352
+ metric("zalo_process_heap_used_bytes", "gauge", "V8 heap in use.", one(s.process.heapUsedBytes));
353
+ metric("zalo_process_uptime_seconds", "gauge", "Seconds since the daemon started.", one(s.uptimeSec));
354
+ metric(
355
+ "zalo_eventloop_lag_p99_seconds",
356
+ "gauge",
357
+ "99th percentile event-loop delay over the last few minutes.",
358
+ one(s.process.eventLoopLagP99Ms / 1000),
359
+ );
360
+ metric(
361
+ "zalo_eventloop_lag_max_seconds",
362
+ "gauge",
363
+ "Worst event-loop delay over the last few minutes.",
364
+ one(s.process.eventLoopLagMaxMs / 1000),
365
+ );
366
+ return lines.join("\n") + "\n";
367
+ }
368
+
369
+ /**
370
+ * The lines `listen --status` / `mcp start --status` print for a running daemon.
371
+ *
372
+ * @param {object} s - From `snapshot()`
373
+ * @returns {string[]}
374
+ */
375
+ export function describeHealth(s) {
376
+ const out = [];
377
+ out.push(
378
+ `Socket: ${s.socket.state} for ${s.socket.forSec}s` +
379
+ (s.socket.reconnects ? `, ${s.socket.reconnects} reconnect(s)` : "") +
380
+ (s.socket.state !== "up" && s.socket.lastClose
381
+ ? `, last close code ${s.socket.lastClose.code ?? "unknown"}`
382
+ : ""),
383
+ );
384
+ out.push(
385
+ s.events.lastEventAgeSec === null
386
+ ? "Events: none yet since start"
387
+ : `Last event ${s.events.lastEventAgeSec}s ago, last message ${s.events.lastMessageAgeSec === null ? "none yet" : `${s.events.lastMessageAgeSec}s ago`}; ${s.events.incomingMessages} incoming message(s) since start`,
388
+ );
389
+ out.push(`Coverage: ${s.coverage.pendingGaps} pending gap(s)`);
390
+ if (s.stage) out.push(`Sync stage running: ${s.stage.running} for ${s.stage.forSec}s`);
391
+ if (s.webhook) {
392
+ out.push(
393
+ `Webhook: ${s.webhook.pending} queued${s.webhook.pending ? ` (oldest ${Math.round(s.webhook.oldestPendingAgeMs / 1000)}s)` : ""}, ${s.webhook.dead} dead-lettered, ${s.webhook.deliveredTotal} delivered` +
394
+ (s.webhook.consecutiveFailures ? `, failing x${s.webhook.consecutiveFailures}` : ""),
395
+ );
396
+ }
397
+ if (s.buffer) out.push(`Buffer: ${s.buffer.messages} message(s) in ${s.buffer.threads} conversation(s)`);
398
+ out.push(
399
+ `Process: ${Math.round(s.process.rssBytes / 1048576)} MB resident, event-loop delay p99 ${s.process.eventLoopLagP99Ms} ms (max ${s.process.eventLoopLagMaxMs} ms), up ${s.uptimeSec}s`,
400
+ );
401
+ for (const w of s.warnings) out.push(`WARNING: ${w}`);
402
+ return out;
403
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * The files `zalo-agent fleet generate` writes: a systemd template unit (one instance per account, the
3
+ * instance name IS the account id), the shared and per-account env files, and a Caddy reverse proxy with a
4
+ * path per account.
5
+ *
6
+ * They are the same files as docs/config-samples/ (the docs are not shipped in the npm package, the CLI
7
+ * is), and a test holds the two together: change one and the other fails until it follows.
8
+ */
9
+
10
+ /** What the unit and the proxy assume unless the caller says otherwise. */
11
+ export const FLEET_DEFAULTS = Object.freeze({
12
+ user: "zalo",
13
+ home: "/home/zalo",
14
+ bin: "/usr/bin/zalo-agent",
15
+ mediaDir: "/var/lib/zalo-media",
16
+ etcDir: "/etc/zalo-mcp",
17
+ });
18
+
19
+ export const UNIT_TEMPLATE = `# /etc/systemd/system/zalo-mcp@.service
20
+ # One instance per Zalo account: the instance name IS the account id.
21
+ # systemctl enable --now zalo-mcp@<ownId>
22
+ # journalctl -u zalo-mcp@<ownId> -f
23
+ #
24
+ # Each instance reads /etc/zalo-mcp/common.env, then /etc/zalo-mcp/accounts/<id>.env
25
+ # (\`zalo-agent fleet generate\` writes both). The account's token and port come from
26
+ # that file, so neither appears on the command line (\`ps\` shows only \`mcp start --account <id>\`).
27
+ [Unit]
28
+ Description=Zalo MCP server for account %i
29
+ Documentation=https://github.com/ardennguyen/zalo-agent-cli/blob/main/docs/configuration.md
30
+ After=network-online.target
31
+ Wants=network-online.target
32
+ # A duplicate-session exit (close code 3000) means another web session took the
33
+ # account; cap the restart loop and alert on \`failed\` instead of fighting it.
34
+ StartLimitIntervalSec=600
35
+ StartLimitBurst=5
36
+
37
+ [Service]
38
+ Type=simple
39
+ User=zalo
40
+ Group=zalo
41
+ WorkingDirectory=/home/zalo
42
+ UMask=0077
43
+ EnvironmentFile=/etc/zalo-mcp/common.env
44
+ EnvironmentFile=/etc/zalo-mcp/accounts/%i.env
45
+ Environment=NODE_ENV=production
46
+ Environment=ZALO_AGENT_NO_UPDATE_CHECK=1
47
+
48
+ # Refuse to start with no token (an empty one would serve unauthenticated).
49
+ ExecStartPre=/usr/bin/test -n "\${ZALO_MCP_AUTH_TOKEN}"
50
+ # Spread the start-up of many instances over 30 seconds: every start logs in and
51
+ # loads the account's friend and group lists, and a whole fleet restarting at once
52
+ # is the heaviest moment of its life (see docs/deployment.md §1.4).
53
+ ExecStartPre=/bin/bash -c 'sleep $((RANDOM % 30))'
54
+ ExecStart=/usr/bin/zalo-agent mcp start --account %i
55
+
56
+ # SIGINT is the shutdown signal mcp start handles; with the default SIGTERM the
57
+ # daemon exits without stamping its last-connected time or releasing its lock.
58
+ KillSignal=SIGINT
59
+ TimeoutStopSec=30
60
+ Restart=always
61
+ RestartSec=15
62
+
63
+ StandardOutput=journal
64
+ StandardError=journal
65
+ SyslogIdentifier=zalo-mcp-%i
66
+
67
+ NoNewPrivileges=yes
68
+ PrivateTmp=yes
69
+ ProtectSystem=strict
70
+ ProtectHome=read-only
71
+ ReadWritePaths=/home/zalo/.zalo-agent-cli /var/lib/zalo-media
72
+ ProtectKernelTunables=yes
73
+ ProtectControlGroups=yes
74
+ RestrictSUIDSGID=yes
75
+ LockPersonality=yes
76
+ # One instance's footprint is about 100 MB at rest; cap a runaway one.
77
+ MemoryMax=600M
78
+
79
+ [Install]
80
+ WantedBy=multi-user.target
81
+ `;
82
+
83
+ /**
84
+ * The unit with the caller's user, home, binary and media directory.
85
+ * @param {Partial<typeof FLEET_DEFAULTS>} [o]
86
+ * @returns {string}
87
+ */
88
+ export function renderUnit(o = {}) {
89
+ const v = { ...FLEET_DEFAULTS, ...o };
90
+ return UNIT_TEMPLATE.replaceAll("User=zalo", `User=${v.user}`)
91
+ .replaceAll("Group=zalo", `Group=${v.user}`)
92
+ .replaceAll("/home/zalo", v.home)
93
+ .replaceAll("/usr/bin/zalo-agent ", `${v.bin} `)
94
+ .replaceAll("/var/lib/zalo-media", v.mediaDir)
95
+ .replaceAll("/etc/zalo-mcp", v.etcDir);
96
+ }
97
+
98
+ export const COMMON_ENV_TEMPLATE = `# /etc/zalo-mcp/common.env read by every zalo-mcp@<id> instance
99
+ # sudo install -m 600 -o zalo -g zalo common.env /etc/zalo-mcp/common.env
100
+ # Per-account settings go in /etc/zalo-mcp/accounts/<id>.env, which is read after this file.
101
+
102
+ ZALO_MCP_HOST=127.0.0.1
103
+ ZALO_MCP_GROUP_CONFIRM=0
104
+ ZALO_DATA_DIR=/var/lib/zalo-agent/accounts
105
+ ZALO_MEDIA_DIR=/var/lib/zalo-media
106
+
107
+ # daemon.log: \`info\` leaves out the line per HTTP request and per message that \`debug\` adds, and
108
+ # rotates at 50 MB keeping 3 files. JSON lines suit a log shipper.
109
+ # ZALO_LOG_LEVEL=info
110
+ # ZALO_LOG_FORMAT=json
111
+ # ZALO_LOG_MAX_SIZE=50m
112
+ # ZALO_LOG_KEEP=3
113
+
114
+ # A fleet restarting together is the heaviest moment of each daemon's life (every start logs in and
115
+ # loads the friend and group lists). The unit already sleeps a random 0-30 s before starting; these two
116
+ # do the same inside the daemon, for any supervisor: wait up to this long before logging in, and let at
117
+ # most this many daemons on the machine be logging in at once (the rest wait for a slot).
118
+ # ZALO_START_JITTER=30
119
+ # ZALO_LOGIN_SLOTS=3
120
+ `;
121
+
122
+ /**
123
+ * One account's env file. The token is generated by the caller.
124
+ * @param {{ ownId: string, port: number, token: string }} a
125
+ * @returns {string}
126
+ */
127
+ export function renderAccountEnv({ ownId, port, token }) {
128
+ return `# /etc/zalo-mcp/accounts/${ownId}.env account ${ownId}; read by zalo-mcp@${ownId}.service
129
+ # sudo install -m 600 -o zalo -g zalo ${ownId}.env /etc/zalo-mcp/accounts/${ownId}.env
130
+ # Generated by \`zalo-agent fleet generate\`. The token is the bearer token a client of THIS account sends.
131
+
132
+ ZALO_MCP_HOST=127.0.0.1
133
+ ZALO_MCP_HTTP_PORT=${port}
134
+ ZALO_MCP_AUTH_TOKEN=${token}
135
+
136
+ # Where this account's incoming messages are POSTed, and the secret that signs them:
137
+ # ZALO_WEBHOOK_URL=https://crm.example.com/hooks/zalo/${ownId}
138
+ # ZALO_WEBHOOK_SECRET=
139
+ `;
140
+ }
141
+
142
+ /**
143
+ * A Caddyfile with one path per account: https://<domain>/a/<id>/mcp reaches that account's daemon.
144
+ * @param {{ domain: string, accounts: Array<{ ownId: string, port: number }> }} a
145
+ * @returns {string}
146
+ */
147
+ export function renderCaddyfile({ domain, accounts }) {
148
+ const blocks = accounts
149
+ .map(
150
+ ({ ownId, port }) => ` handle_path /a/${ownId}/* {
151
+ @internal path /metrics /status
152
+ respond @internal 404
153
+
154
+ reverse_proxy 127.0.0.1:${port} {
155
+ flush_interval -1
156
+ transport http {
157
+ read_timeout 310s
158
+ }
159
+ }
160
+ }
161
+ `,
162
+ )
163
+ .join("\n");
164
+ return `# Caddyfile: several accounts behind one hostname, one path prefix per account.
165
+ # Each account is its own zalo-mcp@<id> instance on its own loopback port. Bearer-token auth per
166
+ # account; OAuth issuer URLs are awkward behind a path prefix, so use tokens here.
167
+ # /metrics and /status are for Prometheus inside your network, so each block answers 404 for them
168
+ # (only /mcp and /events/wait are meant for outside clients).
169
+ ${domain} {
170
+ encode zstd gzip
171
+
172
+ ${blocks}
173
+ # A client for an account talks to https://${domain}/a/<id>/mcp and https://${domain}/a/<id>/events/wait
174
+ respond 404
175
+ }
176
+ `;
177
+ }