zerogramjs 2.1.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/lib/Context.js ADDED
@@ -0,0 +1,195 @@
1
+ const { Api } = require('telegram/tl');
2
+ const { markdownToHtml, markdownToMarkdownV2 } = require('../utils/markdown');
3
+ const Downloader = require('./Downloader');
4
+ const debug = require('debug')('zerogram:context');
5
+
6
+ class ContextFactory {
7
+ constructor(bot) {
8
+ this.bot = bot;
9
+ this.downloader = null;
10
+ }
11
+
12
+ create(update) {
13
+ const rawMessage = update.message ?? {};
14
+ // GramJS usa message.message para el texto, mapearlo a message.text para consistencia
15
+ const message = {
16
+ ...rawMessage,
17
+ text: rawMessage.message || rawMessage.text || ''
18
+ };
19
+ const peer = this._extractPeer(message, update);
20
+ const isCallback = update?.className === 'UpdateBotCallbackQuery' || update?.className === 'UpdateInlineBotCallbackQuery';
21
+ const callbackData = Buffer.isBuffer(update?.data)
22
+ ? update.data.toString('utf8')
23
+ : (update?.data ?? null);
24
+ const payload = callbackData ? callbackData.split(':')[0] : null;
25
+ const downloader = this._getDownloader();
26
+
27
+ return {
28
+ bot: this.bot,
29
+ client: this.bot.client,
30
+ update,
31
+ message,
32
+ isCallback,
33
+ callbackData,
34
+
35
+ reply: this._createReplyFn(peer, message.id),
36
+ send: this._createSendFn(peer),
37
+ sendFile: this._createSendFileFn(peer),
38
+ answerCallback: this._createAnswerCallbackFn(update),
39
+
40
+ getReplyMessage: this._createGetReplyMessageFn(peer, message),
41
+ getMessages: this._createGetMessagesFn(peer),
42
+ thumbFromVideo: (buf, opts) => this.bot.ffutil.thumbnail(buf, opts),
43
+
44
+ downloadMedia: (msg, opts) => downloader.downloadMedia(msg, opts),
45
+ downloadMediaStream: (msg, opts) => downloader.downloadMediaStream(msg, opts),
46
+ downloadMediaBatch: (msgs, opts) => downloader.downloadMediaBatch(msgs, opts),
47
+ downloadControllers: downloader.controllers
48
+ };
49
+ }
50
+
51
+ _getDownloader() {
52
+ if (!this.bot.client) {
53
+ throw new Error('Telegram client no está inicializado todavía');
54
+ }
55
+
56
+ if (!this.downloader || this.downloader.client !== this.bot.client) {
57
+ this.downloader = new Downloader(this.bot.client, this.bot.ffutil);
58
+ }
59
+
60
+ return this.downloader;
61
+ }
62
+
63
+ _extractPeer(message, update) {
64
+ // Para mensajes normales
65
+ const msgPeer = message.peerId ?? message.peer ?? null;
66
+ if (msgPeer) return msgPeer;
67
+
68
+ // Para callbacks: el peer está en update.peer (el chat) o update.userId (el usuario)
69
+ const callbackPeer = update?.peer ?? update?.chat ?? update?.chatInstance ?? null;
70
+ if (callbackPeer) return callbackPeer;
71
+
72
+ // Si no hay peer, intentar obtenerlo del bot
73
+ return update?.userId ?? update?.fromId ?? null;
74
+ }
75
+
76
+ _createAnswerCallbackFn(update) {
77
+ return async (text = '', opts = {}) => {
78
+ // GramJS usa snake_case en los updates
79
+ const queryId = update?.queryId ?? update?.query_id;
80
+
81
+ if (!queryId) {
82
+ throw new Error('answerCallback() solo está disponible en callbacks con queryId');
83
+ }
84
+
85
+ // Convertir a BigInt de forma segura
86
+ let queryIdBigInt;
87
+ try {
88
+ if (typeof queryId === 'bigint') {
89
+ queryIdBigInt = queryId;
90
+ } else if (typeof queryId === 'string') {
91
+ queryIdBigInt = BigInt(queryId);
92
+ } else if (typeof queryId === 'number') {
93
+ queryIdBigInt = BigInt(queryId);
94
+ } else {
95
+ // Puede ser un objeto con toString
96
+ queryIdBigInt = BigInt(queryId.toString());
97
+ }
98
+ } catch (e) {
99
+ throw new Error(`Invalid queryId format: ${e.message}`);
100
+ }
101
+
102
+ return this.bot.client.invoke(new Api.messages.SetBotCallbackAnswer({
103
+ queryId: queryIdBigInt,
104
+ message: text || '',
105
+ alert: !!opts.alert,
106
+ url: opts.url,
107
+ cacheTime: Number.isInteger(opts.cacheTime) ? opts.cacheTime : 0
108
+ }));
109
+ };
110
+ }
111
+
112
+ _createReplyFn(peer, messageId) {
113
+ return (text, opts = {}) => {
114
+ const htmlText = markdownToHtml(text);
115
+
116
+ return (async () => {
117
+ let msg = await this.bot.client.sendMessage(peer, {
118
+ message: htmlText,
119
+ parseMode: 'html',
120
+ replyTo: messageId,
121
+ ...opts
122
+ });
123
+
124
+ if (Array.isArray(msg)) msg = msg[0];
125
+
126
+ msg.safeEdit = (newText, editOpts = {}) => {
127
+ const htmlNewText = markdownToHtml(newText);
128
+ return this.bot.client.editMessage(peer, {
129
+ message: msg.id,
130
+ text: htmlNewText,
131
+ parseMode: 'html',
132
+ ...editOpts
133
+ }).catch((err) => {
134
+ debug('safeEdit error:', err);
135
+ return null;
136
+ });
137
+ };
138
+
139
+ return msg;
140
+ })();
141
+ };
142
+ }
143
+
144
+ _createSendFn(peer) {
145
+ return (text, opts = {}) => {
146
+ const mode = opts.parseMode || 'html';
147
+ const formattedText = mode === 'markdownv2'
148
+ ? markdownToMarkdownV2(text)
149
+ : markdownToHtml(text);
150
+
151
+ return this.bot.client.sendMessage(peer, {
152
+ message: formattedText,
153
+ parseMode: mode,
154
+ ...opts
155
+ });
156
+ };
157
+ }
158
+
159
+ _createSendFileFn(peer) {
160
+ return (file, opts = {}) => {
161
+ return this.bot.client.sendFile(peer, { file, ...opts });
162
+ };
163
+ }
164
+
165
+ _createGetReplyMessageFn(peer, message) {
166
+ return async () => {
167
+ if (!message.replyTo) return null;
168
+
169
+ const id = message.replyTo.replyToMsgId ?? message.replyTo.msgId ?? null;
170
+ if (!id) return null;
171
+
172
+ try {
173
+ const [msg] = await this.bot.client.getMessages(peer, { ids: [id] });
174
+ return msg || null;
175
+ } catch (error) {
176
+ debug('Error getting reply message:', error);
177
+ return null;
178
+ }
179
+ };
180
+ }
181
+
182
+ _createGetMessagesFn(peer) {
183
+ return async (ids) => {
184
+ try {
185
+ if (!Array.isArray(ids)) ids = [ids];
186
+ return await this.bot.client.getMessages(peer, { ids });
187
+ } catch (error) {
188
+ debug('Error getting messages:', error);
189
+ throw error;
190
+ }
191
+ };
192
+ }
193
+ }
194
+
195
+ module.exports = ContextFactory;
@@ -0,0 +1,234 @@
1
+ const path = require('path');
2
+ const { PassThrough } = require('stream');
3
+ const debug = require('debug')('zerogram:downloader');
4
+ const { downloadControllers } = require('../shared/downloadState');
5
+
6
+ /**
7
+ * Maneja descargas de medios con streaming real
8
+ */
9
+ class Downloader {
10
+ constructor(client, ffutil) {
11
+ this.client = client;
12
+ this.ffutil = ffutil;
13
+ this.controllers = downloadControllers;
14
+ }
15
+
16
+ /**
17
+ * Descarga un medio con progreso
18
+ */
19
+ async downloadMedia(msg, opts = {}) {
20
+ const { filePath, onProgress, controller } = opts;
21
+
22
+ if (!msg) {
23
+ throw new Error('Message is required for downloadMedia');
24
+ }
25
+
26
+ if (controller?.signal?.aborted) {
27
+ throw new Error('DOWNLOAD_CANCELLED');
28
+ }
29
+
30
+ const startTime = Date.now();
31
+ let lastBytes = 0;
32
+
33
+ try {
34
+ const buffer = await this.client.downloadMedia(msg, {
35
+ outputFile: filePath,
36
+ progressCallback: (downloadedBytes, totalBytes) => {
37
+ if (controller?.signal?.aborted) {
38
+ throw new Error('DOWNLOAD_CANCELLED');
39
+ }
40
+
41
+ const elapsed = (Date.now() - startTime) / 1000;
42
+ const speed = elapsed > 0 ? downloadedBytes / elapsed : 0;
43
+ const delta = downloadedBytes - lastBytes;
44
+
45
+ if (typeof onProgress === 'function') {
46
+ onProgress({
47
+ downloadedBytes,
48
+ totalBytes,
49
+ speed,
50
+ percent: totalBytes ? (downloadedBytes / totalBytes) * 100 : 0,
51
+ delta,
52
+ elapsed
53
+ });
54
+ }
55
+
56
+ lastBytes = downloadedBytes;
57
+ }
58
+ });
59
+
60
+ return buffer;
61
+ } catch (error) {
62
+ if (error.message === 'DOWNLOAD_CANCELLED') {
63
+ debug('Download cancelled by user');
64
+ }
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Descarga con streaming real - ¡NUEVO!
71
+ * Retorna un stream que empieza a emitir datos inmediatamente
72
+ */
73
+ downloadMediaStream(msg, opts = {}) {
74
+ const { onProgress, controller } = opts;
75
+
76
+ if (!msg) {
77
+ throw new Error('Message is required for downloadMediaStream');
78
+ }
79
+
80
+ const stream = new PassThrough();
81
+ const startTime = Date.now();
82
+ let downloadedBytes = 0;
83
+ let totalBytes = 0;
84
+
85
+ // Obtener el tamaño total del archivo
86
+ if (msg.media?.document) {
87
+ totalBytes = msg.media.document.size || 0;
88
+ } else if (msg.media?.photo) {
89
+ const sizes = msg.media.photo.sizes || [];
90
+ const largest = sizes[sizes.length - 1];
91
+ totalBytes = largest?.size || 0;
92
+ }
93
+
94
+ // Iniciar descarga asíncrona
95
+ (async () => {
96
+ try {
97
+ // Verificar cancelación inicial
98
+ if (controller?.signal?.aborted) {
99
+ stream.destroy(new Error('DOWNLOAD_CANCELLED'));
100
+ return;
101
+ }
102
+
103
+ // Usar iterDownload para streaming real
104
+ for await (const chunk of this.client.iterDownload({
105
+ file: msg.media,
106
+ requestSize: 512 * 1024, // 512KB chunks
107
+ })) {
108
+ // Verificar cancelación en cada chunk
109
+ if (controller?.signal?.aborted) {
110
+ stream.destroy(new Error('DOWNLOAD_CANCELLED'));
111
+ return;
112
+ }
113
+
114
+ downloadedBytes += chunk.length;
115
+
116
+ // Escribir chunk al stream inmediatamente
117
+ if (!stream.write(chunk)) {
118
+ // Esperar si el buffer está lleno
119
+ await new Promise(resolve => stream.once('drain', resolve));
120
+ }
121
+
122
+ // Callback de progreso
123
+ if (typeof onProgress === 'function') {
124
+ const elapsed = (Date.now() - startTime) / 1000;
125
+ const speed = elapsed > 0 ? downloadedBytes / elapsed : 0;
126
+
127
+ onProgress({
128
+ downloadedBytes,
129
+ totalBytes,
130
+ speed,
131
+ percent: totalBytes ? (downloadedBytes / totalBytes) * 100 : 0,
132
+ elapsed
133
+ });
134
+ }
135
+ }
136
+
137
+ // Terminar el stream
138
+ stream.end();
139
+ debug('Stream completed successfully');
140
+
141
+ } catch (error) {
142
+ debug('Stream error:', error);
143
+ stream.destroy(error);
144
+ }
145
+ })();
146
+
147
+ return stream;
148
+ }
149
+
150
+ /**
151
+ * Descarga múltiples medios en lote
152
+ */
153
+ async downloadMediaBatch(messages, opts = {}) {
154
+ const { dir = '.', onProgress, controller } = opts;
155
+
156
+ if (!Array.isArray(messages) || messages.length === 0) {
157
+ throw new Error('messages[] is required and must not be empty');
158
+ }
159
+
160
+ if (controller?.signal?.aborted) {
161
+ throw new Error('DOWNLOAD_CANCELLED');
162
+ }
163
+
164
+ const totalBytes = messages.reduce((acc, msg) => {
165
+ return acc + (msg.media?.document?.size || 0);
166
+ }, 0);
167
+
168
+ let overallDownloaded = 0;
169
+ const results = [];
170
+
171
+ for (let i = 0; i < messages.length; i++) {
172
+ const msg = messages[i];
173
+
174
+ if (controller?.signal?.aborted) {
175
+ throw new Error('DOWNLOAD_CANCELLED');
176
+ }
177
+
178
+ const fileName = this._extractFileName(msg, i);
179
+ const filePath = path.join(dir, fileName);
180
+
181
+ try {
182
+ const buffer = await this.downloadMedia(msg, {
183
+ filePath,
184
+ controller,
185
+ onProgress: (progress) => {
186
+ overallDownloaded += progress.delta;
187
+
188
+ if (typeof onProgress === 'function') {
189
+ onProgress({
190
+ index: i + 1,
191
+ total: messages.length,
192
+ fileName,
193
+ ...progress,
194
+ overallDownloaded,
195
+ overallTotal: totalBytes,
196
+ overallPercent: totalBytes > 0
197
+ ? (overallDownloaded / totalBytes) * 100
198
+ : 0
199
+ });
200
+ }
201
+ }
202
+ });
203
+
204
+ results.push({ filePath, buffer, message: msg });
205
+ } catch (error) {
206
+ if (error.message === 'DOWNLOAD_CANCELLED') {
207
+ throw error;
208
+ }
209
+
210
+ debug(`Error downloading file ${i + 1}:`, error);
211
+ results.push({ filePath, error, message: msg });
212
+ }
213
+ }
214
+
215
+ return results;
216
+ }
217
+
218
+ /**
219
+ * Extrae el nombre de archivo de un mensaje
220
+ * @private
221
+ */
222
+ _extractFileName(msg, index) {
223
+ const attrs = msg.media?.document?.attributes;
224
+ if (attrs) {
225
+ const fileAttr = attrs.find(a => a.fileName);
226
+ if (fileAttr?.fileName) {
227
+ return fileAttr.fileName;
228
+ }
229
+ }
230
+ return `file_${msg.id || index}`;
231
+ }
232
+ }
233
+
234
+ module.exports = Downloader;
package/lib/Router.js ADDED
@@ -0,0 +1,152 @@
1
+ const debug = require('debug')('zerogram:router');
2
+
3
+ /**
4
+ * Maneja el enrutamiento de comandos y callbacks
5
+ */
6
+ class Router {
7
+ constructor() {
8
+ this.commands = new Map();
9
+ this.callbacks = new Map();
10
+ this.middlewares = [];
11
+ }
12
+
13
+ /**
14
+ * Registra un comando
15
+ */
16
+ addCommand(name, handler) {
17
+ this.commands.set(name, handler);
18
+ }
19
+
20
+ /**
21
+ * Registra un callback
22
+ */
23
+ addCallback(action, handler) {
24
+ this.callbacks.set(action, handler);
25
+ }
26
+
27
+ /**
28
+ * Registra un middleware
29
+ */
30
+ addMiddleware(middleware) {
31
+ this.middlewares.push(middleware);
32
+ }
33
+
34
+ /**
35
+ * Procesa una actualización
36
+ */
37
+ async route(ctx, update) {
38
+ // Ejecutar middlewares
39
+ await this._runMiddlewares(ctx);
40
+
41
+ // Detectar tipo de update
42
+ if (this._isCommand(ctx)) {
43
+ return this._handleCommand(ctx);
44
+ }
45
+
46
+ if (this._isCallback(update)) {
47
+ return this._handleCallback(ctx, update);
48
+ }
49
+
50
+ // Evento no manejado
51
+ ctx.bot.emit('unhandled', ctx);
52
+ }
53
+
54
+ /**
55
+ * Verifica si es un comando
56
+ * @private
57
+ */
58
+ _isCommand(ctx) {
59
+ const text = ctx.message.text || ctx.message.message || '';
60
+ return text.startsWith('/');
61
+ }
62
+
63
+ /**
64
+ * Verifica si es un callback
65
+ * @private
66
+ */
67
+ _isCallback(update) {
68
+ const name = update?.className;
69
+ return name === 'UpdateBotCallbackQuery' || name === 'UpdateInlineBotCallbackQuery';
70
+ }
71
+
72
+ /**
73
+ * Maneja comandos
74
+ * @private
75
+ */
76
+ async _handleCommand(ctx) {
77
+ const text = ctx.message.text || ctx.message.message || '';
78
+ const [cmd, ...args] = text.slice(1).split(/\s+/);
79
+
80
+ const handler = this.commands.get(cmd);
81
+ if (handler) {
82
+ debug(`Executing command: /${cmd}`);
83
+ return handler(ctx, args);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Maneja callbacks
89
+ * @private
90
+ */
91
+ async _handleCallback(ctx, update) {
92
+ const data = update.data.toString('utf8');
93
+ let action, payload;
94
+
95
+ if (data.includes(':')) {
96
+ // Formato action:payload (con dos puntos)
97
+ const [actionPart, ...payloadParts] = data.split(':');
98
+ action = actionPart;
99
+ payload = payloadParts.join(':');
100
+ } else if (/_\d+$/.test(data)) {
101
+ // Formato action_payload (con underscore y número al final)
102
+ const lastUnderscore = data.lastIndexOf('_');
103
+ action = data.substring(0, lastUnderscore + 1); // incluye el _
104
+ payload = data.substring(lastUnderscore + 1);
105
+ } else {
106
+ // Formato simple sin separador
107
+ action = data;
108
+ payload = '';
109
+ }
110
+
111
+ debug(`Callback received - data: ${data}, action: ${action}, payload: ${payload}`);
112
+
113
+ const handler = this.callbacks.get(action);
114
+ if (handler) {
115
+ debug(`Executing callback handler: ${action}`);
116
+ return handler({
117
+ ...ctx,
118
+ action,
119
+ payload,
120
+ queryId: update.queryId,
121
+ userId: update.userId?.toString()
122
+ });
123
+ } else {
124
+ debug(`No handler found for callback: ${action}`);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Ejecuta middlewares en cadena
130
+ * @private
131
+ */
132
+ async _runMiddlewares(ctx) {
133
+ let index = -1;
134
+
135
+ const dispatch = async (i) => {
136
+ if (i <= index) {
137
+ throw new Error('next() called multiple times');
138
+ }
139
+
140
+ index = i;
141
+ const middleware = this.middlewares[i];
142
+
143
+ if (!middleware) return;
144
+
145
+ await middleware(ctx, () => dispatch(i + 1));
146
+ };
147
+
148
+ await dispatch(0);
149
+ }
150
+ }
151
+
152
+ module.exports = Router;
package/lib/Session.js ADDED
@@ -0,0 +1,77 @@
1
+ const fs = require('fs').promises;
2
+ const fsSync = require('fs');
3
+ const path = require('path');
4
+ const debug = require('debug')('zerogram:session');
5
+
6
+ /**
7
+ * Maneja la persistencia de sesiones
8
+ */
9
+ class SessionManager {
10
+ constructor(botToken, sessionsDir) {
11
+ this.sessionsDir = path.resolve(sessionsDir);
12
+ this.sessionFile = path.join(
13
+ this.sessionsDir,
14
+ `${botToken.split(':')[0]}.session`
15
+ );
16
+ }
17
+
18
+ /**
19
+ * Carga una sesión existente
20
+ * @returns {Promise<string>} Session data o string vacío
21
+ */
22
+ async load() {
23
+ try {
24
+ // Crear directorio si no existe
25
+ if (!fsSync.existsSync(this.sessionsDir)) {
26
+ await fs.mkdir(this.sessionsDir, { recursive: true });
27
+ debug('Sessions directory created');
28
+ }
29
+
30
+ // Cargar archivo de sesión
31
+ if (fsSync.existsSync(this.sessionFile)) {
32
+ const data = await fs.readFile(this.sessionFile, 'utf8');
33
+ debug('Session loaded from file');
34
+ return data;
35
+ }
36
+
37
+ debug('No existing session found');
38
+ return '';
39
+ } catch (error) {
40
+ debug('Error loading session:', error);
41
+ return '';
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Guarda la sesión actual
47
+ * @param {string} sessionData - Datos de sesión a guardar
48
+ * @returns {Promise<void>}
49
+ */
50
+ async save(sessionData) {
51
+ try {
52
+ await fs.writeFile(this.sessionFile, sessionData, 'utf8');
53
+ debug('Session saved successfully');
54
+ } catch (error) {
55
+ debug('Error saving session:', error);
56
+ throw error;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Elimina la sesión
62
+ * @returns {Promise<void>}
63
+ */
64
+ async delete() {
65
+ try {
66
+ if (fsSync.existsSync(this.sessionFile)) {
67
+ await fs.unlink(this.sessionFile);
68
+ debug('Session deleted');
69
+ }
70
+ } catch (error) {
71
+ debug('Error deleting session:', error);
72
+ throw error;
73
+ }
74
+ }
75
+ }
76
+
77
+ module.exports = SessionManager;