memex-mvp 0.5.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/bot/nexara.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Nexara transcription — POST /audio/transcriptions.
3
+ *
4
+ * Endpoint: https://api.nexara.ru/api/v1/audio/transcriptions
5
+ * Auth: Authorization: Bearer <key>
6
+ *
7
+ * Telegram voice messages arrive as audio/ogg (Opus). Nexara's docs note
8
+ * that ogg/opus may need ffmpeg conversion to mp3 first; this implementation
9
+ * sends the raw OGG. If transcription fails on a particular file, install
10
+ * ffmpeg and we can add a conversion step later.
11
+ */
12
+
13
+ const ENDPOINT = 'https://api.nexara.ru/api/v1/audio/transcriptions';
14
+
15
+ /**
16
+ * @param {object} opts
17
+ * @param {Buffer|Uint8Array} opts.audioBuffer
18
+ * @param {string} opts.apiKey
19
+ * @param {string} [opts.filename='voice.oga']
20
+ * @param {string} [opts.mimeType='audio/ogg']
21
+ * @param {string} [opts.language='ru']
22
+ * @returns {Promise<{ text: string, duration?: number, language?: string, raw: object }>}
23
+ */
24
+ export async function transcribe({
25
+ audioBuffer,
26
+ apiKey,
27
+ filename = 'voice.oga',
28
+ mimeType = 'audio/ogg',
29
+ language = 'ru',
30
+ }) {
31
+ if (!apiKey) throw new Error('nexara: apiKey required');
32
+ if (!audioBuffer) throw new Error('nexara: audioBuffer required');
33
+
34
+ const blob = new Blob([audioBuffer], { type: mimeType });
35
+ const form = new FormData();
36
+ form.append('file', blob, filename);
37
+ form.append('response_format', 'verbose_json');
38
+ form.append('language', language);
39
+
40
+ const resp = await fetch(ENDPOINT, {
41
+ method: 'POST',
42
+ headers: { Authorization: `Bearer ${apiKey}` },
43
+ body: form,
44
+ signal: AbortSignal.timeout(120000),
45
+ });
46
+
47
+ if (!resp.ok) {
48
+ const body = await resp.text().catch(() => '');
49
+ throw new Error(`nexara HTTP ${resp.status}: ${body.slice(0, 200)}`);
50
+ }
51
+ const json = await resp.json();
52
+ if (!json || typeof json.text !== 'string') {
53
+ throw new Error('nexara: response missing .text field');
54
+ }
55
+ return {
56
+ text: json.text.trim(),
57
+ duration: typeof json.duration === 'number' ? json.duration : undefined,
58
+ language: typeof json.language === 'string' ? json.language : undefined,
59
+ raw: json,
60
+ };
61
+ }
package/bot/poll.js ADDED
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Long-poll runner — owns the getUpdates loop, allowlist gating, update
3
+ * dispatch (text / forward / voice / command), and error backoff.
4
+ *
5
+ * Each accepted message is converted into a Telegram-Desktop-export-format
6
+ * JSON snippet and dropped into the memex inbox. The MCP server's existing
7
+ * inbox watcher does the rest (parse → DB → archive). Bot does NOT touch
8
+ * SQLite for ingest, only for /search and /recent reads.
9
+ *
10
+ * State (last update offset) lives in ~/.memex/data/bot-state.json so a
11
+ * restart resumes where it stopped instead of replaying Telegram's 24h
12
+ * server-side buffer.
13
+ */
14
+
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+ import { TelegramClient } from './telegram.js';
18
+ import { writeInboxMessage, tgUpdateToExportMessage } from './inbox.js';
19
+ import { transcribe } from './nexara.js';
20
+ import { searchMemex, recentMemex, renderSearchResults, renderRecent } from './search.js';
21
+
22
+ const HELP_TEXT =
23
+ '*Memex bot* — your mobile capture surface.\n\n' +
24
+ 'Send any text, forward any message, or send a voice note — it goes ' +
25
+ 'straight into your memex memory.\n\n' +
26
+ 'Commands:\n' +
27
+ ' /search <query> — search across all memex sources\n' +
28
+ ' /recent — most recent captures\n' +
29
+ ' /help — this message\n\n' +
30
+ '_Bot is local-only: when your laptop is asleep, messages buffer on ' +
31
+ "Telegram's side for ~24h and catch up on next poll. For longer gaps, " +
32
+ 'export the chat from Telegram Desktop and drop result.json into ' +
33
+ '~/.memex/inbox/.';
34
+
35
+ export class BotRunner {
36
+ constructor({ config, log }) {
37
+ this.config = config;
38
+ this.log = log || ((...a) => console.error('[bot]', ...a));
39
+ this.tg = new TelegramClient(config.telegram_bot_token);
40
+ this.allowlist = new Set(config.allowlist_user_ids);
41
+ this.lastUpdateId = this._loadOffset();
42
+ this.shuttingDown = false;
43
+ }
44
+
45
+ _loadOffset() {
46
+ try {
47
+ if (existsSync(this.config.state_path)) {
48
+ const s = JSON.parse(readFileSync(this.config.state_path, 'utf-8'));
49
+ if (typeof s.lastUpdateId === 'number') return s.lastUpdateId;
50
+ }
51
+ } catch (_) {}
52
+ return 0;
53
+ }
54
+
55
+ _saveOffset() {
56
+ try {
57
+ mkdirSync(dirname(this.config.state_path), { recursive: true });
58
+ const tmp = this.config.state_path + '.tmp';
59
+ writeFileSync(tmp, JSON.stringify({ lastUpdateId: this.lastUpdateId }));
60
+ renameSync(tmp, this.config.state_path);
61
+ } catch (e) {
62
+ this.log(`! could not save offset: ${e.message}`);
63
+ }
64
+ }
65
+
66
+ async start() {
67
+ let me;
68
+ try { me = await this.tg.getMe(); }
69
+ catch (e) {
70
+ throw new Error(`getMe failed — token invalid? ${e.message}`);
71
+ }
72
+ this.log(`✓ connected as @${me.username} (id=${me.id})`);
73
+ this.log(` allowlist: ${[...this.allowlist].join(', ')}`);
74
+ this.log(` inbox: ${this.config.inbox_path}`);
75
+ this.log(` voice: ${this.config.voice_enabled ? 'enabled (Nexara)' : 'disabled'}`);
76
+ this.log(` resuming from update_id ${this.lastUpdateId}`);
77
+
78
+ let backoffMs = 1000;
79
+ while (!this.shuttingDown) {
80
+ try {
81
+ const updates = await this.tg.getUpdates({
82
+ offset: this.lastUpdateId + 1,
83
+ timeout: 30,
84
+ });
85
+ backoffMs = 1000;
86
+ for (const update of updates) {
87
+ if (this.shuttingDown) break;
88
+ this.lastUpdateId = Math.max(this.lastUpdateId, update.update_id);
89
+ try {
90
+ await this._handleUpdate(update);
91
+ } catch (e) {
92
+ this.log(`! handler error on update ${update.update_id}: ${e.stack || e.message}`);
93
+ }
94
+ }
95
+ if (updates.length > 0) this._saveOffset();
96
+ } catch (e) {
97
+ if (this.shuttingDown) break;
98
+ this.log(`! poll error: ${e.message}, retrying in ${backoffMs}ms`);
99
+ await sleep(backoffMs);
100
+ backoffMs = Math.min(backoffMs * 2, 60000);
101
+ }
102
+ }
103
+ this.log('poll loop exited');
104
+ }
105
+
106
+ stop() {
107
+ this.shuttingDown = true;
108
+ }
109
+
110
+ async _handleUpdate(update) {
111
+ const msg = update.message;
112
+ if (!msg) return;
113
+ const fromId = msg.from?.id;
114
+ const chatId = msg.chat?.id;
115
+ if (!fromId || !chatId) return;
116
+
117
+ if (!this.allowlist.has(Number(fromId))) {
118
+ this.log(`! rejecting message from non-allowlisted user ${fromId} (${msg.from?.username || '?'})`);
119
+ try {
120
+ await this.tg.sendMessage(
121
+ chatId,
122
+ 'This bot is private and not accepting messages. (Memex personal capture surface.)'
123
+ );
124
+ } catch (_) {}
125
+ return;
126
+ }
127
+
128
+ // Commands first.
129
+ const text = typeof msg.text === 'string' ? msg.text.trim() : '';
130
+ if (text.startsWith('/')) {
131
+ return this._handleCommand({ chatId, msg, text });
132
+ }
133
+
134
+ // Voice message
135
+ if (msg.voice) {
136
+ return this._handleVoice({ chatId, msg });
137
+ }
138
+
139
+ // Text or forwarded text/caption
140
+ if (text || msg.caption || msg.forward_from || msg.forward_from_chat || msg.forward_sender_name) {
141
+ return this._handleText({ chatId, msg });
142
+ }
143
+
144
+ // Anything else (photo / sticker / document without caption etc.) — not
145
+ // in v0.1 scope; acknowledge so user knows it didn't silently succeed.
146
+ try {
147
+ await this.tg.sendMessage(
148
+ chatId,
149
+ '⚠️ Only text, forwarded messages, and voice notes are captured in this version.'
150
+ );
151
+ } catch (_) {}
152
+ }
153
+
154
+ async _handleText({ chatId, msg }) {
155
+ const exportMsg = tgUpdateToExportMessage({
156
+ tgMessage: msg,
157
+ userId: this.allowlist.values().next().value, // single-user bot — first allowlisted id
158
+ });
159
+ if (!exportMsg.text) {
160
+ // Empty after assembly — nothing to capture.
161
+ return;
162
+ }
163
+ try {
164
+ const path = writeInboxMessage({
165
+ inboxPath: this.config.inbox_path,
166
+ userId: this.allowlist.values().next().value,
167
+ message: exportMsg,
168
+ });
169
+ this.log(`+ captured msg ${msg.message_id} (${exportMsg.text.length} chars) → ${path.split('/').pop()}`);
170
+ const ack = msg.forward_from || msg.forward_from_chat || msg.forward_sender_name
171
+ ? '✓ saved (forward)'
172
+ : '✓ saved';
173
+ await this.tg.sendMessage(chatId, ack, { reply_to_message_id: msg.message_id, disable_notification: true });
174
+ } catch (e) {
175
+ this.log(`! capture failed: ${e.message}`);
176
+ try { await this.tg.sendMessage(chatId, `⚠️ Couldn't save: ${e.message.slice(0, 200)}`); } catch (_) {}
177
+ }
178
+ }
179
+
180
+ async _handleVoice({ chatId, msg }) {
181
+ if (!this.config.voice_enabled) {
182
+ try {
183
+ await this.tg.sendMessage(
184
+ chatId,
185
+ '⚠️ Voice capture is disabled (no Nexara API key configured).',
186
+ { reply_to_message_id: msg.message_id }
187
+ );
188
+ } catch (_) {}
189
+ return;
190
+ }
191
+ try {
192
+ await this.tg.sendChatAction(chatId, 'typing');
193
+ } catch (_) {}
194
+
195
+ let audioBuf, mediaPath = null;
196
+ try {
197
+ const file = await this.tg.getFile(msg.voice.file_id);
198
+ audioBuf = await this.tg.downloadFile(file.file_path);
199
+ } catch (e) {
200
+ this.log(`! voice download failed: ${e.message}`);
201
+ try { await this.tg.sendMessage(chatId, `⚠️ Couldn't download voice: ${e.message.slice(0, 200)}`, { reply_to_message_id: msg.message_id }); } catch (_) {}
202
+ return;
203
+ }
204
+
205
+ // Persist OGG before transcription so the original is recoverable
206
+ // even if Nexara is down.
207
+ try {
208
+ mkdirSync(this.config.media_path, { recursive: true });
209
+ mediaPath = join(this.config.media_path, `${msg.message_id}.oga`);
210
+ writeFileSync(mediaPath, audioBuf);
211
+ } catch (e) {
212
+ this.log(`! could not save voice file: ${e.message}`);
213
+ mediaPath = null;
214
+ }
215
+
216
+ let transcript;
217
+ try {
218
+ const result = await transcribe({
219
+ audioBuffer: audioBuf,
220
+ apiKey: this.config.nexara_api_key,
221
+ filename: `${msg.message_id}.oga`,
222
+ mimeType: 'audio/ogg',
223
+ language: 'ru',
224
+ });
225
+ transcript = result.text;
226
+ } catch (e) {
227
+ this.log(`! transcription failed: ${e.message}`);
228
+ try { await this.tg.sendMessage(chatId, `⚠️ Transcription failed: ${e.message.slice(0, 200)}`, { reply_to_message_id: msg.message_id }); } catch (_) {}
229
+ return;
230
+ }
231
+ if (!transcript) {
232
+ try { await this.tg.sendMessage(chatId, '⚠️ Transcription was empty.', { reply_to_message_id: msg.message_id }); } catch (_) {}
233
+ return;
234
+ }
235
+
236
+ const userId = this.allowlist.values().next().value;
237
+ const exportMsg = tgUpdateToExportMessage({
238
+ tgMessage: msg,
239
+ userId,
240
+ textOverride: `🎙 ${transcript}`,
241
+ mediaPath,
242
+ });
243
+
244
+ try {
245
+ const path = writeInboxMessage({
246
+ inboxPath: this.config.inbox_path,
247
+ userId,
248
+ message: exportMsg,
249
+ });
250
+ this.log(`+ captured voice ${msg.message_id} (${transcript.length} chars) → ${path.split('/').pop()}`);
251
+ const preview = transcript.length > 140 ? transcript.slice(0, 140) + '…' : transcript;
252
+ await this.tg.sendMessage(
253
+ chatId,
254
+ `✓ transcribed: ${preview}`,
255
+ { reply_to_message_id: msg.message_id, disable_notification: true }
256
+ );
257
+ } catch (e) {
258
+ this.log(`! capture failed: ${e.message}`);
259
+ try { await this.tg.sendMessage(chatId, `⚠️ Couldn't save transcript: ${e.message.slice(0, 200)}`); } catch (_) {}
260
+ }
261
+ }
262
+
263
+ async _handleCommand({ chatId, msg, text }) {
264
+ // Strip @botname suffix Telegram appends in groups (e.g. /search@MyBot foo)
265
+ const m = text.match(/^\/(\w+)(?:@\w+)?(?:\s+([\s\S]*))?$/);
266
+ if (!m) return;
267
+ const cmd = m[1].toLowerCase();
268
+ const arg = (m[2] || '').trim();
269
+
270
+ if (cmd === 'help' || cmd === 'start') {
271
+ await this.tg.sendMessage(chatId, HELP_TEXT, { parse_mode: 'Markdown' });
272
+ return;
273
+ }
274
+ if (cmd === 'search') {
275
+ if (!arg) {
276
+ await this.tg.sendMessage(chatId, 'Usage: `/search <query>`', { parse_mode: 'Markdown' });
277
+ return;
278
+ }
279
+ const { rows, error } = searchMemex({ dbPath: this.config.db_path, query: arg, limit: 3 });
280
+ if (error) {
281
+ await this.tg.sendMessage(chatId, `⚠️ ${error}`);
282
+ return;
283
+ }
284
+ const body = renderSearchResults(arg, rows);
285
+ await this.tg.sendMessage(chatId, body, { parse_mode: 'Markdown' });
286
+ return;
287
+ }
288
+ if (cmd === 'recent') {
289
+ const { rows, error } = recentMemex({ dbPath: this.config.db_path, limit: 5 });
290
+ if (error) {
291
+ await this.tg.sendMessage(chatId, `⚠️ ${error}`);
292
+ return;
293
+ }
294
+ await this.tg.sendMessage(chatId, renderRecent(rows), { parse_mode: 'Markdown' });
295
+ return;
296
+ }
297
+ // Unknown command — capture it as text so it isn't lost.
298
+ return this._handleText({ chatId, msg });
299
+ }
300
+ }
301
+
302
+ function sleep(ms) {
303
+ return new Promise((res) => setTimeout(res, ms));
304
+ }
package/bot/search.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Read-only search against memex.db for the bot's /search and /recent
3
+ * slash-commands. Uses better-sqlite3 directly — server.js uses WAL mode
4
+ * so concurrent readers are safe.
5
+ *
6
+ * Lazy DB open: don't error at startup if memex.db doesn't exist yet
7
+ * (first-time install where the bot fires before the MCP server has run).
8
+ * Open on first call instead and surface a friendly message.
9
+ */
10
+
11
+ import Database from 'better-sqlite3';
12
+ import { existsSync } from 'node:fs';
13
+
14
+ let db = null;
15
+ let openError = null;
16
+
17
+ function ensureDb(dbPath) {
18
+ if (db) return db;
19
+ if (openError) return null;
20
+ if (!existsSync(dbPath)) {
21
+ openError = `memex.db not found at ${dbPath}. Run the memex MCP server at least once first.`;
22
+ return null;
23
+ }
24
+ try {
25
+ db = new Database(dbPath, { readonly: true, fileMustExist: true });
26
+ db.pragma('busy_timeout = 5000');
27
+ } catch (e) {
28
+ openError = `failed to open memex.db: ${e.message}`;
29
+ return null;
30
+ }
31
+ return db;
32
+ }
33
+
34
+ function shortDate(unixTs) {
35
+ if (!unixTs) return '?';
36
+ const d = new Date(unixTs * 1000);
37
+ // YYYY-MM-DD HH:MM
38
+ return d.toISOString().slice(0, 16).replace('T', ' ');
39
+ }
40
+
41
+ function sourceLabel(source) {
42
+ switch (source) {
43
+ case 'claude-code': return 'Claude Code';
44
+ case 'claude-cowork': return 'Cowork';
45
+ case 'cursor': return 'Cursor';
46
+ case 'obsidian': return 'Obsidian';
47
+ case 'telegram': return 'Telegram';
48
+ default: return source || '?';
49
+ }
50
+ }
51
+
52
+ function escapeMarkdown(s) {
53
+ // Telegram MarkdownV1 is forgiving — only escape backticks and underscores
54
+ // we actually emit as raw text; we wrap snippets in code blocks so this is
55
+ // mostly defensive.
56
+ return String(s || '').replace(/`/g, "'");
57
+ }
58
+
59
+ /**
60
+ * FTS5 search. Returns up to `limit` rows, one row per match (not grouped).
61
+ * Each row: { snippet, title, source, conversation_id, ts }
62
+ */
63
+ export function searchMemex({ dbPath, query, limit = 3 }) {
64
+ const conn = ensureDb(dbPath);
65
+ if (!conn) return { error: openError };
66
+ const q = String(query || '').trim();
67
+ if (!q) return { error: 'empty query' };
68
+
69
+ let rows;
70
+ try {
71
+ rows = conn
72
+ .prepare(
73
+ `SELECT m.text AS text,
74
+ m.ts AS ts,
75
+ m.source AS source,
76
+ m.conversation_id AS conversation_id,
77
+ c.title AS title,
78
+ snippet(messages_fts, 0, '«', '»', '…', 24) AS snippet
79
+ FROM messages_fts
80
+ JOIN messages m ON messages_fts.rowid = m.id
81
+ LEFT JOIN conversations c ON c.conversation_id = m.conversation_id
82
+ WHERE messages_fts MATCH ?
83
+ AND (c.archived_at IS NULL OR c.archived_at = 0)
84
+ ORDER BY rank
85
+ LIMIT ?`
86
+ )
87
+ .all(q, limit);
88
+ } catch (e) {
89
+ return { error: `search failed: ${e.message}` };
90
+ }
91
+ return { rows };
92
+ }
93
+
94
+ /** Most recent N user-facing messages, time-sorted. */
95
+ export function recentMemex({ dbPath, limit = 5 }) {
96
+ const conn = ensureDb(dbPath);
97
+ if (!conn) return { error: openError };
98
+ let rows;
99
+ try {
100
+ rows = conn
101
+ .prepare(
102
+ `SELECT m.text AS text,
103
+ m.ts AS ts,
104
+ m.source AS source,
105
+ m.conversation_id AS conversation_id,
106
+ c.title AS title
107
+ FROM messages m
108
+ LEFT JOIN conversations c ON c.conversation_id = m.conversation_id
109
+ WHERE (c.archived_at IS NULL OR c.archived_at = 0)
110
+ AND m.text IS NOT NULL
111
+ AND length(m.text) > 0
112
+ ORDER BY m.ts DESC
113
+ LIMIT ?`
114
+ )
115
+ .all(limit);
116
+ } catch (e) {
117
+ return { error: `recent failed: ${e.message}` };
118
+ }
119
+ return { rows };
120
+ }
121
+
122
+ /** Render search results as a single Telegram message body. */
123
+ export function renderSearchResults(query, rows) {
124
+ if (!rows || rows.length === 0) {
125
+ return `🔍 No matches for *${escapeMarkdown(query)}*`;
126
+ }
127
+ const parts = [`🔍 Top ${rows.length} for *${escapeMarkdown(query)}*\n`];
128
+ for (let i = 0; i < rows.length; i++) {
129
+ const r = rows[i];
130
+ const title = r.title ? r.title.slice(0, 80) : r.conversation_id;
131
+ const snippet = (r.snippet || r.text || '').replace(/\s+/g, ' ').slice(0, 240);
132
+ parts.push(
133
+ `*${i + 1}.* [${sourceLabel(r.source)}] ${escapeMarkdown(title)}` +
134
+ `\n_${shortDate(r.ts)}_` +
135
+ `\n\`\`\`\n${escapeMarkdown(snippet)}\n\`\`\``
136
+ );
137
+ }
138
+ return parts.join('\n\n');
139
+ }
140
+
141
+ export function renderRecent(rows) {
142
+ if (!rows || rows.length === 0) return '📭 Nothing in memex yet.';
143
+ const parts = ['🕒 Most recent:\n'];
144
+ for (let i = 0; i < rows.length; i++) {
145
+ const r = rows[i];
146
+ const title = r.title ? r.title.slice(0, 80) : r.conversation_id;
147
+ const snippet = (r.text || '').replace(/\s+/g, ' ').slice(0, 200);
148
+ parts.push(
149
+ `*${i + 1}.* [${sourceLabel(r.source)}] ${escapeMarkdown(title)}` +
150
+ `\n_${shortDate(r.ts)}_` +
151
+ `\n${escapeMarkdown(snippet)}`
152
+ );
153
+ }
154
+ return parts.join('\n\n');
155
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Thin wrapper around Telegram Bot API HTTP endpoints used by the bot.
3
+ * Native fetch only — no node-telegram / telegraf / grammy dependency.
4
+ *
5
+ * - getUpdates: long-polling with offset persistence
6
+ * - sendMessage / sendChatAction
7
+ * - getFile + downloadFile (for voice messages)
8
+ *
9
+ * Errors that aren't fatal (network blips, Telegram 5xx, 429) are retried
10
+ * with exponential backoff in the run loop, not here.
11
+ */
12
+
13
+ const API_BASE = 'https://api.telegram.org';
14
+
15
+ export class TelegramClient {
16
+ constructor(token) {
17
+ if (!token) throw new Error('TelegramClient: token required');
18
+ this.token = token;
19
+ this.api = `${API_BASE}/bot${token}`;
20
+ this.fileApi = `${API_BASE}/file/bot${token}`;
21
+ }
22
+
23
+ async _post(method, body) {
24
+ const resp = await fetch(`${this.api}/${method}`, {
25
+ method: 'POST',
26
+ headers: { 'Content-Type': 'application/json' },
27
+ body: JSON.stringify(body),
28
+ signal: AbortSignal.timeout(60000),
29
+ });
30
+ const json = await resp.json().catch(() => ({}));
31
+ if (!resp.ok || json.ok === false) {
32
+ const err = new Error(`telegram ${method}: HTTP ${resp.status} ${json.description || ''}`);
33
+ err.status = resp.status;
34
+ err.tgError = json;
35
+ throw err;
36
+ }
37
+ return json.result;
38
+ }
39
+
40
+ /** Long-poll for updates. Returns array of update objects. */
41
+ async getUpdates({ offset, timeout = 30, allowedUpdates = ['message'] }) {
42
+ const url = new URL(`${this.api}/getUpdates`);
43
+ if (offset !== undefined && offset !== null) url.searchParams.set('offset', String(offset));
44
+ url.searchParams.set('timeout', String(timeout));
45
+ url.searchParams.set('allowed_updates', JSON.stringify(allowedUpdates));
46
+
47
+ // HTTP timeout = polling timeout + buffer for connection setup.
48
+ const resp = await fetch(url, { signal: AbortSignal.timeout((timeout + 15) * 1000) });
49
+ const json = await resp.json().catch(() => ({}));
50
+ if (!resp.ok || json.ok === false) {
51
+ const err = new Error(`telegram getUpdates: HTTP ${resp.status} ${json.description || ''}`);
52
+ err.status = resp.status;
53
+ throw err;
54
+ }
55
+ return json.result || [];
56
+ }
57
+
58
+ async sendMessage(chatId, text, opts = {}) {
59
+ const body = { chat_id: chatId, text };
60
+ if (opts.parse_mode) body.parse_mode = opts.parse_mode;
61
+ if (opts.reply_to_message_id) body.reply_to_message_id = opts.reply_to_message_id;
62
+ if (opts.disable_notification) body.disable_notification = true;
63
+ return this._post('sendMessage', body);
64
+ }
65
+
66
+ async sendChatAction(chatId, action) {
67
+ return this._post('sendChatAction', { chat_id: chatId, action });
68
+ }
69
+
70
+ /** getFile returns { file_id, file_unique_id, file_size, file_path }. */
71
+ async getFile(fileId) {
72
+ const url = new URL(`${this.api}/getFile`);
73
+ url.searchParams.set('file_id', fileId);
74
+ const resp = await fetch(url, { signal: AbortSignal.timeout(30000) });
75
+ const json = await resp.json().catch(() => ({}));
76
+ if (!resp.ok || json.ok === false) {
77
+ throw new Error(`telegram getFile: HTTP ${resp.status} ${json.description || ''}`);
78
+ }
79
+ return json.result;
80
+ }
81
+
82
+ /** Download a file by file_path (returned by getFile). Returns Buffer. */
83
+ async downloadFile(filePath) {
84
+ const url = `${this.fileApi}/${filePath}`;
85
+ const resp = await fetch(url, { signal: AbortSignal.timeout(120000) });
86
+ if (!resp.ok) {
87
+ throw new Error(`telegram downloadFile: HTTP ${resp.status}`);
88
+ }
89
+ const buf = await resp.arrayBuffer();
90
+ return Buffer.from(buf);
91
+ }
92
+
93
+ async getMe() {
94
+ return this._post('getMe', {});
95
+ }
96
+ }