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,364 @@
1
+ // Historical + identity corpus backfill. READ_ONLY — never sends.
2
+ import { normalizeInboundMessage, utcNow } from "./schema.js";
3
+ import { listGroupsDetailed } from "./discovery.js";
4
+
5
+ function sleep(ms) {
6
+ return new Promise((r) => setTimeout(r, ms));
7
+ }
8
+
9
+ function parseMemberToken(token) {
10
+ // memVerList entries look like "696878...038_0"
11
+ const s = String(token || "");
12
+ const idx = s.lastIndexOf("_");
13
+ if (idx > 0) return s.slice(0, idx);
14
+ return s;
15
+ }
16
+
17
+ function asMessageLike(groupMsg, groupId) {
18
+ if (groupMsg?.data && groupMsg?.threadId != null) {
19
+ return {
20
+ type: groupMsg.type ?? 1,
21
+ threadId: String(groupMsg.threadId || groupId),
22
+ isSelf: Boolean(groupMsg.isSelf),
23
+ data: groupMsg.data,
24
+ };
25
+ }
26
+ const data = groupMsg?.data || groupMsg || {};
27
+ return {
28
+ type: 1,
29
+ threadId: String(groupId),
30
+ isSelf: false,
31
+ data,
32
+ };
33
+ }
34
+
35
+ async function fetchOldGroupMessages(api, { timeoutMs = 15000, maxPages = 5 } = {}) {
36
+ if (!api?.listener?.requestOldMessages) return [];
37
+ const ThreadTypeGroup = 1;
38
+ try {
39
+ api.listener.start?.({ retryOnClose: true });
40
+ } catch {
41
+ /* already started */
42
+ }
43
+ await sleep(1000);
44
+
45
+ const collected = [];
46
+ const seen = new Set();
47
+ let lastId = null;
48
+
49
+ for (let page = 0; page < maxPages; page++) {
50
+ const batch = await new Promise((resolve) => {
51
+ let done = false;
52
+ const rows = [];
53
+ const finish = () => {
54
+ if (done) return;
55
+ done = true;
56
+ try {
57
+ api.listener.off("old_messages", onOld);
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ clearTimeout(timer);
62
+ resolve(rows);
63
+ };
64
+ const onOld = (messages, type) => {
65
+ if (!(type === ThreadTypeGroup || type === 1 || type === "group" || type == null)) return;
66
+ for (const m of Array.isArray(messages) ? messages : []) rows.push(m);
67
+ // settle after first batch for this page
68
+ setTimeout(finish, 1200);
69
+ };
70
+ const timer = setTimeout(finish, Math.max(3000, Math.floor(timeoutMs / maxPages)));
71
+ api.listener.on("old_messages", onOld);
72
+ try {
73
+ api.listener.requestOldMessages(ThreadTypeGroup, lastId);
74
+ } catch {
75
+ finish();
76
+ }
77
+ });
78
+
79
+ if (!batch.length) break;
80
+ let newCount = 0;
81
+ for (const m of batch) {
82
+ const mid = String(m?.data?.msgId || m?.data?.cliMsgId || m?.messageId || "");
83
+ const key = `${m?.threadId || ""}:${mid}`;
84
+ if (seen.has(key)) continue;
85
+ seen.add(key);
86
+ collected.push(m);
87
+ newCount += 1;
88
+ if (mid) lastId = mid;
89
+ }
90
+ if (!newCount) break;
91
+ await sleep(400);
92
+ }
93
+ return collected;
94
+ }
95
+
96
+ /**
97
+ * Full corpus crawl for one account:
98
+ * - groups (id/name/members)
99
+ * - users (id/display)
100
+ * - recent history messages per group (via websocket old_messages when REST history 404)
101
+ */
102
+ export async function backfillAccountCorpus({
103
+ api,
104
+ store,
105
+ accountId,
106
+ historyCount = 50,
107
+ maxGroups = 200,
108
+ delayMs = 250,
109
+ groupIds = null,
110
+ }) {
111
+ const result = {
112
+ account_id: accountId,
113
+ started_at: utcNow(),
114
+ groups_seen: 0,
115
+ members_seen: 0,
116
+ users_upserted: 0,
117
+ messages_ingested: 0,
118
+ messages_skipped: 0,
119
+ history_method: "none",
120
+ errors: [],
121
+ finished_at: null,
122
+ };
123
+
124
+ if (!api) {
125
+ result.errors.push("not_connected");
126
+ result.finished_at = utcNow();
127
+ return result;
128
+ }
129
+
130
+ let groups = [];
131
+ try {
132
+ groups = await listGroupsDetailed(api);
133
+ } catch (err) {
134
+ result.errors.push(`list_groups:${String(err?.message || err)}`);
135
+ groups = store.getSetting(`discovered_groups_${accountId}`, []) || [];
136
+ }
137
+ if (Array.isArray(groupIds) && groupIds.length) {
138
+ const allow = new Set(groupIds.map(String));
139
+ groups = groups.filter((g) => allow.has(String(g.source_id)));
140
+ }
141
+ groups = groups.slice(0, maxGroups);
142
+ result.groups_seen = groups.length;
143
+ store.setSetting(`discovered_groups_${accountId}`, groups);
144
+
145
+ // One websocket old_messages dump can contain many groups — pull once then partition.
146
+ let sharedOld = [];
147
+ try {
148
+ sharedOld = await fetchOldGroupMessages(api, { timeoutMs: 20000, maxPages: 8 });
149
+ if (sharedOld.length) result.history_method = "ws_old_messages";
150
+ result.ws_old_count = sharedOld.length;
151
+ } catch (err) {
152
+ result.errors.push(`ws_old:${String(err?.message || err)}`);
153
+ }
154
+
155
+ const oldByGroup = new Map();
156
+ for (const m of sharedOld) {
157
+ const tid = String(m.threadId || m.data?.idTo || "");
158
+ if (!tid) continue;
159
+ if (!oldByGroup.has(tid)) oldByGroup.set(tid, []);
160
+ oldByGroup.get(tid).push(m);
161
+ }
162
+
163
+ for (const g of groups) {
164
+ const gid = String(g.source_id || "");
165
+ if (!gid) continue;
166
+ let gname = String(g.source_name || "");
167
+
168
+ store.upsertSource({
169
+ accountId,
170
+ sourceId: gid,
171
+ sourceType: "group",
172
+ sourceName: gname,
173
+ mode: "listen_only",
174
+ isAllowed: true,
175
+ });
176
+
177
+ let memberIds = [];
178
+ let adminIds = [];
179
+ let creatorId = "";
180
+ try {
181
+ if (typeof api.getGroupInfo === "function") {
182
+ const info = await api.getGroupInfo(gid);
183
+ const gi = info?.gridInfoMap?.[gid] || {};
184
+ if (gi.name) gname = String(gi.name);
185
+ creatorId = String(gi.creatorId || "");
186
+ adminIds = Array.isArray(gi.adminIds) ? gi.adminIds.map(String) : [];
187
+ if (Array.isArray(gi.memberIds) && gi.memberIds.length) {
188
+ memberIds = gi.memberIds.map(String);
189
+ } else if (Array.isArray(gi.memVerList) && gi.memVerList.length) {
190
+ memberIds = gi.memVerList.map(parseMemberToken).filter(Boolean);
191
+ }
192
+ if (Array.isArray(gi.currentMems)) {
193
+ for (const m of gi.currentMems) {
194
+ const uid = String(m.id || m.uid || m.userId || "");
195
+ if (!uid) continue;
196
+ store.upsertUser({
197
+ accountId,
198
+ userId: uid,
199
+ displayName: m.dName || m.displayName || m.zaloName || "",
200
+ avatar: m.avatar || "",
201
+ globalId: m.globalId || "",
202
+ raw: m,
203
+ });
204
+ result.users_upserted += 1;
205
+ }
206
+ }
207
+ store.upsertSource({
208
+ accountId,
209
+ sourceId: gid,
210
+ sourceType: "group",
211
+ sourceName: gname,
212
+ mode: "listen_only",
213
+ isAllowed: true,
214
+ });
215
+ }
216
+ } catch (err) {
217
+ result.errors.push(`group_info:${gid}:${String(err?.message || err)}`);
218
+ }
219
+
220
+ if (creatorId) memberIds.push(creatorId);
221
+ memberIds = [...new Set(memberIds.filter(Boolean))];
222
+
223
+ const roleOf = (uid) => {
224
+ if (creatorId && uid === creatorId) return "creator";
225
+ if (adminIds.includes(uid)) return "admin";
226
+ return "member";
227
+ };
228
+ for (const uid of memberIds) {
229
+ store.upsertSourceMember({
230
+ accountId,
231
+ sourceId: gid,
232
+ userId: uid,
233
+ role: roleOf(uid),
234
+ });
235
+ result.members_seen += 1;
236
+ }
237
+
238
+ if (memberIds.length && typeof api.getGroupMembersInfo === "function") {
239
+ for (let i = 0; i < memberIds.length; i += 20) {
240
+ const chunk = memberIds.slice(i, i + 20);
241
+ try {
242
+ const res = await api.getGroupMembersInfo(chunk);
243
+ const profiles = res?.profiles || {};
244
+ for (const [uid, p] of Object.entries(profiles)) {
245
+ store.upsertUser({
246
+ accountId,
247
+ userId: uid,
248
+ displayName: p.displayName || p.zaloName || "",
249
+ avatar: p.avatar || "",
250
+ globalId: p.globalId || "",
251
+ accountStatus: p.accountStatus,
252
+ raw: p,
253
+ });
254
+ result.users_upserted += 1;
255
+ }
256
+ } catch (err) {
257
+ result.errors.push(`members_info:${gid}:${String(err?.message || err)}`);
258
+ }
259
+ await sleep(delayMs);
260
+ }
261
+ }
262
+
263
+ // History: prefer the current cloud-history adapter, then legacy REST,
264
+ // then the shared websocket dump. Zalo removed the legacy endpoint in
265
+ // August 2026, so the v2 adapter must win when installed.
266
+ let msgs = [];
267
+ if (typeof api.getGroupChatHistoryV2 === "function") {
268
+ try {
269
+ const hist = await api.getGroupChatHistoryV2({ groupId: gid, count: historyCount });
270
+ msgs = Array.isArray(hist?.groupMsgs) ? hist.groupMsgs : [];
271
+ if (msgs.length) result.history_method = "cloud_history_v2";
272
+ } catch (err) {
273
+ result.errors.push(`history_v2:${gid}:${String(err?.message || err).slice(0, 180)}`);
274
+ }
275
+ }
276
+ if (!msgs.length && typeof api.getGroupChatHistory === "function") {
277
+ try {
278
+ const hist = await api.getGroupChatHistory(gid, historyCount);
279
+ msgs = Array.isArray(hist?.groupMsgs) ? hist.groupMsgs : [];
280
+ if (msgs.length) result.history_method = "rest_history";
281
+ } catch (err) {
282
+ result.errors.push(`history_rest:${gid}:404/err`);
283
+ }
284
+ }
285
+ if (!msgs.length) {
286
+ msgs = (oldByGroup.get(gid) || []).slice(0, historyCount);
287
+ }
288
+
289
+ for (const m of msgs) {
290
+ try {
291
+ const message = asMessageLike(m, gid);
292
+ if (!message.threadId) message.threadId = gid;
293
+ const event = normalizeInboundMessage({
294
+ accountId,
295
+ message,
296
+ sourceName: gname,
297
+ });
298
+ event.raw_metadata = {
299
+ ...(event.raw_metadata || {}),
300
+ sender_user_id: event.sender_id,
301
+ backfill: true,
302
+ };
303
+ if (event.sender_id) {
304
+ store.upsertUser({
305
+ accountId,
306
+ userId: event.sender_id,
307
+ displayName: event.sender_name || "",
308
+ });
309
+ }
310
+ const inserted = store.putEvent(event);
311
+ if (inserted) result.messages_ingested += 1;
312
+ else result.messages_skipped += 1;
313
+ } catch (err) {
314
+ result.messages_skipped += 1;
315
+ result.errors.push(`msg:${gid}:${String(err?.message || err)}`);
316
+ }
317
+ }
318
+
319
+ await sleep(delayMs);
320
+ store.setHealth(`backfill_progress_${accountId}`, {
321
+ at: utcNow(),
322
+ last_group_id: gid,
323
+ groups_done: result.groups_seen,
324
+ messages_ingested: result.messages_ingested,
325
+ users_upserted: result.users_upserted,
326
+ members_seen: result.members_seen,
327
+ });
328
+ }
329
+
330
+ try {
331
+ if (typeof api.getAllFriends === "function") {
332
+ const friends = await api.getAllFriends(200, 1);
333
+ const list = Array.isArray(friends) ? friends : friends?.data || friends?.friends || [];
334
+ for (const f of list) {
335
+ const uid = String(f.userId || f.uid || f.id || "");
336
+ if (!uid) continue;
337
+ store.upsertUser({
338
+ accountId,
339
+ userId: uid,
340
+ displayName: f.displayName || f.zaloName || f.dName || "",
341
+ avatar: f.avatar || "",
342
+ globalId: f.globalId || "",
343
+ raw: { friend: true },
344
+ });
345
+ result.users_upserted += 1;
346
+ }
347
+ }
348
+ } catch (err) {
349
+ result.errors.push(`friends:${String(err?.message || err)}`);
350
+ }
351
+
352
+ result.finished_at = utcNow();
353
+ store.setHealth(`backfill_${accountId}`, {
354
+ ...result,
355
+ errors: result.errors.slice(-50),
356
+ });
357
+ store.audit({
358
+ accountId,
359
+ actorId: "system",
360
+ action: "corpus_backfill",
361
+ detail: `groups=${result.groups_seen};msgs=${result.messages_ingested};users=${result.users_upserted};members=${result.members_seen}`,
362
+ });
363
+ return result;
364
+ }
@@ -0,0 +1,180 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { OaAdapter } from "./oa_adapter.js";
5
+
6
+ const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
7
+ const ENV_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
8
+ const ADAPTERS = new Set(["zalo_personal_qr", "zalo_oa"]);
9
+ // `inbound_auto_reply` may be reached only by a signed, normalized OA webhook.
10
+ // It is not accepted by the generic outbound-send policy.
11
+ const POLICY_MODES = new Set(["disabled", "draft_first", "approved_send", "inbound_auto_reply", "paused"]);
12
+
13
+ export class BotRegistryError extends Error {
14
+ constructor(code, message = code) {
15
+ super(message);
16
+ Object.defineProperties(this, {
17
+ name: { value: "BotRegistryError", enumerable: false, configurable: true },
18
+ code: { value: code, enumerable: false },
19
+ });
20
+ }
21
+ }
22
+
23
+ function text(value) {
24
+ return String(value ?? "").trim();
25
+ }
26
+
27
+ function requireId(value, code) {
28
+ const id = text(value);
29
+ if (!ID_RE.test(id)) throw new BotRegistryError(code, `${code}: invalid identifier`);
30
+ return id;
31
+ }
32
+
33
+ function requireEnvRef(value) {
34
+ const name = text(value);
35
+ if (!ENV_RE.test(name)) throw new BotRegistryError("invalid_credential_ref", "credential must reference an env variable");
36
+ return name;
37
+ }
38
+
39
+ function normalizePolicy(policy = {}) {
40
+ if (!policy || typeof policy !== "object" || Array.isArray(policy)) {
41
+ throw new BotRegistryError("invalid_policy", "policy must be an object");
42
+ }
43
+ const mode = text(policy.mode || "draft_first").toLowerCase();
44
+ if (!POLICY_MODES.has(mode)) throw new BotRegistryError("invalid_policy_mode", `unsupported policy mode: ${mode}`);
45
+ const allowUserIds = Array.isArray(policy.allow_user_ids)
46
+ ? policy.allow_user_ids.map((id) => text(id)).filter(Boolean)
47
+ : [];
48
+ if (allowUserIds.some((id) => id.length > 256)) {
49
+ throw new BotRegistryError("invalid_policy", "allow_user_ids contains an oversized id");
50
+ }
51
+ return {
52
+ mode,
53
+ allow_user_ids: [...new Set(allowUserIds)],
54
+ max_messages_per_hour: Number.isFinite(Number(policy.max_messages_per_hour))
55
+ ? Math.max(0, Math.floor(Number(policy.max_messages_per_hour)))
56
+ : 20,
57
+ };
58
+ }
59
+
60
+ function normalizeBot(bot) {
61
+ if (!bot || typeof bot !== "object" || Array.isArray(bot)) {
62
+ throw new BotRegistryError("invalid_bot", "each bot must be an object");
63
+ }
64
+ const botId = requireId(bot.bot_id, "invalid_bot_id");
65
+ const tenantId = requireId(bot.tenant_id || "default", "invalid_tenant_id");
66
+ const adapter = text(bot.adapter).toLowerCase();
67
+ if (!ADAPTERS.has(adapter)) throw new BotRegistryError("invalid_adapter", `unsupported adapter: ${adapter}`);
68
+
69
+ const normalized = {
70
+ bot_id: botId,
71
+ tenant_id: tenantId,
72
+ name: text(bot.name || botId).slice(0, 120),
73
+ adapter,
74
+ enabled: bot.enabled !== false,
75
+ policy: normalizePolicy(bot.policy),
76
+ };
77
+
78
+ if (adapter === "zalo_oa") {
79
+ const credential = bot.credential;
80
+ if (!credential || typeof credential !== "object" || Array.isArray(credential)) {
81
+ throw new BotRegistryError("credential_missing", `${botId}: OA credential references are required`);
82
+ }
83
+ normalized.credential = {
84
+ app_id_env: requireEnvRef(credential.app_id_env),
85
+ app_secret_env: requireEnvRef(credential.app_secret_env),
86
+ refresh_token_env: requireEnvRef(credential.refresh_token_env),
87
+ token_url: text(credential.token_url || ""),
88
+ message_url: text(credential.message_url || ""),
89
+ };
90
+ } else {
91
+ const accountId = text(bot.account_id || botId);
92
+ if (!ID_RE.test(accountId)) throw new BotRegistryError("invalid_account_id", `${botId}: invalid account_id`);
93
+ normalized.account_id = accountId;
94
+ }
95
+ return normalized;
96
+ }
97
+
98
+ export function validateBotRegistry(input) {
99
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
100
+ throw new BotRegistryError("config_invalid", "bot registry must be an object");
101
+ }
102
+ const version = Number(input.version || 1);
103
+ if (version !== 1) throw new BotRegistryError("unsupported_registry_version", `unsupported registry version: ${version}`);
104
+ if (!Array.isArray(input.bots)) throw new BotRegistryError("invalid_bots", "bots must be an array");
105
+ if (input.bots.length > 1000) throw new BotRegistryError("invalid_bots", "too many bots");
106
+
107
+ const bots = input.bots.map(normalizeBot);
108
+ const ids = new Set();
109
+ for (const bot of bots) {
110
+ if (ids.has(bot.bot_id)) throw new BotRegistryError("duplicate_bot_id", `duplicate bot_id: ${bot.bot_id}`);
111
+ ids.add(bot.bot_id);
112
+ }
113
+ return { version: 1, bots };
114
+ }
115
+
116
+ export function loadBotRegistry(filePath = process.env.BOTS_FILE || "./config/bots.json") {
117
+ const abs = path.resolve(filePath);
118
+ if (!fs.existsSync(abs)) return { version: 1, bots: [] };
119
+ let parsed;
120
+ try {
121
+ parsed = JSON.parse(fs.readFileSync(abs, "utf8"));
122
+ } catch {
123
+ throw new BotRegistryError("config_invalid", "bot registry is not valid JSON");
124
+ }
125
+ return validateBotRegistry(parsed);
126
+ }
127
+
128
+ function envValue(env, key) {
129
+ return text(env?.[key]);
130
+ }
131
+
132
+ export function createOaAdapterMap({ registry, env = process.env, fetchImpl, now } = {}) {
133
+ const normalized = validateBotRegistry(registry || { version: 1, bots: [] });
134
+ const adapters = new Map();
135
+ for (const bot of normalized.bots) {
136
+ if (!bot.enabled || bot.adapter !== "zalo_oa") continue;
137
+ adapters.set(
138
+ bot.bot_id,
139
+ new OaAdapter({
140
+ botId: bot.bot_id,
141
+ tenantId: bot.tenant_id,
142
+ appId: envValue(env, bot.credential.app_id_env),
143
+ appSecret: envValue(env, bot.credential.app_secret_env),
144
+ refreshToken: envValue(env, bot.credential.refresh_token_env),
145
+ tokenUrl: bot.credential.token_url || undefined,
146
+ messageUrl: bot.credential.message_url || undefined,
147
+ fetchImpl,
148
+ now,
149
+ }),
150
+ );
151
+ }
152
+ return adapters;
153
+ }
154
+
155
+ export function redactBotRegistry(registry) {
156
+ const normalized = validateBotRegistry(registry || { version: 1, bots: [] });
157
+ return {
158
+ version: normalized.version,
159
+ bots: normalized.bots.map((bot) => ({
160
+ ...bot,
161
+ credential: bot.credential
162
+ ? {
163
+ app_id_env: bot.credential.app_id_env,
164
+ app_secret_env: bot.credential.app_secret_env,
165
+ refresh_token_env: bot.credential.refresh_token_env,
166
+ ...(bot.credential.token_url ? { token_url: bot.credential.token_url } : {}),
167
+ ...(bot.credential.message_url ? { message_url: bot.credential.message_url } : {}),
168
+ }
169
+ : undefined,
170
+ })),
171
+ };
172
+ }
173
+
174
+ export function getBot(registry, botId) {
175
+ const normalized = validateBotRegistry(registry || { version: 1, bots: [] });
176
+ return normalized.bots.find((bot) => bot.bot_id === text(botId)) || null;
177
+ }
178
+
179
+ export const BOT_ADAPTERS = Object.freeze([...ADAPTERS]);
180
+ export const BOT_POLICY_MODES = Object.freeze([...POLICY_MODES]);
package/src/brand.js ADDED
@@ -0,0 +1,31 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
6
+
7
+ /**
8
+ * anyone running this fork should be able to present it as their own tool.
9
+ */
10
+ function readPackage() {
11
+ try {
12
+ return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8"));
13
+ } catch {
14
+ return {};
15
+ }
16
+ }
17
+
18
+ const pkg = readPackage();
19
+
20
+ export const serviceMetadata = Object.freeze({
21
+ name: pkg.name || "abs-zalo-bot",
22
+ version: pkg.version || "0.0.0",
23
+ license: pkg.license || "MIT",
24
+ });
25
+
26
+ /** Kept for backward compatibility with existing imports. */
27
+ export function publicBrandMetadata() {
28
+ return { ...serviceMetadata };
29
+ }
30
+
31
+ export default serviceMetadata;