gramobase 1.0.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/CODE_OF_CONDUCT.md +132 -0
- package/LICENSE +21 -0
- package/README.md +314 -0
- package/dist/BotWorkerPool-9ndHQt2g.d.cts +201 -0
- package/dist/BotWorkerPool-9ndHQt2g.d.ts +201 -0
- package/dist/GramoBaseAuth-00fg0u_b.d.ts +218 -0
- package/dist/GramoBaseAuth-CHNn2_e5.d.cts +218 -0
- package/dist/auth/index.cjs +226 -0
- package/dist/auth/index.cjs.map +1 -0
- package/dist/auth/index.d.cts +5 -0
- package/dist/auth/index.d.ts +5 -0
- package/dist/auth/index.js +203 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/bin/gramobase.cjs +167 -0
- package/dist/bin/gramobase.cjs.map +1 -0
- package/dist/bin/gramobase.d.cts +1 -0
- package/dist/bin/gramobase.d.ts +1 -0
- package/dist/bin/gramobase.js +160 -0
- package/dist/bin/gramobase.js.map +1 -0
- package/dist/index.cjs +1644 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +104 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +1611 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations/index.cjs +98 -0
- package/dist/migrations/index.cjs.map +1 -0
- package/dist/migrations/index.d.cts +23 -0
- package/dist/migrations/index.d.ts +23 -0
- package/dist/migrations/index.js +96 -0
- package/dist/migrations/index.js.map +1 -0
- package/package.json +70 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1611 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import TelegramBot from 'node-telegram-bot-api';
|
|
3
|
+
import PQueue from 'p-queue';
|
|
4
|
+
import pRetry, { AbortError } from 'p-retry';
|
|
5
|
+
import EventEmitter from 'eventemitter3';
|
|
6
|
+
import { LRUCache } from 'lru-cache';
|
|
7
|
+
import { randomUUID, createHash, randomBytes, createCipheriv, createDecipheriv } from 'crypto';
|
|
8
|
+
import * as jwt from 'jsonwebtoken';
|
|
9
|
+
import * as bcrypt from 'bcryptjs';
|
|
10
|
+
import { existsSync, readFileSync } from 'fs';
|
|
11
|
+
|
|
12
|
+
// gramobase — Telegram as your free, infinite backend
|
|
13
|
+
|
|
14
|
+
var BotWorkerPool = class extends EventEmitter {
|
|
15
|
+
bots = [];
|
|
16
|
+
queues = [];
|
|
17
|
+
stats = [];
|
|
18
|
+
currentIndex = 0;
|
|
19
|
+
debug;
|
|
20
|
+
constructor(tokens, concurrency = 25, debug = false) {
|
|
21
|
+
super();
|
|
22
|
+
this.debug = debug;
|
|
23
|
+
if (tokens.length === 0) throw new Error("[gramobase] At least one bot token required");
|
|
24
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
25
|
+
const token = tokens[i];
|
|
26
|
+
this.bots.push(new TelegramBot(token, { polling: false }));
|
|
27
|
+
this.queues.push(new PQueue({ concurrency, intervalCap: 25, interval: 1e3 }));
|
|
28
|
+
this.stats.push({
|
|
29
|
+
tokenIndex: i,
|
|
30
|
+
requestCount: 0,
|
|
31
|
+
errorCount: 0,
|
|
32
|
+
lastUsed: 0,
|
|
33
|
+
rateLimitHits: 0
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Execute a Telegram API call through the pool with automatic retry
|
|
39
|
+
* and token rotation on rate limits.
|
|
40
|
+
*/
|
|
41
|
+
async execute(fn, priority = 5) {
|
|
42
|
+
const idx = this.pickWorker();
|
|
43
|
+
const queue = this.queues[idx];
|
|
44
|
+
const bot = this.bots[idx];
|
|
45
|
+
const stat = this.stats[idx];
|
|
46
|
+
return queue.add(
|
|
47
|
+
() => pRetry(
|
|
48
|
+
async () => {
|
|
49
|
+
stat.requestCount++;
|
|
50
|
+
stat.lastUsed = Date.now();
|
|
51
|
+
try {
|
|
52
|
+
const result = await fn(bot);
|
|
53
|
+
return result;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
stat.errorCount++;
|
|
56
|
+
if (this.isFloodError(err)) {
|
|
57
|
+
stat.rateLimitHits++;
|
|
58
|
+
this.emit("worker:rotate", idx);
|
|
59
|
+
const retryAfter = this.extractRetryAfter(err) * 1e3;
|
|
60
|
+
if (this.debug) {
|
|
61
|
+
console.warn(`[gramobase] Worker ${idx} flood limited, retrying after ${retryAfter}ms`);
|
|
62
|
+
}
|
|
63
|
+
await this.sleep(retryAfter);
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
if (this.isRetryableError(err)) {
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
throw new AbortError(err instanceof Error ? err : new Error(String(err)));
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
retries: 5,
|
|
74
|
+
factor: 2,
|
|
75
|
+
minTimeout: 1e3,
|
|
76
|
+
maxTimeout: 3e4,
|
|
77
|
+
onFailedAttempt: (error) => {
|
|
78
|
+
if (this.debug) {
|
|
79
|
+
console.warn(`[gramobase] Attempt ${error.attemptNumber} failed:`, error.message);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
),
|
|
84
|
+
{ priority }
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Round-robin with recency bias — prefer the worker that was least recently used.
|
|
89
|
+
*/
|
|
90
|
+
pickWorker() {
|
|
91
|
+
if (this.bots.length === 1) return 0;
|
|
92
|
+
let bestIdx = 0;
|
|
93
|
+
let oldestTime = Infinity;
|
|
94
|
+
for (let i = 0; i < this.stats.length; i++) {
|
|
95
|
+
const stat = this.stats[i];
|
|
96
|
+
if (stat.lastUsed < oldestTime) {
|
|
97
|
+
oldestTime = stat.lastUsed;
|
|
98
|
+
bestIdx = i;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
this.currentIndex = bestIdx;
|
|
102
|
+
return bestIdx;
|
|
103
|
+
}
|
|
104
|
+
isFloodError(err) {
|
|
105
|
+
if (err && typeof err === "object") {
|
|
106
|
+
const e = err;
|
|
107
|
+
return e.code === "ETELEGRAM" && e.response?.statusCode === 429;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
isRetryableError(err) {
|
|
112
|
+
if (!err) return false;
|
|
113
|
+
if (err instanceof Error) {
|
|
114
|
+
if (err.name === "TypeError" || err.name === "ReferenceError" || err.name === "ValidationError") {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (typeof err === "object") {
|
|
119
|
+
const e = err;
|
|
120
|
+
if (e.code === "ETELEGRAM" && e.response?.statusCode === 429) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (e.response?.statusCode && e.response.statusCode >= 500) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
const retryableCodes = ["ETIMEDOUT", "ECONNRESET", "ENOTFOUND", "EAI_AGAIN", "ECONNREFUSED"];
|
|
127
|
+
if (e.code && retryableCodes.includes(e.code)) {
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
extractRetryAfter(err) {
|
|
134
|
+
if (err && typeof err === "object") {
|
|
135
|
+
const e = err;
|
|
136
|
+
return e.response?.body?.parameters?.retry_after ?? 5;
|
|
137
|
+
}
|
|
138
|
+
return 5;
|
|
139
|
+
}
|
|
140
|
+
getBot(index = 0) {
|
|
141
|
+
return this.bots[index] ?? this.bots[0];
|
|
142
|
+
}
|
|
143
|
+
getStats() {
|
|
144
|
+
return [...this.stats];
|
|
145
|
+
}
|
|
146
|
+
getQueueSizes() {
|
|
147
|
+
return this.queues.map((q) => q.size);
|
|
148
|
+
}
|
|
149
|
+
sleep(ms) {
|
|
150
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
151
|
+
}
|
|
152
|
+
async destroy() {
|
|
153
|
+
await Promise.all(this.queues.map((q) => q.onIdle()));
|
|
154
|
+
for (const bot of this.bots) {
|
|
155
|
+
await bot.stopPolling();
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
var HotCache = class extends EventEmitter {
|
|
160
|
+
cache;
|
|
161
|
+
collectionIndexes = /* @__PURE__ */ new Map();
|
|
162
|
+
stats = { hits: 0, misses: 0, evictions: 0 };
|
|
163
|
+
constructor(maxBytes = 64 * 1024 * 1024, ttlMs = 6e4) {
|
|
164
|
+
super();
|
|
165
|
+
this.cache = new LRUCache({
|
|
166
|
+
maxSize: maxBytes,
|
|
167
|
+
sizeCalculation: (val) => JSON.stringify(val).length * 2,
|
|
168
|
+
// rough UTF-16 byte estimate
|
|
169
|
+
ttl: ttlMs,
|
|
170
|
+
dispose: () => {
|
|
171
|
+
this.stats.evictions++;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// ─── Document cache ──────────────────────────────────────────────────────
|
|
176
|
+
get(collection, id) {
|
|
177
|
+
const key = this.docKey(collection, id);
|
|
178
|
+
const entry = this.cache.get(key);
|
|
179
|
+
if (entry) {
|
|
180
|
+
this.stats.hits++;
|
|
181
|
+
this.emit("cache:hit", { collection, key: id });
|
|
182
|
+
return entry.data;
|
|
183
|
+
}
|
|
184
|
+
this.stats.misses++;
|
|
185
|
+
this.emit("cache:miss", { collection, key: id });
|
|
186
|
+
return void 0;
|
|
187
|
+
}
|
|
188
|
+
set(collection, id, data) {
|
|
189
|
+
const key = this.docKey(collection, id);
|
|
190
|
+
this.cache.set(key, { data, collection, cachedAt: Date.now() });
|
|
191
|
+
}
|
|
192
|
+
delete(collection, id) {
|
|
193
|
+
this.cache.delete(this.docKey(collection, id));
|
|
194
|
+
}
|
|
195
|
+
invalidateCollection(collection) {
|
|
196
|
+
for (const key of this.cache.keys()) {
|
|
197
|
+
if (key.startsWith(`doc:${collection}:`)) {
|
|
198
|
+
this.cache.delete(key);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
this.collectionIndexes.delete(collection);
|
|
202
|
+
}
|
|
203
|
+
// ─── Query cache ─────────────────────────────────────────────────────────
|
|
204
|
+
getQuery(queryHash) {
|
|
205
|
+
const entry = this.cache.get(`query:${queryHash}`);
|
|
206
|
+
return entry?.data;
|
|
207
|
+
}
|
|
208
|
+
setQuery(queryHash, results) {
|
|
209
|
+
this.cache.set(`query:${queryHash}`, {
|
|
210
|
+
data: results,
|
|
211
|
+
collection: "__query__",
|
|
212
|
+
cachedAt: Date.now()
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
invalidateQuery(collection) {
|
|
216
|
+
for (const key of this.cache.keys()) {
|
|
217
|
+
if (key.startsWith(`query:${collection}:`)) {
|
|
218
|
+
this.cache.delete(key);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// ─── Collection index ─────────────────────────────────────────────────────
|
|
223
|
+
// Maps document _id → Telegram message ID for O(1) lookup
|
|
224
|
+
getIndex(collection) {
|
|
225
|
+
return this.collectionIndexes.get(collection);
|
|
226
|
+
}
|
|
227
|
+
setIndex(collection, index) {
|
|
228
|
+
this.collectionIndexes.set(collection, index);
|
|
229
|
+
}
|
|
230
|
+
updateIndexEntry(collection, id, msgId) {
|
|
231
|
+
const idx = this.collectionIndexes.get(collection);
|
|
232
|
+
if (idx) {
|
|
233
|
+
idx.set(id, msgId);
|
|
234
|
+
} else {
|
|
235
|
+
this.collectionIndexes.set(collection, /* @__PURE__ */ new Map([[id, msgId]]));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
deleteIndexEntry(collection, id) {
|
|
239
|
+
this.collectionIndexes.get(collection)?.delete(id);
|
|
240
|
+
}
|
|
241
|
+
getMsgId(collection, id) {
|
|
242
|
+
return this.collectionIndexes.get(collection)?.get(id);
|
|
243
|
+
}
|
|
244
|
+
// ─── Bulk read ────────────────────────────────────────────────────────────
|
|
245
|
+
getMany(collection, ids) {
|
|
246
|
+
const result = /* @__PURE__ */ new Map();
|
|
247
|
+
for (const id of ids) {
|
|
248
|
+
const val = this.get(collection, id);
|
|
249
|
+
if (val !== void 0) result.set(id, val);
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
// ─── Stats ────────────────────────────────────────────────────────────────
|
|
254
|
+
getStats() {
|
|
255
|
+
return {
|
|
256
|
+
...this.stats,
|
|
257
|
+
size: this.cache.size,
|
|
258
|
+
hitRate: this.stats.hits + this.stats.misses > 0 ? this.stats.hits / (this.stats.hits + this.stats.misses) : 0
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
clear() {
|
|
262
|
+
this.cache.clear();
|
|
263
|
+
this.collectionIndexes.clear();
|
|
264
|
+
}
|
|
265
|
+
docKey(collection, id) {
|
|
266
|
+
return `doc:${collection}:${id}`;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
var INDEX_TAG = "__GRAMOBASE_INDEX__";
|
|
270
|
+
var DOC_TAG = "__GRAMOBASE_DOC__";
|
|
271
|
+
var MAX_MSG_BYTES = 4e3;
|
|
272
|
+
var TelegramStorage = class {
|
|
273
|
+
constructor(pool, defaultChannelId, encryptionKey, debug = false) {
|
|
274
|
+
this.pool = pool;
|
|
275
|
+
this.defaultChannelId = defaultChannelId;
|
|
276
|
+
this.debug = debug;
|
|
277
|
+
if (encryptionKey) {
|
|
278
|
+
this.encryptionKey = createHash("sha256").update(encryptionKey).digest();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
pool;
|
|
282
|
+
defaultChannelId;
|
|
283
|
+
debug;
|
|
284
|
+
encryptionKey = null;
|
|
285
|
+
// collection → pinned index message ID
|
|
286
|
+
indexMsgIds = /* @__PURE__ */ new Map();
|
|
287
|
+
// ─── Index management ─────────────────────────────────────────────────────
|
|
288
|
+
async loadIndex(collection, channelId) {
|
|
289
|
+
const channel = channelId ?? this.defaultChannelId;
|
|
290
|
+
try {
|
|
291
|
+
const chat = await this.pool.execute((bot) => bot.getChat(channel));
|
|
292
|
+
if (chat.pinned_message?.text?.startsWith(INDEX_TAG)) {
|
|
293
|
+
const json = chat.pinned_message.text.replace(INDEX_TAG + "\n", "");
|
|
294
|
+
const parsed = JSON.parse(json);
|
|
295
|
+
this.indexMsgIds.set(collection, chat.pinned_message.message_id);
|
|
296
|
+
return parsed;
|
|
297
|
+
}
|
|
298
|
+
} catch {
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
collection,
|
|
302
|
+
entries: {},
|
|
303
|
+
walSeq: 0,
|
|
304
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
async saveIndex(index, channelId) {
|
|
308
|
+
const channel = channelId ?? this.defaultChannelId;
|
|
309
|
+
const text = `${INDEX_TAG}
|
|
310
|
+
${JSON.stringify(index)}`;
|
|
311
|
+
const existingMsgId = this.indexMsgIds.get(index.collection);
|
|
312
|
+
if (existingMsgId) {
|
|
313
|
+
try {
|
|
314
|
+
await this.pool.execute(
|
|
315
|
+
(bot) => bot.editMessageText(text, {
|
|
316
|
+
chat_id: channel,
|
|
317
|
+
message_id: existingMsgId
|
|
318
|
+
})
|
|
319
|
+
);
|
|
320
|
+
return;
|
|
321
|
+
} catch {
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const msg = await this.pool.execute(
|
|
325
|
+
(bot) => bot.sendMessage(channel, text, { disable_notification: true })
|
|
326
|
+
);
|
|
327
|
+
this.indexMsgIds.set(index.collection, msg.message_id);
|
|
328
|
+
await this.pool.execute(
|
|
329
|
+
(bot) => bot.pinChatMessage(channel, msg.message_id, { disable_notification: true })
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
// ─── Document CRUD ────────────────────────────────────────────────────────
|
|
333
|
+
async writeDocument(doc, channelId) {
|
|
334
|
+
const channel = channelId ?? this.defaultChannelId;
|
|
335
|
+
let text = JSON.stringify({ [DOC_TAG]: true, ...doc });
|
|
336
|
+
if (this.encryptionKey) {
|
|
337
|
+
text = this.encrypt(text);
|
|
338
|
+
}
|
|
339
|
+
if (Buffer.byteLength(text, "utf8") > MAX_MSG_BYTES) {
|
|
340
|
+
return this.writeChunked(text, channel);
|
|
341
|
+
}
|
|
342
|
+
const msg = await this.pool.execute(
|
|
343
|
+
(bot) => bot.sendMessage(channel, text, { disable_notification: true })
|
|
344
|
+
);
|
|
345
|
+
return msg.message_id;
|
|
346
|
+
}
|
|
347
|
+
async readDocument(msgId, channelId) {
|
|
348
|
+
const channel = channelId ?? this.defaultChannelId;
|
|
349
|
+
try {
|
|
350
|
+
const msgs = await this.pool.execute(
|
|
351
|
+
(bot) => bot.forwardMessages(channel, channel, [msgId])
|
|
352
|
+
);
|
|
353
|
+
const msg = Array.isArray(msgs) ? msgs[0] : msgs;
|
|
354
|
+
if (!msg?.text) return null;
|
|
355
|
+
let text = msg.text;
|
|
356
|
+
if (this.encryptionKey && text.startsWith("ENC:")) {
|
|
357
|
+
text = this.decrypt(text);
|
|
358
|
+
}
|
|
359
|
+
if (text.startsWith("CHUNK:")) {
|
|
360
|
+
text = await this.readChunked(text, channel);
|
|
361
|
+
}
|
|
362
|
+
const parsed = JSON.parse(text);
|
|
363
|
+
delete parsed[DOC_TAG];
|
|
364
|
+
return parsed;
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
async deleteDocument(msgId, channelId) {
|
|
370
|
+
const channel = channelId ?? this.defaultChannelId;
|
|
371
|
+
await this.pool.execute(
|
|
372
|
+
(bot) => bot.deleteMessage(channel, msgId)
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
async updateDocument(msgId, doc, channelId) {
|
|
376
|
+
await this.deleteDocument(msgId, channelId);
|
|
377
|
+
return this.writeDocument(doc, channelId);
|
|
378
|
+
}
|
|
379
|
+
// ─── Chunked large documents ──────────────────────────────────────────────
|
|
380
|
+
async writeChunked(text, channel) {
|
|
381
|
+
const chunks = [];
|
|
382
|
+
for (let i = 0; i < text.length; i += MAX_MSG_BYTES) {
|
|
383
|
+
chunks.push(text.slice(i, i + MAX_MSG_BYTES));
|
|
384
|
+
}
|
|
385
|
+
const msgIds = [];
|
|
386
|
+
for (const chunk of chunks) {
|
|
387
|
+
const msg = await this.pool.execute(
|
|
388
|
+
(bot) => bot.sendMessage(channel, chunk, { disable_notification: true })
|
|
389
|
+
);
|
|
390
|
+
msgIds.push(msg.message_id);
|
|
391
|
+
}
|
|
392
|
+
const header = `CHUNK:${JSON.stringify(msgIds)}`;
|
|
393
|
+
const headerMsg = await this.pool.execute(
|
|
394
|
+
(bot) => bot.sendMessage(channel, header, { disable_notification: true })
|
|
395
|
+
);
|
|
396
|
+
return headerMsg.message_id;
|
|
397
|
+
}
|
|
398
|
+
async readChunked(headerText, channel) {
|
|
399
|
+
const msgIds = JSON.parse(headerText.replace("CHUNK:", ""));
|
|
400
|
+
const parts = [];
|
|
401
|
+
for (const id of msgIds) {
|
|
402
|
+
const msgs = await this.pool.execute(
|
|
403
|
+
(bot) => bot.forwardMessages(channel, channel, [id])
|
|
404
|
+
);
|
|
405
|
+
const msg = Array.isArray(msgs) ? msgs[0] : msgs;
|
|
406
|
+
if (msg?.text) parts.push(msg.text);
|
|
407
|
+
}
|
|
408
|
+
return parts.join("");
|
|
409
|
+
}
|
|
410
|
+
// ─── File storage ─────────────────────────────────────────────────────────
|
|
411
|
+
async uploadFile(data, fileName, mimeType, channelId) {
|
|
412
|
+
const channel = channelId ?? this.defaultChannelId;
|
|
413
|
+
const isImage = mimeType.startsWith("image/");
|
|
414
|
+
let msg;
|
|
415
|
+
if (isImage) {
|
|
416
|
+
msg = await this.pool.execute(
|
|
417
|
+
(bot) => bot.sendPhoto(channel, data, {
|
|
418
|
+
caption: fileName,
|
|
419
|
+
disable_notification: true
|
|
420
|
+
})
|
|
421
|
+
);
|
|
422
|
+
} else {
|
|
423
|
+
msg = await this.pool.execute(
|
|
424
|
+
(bot) => bot.sendDocument(channel, data, {
|
|
425
|
+
caption: fileName,
|
|
426
|
+
disable_notification: true
|
|
427
|
+
})
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
const fileId = isImage ? msg.photo?.[msg.photo.length - 1]?.file_id : msg.document?.file_id;
|
|
431
|
+
return { fileId, msgId: msg.message_id };
|
|
432
|
+
}
|
|
433
|
+
async getFileUrl(fileId) {
|
|
434
|
+
return this.pool.execute((bot) => bot.getFileLink(fileId));
|
|
435
|
+
}
|
|
436
|
+
// ─── Encryption ───────────────────────────────────────────────────────────
|
|
437
|
+
encrypt(text) {
|
|
438
|
+
if (!this.encryptionKey) return text;
|
|
439
|
+
const iv = randomBytes(16);
|
|
440
|
+
const cipher = createCipheriv("aes-256-cbc", this.encryptionKey, iv);
|
|
441
|
+
const encrypted = Buffer.concat([
|
|
442
|
+
cipher.update(text, "utf8"),
|
|
443
|
+
cipher.final()
|
|
444
|
+
]);
|
|
445
|
+
return `ENC:${iv.toString("hex")}:${encrypted.toString("base64")}`;
|
|
446
|
+
}
|
|
447
|
+
decrypt(text) {
|
|
448
|
+
if (!this.encryptionKey) return text;
|
|
449
|
+
const [, ivHex, encB64] = text.split(":");
|
|
450
|
+
if (!ivHex || !encB64) throw new Error("Invalid encrypted payload");
|
|
451
|
+
const iv = Buffer.from(ivHex, "hex");
|
|
452
|
+
const encBuf = Buffer.from(encB64, "base64");
|
|
453
|
+
const decipher = createDecipheriv("aes-256-cbc", this.encryptionKey, iv);
|
|
454
|
+
return Buffer.concat([decipher.update(encBuf), decipher.final()]).toString("utf8");
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
var WAL_HEADER = "__WAL__";
|
|
458
|
+
var WAL_SEQ_TAG = "__WAL_SEQ__";
|
|
459
|
+
var WriteAheadLog = class {
|
|
460
|
+
constructor(pool, walChannelId, debug = false) {
|
|
461
|
+
this.pool = pool;
|
|
462
|
+
this.walChannelId = walChannelId;
|
|
463
|
+
this.debug = debug;
|
|
464
|
+
}
|
|
465
|
+
pool;
|
|
466
|
+
walChannelId;
|
|
467
|
+
debug;
|
|
468
|
+
seq = 0;
|
|
469
|
+
buffer = [];
|
|
470
|
+
flushTimer = null;
|
|
471
|
+
FLUSH_INTERVAL_MS = 2e3;
|
|
472
|
+
BUFFER_LIMIT = 50;
|
|
473
|
+
async init() {
|
|
474
|
+
try {
|
|
475
|
+
const msgs = await this.pool.execute(
|
|
476
|
+
(bot) => bot.getChatHistory(this.walChannelId, { limit: 10 })
|
|
477
|
+
);
|
|
478
|
+
for (const msg of msgs.reverse()) {
|
|
479
|
+
if (msg.text?.includes(WAL_SEQ_TAG)) {
|
|
480
|
+
const match = msg.text.match(/__WAL_SEQ__:(\d+)/);
|
|
481
|
+
if (match) {
|
|
482
|
+
this.seq = parseInt(match[1], 10);
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
} catch {
|
|
488
|
+
}
|
|
489
|
+
if (this.debug) console.log(`[WAL] Initialized at seq=${this.seq}`);
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Append an entry to the WAL buffer. Flushes immediately if buffer is full.
|
|
493
|
+
*/
|
|
494
|
+
async append(op, collection, id, data) {
|
|
495
|
+
this.seq++;
|
|
496
|
+
const entry = {
|
|
497
|
+
seq: this.seq,
|
|
498
|
+
op,
|
|
499
|
+
collection,
|
|
500
|
+
id,
|
|
501
|
+
data,
|
|
502
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
503
|
+
checksum: ""
|
|
504
|
+
};
|
|
505
|
+
entry.checksum = this.checksum(entry);
|
|
506
|
+
this.buffer.push(entry);
|
|
507
|
+
if (this.buffer.length >= this.BUFFER_LIMIT) {
|
|
508
|
+
await this.flush();
|
|
509
|
+
} else {
|
|
510
|
+
this.scheduleFlush();
|
|
511
|
+
}
|
|
512
|
+
return entry;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Flush buffered WAL entries to Telegram.
|
|
516
|
+
*/
|
|
517
|
+
async flush() {
|
|
518
|
+
if (this.buffer.length === 0) return;
|
|
519
|
+
if (this.flushTimer) {
|
|
520
|
+
clearTimeout(this.flushTimer);
|
|
521
|
+
this.flushTimer = null;
|
|
522
|
+
}
|
|
523
|
+
const batch = [...this.buffer];
|
|
524
|
+
this.buffer = [];
|
|
525
|
+
const payload = JSON.stringify({
|
|
526
|
+
__wal: true,
|
|
527
|
+
[WAL_SEQ_TAG]: batch[batch.length - 1].seq,
|
|
528
|
+
entries: batch
|
|
529
|
+
});
|
|
530
|
+
const chunks = this.chunk(payload, 4e3);
|
|
531
|
+
for (const chunk of chunks) {
|
|
532
|
+
await this.pool.execute(
|
|
533
|
+
(bot) => bot.sendMessage(this.walChannelId, `${WAL_HEADER}
|
|
534
|
+
${chunk}`, {
|
|
535
|
+
disable_notification: true
|
|
536
|
+
})
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
if (this.debug) console.log(`[WAL] Flushed ${batch.length} entries`);
|
|
540
|
+
this.pool.emit("wal:flush", batch.length);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Replay all WAL entries since a given sequence number.
|
|
544
|
+
* Returns entries in order — the caller applies them to restore state.
|
|
545
|
+
*/
|
|
546
|
+
async replay(sinceSeq = 0) {
|
|
547
|
+
const entries = [];
|
|
548
|
+
try {
|
|
549
|
+
const msgs = await this.pool.execute(
|
|
550
|
+
(bot) => bot.getChatHistory(this.walChannelId, { limit: 100 })
|
|
551
|
+
);
|
|
552
|
+
for (const msg of msgs) {
|
|
553
|
+
if (!msg.text?.startsWith(WAL_HEADER)) continue;
|
|
554
|
+
const jsonStr = msg.text.replace(WAL_HEADER + "\n", "");
|
|
555
|
+
try {
|
|
556
|
+
const parsed = JSON.parse(jsonStr);
|
|
557
|
+
if (parsed.__wal && Array.isArray(parsed.entries)) {
|
|
558
|
+
for (const e of parsed.entries) {
|
|
559
|
+
if (e.seq > sinceSeq && this.verifyChecksum(e)) {
|
|
560
|
+
entries.push(e);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
} catch {
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
} catch {
|
|
568
|
+
}
|
|
569
|
+
return entries.sort((a, b) => a.seq - b.seq);
|
|
570
|
+
}
|
|
571
|
+
scheduleFlush() {
|
|
572
|
+
if (this.flushTimer) return;
|
|
573
|
+
this.flushTimer = setTimeout(() => this.flush(), this.FLUSH_INTERVAL_MS);
|
|
574
|
+
}
|
|
575
|
+
checksum(entry) {
|
|
576
|
+
const str = `${entry.seq}:${entry.op}:${entry.collection}:${entry.id}:${JSON.stringify(entry.data)}`;
|
|
577
|
+
return createHash("sha256").update(str).digest("hex").slice(0, 16);
|
|
578
|
+
}
|
|
579
|
+
verifyChecksum(entry) {
|
|
580
|
+
const { checksum, ...rest } = entry;
|
|
581
|
+
return checksum === this.checksum(rest);
|
|
582
|
+
}
|
|
583
|
+
chunk(str, size) {
|
|
584
|
+
const chunks = [];
|
|
585
|
+
for (let i = 0; i < str.length; i += size) {
|
|
586
|
+
chunks.push(str.slice(i, i + size));
|
|
587
|
+
}
|
|
588
|
+
return chunks;
|
|
589
|
+
}
|
|
590
|
+
getCurrentSeq() {
|
|
591
|
+
return this.seq;
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
var REGISTRY_TAG = "__GRAMOBASE_REGISTRY__";
|
|
595
|
+
var LEASE_TTL_MS = 3e4;
|
|
596
|
+
var HEARTBEAT_MS = 1e4;
|
|
597
|
+
var Registry = class {
|
|
598
|
+
constructor(pool, channelId, debug = false) {
|
|
599
|
+
this.pool = pool;
|
|
600
|
+
this.channelId = channelId;
|
|
601
|
+
this.debug = debug;
|
|
602
|
+
this.instanceId = randomUUID();
|
|
603
|
+
this.state = {
|
|
604
|
+
activeLease: null,
|
|
605
|
+
instanceId: this.instanceId,
|
|
606
|
+
registryMsgId: null
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
pool;
|
|
610
|
+
channelId;
|
|
611
|
+
debug;
|
|
612
|
+
state;
|
|
613
|
+
instanceId;
|
|
614
|
+
async acquireWriteLease() {
|
|
615
|
+
const existing = await this.readRegistryMessage();
|
|
616
|
+
if (existing?.activeLease) {
|
|
617
|
+
const lease2 = existing.activeLease;
|
|
618
|
+
if (lease2.instanceId !== this.instanceId && Date.now() < lease2.expiresAt) {
|
|
619
|
+
throw new Error(
|
|
620
|
+
`[gramobase Registry] Another instance (${lease2.instanceId}) holds the write lease until ${new Date(lease2.expiresAt).toISOString()}. Use Registry.forceRelease() to break a stale lease.`
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const lease = {
|
|
625
|
+
instanceId: this.instanceId,
|
|
626
|
+
acquiredAt: Date.now(),
|
|
627
|
+
expiresAt: Date.now() + LEASE_TTL_MS,
|
|
628
|
+
heartbeatInterval: null
|
|
629
|
+
};
|
|
630
|
+
await this.writeRegistryMessage({ activeLease: lease });
|
|
631
|
+
this.state.activeLease = lease;
|
|
632
|
+
lease.heartbeatInterval = setInterval(
|
|
633
|
+
() => this.heartbeat(),
|
|
634
|
+
HEARTBEAT_MS
|
|
635
|
+
);
|
|
636
|
+
if (this.debug) console.log(`[Registry] Acquired write lease: ${this.instanceId}`);
|
|
637
|
+
return lease;
|
|
638
|
+
}
|
|
639
|
+
async releaseWriteLease() {
|
|
640
|
+
if (!this.state.activeLease) return;
|
|
641
|
+
if (this.state.activeLease.heartbeatInterval) {
|
|
642
|
+
clearInterval(this.state.activeLease.heartbeatInterval);
|
|
643
|
+
}
|
|
644
|
+
await this.writeRegistryMessage({ activeLease: null });
|
|
645
|
+
this.state.activeLease = null;
|
|
646
|
+
if (this.debug) console.log(`[Registry] Released write lease: ${this.instanceId}`);
|
|
647
|
+
}
|
|
648
|
+
async forceRelease() {
|
|
649
|
+
await this.writeRegistryMessage({ activeLease: null });
|
|
650
|
+
this.state.activeLease = null;
|
|
651
|
+
if (this.debug) console.log("[Registry] Forced lease release");
|
|
652
|
+
}
|
|
653
|
+
async isWriteLeaseHeld() {
|
|
654
|
+
const state = await this.readRegistryMessage();
|
|
655
|
+
if (!state?.activeLease) return false;
|
|
656
|
+
const { activeLease } = state;
|
|
657
|
+
return activeLease.instanceId === this.instanceId && Date.now() < activeLease.expiresAt;
|
|
658
|
+
}
|
|
659
|
+
async heartbeat() {
|
|
660
|
+
if (!this.state.activeLease) return;
|
|
661
|
+
this.state.activeLease.expiresAt = Date.now() + LEASE_TTL_MS;
|
|
662
|
+
await this.writeRegistryMessage({ activeLease: this.state.activeLease });
|
|
663
|
+
if (this.debug) console.log("[Registry] Heartbeat sent");
|
|
664
|
+
}
|
|
665
|
+
async readRegistryMessage() {
|
|
666
|
+
try {
|
|
667
|
+
const chat = await this.pool.execute(
|
|
668
|
+
(bot) => bot.getChat(this.channelId)
|
|
669
|
+
);
|
|
670
|
+
if (chat.pinned_message?.text?.startsWith(REGISTRY_TAG)) {
|
|
671
|
+
this.state.registryMsgId = chat.pinned_message.message_id;
|
|
672
|
+
const json = chat.pinned_message.text.replace(REGISTRY_TAG + "\n", "");
|
|
673
|
+
return JSON.parse(json);
|
|
674
|
+
}
|
|
675
|
+
} catch {
|
|
676
|
+
}
|
|
677
|
+
return null;
|
|
678
|
+
}
|
|
679
|
+
async writeRegistryMessage(data) {
|
|
680
|
+
const text = `${REGISTRY_TAG}
|
|
681
|
+
${JSON.stringify(data, null, 0)}`;
|
|
682
|
+
if (this.state.registryMsgId) {
|
|
683
|
+
try {
|
|
684
|
+
await this.pool.execute(
|
|
685
|
+
(bot) => bot.editMessageText(text, {
|
|
686
|
+
chat_id: this.channelId,
|
|
687
|
+
message_id: this.state.registryMsgId
|
|
688
|
+
})
|
|
689
|
+
);
|
|
690
|
+
return;
|
|
691
|
+
} catch {
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
const msg = await this.pool.execute(
|
|
695
|
+
(bot) => bot.sendMessage(this.channelId, text, { disable_notification: true })
|
|
696
|
+
);
|
|
697
|
+
this.state.registryMsgId = msg.message_id;
|
|
698
|
+
await this.pool.execute(
|
|
699
|
+
(bot) => bot.pinChatMessage(this.channelId, msg.message_id, {
|
|
700
|
+
disable_notification: true
|
|
701
|
+
})
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
getInstanceId() {
|
|
705
|
+
return this.instanceId;
|
|
706
|
+
}
|
|
707
|
+
getCurrentLease() {
|
|
708
|
+
return this.state.activeLease;
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
var Collection = class {
|
|
712
|
+
name;
|
|
713
|
+
config;
|
|
714
|
+
cache;
|
|
715
|
+
storage;
|
|
716
|
+
wal;
|
|
717
|
+
channelId;
|
|
718
|
+
indexLoaded = false;
|
|
719
|
+
constructor(name, config, cache, storage, wal, defaultChannelId) {
|
|
720
|
+
this.name = name;
|
|
721
|
+
this.config = config;
|
|
722
|
+
this.cache = cache;
|
|
723
|
+
this.storage = storage;
|
|
724
|
+
this.wal = wal;
|
|
725
|
+
this.channelId = config.channelId ?? defaultChannelId;
|
|
726
|
+
}
|
|
727
|
+
// ─── Init ──────────────────────────────────────────────────────────────
|
|
728
|
+
async ensureIndexLoaded() {
|
|
729
|
+
if (this.indexLoaded) return;
|
|
730
|
+
const idx = await this.storage.loadIndex(this.name, this.channelId);
|
|
731
|
+
this.cache.setIndex(this.name, new Map(Object.entries(idx.entries).map(([k, v]) => [k, v])));
|
|
732
|
+
this.indexLoaded = true;
|
|
733
|
+
}
|
|
734
|
+
// ─── Insert ────────────────────────────────────────────────────────────
|
|
735
|
+
async insertOne(data) {
|
|
736
|
+
const validated = this.config.schema.parse(data);
|
|
737
|
+
const doc = {
|
|
738
|
+
...validated,
|
|
739
|
+
_id: randomUUID(),
|
|
740
|
+
_collection: this.name,
|
|
741
|
+
_msgId: 0,
|
|
742
|
+
_createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
743
|
+
_updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
744
|
+
};
|
|
745
|
+
await this.wal.append("INSERT", this.name, doc._id, doc);
|
|
746
|
+
const msgId = await this.storage.writeDocument(doc, this.channelId);
|
|
747
|
+
doc._msgId = msgId;
|
|
748
|
+
this.cache.set(this.name, doc._id, doc);
|
|
749
|
+
this.cache.updateIndexEntry(this.name, doc._id, msgId);
|
|
750
|
+
this.cache.invalidateQuery(this.name);
|
|
751
|
+
await this.flushIndex();
|
|
752
|
+
return doc;
|
|
753
|
+
}
|
|
754
|
+
async insertMany(items) {
|
|
755
|
+
return Promise.all(items.map((item) => this.insertOne(item)));
|
|
756
|
+
}
|
|
757
|
+
// ─── Find ──────────────────────────────────────────────────────────────
|
|
758
|
+
async findById(id) {
|
|
759
|
+
const cached = this.cache.get(this.name, id);
|
|
760
|
+
if (cached) return cached;
|
|
761
|
+
await this.ensureIndexLoaded();
|
|
762
|
+
const msgId = this.cache.getMsgId(this.name, id);
|
|
763
|
+
if (!msgId) return null;
|
|
764
|
+
const doc = await this.storage.readDocument(msgId, this.channelId);
|
|
765
|
+
if (!doc) return null;
|
|
766
|
+
this.cache.set(this.name, id, doc);
|
|
767
|
+
return doc;
|
|
768
|
+
}
|
|
769
|
+
async findOne(filter = {}) {
|
|
770
|
+
const results = await this.find({ filter, limit: 1 });
|
|
771
|
+
return results[0] ?? null;
|
|
772
|
+
}
|
|
773
|
+
async find(options = {}) {
|
|
774
|
+
const { filter = {}, sort, limit, skip = 0, projection, useCache = true } = options;
|
|
775
|
+
const queryHash = this.hashQuery(filter, sort, limit, skip);
|
|
776
|
+
if (useCache) {
|
|
777
|
+
const cached = this.cache.getQuery(queryHash);
|
|
778
|
+
if (cached) return cached;
|
|
779
|
+
}
|
|
780
|
+
await this.ensureIndexLoaded();
|
|
781
|
+
const index = this.cache.getIndex(this.name);
|
|
782
|
+
if (!index) return [];
|
|
783
|
+
const docs = [];
|
|
784
|
+
const ids = [...index.keys()];
|
|
785
|
+
const uncachedIds = [];
|
|
786
|
+
for (const id of ids) {
|
|
787
|
+
const cached = this.cache.get(this.name, id);
|
|
788
|
+
if (cached) {
|
|
789
|
+
docs.push(cached);
|
|
790
|
+
} else {
|
|
791
|
+
uncachedIds.push(id);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
await Promise.all(
|
|
795
|
+
uncachedIds.map(async (id) => {
|
|
796
|
+
const doc = await this.findById(id);
|
|
797
|
+
if (doc) docs.push(doc);
|
|
798
|
+
})
|
|
799
|
+
);
|
|
800
|
+
let results = docs.filter((doc) => this.matchesFilter(doc, filter));
|
|
801
|
+
if (sort) {
|
|
802
|
+
results = this.applySort(results, sort);
|
|
803
|
+
}
|
|
804
|
+
results = results.slice(skip, limit ? skip + limit : void 0);
|
|
805
|
+
if (projection) {
|
|
806
|
+
results = results.map((doc) => this.applyProjection(doc, projection));
|
|
807
|
+
}
|
|
808
|
+
if (useCache) {
|
|
809
|
+
this.cache.setQuery(queryHash, results);
|
|
810
|
+
}
|
|
811
|
+
return results;
|
|
812
|
+
}
|
|
813
|
+
async count(filter = {}) {
|
|
814
|
+
const results = await this.find({ filter, useCache: true });
|
|
815
|
+
return results.length;
|
|
816
|
+
}
|
|
817
|
+
// ─── Update ────────────────────────────────────────────────────────────
|
|
818
|
+
async updateOne(filter, update) {
|
|
819
|
+
const doc = await this.findOne(filter);
|
|
820
|
+
if (!doc) return null;
|
|
821
|
+
return this.applyUpdate(doc, update);
|
|
822
|
+
}
|
|
823
|
+
async updateMany(filter, update) {
|
|
824
|
+
const docs = await this.find({ filter });
|
|
825
|
+
return Promise.all(docs.map((doc) => this.applyUpdate(doc, update)));
|
|
826
|
+
}
|
|
827
|
+
async findByIdAndUpdate(id, update) {
|
|
828
|
+
const doc = await this.findById(id);
|
|
829
|
+
if (!doc) return null;
|
|
830
|
+
return this.applyUpdate(doc, update);
|
|
831
|
+
}
|
|
832
|
+
async applyUpdate(doc, update) {
|
|
833
|
+
const updated = { ...doc, _updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
834
|
+
if (update.$set) Object.assign(updated, update.$set);
|
|
835
|
+
if (update.$unset) {
|
|
836
|
+
for (const key of Object.keys(update.$unset)) {
|
|
837
|
+
delete updated[key];
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (update.$inc) {
|
|
841
|
+
for (const [key, val] of Object.entries(update.$inc)) {
|
|
842
|
+
updated[key] = (updated[key] ?? 0) + val;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (update.$push) {
|
|
846
|
+
for (const [key, val] of Object.entries(update.$push)) {
|
|
847
|
+
const arr = updated[key];
|
|
848
|
+
updated[key] = Array.isArray(arr) ? [...arr, val] : [val];
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
this.config.schema.parse(updated);
|
|
852
|
+
await this.wal.append("UPDATE", this.name, updated._id, updated);
|
|
853
|
+
const newMsgId = await this.storage.updateDocument(
|
|
854
|
+
updated._msgId,
|
|
855
|
+
updated,
|
|
856
|
+
this.channelId
|
|
857
|
+
);
|
|
858
|
+
updated._msgId = newMsgId;
|
|
859
|
+
this.cache.set(this.name, updated._id, updated);
|
|
860
|
+
this.cache.updateIndexEntry(this.name, updated._id, newMsgId);
|
|
861
|
+
this.cache.invalidateQuery(this.name);
|
|
862
|
+
await this.flushIndex();
|
|
863
|
+
return updated;
|
|
864
|
+
}
|
|
865
|
+
// ─── Delete ────────────────────────────────────────────────────────────
|
|
866
|
+
async deleteOne(filter) {
|
|
867
|
+
const doc = await this.findOne(filter);
|
|
868
|
+
if (!doc) return false;
|
|
869
|
+
return this.deleteById(doc._id);
|
|
870
|
+
}
|
|
871
|
+
async deleteMany(filter) {
|
|
872
|
+
const docs = await this.find({ filter });
|
|
873
|
+
await Promise.all(docs.map((doc) => this.deleteById(doc._id)));
|
|
874
|
+
return docs.length;
|
|
875
|
+
}
|
|
876
|
+
async deleteById(id) {
|
|
877
|
+
const msgId = this.cache.getMsgId(this.name, id);
|
|
878
|
+
if (!msgId) return false;
|
|
879
|
+
await this.wal.append("DELETE", this.name, id);
|
|
880
|
+
await this.storage.deleteDocument(msgId, this.channelId);
|
|
881
|
+
this.cache.delete(this.name, id);
|
|
882
|
+
this.cache.deleteIndexEntry(this.name, id);
|
|
883
|
+
this.cache.invalidateQuery(this.name);
|
|
884
|
+
await this.flushIndex();
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
// ─── Index flush ──────────────────────────────────────────────────────
|
|
888
|
+
async flushIndex() {
|
|
889
|
+
const index = this.cache.getIndex(this.name);
|
|
890
|
+
if (!index) return;
|
|
891
|
+
await this.storage.saveIndex(
|
|
892
|
+
{
|
|
893
|
+
collection: this.name,
|
|
894
|
+
entries: Object.fromEntries(index.entries()),
|
|
895
|
+
walSeq: this.wal.getCurrentSeq(),
|
|
896
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
897
|
+
},
|
|
898
|
+
this.channelId
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
// ─── Filter engine ─────────────────────────────────────────────────────
|
|
902
|
+
matchesFilter(doc, filter) {
|
|
903
|
+
for (const [key, condition] of Object.entries(filter)) {
|
|
904
|
+
if (key === "$and") {
|
|
905
|
+
if (!condition.every((f) => this.matchesFilter(doc, f)))
|
|
906
|
+
return false;
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
if (key === "$or") {
|
|
910
|
+
if (!condition.some((f) => this.matchesFilter(doc, f)))
|
|
911
|
+
return false;
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
if (key === "$not") {
|
|
915
|
+
if (this.matchesFilter(doc, condition)) return false;
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
const val = doc[key];
|
|
919
|
+
if (condition === null || typeof condition !== "object" || condition instanceof RegExp) {
|
|
920
|
+
if (Array.isArray(val)) {
|
|
921
|
+
if (condition instanceof RegExp) {
|
|
922
|
+
if (!val.some((item) => condition.test(String(item)))) return false;
|
|
923
|
+
} else {
|
|
924
|
+
if (!val.includes(condition)) return false;
|
|
925
|
+
}
|
|
926
|
+
} else {
|
|
927
|
+
if (condition instanceof RegExp) {
|
|
928
|
+
if (!condition.test(String(val))) return false;
|
|
929
|
+
} else {
|
|
930
|
+
if (val !== condition) return false;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
const ops = condition;
|
|
936
|
+
if ("$eq" in ops) {
|
|
937
|
+
const eqVal = ops["$eq"];
|
|
938
|
+
if (Array.isArray(val)) {
|
|
939
|
+
if (Array.isArray(eqVal)) {
|
|
940
|
+
if (val.length !== eqVal.length || !val.every((v, i) => v === eqVal[i])) return false;
|
|
941
|
+
} else {
|
|
942
|
+
if (!val.includes(eqVal)) return false;
|
|
943
|
+
}
|
|
944
|
+
} else {
|
|
945
|
+
if (val !== eqVal) return false;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if ("$ne" in ops) {
|
|
949
|
+
const neVal = ops["$ne"];
|
|
950
|
+
if (Array.isArray(val)) {
|
|
951
|
+
if (Array.isArray(neVal)) {
|
|
952
|
+
if (val.length === neVal.length && val.every((v, i) => v === neVal[i])) return false;
|
|
953
|
+
} else {
|
|
954
|
+
if (val.includes(neVal)) return false;
|
|
955
|
+
}
|
|
956
|
+
} else {
|
|
957
|
+
if (val === neVal) return false;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
if ("$gt" in ops && !(val > ops["$gt"])) return false;
|
|
961
|
+
if ("$gte" in ops && !(val >= ops["$gte"])) return false;
|
|
962
|
+
if ("$lt" in ops && !(val < ops["$lt"])) return false;
|
|
963
|
+
if ("$lte" in ops && !(val <= ops["$lte"])) return false;
|
|
964
|
+
if ("$in" in ops) {
|
|
965
|
+
const inList = ops["$in"];
|
|
966
|
+
if (Array.isArray(val)) {
|
|
967
|
+
const hasIntersection = val.some((v) => inList.includes(v));
|
|
968
|
+
const hasExactArray = inList.some(
|
|
969
|
+
(item) => Array.isArray(item) && item.length === val.length && item.every((v, i) => v === val[i])
|
|
970
|
+
);
|
|
971
|
+
if (!hasIntersection && !hasExactArray) return false;
|
|
972
|
+
} else {
|
|
973
|
+
if (!inList.includes(val)) return false;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
if ("$nin" in ops) {
|
|
977
|
+
const ninList = ops["$nin"];
|
|
978
|
+
if (Array.isArray(val)) {
|
|
979
|
+
const hasIntersection = val.some((v) => ninList.includes(v));
|
|
980
|
+
const hasExactArray = ninList.some(
|
|
981
|
+
(item) => Array.isArray(item) && item.length === val.length && item.every((v, i) => v === val[i])
|
|
982
|
+
);
|
|
983
|
+
if (hasIntersection || hasExactArray) return false;
|
|
984
|
+
} else {
|
|
985
|
+
if (ninList.includes(val)) return false;
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
if ("$exists" in ops) {
|
|
989
|
+
const exists = val !== void 0 && val !== null;
|
|
990
|
+
if (exists !== ops["$exists"]) return false;
|
|
991
|
+
}
|
|
992
|
+
if ("$regex" in ops) {
|
|
993
|
+
const re = ops["$regex"] instanceof RegExp ? ops["$regex"] : new RegExp(ops["$regex"]);
|
|
994
|
+
if (Array.isArray(val)) {
|
|
995
|
+
if (!val.some((item) => re.test(String(item)))) return false;
|
|
996
|
+
} else {
|
|
997
|
+
if (!re.test(String(val))) return false;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return true;
|
|
1002
|
+
}
|
|
1003
|
+
applySort(docs, sort) {
|
|
1004
|
+
return [...docs].sort((a, b) => {
|
|
1005
|
+
for (const [key, dir] of Object.entries(sort)) {
|
|
1006
|
+
const av = a[key];
|
|
1007
|
+
const bv = b[key];
|
|
1008
|
+
if (av === bv) continue;
|
|
1009
|
+
if (av == null) return dir;
|
|
1010
|
+
if (bv == null) return -dir;
|
|
1011
|
+
return av < bv ? -dir : dir;
|
|
1012
|
+
}
|
|
1013
|
+
return 0;
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
applyProjection(doc, projection) {
|
|
1017
|
+
const result = {};
|
|
1018
|
+
const isInclusive = Object.values(projection).some((v) => v === 1);
|
|
1019
|
+
for (const [key, val] of Object.entries(doc)) {
|
|
1020
|
+
if (isInclusive) {
|
|
1021
|
+
if (projection[key] === 1 || key.startsWith("_")) result[key] = val;
|
|
1022
|
+
} else {
|
|
1023
|
+
if (projection[key] !== 0) result[key] = val;
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
return result;
|
|
1027
|
+
}
|
|
1028
|
+
hashQuery(...args) {
|
|
1029
|
+
return `${this.name}:` + createHash("md5").update(JSON.stringify(args)).digest("hex");
|
|
1030
|
+
}
|
|
1031
|
+
getName() {
|
|
1032
|
+
return this.name;
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
z.object({
|
|
1036
|
+
email: z.string().email(),
|
|
1037
|
+
passwordHash: z.string(),
|
|
1038
|
+
roles: z.array(z.string()).default(["user"]),
|
|
1039
|
+
metadata: z.record(z.unknown()).optional(),
|
|
1040
|
+
createdAt: z.string(),
|
|
1041
|
+
updatedAt: z.string()
|
|
1042
|
+
});
|
|
1043
|
+
function resolveJwtSecret(configSecret) {
|
|
1044
|
+
if (configSecret) return configSecret;
|
|
1045
|
+
if (process.env["JWT_SECRET"]) return process.env["JWT_SECRET"];
|
|
1046
|
+
const secretFile = "./jwt_secret.txt";
|
|
1047
|
+
if (existsSync(secretFile)) return readFileSync(secretFile, "utf-8").trim();
|
|
1048
|
+
const ephemeral = randomBytes(32).toString("hex");
|
|
1049
|
+
console.warn(
|
|
1050
|
+
"[gramobase Auth] WARNING: No JWT_SECRET provided. Using an ephemeral random secret. Tokens will be invalidated on restart. Set JWT_SECRET env variable for production!"
|
|
1051
|
+
);
|
|
1052
|
+
return ephemeral;
|
|
1053
|
+
}
|
|
1054
|
+
function validatePasswordStrength(password) {
|
|
1055
|
+
if (password.length < 8) {
|
|
1056
|
+
throw new Error("[Auth] Password must be at least 8 characters long");
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
var GramoBaseAuth = class {
|
|
1060
|
+
constructor(users, config) {
|
|
1061
|
+
this.users = users;
|
|
1062
|
+
this.config = config;
|
|
1063
|
+
this.resolvedSecret = resolveJwtSecret(config.jwtSecret);
|
|
1064
|
+
}
|
|
1065
|
+
users;
|
|
1066
|
+
config;
|
|
1067
|
+
DEFAULT_ROUNDS = 12;
|
|
1068
|
+
resolvedSecret;
|
|
1069
|
+
// ─── Registration ─────────────────────────────────────────────────────
|
|
1070
|
+
async register(email, password, roles = ["user"], metadata) {
|
|
1071
|
+
if (!email || typeof email !== "string") {
|
|
1072
|
+
throw new Error("[Auth] Invalid email");
|
|
1073
|
+
}
|
|
1074
|
+
validatePasswordStrength(password);
|
|
1075
|
+
const existing = await this.users.findOne({ email: { $eq: email } });
|
|
1076
|
+
if (existing) throw new Error("[Auth] Email already registered");
|
|
1077
|
+
const passwordHash = await bcrypt.hash(
|
|
1078
|
+
password,
|
|
1079
|
+
this.config.bcryptRounds ?? this.DEFAULT_ROUNDS
|
|
1080
|
+
);
|
|
1081
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1082
|
+
const doc = await this.users.insertOne({
|
|
1083
|
+
email,
|
|
1084
|
+
passwordHash,
|
|
1085
|
+
roles,
|
|
1086
|
+
metadata,
|
|
1087
|
+
createdAt: now,
|
|
1088
|
+
updatedAt: now
|
|
1089
|
+
});
|
|
1090
|
+
const user = doc;
|
|
1091
|
+
const session = this.createSession(user);
|
|
1092
|
+
await this.config.onSignIn?.(user);
|
|
1093
|
+
return { user, session };
|
|
1094
|
+
}
|
|
1095
|
+
// ─── Login ────────────────────────────────────────────────────────────
|
|
1096
|
+
async login(email, password) {
|
|
1097
|
+
const doc = await this.users.findOne({ email: { $eq: email } });
|
|
1098
|
+
if (!doc) {
|
|
1099
|
+
await bcrypt.compare(password, "$2a$12$invalidhashtopreventtimingattacks");
|
|
1100
|
+
throw new Error("[Auth] Invalid credentials");
|
|
1101
|
+
}
|
|
1102
|
+
const user = doc;
|
|
1103
|
+
const valid = await bcrypt.compare(password, user.passwordHash);
|
|
1104
|
+
if (!valid) throw new Error("[Auth] Invalid credentials");
|
|
1105
|
+
const session = this.createSession(user);
|
|
1106
|
+
await this.config.onSignIn?.(user);
|
|
1107
|
+
return { user, session };
|
|
1108
|
+
}
|
|
1109
|
+
// ─── Token verification ───────────────────────────────────────────────
|
|
1110
|
+
verifyToken(token) {
|
|
1111
|
+
try {
|
|
1112
|
+
const payload = jwt.verify(token, this.resolvedSecret, {
|
|
1113
|
+
algorithms: ["HS256"]
|
|
1114
|
+
});
|
|
1115
|
+
return payload;
|
|
1116
|
+
} catch {
|
|
1117
|
+
throw new Error("[Auth] Invalid or expired token");
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
// ─── Role-based access ────────────────────────────────────────────────
|
|
1121
|
+
requireRole(session, role) {
|
|
1122
|
+
if (!session.roles.includes(role) && !session.roles.includes("admin")) {
|
|
1123
|
+
throw new Error(`[Auth] Requires role: ${role}`);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
requireAnyRole(session, roles) {
|
|
1127
|
+
const hasRole = roles.some(
|
|
1128
|
+
(r) => session.roles.includes(r) || session.roles.includes("admin")
|
|
1129
|
+
);
|
|
1130
|
+
if (!hasRole) {
|
|
1131
|
+
throw new Error(`[Auth] Requires one of roles: ${roles.join(", ")}`);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// ─── Password management ──────────────────────────────────────────────
|
|
1135
|
+
async changePassword(userId, oldPassword, newPassword) {
|
|
1136
|
+
const doc = await this.users.findById(userId);
|
|
1137
|
+
if (!doc) throw new Error("[Auth] User not found");
|
|
1138
|
+
const user = doc;
|
|
1139
|
+
const valid = await bcrypt.compare(oldPassword, user.passwordHash);
|
|
1140
|
+
if (!valid) throw new Error("[Auth] Old password incorrect");
|
|
1141
|
+
validatePasswordStrength(newPassword);
|
|
1142
|
+
const newHash = await bcrypt.hash(
|
|
1143
|
+
newPassword,
|
|
1144
|
+
this.config.bcryptRounds ?? this.DEFAULT_ROUNDS
|
|
1145
|
+
);
|
|
1146
|
+
await this.users.findByIdAndUpdate(userId, {
|
|
1147
|
+
$set: { passwordHash: newHash, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
async resetPassword(userId, newPassword) {
|
|
1151
|
+
validatePasswordStrength(newPassword);
|
|
1152
|
+
const newHash = await bcrypt.hash(
|
|
1153
|
+
newPassword,
|
|
1154
|
+
this.config.bcryptRounds ?? this.DEFAULT_ROUNDS
|
|
1155
|
+
);
|
|
1156
|
+
await this.users.findByIdAndUpdate(userId, {
|
|
1157
|
+
$set: { passwordHash: newHash, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
// ─── User management ─────────────────────────────────────────────────
|
|
1161
|
+
async getUserById(id) {
|
|
1162
|
+
const doc = await this.users.findById(id);
|
|
1163
|
+
return doc ? doc : null;
|
|
1164
|
+
}
|
|
1165
|
+
async getUserByEmail(email) {
|
|
1166
|
+
const doc = await this.users.findOne({ email: { $eq: email } });
|
|
1167
|
+
return doc ? doc : null;
|
|
1168
|
+
}
|
|
1169
|
+
async updateRoles(userId, roles) {
|
|
1170
|
+
await this.users.findByIdAndUpdate(userId, {
|
|
1171
|
+
$set: { roles, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
async deleteUser(userId) {
|
|
1175
|
+
await this.users.deleteById(userId);
|
|
1176
|
+
await this.config.onSignOut?.(userId);
|
|
1177
|
+
}
|
|
1178
|
+
// ─── Session helpers ──────────────────────────────────────────────────
|
|
1179
|
+
createSession(user) {
|
|
1180
|
+
const expiresIn = this.config.jwtExpiresIn ?? "7d";
|
|
1181
|
+
const payload = {
|
|
1182
|
+
sub: user._id,
|
|
1183
|
+
userId: user._id,
|
|
1184
|
+
roles: user.roles,
|
|
1185
|
+
expiresAt: Date.now() + this.parseExpiry(expiresIn)
|
|
1186
|
+
};
|
|
1187
|
+
const token = jwt.sign(payload, this.resolvedSecret, {
|
|
1188
|
+
expiresIn,
|
|
1189
|
+
algorithm: "HS256"
|
|
1190
|
+
});
|
|
1191
|
+
return { ...payload, token };
|
|
1192
|
+
}
|
|
1193
|
+
parseExpiry(s) {
|
|
1194
|
+
const n = parseInt(s);
|
|
1195
|
+
if (s.endsWith("d")) return n * 864e5;
|
|
1196
|
+
if (s.endsWith("h")) return n * 36e5;
|
|
1197
|
+
if (s.endsWith("m")) return n * 6e4;
|
|
1198
|
+
return n * 1e3;
|
|
1199
|
+
}
|
|
1200
|
+
// ─── Middleware factory (Express/Fastify compatible) ──────────────────
|
|
1201
|
+
middleware() {
|
|
1202
|
+
return (req, res, next) => {
|
|
1203
|
+
const auth = req.headers["authorization"];
|
|
1204
|
+
if (!auth?.startsWith("Bearer ")) {
|
|
1205
|
+
return res.status(401).json({ error: "Missing token" });
|
|
1206
|
+
}
|
|
1207
|
+
try {
|
|
1208
|
+
req.session = this.verifyToken(auth.slice(7));
|
|
1209
|
+
next();
|
|
1210
|
+
} catch {
|
|
1211
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
requireRoleMiddleware(role) {
|
|
1216
|
+
return (req, res, next) => {
|
|
1217
|
+
try {
|
|
1218
|
+
this.requireRole(req.session, role);
|
|
1219
|
+
next();
|
|
1220
|
+
} catch {
|
|
1221
|
+
res.status(403).json({ error: "Forbidden" });
|
|
1222
|
+
}
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
var RealtimeManager = class extends EventEmitter {
|
|
1227
|
+
constructor(pool, webhookUrl, debug = false) {
|
|
1228
|
+
super();
|
|
1229
|
+
this.pool = pool;
|
|
1230
|
+
this.webhookUrl = webhookUrl;
|
|
1231
|
+
this.debug = debug;
|
|
1232
|
+
}
|
|
1233
|
+
pool;
|
|
1234
|
+
webhookUrl;
|
|
1235
|
+
debug;
|
|
1236
|
+
pollingActive = false;
|
|
1237
|
+
pollingInterval = null;
|
|
1238
|
+
lastUpdateId = 0;
|
|
1239
|
+
async start() {
|
|
1240
|
+
if (this.webhookUrl) {
|
|
1241
|
+
await this.setupWebhook();
|
|
1242
|
+
} else {
|
|
1243
|
+
this.startPolling();
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
async stop() {
|
|
1247
|
+
if (this.pollingInterval) {
|
|
1248
|
+
clearInterval(this.pollingInterval);
|
|
1249
|
+
this.pollingInterval = null;
|
|
1250
|
+
}
|
|
1251
|
+
this.pollingActive = false;
|
|
1252
|
+
}
|
|
1253
|
+
// ─── Subscribe helpers ────────────────────────────────────────────────
|
|
1254
|
+
onInsert(collection, cb) {
|
|
1255
|
+
const handler = (ev) => {
|
|
1256
|
+
if (ev.type === "insert" && ev.collection === collection) {
|
|
1257
|
+
cb(ev.doc);
|
|
1258
|
+
}
|
|
1259
|
+
};
|
|
1260
|
+
this.on("event", handler);
|
|
1261
|
+
return () => this.off("event", handler);
|
|
1262
|
+
}
|
|
1263
|
+
onUpdate(collection, cb) {
|
|
1264
|
+
const handler = (ev) => {
|
|
1265
|
+
if (ev.type === "update" && ev.collection === collection) {
|
|
1266
|
+
cb(ev.id, ev.changes, ev.doc);
|
|
1267
|
+
}
|
|
1268
|
+
};
|
|
1269
|
+
this.on("event", handler);
|
|
1270
|
+
return () => this.off("event", handler);
|
|
1271
|
+
}
|
|
1272
|
+
onDelete(collection, cb) {
|
|
1273
|
+
const handler = (ev) => {
|
|
1274
|
+
if (ev.type === "delete" && ev.collection === collection) {
|
|
1275
|
+
cb(ev.id);
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
this.on("event", handler);
|
|
1279
|
+
return () => this.off("event", handler);
|
|
1280
|
+
}
|
|
1281
|
+
onAny(cb) {
|
|
1282
|
+
const handler = (ev) => cb(ev);
|
|
1283
|
+
this.on("event", handler);
|
|
1284
|
+
return () => this.off("event", handler);
|
|
1285
|
+
}
|
|
1286
|
+
// ─── SSE adapter ──────────────────────────────────────────────────────
|
|
1287
|
+
// Usage: app.get('/events', db.realtime.sseHandler())
|
|
1288
|
+
sseHandler(collection) {
|
|
1289
|
+
return (req, res) => {
|
|
1290
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
1291
|
+
res.setHeader("Cache-Control", "no-cache, no-store");
|
|
1292
|
+
res.setHeader("Connection", "keep-alive");
|
|
1293
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
1294
|
+
res.flushHeaders?.();
|
|
1295
|
+
const send = (ev) => {
|
|
1296
|
+
if (!collection || "collection" in ev && ev.collection === collection) {
|
|
1297
|
+
res.write(`data: ${JSON.stringify(ev)}
|
|
1298
|
+
|
|
1299
|
+
`);
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
this.on("event", send);
|
|
1303
|
+
const keepalive = setInterval(() => res.write(": ping\n\n"), 25e3);
|
|
1304
|
+
req.on("close", () => {
|
|
1305
|
+
this.off("event", send);
|
|
1306
|
+
clearInterval(keepalive);
|
|
1307
|
+
});
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
// ─── Internal event dispatch ──────────────────────────────────────────
|
|
1311
|
+
dispatch(event) {
|
|
1312
|
+
this.emit("event", event);
|
|
1313
|
+
if (this.debug) {
|
|
1314
|
+
console.log("[Realtime]", event.type, "collection" in event ? event.collection : "");
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
// ─── Webhook setup ────────────────────────────────────────────────────
|
|
1318
|
+
async setupWebhook() {
|
|
1319
|
+
const bot = this.pool.getBot();
|
|
1320
|
+
await bot.setWebHook(this.webhookUrl);
|
|
1321
|
+
if (this.debug) console.log("[Realtime] Webhook set");
|
|
1322
|
+
}
|
|
1323
|
+
// ─── Long polling fallback ────────────────────────────────────────────
|
|
1324
|
+
startPolling() {
|
|
1325
|
+
this.pollingActive = true;
|
|
1326
|
+
const POLL_INTERVAL = 2e3;
|
|
1327
|
+
this.pollingInterval = setInterval(async () => {
|
|
1328
|
+
try {
|
|
1329
|
+
const updates = await this.pool.execute(
|
|
1330
|
+
(bot) => bot.getUpdates({ offset: this.lastUpdateId + 1, limit: 100, timeout: 0 })
|
|
1331
|
+
);
|
|
1332
|
+
for (const update of updates) {
|
|
1333
|
+
this.lastUpdateId = Math.max(this.lastUpdateId, update.update_id);
|
|
1334
|
+
this.processUpdate(update);
|
|
1335
|
+
}
|
|
1336
|
+
} catch {
|
|
1337
|
+
}
|
|
1338
|
+
}, POLL_INTERVAL);
|
|
1339
|
+
}
|
|
1340
|
+
processUpdate(update) {
|
|
1341
|
+
const msg = update.channel_post ?? update.message;
|
|
1342
|
+
if (!msg?.text?.includes('"__gramobase"')) return;
|
|
1343
|
+
try {
|
|
1344
|
+
const payload = JSON.parse(msg.text);
|
|
1345
|
+
if (payload.__event) {
|
|
1346
|
+
this.dispatch(payload.__event);
|
|
1347
|
+
}
|
|
1348
|
+
} catch {
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
1352
|
+
|
|
1353
|
+
// src/migrations/MigrationRunner.ts
|
|
1354
|
+
var MIGRATION_TAG = "__GRAMOBASE_MIGRATIONS__";
|
|
1355
|
+
var MigrationRunner = class {
|
|
1356
|
+
constructor(pool, channelId, debug = false) {
|
|
1357
|
+
this.pool = pool;
|
|
1358
|
+
this.channelId = channelId;
|
|
1359
|
+
this.debug = debug;
|
|
1360
|
+
}
|
|
1361
|
+
pool;
|
|
1362
|
+
channelId;
|
|
1363
|
+
debug;
|
|
1364
|
+
historyMsgId = null;
|
|
1365
|
+
async run(migrations, db) {
|
|
1366
|
+
const applied = await this.loadHistory();
|
|
1367
|
+
const appliedVersions = new Set(applied.map((m) => m.version));
|
|
1368
|
+
const pending = migrations.filter((m) => !appliedVersions.has(m.version)).sort((a, b) => a.version - b.version);
|
|
1369
|
+
if (pending.length === 0) {
|
|
1370
|
+
if (this.debug) console.log("[Migrations] Nothing to run");
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
for (const migration of pending) {
|
|
1374
|
+
console.log(`[Migrations] Running: v${migration.version} \u2014 ${migration.name}`);
|
|
1375
|
+
await migration.up(db);
|
|
1376
|
+
applied.push({
|
|
1377
|
+
version: migration.version,
|
|
1378
|
+
name: migration.name,
|
|
1379
|
+
appliedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1380
|
+
});
|
|
1381
|
+
await this.saveHistory(applied);
|
|
1382
|
+
console.log(`[Migrations] \u2713 v${migration.version}`);
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
async rollback(migrations, db, steps = 1) {
|
|
1386
|
+
const applied = await this.loadHistory();
|
|
1387
|
+
const toRollback = applied.sort((a, b) => b.version - a.version).slice(0, steps);
|
|
1388
|
+
for (const record of toRollback) {
|
|
1389
|
+
const migration = migrations.find((m) => m.version === record.version);
|
|
1390
|
+
if (!migration) throw new Error(`Migration v${record.version} not found`);
|
|
1391
|
+
console.log(`[Migrations] Rolling back: v${record.version} \u2014 ${record.name}`);
|
|
1392
|
+
await migration.down(db);
|
|
1393
|
+
applied.splice(applied.indexOf(record), 1);
|
|
1394
|
+
await this.saveHistory(applied);
|
|
1395
|
+
console.log(`[Migrations] \u2713 Rolled back v${record.version}`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
async status(migrations) {
|
|
1399
|
+
const applied = await this.loadHistory();
|
|
1400
|
+
const appliedVersions = new Set(applied.map((m) => m.version));
|
|
1401
|
+
console.log("\n gramobase migration status\n");
|
|
1402
|
+
for (const m of migrations.sort((a, b) => a.version - b.version)) {
|
|
1403
|
+
const status = appliedVersions.has(m.version) ? "\u2713" : "\u25CB";
|
|
1404
|
+
const appliedAt = applied.find((a) => a.version === m.version)?.appliedAt ?? "";
|
|
1405
|
+
console.log(` ${status} v${m.version} ${m.name.padEnd(40)} ${appliedAt}`);
|
|
1406
|
+
}
|
|
1407
|
+
console.log();
|
|
1408
|
+
}
|
|
1409
|
+
async loadHistory() {
|
|
1410
|
+
try {
|
|
1411
|
+
const chat = await this.pool.execute((bot) => bot.getChat(this.channelId));
|
|
1412
|
+
for (const msg of chat.pinned_messages ?? []) {
|
|
1413
|
+
if (msg.text?.startsWith(MIGRATION_TAG)) {
|
|
1414
|
+
this.historyMsgId = msg.message_id;
|
|
1415
|
+
return JSON.parse(msg.text.replace(MIGRATION_TAG + "\n", ""));
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
} catch {
|
|
1419
|
+
}
|
|
1420
|
+
return [];
|
|
1421
|
+
}
|
|
1422
|
+
async saveHistory(records) {
|
|
1423
|
+
const text = `${MIGRATION_TAG}
|
|
1424
|
+
${JSON.stringify(records)}`;
|
|
1425
|
+
if (this.historyMsgId) {
|
|
1426
|
+
try {
|
|
1427
|
+
await this.pool.execute(
|
|
1428
|
+
(bot) => bot.editMessageText(text, {
|
|
1429
|
+
chat_id: this.channelId,
|
|
1430
|
+
message_id: this.historyMsgId
|
|
1431
|
+
})
|
|
1432
|
+
);
|
|
1433
|
+
return;
|
|
1434
|
+
} catch {
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
const msg = await this.pool.execute(
|
|
1438
|
+
(bot) => bot.sendMessage(this.channelId, text, { disable_notification: true })
|
|
1439
|
+
);
|
|
1440
|
+
this.historyMsgId = msg.message_id;
|
|
1441
|
+
}
|
|
1442
|
+
};
|
|
1443
|
+
var GramoBase = class {
|
|
1444
|
+
pool;
|
|
1445
|
+
cache;
|
|
1446
|
+
storage;
|
|
1447
|
+
wal;
|
|
1448
|
+
registry;
|
|
1449
|
+
realtime;
|
|
1450
|
+
migrations;
|
|
1451
|
+
collections = /* @__PURE__ */ new Map();
|
|
1452
|
+
initialized = false;
|
|
1453
|
+
config;
|
|
1454
|
+
constructor(config) {
|
|
1455
|
+
this.config = config;
|
|
1456
|
+
const tokens = Array.isArray(config.botToken) ? config.botToken : [config.botToken];
|
|
1457
|
+
this.pool = new BotWorkerPool(tokens, config.concurrency ?? 25, config.debug ?? false);
|
|
1458
|
+
this.cache = new HotCache(config.cacheMaxBytes, config.cacheTtlMs);
|
|
1459
|
+
this.storage = new TelegramStorage(
|
|
1460
|
+
this.pool,
|
|
1461
|
+
config.channelId,
|
|
1462
|
+
config.encryptionKey,
|
|
1463
|
+
config.debug ?? false
|
|
1464
|
+
);
|
|
1465
|
+
this.wal = new WriteAheadLog(
|
|
1466
|
+
this.pool,
|
|
1467
|
+
config.walChannelId ?? config.channelId,
|
|
1468
|
+
config.debug ?? false
|
|
1469
|
+
);
|
|
1470
|
+
this.registry = new Registry(
|
|
1471
|
+
this.pool,
|
|
1472
|
+
config.indexChannelId ?? config.channelId,
|
|
1473
|
+
config.debug ?? false
|
|
1474
|
+
);
|
|
1475
|
+
this.realtime = new RealtimeManager(
|
|
1476
|
+
this.pool,
|
|
1477
|
+
config.webhookUrl,
|
|
1478
|
+
config.debug ?? false
|
|
1479
|
+
);
|
|
1480
|
+
this.migrations = new MigrationRunner(
|
|
1481
|
+
this.pool,
|
|
1482
|
+
config.channelId,
|
|
1483
|
+
config.debug ?? false
|
|
1484
|
+
);
|
|
1485
|
+
this.pool.on("worker:rotate", (idx) => {
|
|
1486
|
+
this.realtime.dispatch({ type: "worker:rotate", tokenIndex: idx });
|
|
1487
|
+
});
|
|
1488
|
+
this.pool.on("wal:flush", (count) => {
|
|
1489
|
+
this.realtime.dispatch({ type: "wal:flush", entries: count });
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
// ─── Lifecycle ────────────────────────────────────────────────────────
|
|
1493
|
+
async connect() {
|
|
1494
|
+
if (this.initialized) return this;
|
|
1495
|
+
await this.wal.init();
|
|
1496
|
+
await this.registry.acquireWriteLease();
|
|
1497
|
+
await this.realtime.start();
|
|
1498
|
+
const walEntries = await this.wal.replay();
|
|
1499
|
+
if (walEntries.length > 0 && this.config.debug) {
|
|
1500
|
+
console.log(`[gramobase] Replaying ${walEntries.length} WAL entries`);
|
|
1501
|
+
}
|
|
1502
|
+
this.initialized = true;
|
|
1503
|
+
if (this.config.debug) console.log("[gramobase] Connected \u2713");
|
|
1504
|
+
return this;
|
|
1505
|
+
}
|
|
1506
|
+
async disconnect() {
|
|
1507
|
+
await this.wal.flush();
|
|
1508
|
+
await this.registry.releaseWriteLease();
|
|
1509
|
+
await this.realtime.stop();
|
|
1510
|
+
await this.pool.destroy();
|
|
1511
|
+
this.initialized = false;
|
|
1512
|
+
}
|
|
1513
|
+
// ─── Collection factory ───────────────────────────────────────────────
|
|
1514
|
+
collection(name, config) {
|
|
1515
|
+
if (this.collections.has(name)) {
|
|
1516
|
+
return this.collections.get(name);
|
|
1517
|
+
}
|
|
1518
|
+
const col = new Collection(
|
|
1519
|
+
name,
|
|
1520
|
+
config,
|
|
1521
|
+
this.cache,
|
|
1522
|
+
this.storage,
|
|
1523
|
+
this.wal,
|
|
1524
|
+
this.config.channelId
|
|
1525
|
+
);
|
|
1526
|
+
this.collections.set(name, col);
|
|
1527
|
+
return col;
|
|
1528
|
+
}
|
|
1529
|
+
// ─── Auth factory ─────────────────────────────────────────────────────
|
|
1530
|
+
createAuth(config) {
|
|
1531
|
+
const UserSchema2 = z.object({
|
|
1532
|
+
email: z.string().email(),
|
|
1533
|
+
passwordHash: z.string(),
|
|
1534
|
+
roles: z.array(z.string()).default(["user"]),
|
|
1535
|
+
metadata: z.record(z.unknown()).optional(),
|
|
1536
|
+
createdAt: z.string(),
|
|
1537
|
+
updatedAt: z.string()
|
|
1538
|
+
});
|
|
1539
|
+
const users = this.collection("__gramobase_users__", { schema: UserSchema2 });
|
|
1540
|
+
return new GramoBaseAuth(users, config);
|
|
1541
|
+
}
|
|
1542
|
+
// ─── File storage ─────────────────────────────────────────────────────
|
|
1543
|
+
async uploadFile(data, options = {}) {
|
|
1544
|
+
const { fileName = "file", mimeType = "application/octet-stream", metadata } = options;
|
|
1545
|
+
const channelId = this.config.channelId;
|
|
1546
|
+
const { fileId, msgId } = await this.storage.uploadFile(data, fileName, mimeType, channelId);
|
|
1547
|
+
const url = await this.storage.getFileUrl(fileId);
|
|
1548
|
+
const record = {
|
|
1549
|
+
_id: randomUUID(),
|
|
1550
|
+
fileId,
|
|
1551
|
+
fileName,
|
|
1552
|
+
mimeType,
|
|
1553
|
+
sizeBytes: data.length,
|
|
1554
|
+
uploadedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1555
|
+
...url !== void 0 ? { url } : {},
|
|
1556
|
+
...metadata !== void 0 ? { metadata } : {}
|
|
1557
|
+
};
|
|
1558
|
+
const files = this.collection("__gramobase_files__", {
|
|
1559
|
+
schema: z.object({
|
|
1560
|
+
_id: z.string(),
|
|
1561
|
+
fileId: z.string(),
|
|
1562
|
+
fileName: z.string(),
|
|
1563
|
+
mimeType: z.string(),
|
|
1564
|
+
sizeBytes: z.number(),
|
|
1565
|
+
url: z.string().optional(),
|
|
1566
|
+
uploadedAt: z.string(),
|
|
1567
|
+
metadata: z.record(z.unknown()).optional()
|
|
1568
|
+
})
|
|
1569
|
+
});
|
|
1570
|
+
await files.insertOne(record);
|
|
1571
|
+
return record;
|
|
1572
|
+
}
|
|
1573
|
+
async getFileUrl(fileId) {
|
|
1574
|
+
return this.storage.getFileUrl(fileId);
|
|
1575
|
+
}
|
|
1576
|
+
// ─── Migrations ───────────────────────────────────────────────────────
|
|
1577
|
+
async migrate(migrations) {
|
|
1578
|
+
await this.migrations.run(migrations, this);
|
|
1579
|
+
}
|
|
1580
|
+
async rollback(migrations, steps = 1) {
|
|
1581
|
+
await this.migrations.rollback(migrations, this, steps);
|
|
1582
|
+
}
|
|
1583
|
+
async migrationStatus(migrations) {
|
|
1584
|
+
await this.migrations.status(migrations);
|
|
1585
|
+
}
|
|
1586
|
+
// ─── State helpers ────────────────────────────────────────────────────
|
|
1587
|
+
getCacheStats() {
|
|
1588
|
+
return this.cache.getStats();
|
|
1589
|
+
}
|
|
1590
|
+
getWorkerStats() {
|
|
1591
|
+
return this.pool.getStats();
|
|
1592
|
+
}
|
|
1593
|
+
getRegistryInstanceId() {
|
|
1594
|
+
return this.registry.getInstanceId();
|
|
1595
|
+
}
|
|
1596
|
+
/**
|
|
1597
|
+
* Warm up the cache by pre-loading all collection indexes.
|
|
1598
|
+
*/
|
|
1599
|
+
async warmCache() {
|
|
1600
|
+
for (const col of this.collections.values()) {
|
|
1601
|
+
await col.ensureIndexLoaded();
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
};
|
|
1605
|
+
function createClient(config) {
|
|
1606
|
+
return new GramoBase(config);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
export { Collection, GramoBase, GramoBaseAuth, RealtimeManager, createClient };
|
|
1610
|
+
//# sourceMappingURL=index.js.map
|
|
1611
|
+
//# sourceMappingURL=index.js.map
|