abs-zalo-bot 0.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.
@@ -0,0 +1,185 @@
1
+ // Auto-discover Zalo group IDs + owner uid after QR. No secrets logged.
2
+ import { utcNow } from "./schema.js";
3
+
4
+ export function extractAccountIdentity(info) {
5
+ // fetchAccountInfo → { profile: User } or flat shapes
6
+ const p = info?.profile || info?.userInfo || info || {};
7
+ return {
8
+ user_id: String(p.userId || p.uid || p.user_id || info?.userId || info?.uid || ""),
9
+ display_name: String(p.displayName || p.display_name || p.zaloName || p.zalo_name || p.name || ""),
10
+ phone: String(p.phoneNumber || p.phone || ""),
11
+ };
12
+ }
13
+
14
+ export async function listGroupsDetailed(api) {
15
+ if (!api || typeof api.getAllGroups !== "function") return [];
16
+ const raw = await api.getAllGroups();
17
+ const ids = raw?.gridVerMap
18
+ ? Object.keys(raw.gridVerMap)
19
+ : Array.isArray(raw)
20
+ ? raw.map((g) => String(g.groupId || g.id || "")).filter(Boolean)
21
+ : Object.keys(raw || {}).filter((k) => k !== "version" && k !== "gridVerMap");
22
+
23
+ const out = [];
24
+ // batch getGroupInfo when available
25
+ if (typeof api.getGroupInfo === "function" && ids.length) {
26
+ const chunkSize = 20;
27
+ for (let i = 0; i < ids.length; i += chunkSize) {
28
+ const chunk = ids.slice(i, i + chunkSize);
29
+ try {
30
+ const info = await api.getGroupInfo(chunk.length === 1 ? chunk[0] : chunk);
31
+ const map = info?.gridInfoMap || {};
32
+ for (const id of chunk) {
33
+ const g = map[id] || {};
34
+ out.push({
35
+ source_id: String(id),
36
+ source_name: String(g.name || g.groupName || ""),
37
+ total_member: Number(g.totalMember || 0),
38
+ source_type: "group",
39
+ });
40
+ }
41
+ } catch {
42
+ for (const id of chunk) {
43
+ out.push({ source_id: String(id), source_name: "", total_member: 0, source_type: "group" });
44
+ }
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ return ids.map((id) => ({
51
+ source_id: String(id),
52
+ source_name: "",
53
+ total_member: 0,
54
+ source_type: "group",
55
+ }));
56
+ }
57
+
58
+ export function matchGroupByName(groups, groupName) {
59
+ const want = String(groupName || "").trim().toLowerCase();
60
+ if (!want) return null;
61
+ const exact = groups.find((g) => String(g.source_name || "").trim().toLowerCase() === want);
62
+ if (exact) return exact;
63
+ const partial = groups.find((g) => String(g.source_name || "").toLowerCase().includes(want));
64
+ return partial || null;
65
+ }
66
+
67
+ /**
68
+ * After connect: owner uid, destination resolve, optional source catalog.
69
+ */
70
+ export async function bootstrapAccount({ api, store, accountId, destinationName = "configured destination" }) {
71
+ const result = {
72
+ account_id: accountId,
73
+ owner_user_id: "",
74
+ owner_display_name: "",
75
+ groups_found: 0,
76
+ destination: null,
77
+ sources_seeded: 0,
78
+ at: utcNow(),
79
+ };
80
+
81
+ let info = null;
82
+ try {
83
+ if (typeof api.fetchAccountInfo === "function") info = await api.fetchAccountInfo();
84
+ } catch {
85
+ info = null;
86
+ }
87
+ const identity = extractAccountIdentity(info || {});
88
+ result.owner_user_id = identity.user_id;
89
+ result.owner_display_name = identity.display_name;
90
+
91
+ if (identity.user_id) {
92
+ store.setAccountStatus(accountId, "connected", {
93
+ zalo_user_id: identity.user_id,
94
+ display_name: identity.display_name || "",
95
+ last_error: "",
96
+ });
97
+ // logged-in account is owner for ops asks
98
+ store.upsertPermission({
99
+ accountId,
100
+ userId: identity.user_id,
101
+ role: "owner",
102
+ displayName: identity.display_name || "zalo-owner",
103
+ });
104
+ store.audit({
105
+ accountId,
106
+ actorId: "system",
107
+ action: "auto_owner",
108
+ detail: identity.user_id,
109
+ });
110
+ }
111
+
112
+ let groups = [];
113
+ try {
114
+ groups = await listGroupsDetailed(api);
115
+ } catch (err) {
116
+ store.setHealth(`discover_groups_error_${accountId}`, String(err?.message || err));
117
+ groups = [];
118
+ }
119
+ result.groups_found = groups.length;
120
+ store.setSetting(`discovered_groups_${accountId}`, groups);
121
+ store.setHealth(`discovered_groups_count_${accountId}`, String(groups.length));
122
+
123
+ // seed listen_only catalog for visibility (listen_all_groups already stores all)
124
+ for (const g of groups) {
125
+ if (!g.source_id) continue;
126
+ const existing = store.getSource(accountId, g.source_id);
127
+ if (!existing) {
128
+ store.upsertSource({
129
+ accountId,
130
+ sourceId: g.source_id,
131
+ sourceType: "group",
132
+ sourceName: g.source_name || "",
133
+ mode: "listen_only",
134
+ isAllowed: true,
135
+ });
136
+ result.sources_seeded += 1;
137
+ } else if (g.source_name && !existing.source_name) {
138
+ store.upsertSource({
139
+ accountId,
140
+ sourceId: g.source_id,
141
+ sourceType: "group",
142
+ sourceName: g.source_name,
143
+ mode: existing.mode || "listen_only",
144
+ isAllowed: existing.is_allowed,
145
+ muted: existing.muted,
146
+ });
147
+ }
148
+ }
149
+
150
+ const want =
151
+ destinationName ||
152
+ store.getDestination(accountId).group_name ||
153
+ process.env.DESTINATION_GROUP_NAME ||
154
+ "configured destination";
155
+ const hit = matchGroupByName(groups, want);
156
+ if (hit?.source_id) {
157
+ store.setDestination(accountId, hit.source_id, hit.source_name || want);
158
+ store.audit({
159
+ accountId,
160
+ actorId: "system",
161
+ action: "auto_resolve_destination",
162
+ detail: `${hit.source_name}:${hit.source_id}`,
163
+ });
164
+ result.destination = {
165
+ group_id: hit.source_id,
166
+ group_name: hit.source_name || want,
167
+ };
168
+ } else {
169
+ // keep name placeholder
170
+ const cur = store.getDestination(accountId);
171
+ if (!cur.group_id) store.setDestination(accountId, "", want);
172
+ result.destination = { group_id: "", group_name: want, unresolved: true };
173
+ }
174
+
175
+ store.setHealth(`bootstrap_${accountId}`, result);
176
+ return result;
177
+ }
178
+
179
+ // keep name used by older connect path
180
+ export async function resolveDestinationByName(api, groupName) {
181
+ const groups = await listGroupsDetailed(api);
182
+ const hit = matchGroupByName(groups, groupName);
183
+ if (!hit) return null;
184
+ return { group_id: hit.source_id, group_name: hit.source_name || groupName };
185
+ }
@@ -0,0 +1,302 @@
1
+ // Hermes brain client.
2
+ // 1) Prefer local Hermes API server OpenAI-compatible chat (/v1/chat/completions)
3
+ // 2) Else optional webhook JSON contract
4
+ // 3) Else local mock / ops fallback
5
+ import { randomUUID } from "node:crypto";
6
+ import { sanitizeForHermes } from "./privacy.js";
7
+
8
+ export function buildHermesRequest({
9
+ accountId,
10
+ purpose,
11
+ source,
12
+ messages,
13
+ constraints = {},
14
+ }) {
15
+ const safeMessages = [];
16
+ for (const m of messages || []) {
17
+ const s = sanitizeForHermes(m.text || "");
18
+ if (!s.ok) continue;
19
+ safeMessages.push({
20
+ message_id: m.message_id || m.event_id || "",
21
+ sender_name: m.sender_name || m.sender_display_name || "",
22
+ text: s.text,
23
+ created_at: m.created_at || "",
24
+ });
25
+ }
26
+ return {
27
+ request_id: randomUUID(),
28
+ account_id: String(accountId),
29
+ purpose: purpose || "digest",
30
+ source: source || {},
31
+ messages: safeMessages,
32
+ constraints: {
33
+ max_output_messages: 1,
34
+ output_destination: "destination_group",
35
+ no_raw_sensitive_data: true,
36
+ tone: "ngắn gọn, rõ ý, tiếng Việt",
37
+ ...constraints,
38
+ },
39
+ };
40
+ }
41
+
42
+ export function validateHermesResponse(data) {
43
+ if (!data || typeof data !== "object") {
44
+ return { ok: false, reason: "not_object" };
45
+ }
46
+ const action = String(data.action || "").toLowerCase();
47
+ if (!["send", "ignore", "manual_review"].includes(action)) {
48
+ return { ok: false, reason: "bad_action" };
49
+ }
50
+ const priority = String(data.priority || "low").toLowerCase();
51
+ if (!["low", "medium", "high"].includes(priority)) {
52
+ return { ok: false, reason: "bad_priority" };
53
+ }
54
+ if (action === "send" && (!data.message || !String(data.message).trim())) {
55
+ return { ok: false, reason: "empty_message" };
56
+ }
57
+ return {
58
+ ok: true,
59
+ value: {
60
+ action,
61
+ priority,
62
+ message: data.message ? String(data.message).slice(0, 3400) : "",
63
+ reason: String(data.reason || ""),
64
+ tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
65
+ },
66
+ };
67
+ }
68
+
69
+ function resolveApiConfig(config) {
70
+ const base =
71
+ config?.hermes?.api_base ||
72
+ process.env.HERMES_API_BASE ||
73
+ "http://127.0.0.1:8642/v1";
74
+ const key =
75
+ process.env.HERMES_API_SERVER_KEY ||
76
+ process.env.API_SERVER_KEY ||
77
+ config?.hermes?.api_key ||
78
+ "";
79
+ const model = config?.hermes?.model || process.env.HERMES_API_MODEL || "hermes-agent";
80
+ return {
81
+ base: String(base).replace(/\/$/, ""),
82
+ key: String(key || ""),
83
+ model: String(model || "hermes-agent"),
84
+ timeout_ms: Number(config?.hermes?.timeout_ms || 45000),
85
+ };
86
+ }
87
+
88
+ /**
89
+ * Call Hermes as the real brain (OpenAI chat completions compatible).
90
+ * Returns plain text for Zalo outbound after Policy Guard.
91
+ */
92
+ export async function callHermesBrain({
93
+ config,
94
+ prompt,
95
+ fetchImpl = globalThis.fetch,
96
+ maxRetries = 2,
97
+ system =
98
+ "Bạn là Hermes Agent. Trả lời ngắn, tiếng Việt, đúng format tin điều hành. Không jargon kỹ thuật. Không bịa.",
99
+ }) {
100
+ const api = resolveApiConfig(config);
101
+ if (!api.key) {
102
+ // No API key → caller uses local ops fallback.
103
+ return { ok: false, reason: "hermes_api_key_missing" };
104
+ }
105
+ if (!fetchImpl) return { ok: false, reason: "no_fetch", manual_review: true };
106
+
107
+ let lastErr = null;
108
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
109
+ const ctrl = new AbortController();
110
+ const t = setTimeout(() => ctrl.abort(), api.timeout_ms);
111
+ try {
112
+ const res = await fetchImpl(`${api.base}/chat/completions`, {
113
+ method: "POST",
114
+ headers: {
115
+ "content-type": "application/json",
116
+ authorization: `Bearer ${api.key}`,
117
+ },
118
+ body: JSON.stringify({
119
+ model: api.model,
120
+ messages: [
121
+ { role: "system", content: system },
122
+ { role: "user", content: String(prompt || "") },
123
+ ],
124
+ temperature: 0.2,
125
+ max_tokens: 900,
126
+ }),
127
+ signal: ctrl.signal,
128
+ });
129
+ const text = await res.text();
130
+ if (!res.ok) {
131
+ lastErr = `http_${res.status}:${text.slice(0, 180)}`;
132
+ await sleep(200 * attempt);
133
+ continue;
134
+ }
135
+ let data;
136
+ try {
137
+ data = JSON.parse(text);
138
+ } catch {
139
+ lastErr = "malformed_json";
140
+ await sleep(200 * attempt);
141
+ continue;
142
+ }
143
+ const content = data?.choices?.[0]?.message?.content;
144
+ if (!content || !String(content).trim()) {
145
+ lastErr = "empty_content";
146
+ await sleep(200 * attempt);
147
+ continue;
148
+ }
149
+ return {
150
+ ok: true,
151
+ text: String(content).trim().slice(0, 3400),
152
+ reason: "api_chat_completions",
153
+ attempts: attempt,
154
+ };
155
+ } catch (err) {
156
+ lastErr = String(err?.message || err);
157
+ await sleep(200 * attempt);
158
+ } finally {
159
+ clearTimeout(t);
160
+ }
161
+ }
162
+ return { ok: false, reason: lastErr || "retries_exhausted", manual_review: true };
163
+ }
164
+
165
+ export async function callHermes({
166
+ config,
167
+ payload,
168
+ fetchImpl = globalThis.fetch,
169
+ maxRetries = 3,
170
+ }) {
171
+ // Prefer real Hermes brain for ops_report / destination asks.
172
+ if (payload?.purpose === "ops_report" || payload?.purpose === "destination_ask") {
173
+ const joined = (payload.messages || [])
174
+ .map((m) => m.text)
175
+ .filter(Boolean)
176
+ .join("\n");
177
+ const brain = await callHermesBrain({
178
+ config,
179
+ prompt: joined,
180
+ fetchImpl,
181
+ maxRetries,
182
+ });
183
+ if (brain.ok) {
184
+ return {
185
+ ok: true,
186
+ response: {
187
+ action: "send",
188
+ priority: "medium",
189
+ message: brain.text,
190
+ reason: brain.reason,
191
+ tags: ["brain"],
192
+ },
193
+ attempts: brain.attempts,
194
+ };
195
+ }
196
+ // fall through to webhook/mock
197
+ }
198
+
199
+ const url = config.hermes?.webhook_url || process.env.HERMES_WEBHOOK_URL || "";
200
+ if (!url) {
201
+ return mockHermes(payload);
202
+ }
203
+ if (!fetchImpl) return { ok: false, reason: "no_fetch", manual_review: true };
204
+
205
+ const headers = { "content-type": "application/json" };
206
+ if (process.env.HERMES_WEBHOOK_TOKEN) {
207
+ headers.authorization = `Bearer ${process.env.HERMES_WEBHOOK_TOKEN}`;
208
+ }
209
+
210
+ let lastErr = null;
211
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
212
+ const ctrl = new AbortController();
213
+ const t = setTimeout(() => ctrl.abort(), config.hermes?.timeout_ms || 15000);
214
+ try {
215
+ const res = await fetchImpl(url, {
216
+ method: "POST",
217
+ headers,
218
+ body: JSON.stringify(payload),
219
+ signal: ctrl.signal,
220
+ });
221
+ const text = await res.text();
222
+ let data;
223
+ try {
224
+ data = JSON.parse(text);
225
+ } catch {
226
+ lastErr = "malformed_json";
227
+ await sleep(200 * attempt);
228
+ continue;
229
+ }
230
+ const validated = validateHermesResponse(data);
231
+ if (!validated.ok) {
232
+ lastErr = validated.reason;
233
+ await sleep(200 * attempt);
234
+ continue;
235
+ }
236
+ return { ok: true, response: validated.value, attempts: attempt };
237
+ } catch (err) {
238
+ lastErr = String(err?.message || err);
239
+ await sleep(200 * attempt);
240
+ } finally {
241
+ clearTimeout(t);
242
+ }
243
+ }
244
+ return { ok: false, reason: lastErr || "retries_exhausted", manual_review: true };
245
+ }
246
+
247
+ function sleep(ms) {
248
+ return new Promise((r) => setTimeout(r, ms));
249
+ }
250
+
251
+ export function mockHermes(payload) {
252
+ const texts = (payload.messages || []).map((m) => m.text).filter(Boolean);
253
+ if (!texts.length) {
254
+ return {
255
+ ok: true,
256
+ response: {
257
+ action: "ignore",
258
+ priority: "low",
259
+ message: "",
260
+ reason: "no_messages",
261
+ tags: [],
262
+ },
263
+ mock: true,
264
+ };
265
+ }
266
+
267
+ if (payload.purpose === "alert") {
268
+ const joined = texts.join(" · ").slice(0, 400);
269
+ const high =
270
+ /gấp|khẩn|scam|lừa|lead|chốt|book|hủy|sập|ban|phốt|khủng hoảng/i.test(joined);
271
+ return {
272
+ ok: true,
273
+ response: {
274
+ action: "send",
275
+ priority: high ? "high" : "medium",
276
+ message: [
277
+ "Cảnh báo — destination đã cấu hình",
278
+ "",
279
+ "Trạng thái: có tín hiệu cần xem sớm.",
280
+ `Nội dung: ${joined}`,
281
+ "Việc tiếp theo: kiểm tra nhóm nguồn và xử lý nếu cần.",
282
+ ].join("\n"),
283
+ reason: "mock_alert",
284
+ tags: high ? ["high", "mock"] : ["mock"],
285
+ },
286
+ mock: true,
287
+ };
288
+ }
289
+
290
+ // Digest/ask without API key: local ops_report owns final text.
291
+ return {
292
+ ok: true,
293
+ response: {
294
+ action: "ignore",
295
+ priority: "low",
296
+ message: "",
297
+ reason: "use_local_ops_report",
298
+ tags: ["mock"],
299
+ },
300
+ mock: true,
301
+ };
302
+ }