instar 1.1.4 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,353 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * telegram-history-backfill — CLI entry for one-shot user-account MTProto
4
+ * backfill of a Telegram forum supergroup into instar topic-memory.db.
5
+ *
6
+ * Context. On 2026-05-20 Echo's local topic-memory.db was truncated during
7
+ * the Node 22→25 better-sqlite3 native-module cascade recovery. The Bot API
8
+ * has no history-fetch primitive (forward-only by design). A user-account
9
+ * MTProto session is the only path back. This is that path.
10
+ *
11
+ * Two-step usage (interactive auth required the first time):
12
+ *
13
+ * # 1. One-time auth — captures the session string for re-use
14
+ * node scripts/telegram-history-backfill.mjs --auth
15
+ *
16
+ * # 2. Backfill the lifeline supergroup (all topics, idempotent)
17
+ * node scripts/telegram-history-backfill.mjs --chat -1003742343280
18
+ *
19
+ * # Single-topic backfill (e.g. topic 10873):
20
+ * node scripts/telegram-history-backfill.mjs --chat -1003742343280 --topic 10873
21
+ *
22
+ * # Dry run (no writes; counts only):
23
+ * node scripts/telegram-history-backfill.mjs --chat -1003742343280 --dry-run
24
+ *
25
+ * Credential resolution (in order of preference):
26
+ * 1. --secrets-path <file> (JSON: { api_id, api_hash, sessionFilePath })
27
+ * 2. INSTAR_TELEGRAM_API_ID + INSTAR_TELEGRAM_API_HASH env vars
28
+ * 3. Default: ~/.local/share/instar-echo-secrets/telegram-mtproto.json
29
+ *
30
+ * The session string lives at sessionFilePath (mode 0600). Treat it like a
31
+ * password. It expires only when the operator revokes it via Telegram
32
+ * Settings → Devices.
33
+ */
34
+
35
+ import fs from 'node:fs';
36
+ import os from 'node:os';
37
+ import path from 'node:path';
38
+ import readline from 'node:readline';
39
+ import { TelegramHistorian } from './lib/telegram-historian.mjs';
40
+ import { TopicMemoryImporter } from './lib/topic-memory-importer.mjs';
41
+
42
+ function parseArgs(argv) {
43
+ const args = { _: [] };
44
+ for (let i = 2; i < argv.length; i++) {
45
+ const a = argv[i];
46
+ if (a === '--auth') args.auth = true;
47
+ else if (a === '--dry-run') args.dryRun = true;
48
+ else if (a === '--verbose' || a === '-v') args.verbose = true;
49
+ else if (a === '--chat') args.chat = argv[++i];
50
+ else if (a === '--topic') args.topic = Number(argv[++i]);
51
+ else if (a === '--db') args.db = argv[++i];
52
+ else if (a === '--secrets-path') args.secretsPath = argv[++i];
53
+ else if (a === '--min-message-id') args.minMessageId = Number(argv[++i]);
54
+ else if (a === '--phone') args.phone = argv[++i];
55
+ else if (a === '--code-file') args.codeFile = argv[++i];
56
+ else if (a === '--password-file') args.passwordFile = argv[++i];
57
+ else if (a === '--code-poll-seconds') args.codePollSeconds = Number(argv[++i]);
58
+ else if (a === '--help' || a === '-h') args.help = true;
59
+ else args._.push(a);
60
+ }
61
+ return args;
62
+ }
63
+
64
+ function pollForFileContent(filePath, opts = {}) {
65
+ const intervalMs = (opts.intervalSeconds ?? 2) * 1000;
66
+ const timeoutMs = (opts.timeoutSeconds ?? 600) * 1000;
67
+ const onWait = opts.onWait ?? (() => {});
68
+ return new Promise((resolve, reject) => {
69
+ const started = Date.now();
70
+ onWait(filePath);
71
+ const t = setInterval(() => {
72
+ try {
73
+ if (fs.existsSync(filePath)) {
74
+ const v = fs.readFileSync(filePath, 'utf8').trim();
75
+ if (v.length > 0) {
76
+ clearInterval(t);
77
+ // wipe the file after read so the next prompt requires a fresh write
78
+ try { fs.writeFileSync(filePath, ''); } catch {}
79
+ resolve(v);
80
+ return;
81
+ }
82
+ }
83
+ } catch {
84
+ // keep polling
85
+ }
86
+ if (Date.now() - started > timeoutMs) {
87
+ clearInterval(t);
88
+ reject(new Error(`Timed out waiting for ${filePath} after ${timeoutMs / 1000}s`));
89
+ }
90
+ }, intervalMs);
91
+ });
92
+ }
93
+
94
+ function helpText() {
95
+ return [
96
+ 'telegram-history-backfill — one-shot MTProto backfill into topic-memory.db',
97
+ '',
98
+ 'USAGE',
99
+ ' node scripts/telegram-history-backfill.mjs [--auth] [--chat <id>] [--topic <id>]',
100
+ ' [--dry-run] [--verbose]',
101
+ ' [--db <path>] [--secrets-path <path>]',
102
+ '',
103
+ 'FLAGS',
104
+ ' --auth One-time interactive auth (captures the session string)',
105
+ ' --chat <id> Supergroup id (e.g. -1003742343280) or @username',
106
+ ' --topic <id> Optional. Single forum topic id (skips listing topics)',
107
+ ' --dry-run Read-only; reports counts but does not write to db',
108
+ ' --verbose Per-page progress logs to stderr',
109
+ ' --db <path> Override topic-memory.db path (default: <agent>/.instar/topic-memory.db)',
110
+ ' --secrets-path <p> Override credentials file path',
111
+ ' --min-message-id N Skip messages with id ≤ N (resumes from a known watermark)',
112
+ '',
113
+ 'EXIT CODES',
114
+ ' 0 success',
115
+ ' 1 recoverable error (network, auth)',
116
+ ' 2 bad input (missing creds, invalid flag)',
117
+ ].join('\n');
118
+ }
119
+
120
+ function resolveSecretsPath(arg) {
121
+ if (arg) return arg;
122
+ return path.join(os.homedir(), '.local', 'share', 'instar-echo-secrets', 'telegram-mtproto.json');
123
+ }
124
+
125
+ function loadCredentials(secretsPath) {
126
+ const fromEnv = (process.env.INSTAR_TELEGRAM_API_ID && process.env.INSTAR_TELEGRAM_API_HASH)
127
+ ? {
128
+ apiId: Number(process.env.INSTAR_TELEGRAM_API_ID),
129
+ apiHash: process.env.INSTAR_TELEGRAM_API_HASH,
130
+ sessionFilePath:
131
+ process.env.INSTAR_TELEGRAM_SESSION_PATH
132
+ ?? path.join(os.homedir(), '.local', 'share', 'instar-echo-secrets', 'telegram-mtproto.session'),
133
+ }
134
+ : null;
135
+ if (fromEnv) return fromEnv;
136
+
137
+ if (!fs.existsSync(secretsPath)) {
138
+ throw new Error(
139
+ `No credentials found. Expected one of:\n` +
140
+ ` - file at ${secretsPath}\n` +
141
+ ` - env vars INSTAR_TELEGRAM_API_ID + INSTAR_TELEGRAM_API_HASH`,
142
+ );
143
+ }
144
+ const raw = JSON.parse(fs.readFileSync(secretsPath, 'utf8'));
145
+ if (!raw.api_id || !raw.api_hash) {
146
+ throw new Error(`Credentials file ${secretsPath} missing api_id or api_hash`);
147
+ }
148
+ return {
149
+ apiId: Number(raw.api_id),
150
+ apiHash: String(raw.api_hash),
151
+ sessionFilePath: raw.sessionFilePath
152
+ ? raw.sessionFilePath.replace(/^~/, os.homedir())
153
+ : path.join(path.dirname(secretsPath), 'telegram-mtproto.session'),
154
+ };
155
+ }
156
+
157
+ function resolveDbPath(arg) {
158
+ if (arg) return arg;
159
+ // Default: this worktree's parent agent home if we can find one. Fall back
160
+ // to the canonical path Echo uses.
161
+ const candidates = [
162
+ process.env.INSTAR_AGENT_HOME && path.join(process.env.INSTAR_AGENT_HOME, '.instar', 'topic-memory.db'),
163
+ path.join(os.homedir(), '.instar', 'agents', 'echo', '.instar', 'topic-memory.db'),
164
+ ].filter(Boolean);
165
+ for (const c of candidates) {
166
+ if (fs.existsSync(c)) return c;
167
+ }
168
+ throw new Error(`No topic-memory.db found. Tried: ${candidates.join(', ')}`);
169
+ }
170
+
171
+ function makeRl() {
172
+ return readline.createInterface({ input: process.stdin, output: process.stderr });
173
+ }
174
+
175
+ function ask(rl, prompt, opts = {}) {
176
+ return new Promise((resolve) => {
177
+ const wasMuted = opts.silent === true;
178
+ if (wasMuted) {
179
+ // crude muted prompt — does not echo
180
+ rl.question(prompt, (answer) => resolve(answer));
181
+ // intentionally not toggling stdout — gramjs internals handle this for
182
+ // the password case, and the SMS code is not strictly secret post-use.
183
+ } else {
184
+ rl.question(prompt, (answer) => resolve(answer));
185
+ }
186
+ });
187
+ }
188
+
189
+ async function runAuth(creds, opts) {
190
+ const historian = new TelegramHistorian(creds, { verbose: opts.verbose });
191
+
192
+ // Auth supports three driving modes:
193
+ // 1. Fully interactive (stdin readline) — original behaviour, used when
194
+ // a human sits at the terminal.
195
+ // 2. Non-interactive with phone via --phone and code via --code-file
196
+ // (the autonomous-agent path: the orchestrator writes the SMS code
197
+ // to the file when the operator sends it, and the script picks it
198
+ // up via a file-poll).
199
+ // 3. Mix: --phone provided non-interactively, code prompted via stdin.
200
+ const useStdinForPhone = !opts.phone;
201
+ const useStdinForCode = !opts.codeFile;
202
+ const usePasswordFile = Boolean(opts.passwordFile);
203
+ const rl = useStdinForPhone || useStdinForCode ? makeRl() : null;
204
+
205
+ try {
206
+ await historian.connect({
207
+ onPhoneNumber: async () => {
208
+ if (opts.phone) {
209
+ process.stderr.write(`[auth] using phone from --phone flag\n`);
210
+ return opts.phone.trim();
211
+ }
212
+ const v = await ask(rl, '[auth] Phone number (with country code, e.g. +1...): ');
213
+ return v.trim();
214
+ },
215
+ onCode: async () => {
216
+ if (opts.codeFile) {
217
+ process.stderr.write(
218
+ `[auth] AWAITING_SMS_CODE — write the code to ${opts.codeFile} (one line, digits only)\n`,
219
+ );
220
+ return await pollForFileContent(opts.codeFile, {
221
+ intervalSeconds: 2,
222
+ timeoutSeconds: opts.codePollSeconds ?? 600,
223
+ onWait: (p) => process.stderr.write(`[auth] polling ${p} every 2s (timeout 10m)\n`),
224
+ });
225
+ }
226
+ const v = await ask(rl, '[auth] SMS code: ');
227
+ return v.trim();
228
+ },
229
+ onPassword: async () => {
230
+ if (usePasswordFile) {
231
+ process.stderr.write(`[auth] reading 2FA password from ${opts.passwordFile}\n`);
232
+ const v = fs.readFileSync(opts.passwordFile, 'utf8').trim();
233
+ if (!v) throw new Error(`Password file ${opts.passwordFile} is empty`);
234
+ return v;
235
+ }
236
+ if (!rl) {
237
+ throw new Error('2FA password required but no readline + no --password-file');
238
+ }
239
+ const v = await ask(rl, '[auth] 2FA password (if enabled): ', { silent: true });
240
+ return v.trim();
241
+ },
242
+ onError: (err) => {
243
+ process.stderr.write(`[auth] error: ${err.message}\n`);
244
+ },
245
+ });
246
+ process.stderr.write('[auth] success — session string saved.\n');
247
+ } finally {
248
+ if (rl) rl.close();
249
+ await historian.disconnect();
250
+ }
251
+ }
252
+
253
+ async function runBackfill(creds, opts) {
254
+ if (!opts.chat) throw new Error('--chat is required (e.g. --chat -1003742343280)');
255
+ const dbPath = resolveDbPath(opts.db);
256
+ const historian = new TelegramHistorian(creds, { verbose: opts.verbose });
257
+
258
+ await historian.connect({
259
+ onPhoneNumber: async () => {
260
+ throw new Error('No saved session — run --auth first');
261
+ },
262
+ onCode: async () => {
263
+ throw new Error('No saved session — run --auth first');
264
+ },
265
+ });
266
+
267
+ const importer = opts.dryRun ? null : new TopicMemoryImporter(dbPath);
268
+ const totals = { inserted: 0, skipped: 0, fetched: 0, topicsTouched: 0 };
269
+
270
+ try {
271
+ let topics;
272
+ if (opts.topic) {
273
+ topics = [{ id: opts.topic, title: `(single topic ${opts.topic})`, topMessageId: opts.topic, closed: false }];
274
+ } else {
275
+ process.stderr.write('[backfill] listing forum topics...\n');
276
+ topics = await historian.listForumTopics(opts.chat);
277
+ process.stderr.write(`[backfill] found ${topics.length} topics\n`);
278
+ }
279
+
280
+ for (const t of topics) {
281
+ process.stderr.write(`[backfill] topic ${t.id} (${t.title})...\n`);
282
+ let batch = [];
283
+ let topicFetched = 0;
284
+ for await (const msg of historian.iterTopicMessages(opts.chat, t.id, {
285
+ minMessageId: opts.minMessageId ?? 0,
286
+ batchSize: 100,
287
+ })) {
288
+ batch.push(msg);
289
+ topicFetched++;
290
+ totals.fetched++;
291
+ if (batch.length >= 200) {
292
+ if (importer) {
293
+ const r = importer.importBatch(batch);
294
+ totals.inserted += r.inserted;
295
+ totals.skipped += r.skipped;
296
+ }
297
+ batch = [];
298
+ }
299
+ }
300
+ if (batch.length && importer) {
301
+ const r = importer.importBatch(batch);
302
+ totals.inserted += r.inserted;
303
+ totals.skipped += r.skipped;
304
+ }
305
+ totals.topicsTouched++;
306
+ process.stderr.write(
307
+ `[backfill] topic ${t.id} done: fetched=${topicFetched}` +
308
+ (importer ? ` inserted=${totals.inserted} skipped=${totals.skipped}` : ' (dry-run)') +
309
+ '\n',
310
+ );
311
+ }
312
+ } finally {
313
+ if (importer) importer.close();
314
+ await historian.disconnect();
315
+ }
316
+
317
+ process.stderr.write(
318
+ `[backfill] summary: topics=${totals.topicsTouched} fetched=${totals.fetched} ` +
319
+ `inserted=${totals.inserted} skipped=${totals.skipped} (dry-run=${Boolean(opts.dryRun)})\n`,
320
+ );
321
+ process.stdout.write(JSON.stringify(totals) + '\n');
322
+ }
323
+
324
+ async function main() {
325
+ const args = parseArgs(process.argv);
326
+ if (args.help) {
327
+ process.stdout.write(helpText() + '\n');
328
+ return 0;
329
+ }
330
+ const secretsPath = resolveSecretsPath(args.secretsPath);
331
+ let creds;
332
+ try {
333
+ creds = loadCredentials(secretsPath);
334
+ } catch (err) {
335
+ process.stderr.write(`[creds] ${err.message}\n`);
336
+ return 2;
337
+ }
338
+ if (args.auth) {
339
+ await runAuth(creds, args);
340
+ return 0;
341
+ }
342
+ if (args.chat) {
343
+ await runBackfill(creds, args);
344
+ return 0;
345
+ }
346
+ process.stderr.write(helpText() + '\n');
347
+ return 2;
348
+ }
349
+
350
+ main().then((code) => process.exit(code ?? 0)).catch((err) => {
351
+ process.stderr.write(`[fatal] ${err.stack ?? err.message}\n`);
352
+ process.exit(1);
353
+ });