@imadtg/tgsm 0.0.1

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/dist/index.js ADDED
@@ -0,0 +1,1119 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { createInterface } from "readline/promises";
5
+ import process2 from "process";
6
+ import { Command } from "commander";
7
+
8
+ // ../core/src/errors.ts
9
+ var TgsmError = class extends Error {
10
+ code;
11
+ retryable;
12
+ suggestion;
13
+ constructor(shape) {
14
+ super(shape.message);
15
+ this.name = "TgsmError";
16
+ this.code = shape.code;
17
+ this.retryable = shape.retryable;
18
+ this.suggestion = shape.suggestion;
19
+ }
20
+ toJSON() {
21
+ return {
22
+ code: this.code,
23
+ message: this.message,
24
+ retryable: this.retryable,
25
+ suggestion: this.suggestion
26
+ };
27
+ }
28
+ };
29
+
30
+ // ../core/src/paths.ts
31
+ import os from "os";
32
+ import path from "path";
33
+ import { mkdir } from "fs/promises";
34
+ function getHomeDir(homeDir) {
35
+ return homeDir ?? process.env.TGSM_HOME ?? path.join(os.homedir(), ".tgsm");
36
+ }
37
+ function getAccountName(account) {
38
+ return account ?? "default";
39
+ }
40
+ function getAccountDir(options = {}) {
41
+ return path.join(getHomeDir(options.homeDir), getAccountName(options.account));
42
+ }
43
+ function getCachePath(options = {}) {
44
+ return path.join(getAccountDir(options), "cache.json");
45
+ }
46
+ async function ensureAccountDir(options = {}) {
47
+ const accountDir = getAccountDir(options);
48
+ await mkdir(accountDir, { recursive: true });
49
+ return accountDir;
50
+ }
51
+
52
+ // ../core/src/cache.ts
53
+ import { readFile, writeFile } from "fs/promises";
54
+ var EMPTY_CACHE = {
55
+ version: 1,
56
+ backend: "fixture",
57
+ account: null,
58
+ synced_at: null,
59
+ dialogs: [],
60
+ messages: []
61
+ };
62
+ async function readCache(cachePath) {
63
+ try {
64
+ const raw = await readFile(cachePath, "utf8");
65
+ return JSON.parse(raw);
66
+ } catch (error) {
67
+ if (error.code === "ENOENT") {
68
+ return EMPTY_CACHE;
69
+ }
70
+ throw error;
71
+ }
72
+ }
73
+ async function writeCache(cachePath, snapshot) {
74
+ const state = {
75
+ version: 1,
76
+ backend: snapshot.backend,
77
+ account: snapshot.account,
78
+ synced_at: snapshot.synced_at,
79
+ dialogs: snapshot.dialogs,
80
+ messages: snapshot.messages
81
+ };
82
+ await writeFile(cachePath, `${JSON.stringify(state, null, 2)}
83
+ `, "utf8");
84
+ }
85
+
86
+ // ../core/src/fixture.ts
87
+ import { readFile as readFile2 } from "fs/promises";
88
+ var FixtureSource = class {
89
+ constructor(fixturePath) {
90
+ this.fixturePath = fixturePath;
91
+ }
92
+ backend = "fixture";
93
+ async sync() {
94
+ const raw = await readFile2(this.fixturePath, "utf8");
95
+ const parsed = JSON.parse(raw);
96
+ if (!Array.isArray(parsed.dialogs) || !Array.isArray(parsed.messages)) {
97
+ throw new TgsmError({
98
+ code: "INVALID_FIXTURE",
99
+ message: `Fixture at ${this.fixturePath} is missing dialogs/messages arrays`,
100
+ retryable: false
101
+ });
102
+ }
103
+ const syncedAt = (/* @__PURE__ */ new Date()).toISOString();
104
+ return {
105
+ backend: this.backend,
106
+ account: parsed.account ?? {
107
+ id: "fixture",
108
+ display_name: "Fixture Account"
109
+ },
110
+ dialogs: parsed.dialogs.map((dialog) => ({
111
+ ...dialog,
112
+ last_synced_at: syncedAt
113
+ })),
114
+ messages: parsed.messages,
115
+ synced_at: syncedAt
116
+ };
117
+ }
118
+ async authStatus() {
119
+ return {
120
+ authenticated: true,
121
+ user: {
122
+ id: "fixture",
123
+ display_name: "Fixture Account"
124
+ }
125
+ };
126
+ }
127
+ };
128
+
129
+ // ../core/src/service.ts
130
+ import path2 from "path";
131
+ var DEFAULT_CHRONOLOGY_LIMIT = 20;
132
+ var TgsmService = class {
133
+ constructor(options) {
134
+ this.options = options;
135
+ }
136
+ async authLogin(input) {
137
+ if (!this.options.source?.authLogin) {
138
+ throw new TgsmError({
139
+ code: "AUTH_UNSUPPORTED",
140
+ message: "This backend does not support auth login.",
141
+ retryable: false
142
+ });
143
+ }
144
+ return this.options.source.authLogin(this.accountDir(), input);
145
+ }
146
+ async authStatus() {
147
+ if (!this.options.source?.authStatus) {
148
+ return {
149
+ authenticated: false,
150
+ user: null
151
+ };
152
+ }
153
+ return this.options.source.authStatus(this.accountDir());
154
+ }
155
+ async sync() {
156
+ if (!this.options.source) {
157
+ throw new TgsmError({
158
+ code: "SYNC_UNAVAILABLE",
159
+ message: "No source backend configured for sync.",
160
+ retryable: false
161
+ });
162
+ }
163
+ const snapshot = await this.options.source.sync(this.accountDir());
164
+ await writeCache(this.options.cachePath, snapshot);
165
+ return {
166
+ backend: snapshot.backend,
167
+ synced_at: snapshot.synced_at,
168
+ synced_dialogs: snapshot.dialogs.length,
169
+ synced_messages: snapshot.messages.length
170
+ };
171
+ }
172
+ async listSavedDialogs() {
173
+ const cache = await this.loadCache();
174
+ return [...cache.dialogs].sort((a, b) => {
175
+ const left = a.last_synced_at ?? "";
176
+ const right = b.last_synced_at ?? "";
177
+ return right.localeCompare(left) || a.saved_peer_id.localeCompare(b.saved_peer_id);
178
+ });
179
+ }
180
+ async listMessages(options = {}) {
181
+ const cache = await this.loadCache();
182
+ const indexes = this.buildIndexes(cache);
183
+ const dialogId = options.dialog ?? null;
184
+ if (dialogId && !indexes.dialogsById.has(dialogId)) {
185
+ throw new TgsmError({
186
+ code: "DIALOG_NOT_FOUND",
187
+ message: `Saved dialog ${dialogId} was not found.`,
188
+ retryable: false,
189
+ suggestion: "Run `tgsm saved-dialogs list` to inspect available dialogs."
190
+ });
191
+ }
192
+ const scoped = dialogId ? indexes.messagesByDialog.get(dialogId) ?? [] : cache.messages;
193
+ const filtered = options.search ? scoped.filter((message) => matchesSearch(message, options.search)) : scoped;
194
+ const sorted = [...filtered].sort(compareByDateDesc);
195
+ const limit = Math.max(1, options.limit ?? 20);
196
+ const offset = decodeCursor(options.cursor);
197
+ const slice = sorted.slice(offset, offset + limit);
198
+ return {
199
+ items: slice.map((message) => this.toMessageListItem(message, indexes)),
200
+ scope: dialogId ? "saved_dialog" : "all_saved_dialogs",
201
+ saved_peer_id: dialogId,
202
+ next_cursor: offset + limit < sorted.length ? encodeCursor(offset + limit) : null,
203
+ result_count: filtered.length
204
+ };
205
+ }
206
+ async getMessage(messageId, options = {}) {
207
+ const cache = await this.loadCache();
208
+ const indexes = this.buildIndexes(cache);
209
+ const target = this.findMessage(indexes, messageId, options.dialog);
210
+ return this.buildContextBundle(target, indexes);
211
+ }
212
+ async getContext(messageId, options = {}) {
213
+ return this.getMessage(messageId, options);
214
+ }
215
+ async inspectThread(messageId, options = {}) {
216
+ const cache = await this.loadCache();
217
+ const indexes = this.buildIndexes(cache);
218
+ const target = this.findMessage(indexes, messageId, options.dialog);
219
+ const root = this.findThreadRoot(target, indexes);
220
+ const dialog = indexes.dialogsById.get(root.saved_peer_id);
221
+ if (!dialog) {
222
+ throw new TgsmError({
223
+ code: "DIALOG_NOT_FOUND",
224
+ message: `Saved dialog ${root.saved_peer_id} was not found.`,
225
+ retryable: false
226
+ });
227
+ }
228
+ return {
229
+ dialog,
230
+ root: this.toEnvelope(root, indexes),
231
+ nodes: [this.buildThreadNode(root, indexes, 0)]
232
+ };
233
+ }
234
+ accountDir() {
235
+ return path2.dirname(this.options.cachePath);
236
+ }
237
+ async loadCache() {
238
+ return readCache(this.options.cachePath);
239
+ }
240
+ buildIndexes(cache) {
241
+ const dialogsById = new Map(
242
+ cache.dialogs.map((dialog) => [dialog.saved_peer_id, dialog])
243
+ );
244
+ const messagesByKey = /* @__PURE__ */ new Map();
245
+ const messagesByGlobalId = /* @__PURE__ */ new Map();
246
+ const messagesByDialog = /* @__PURE__ */ new Map();
247
+ const backreplyIndex = /* @__PURE__ */ new Map();
248
+ for (const message of cache.messages) {
249
+ messagesByKey.set(makeMessageKey(message.saved_peer_id, message.message_id), message);
250
+ const existingGlobal = messagesByGlobalId.get(message.message_id) ?? [];
251
+ existingGlobal.push(message);
252
+ messagesByGlobalId.set(message.message_id, existingGlobal);
253
+ const dialogMessages = messagesByDialog.get(message.saved_peer_id) ?? [];
254
+ dialogMessages.push(message);
255
+ messagesByDialog.set(message.saved_peer_id, dialogMessages);
256
+ if (message.reply_to_message_id !== null) {
257
+ const replyDialog = message.reply_to_saved_peer_id ?? message.saved_peer_id;
258
+ const key = makeMessageKey(replyDialog, message.reply_to_message_id);
259
+ const children = backreplyIndex.get(key) ?? [];
260
+ children.push(message);
261
+ backreplyIndex.set(key, children);
262
+ }
263
+ }
264
+ for (const records of messagesByDialog.values()) {
265
+ records.sort(compareByDateAsc);
266
+ }
267
+ for (const records of backreplyIndex.values()) {
268
+ records.sort(compareByDateAsc);
269
+ }
270
+ return {
271
+ dialogsById,
272
+ messagesByKey,
273
+ messagesByGlobalId,
274
+ messagesByDialog,
275
+ backreplyIndex
276
+ };
277
+ }
278
+ findMessage(indexes, messageId, dialog) {
279
+ if (dialog) {
280
+ const scoped = indexes.messagesByKey.get(makeMessageKey(dialog, messageId));
281
+ if (!scoped) {
282
+ throw new TgsmError({
283
+ code: "MESSAGE_NOT_FOUND",
284
+ message: `Message ${messageId} was not found in dialog ${dialog}.`,
285
+ retryable: false,
286
+ suggestion: "Run `tgsm messages list --dialog <saved_peer_id>` to inspect the dialog."
287
+ });
288
+ }
289
+ return scoped;
290
+ }
291
+ const candidates = indexes.messagesByGlobalId.get(messageId) ?? [];
292
+ if (candidates.length === 0) {
293
+ throw new TgsmError({
294
+ code: "MESSAGE_NOT_FOUND",
295
+ message: `Message ${messageId} was not found in the selected scope.`,
296
+ retryable: false,
297
+ suggestion: "Run `tgsm messages list` or narrow the dialog scope."
298
+ });
299
+ }
300
+ if (candidates.length > 1) {
301
+ throw new TgsmError({
302
+ code: "AMBIGUOUS_MESSAGE_ID",
303
+ message: `Message ID ${messageId} exists in multiple saved dialogs.`,
304
+ retryable: false,
305
+ suggestion: "Pass `--dialog <saved_peer_id>` to disambiguate."
306
+ });
307
+ }
308
+ return candidates[0];
309
+ }
310
+ buildContextBundle(target, indexes) {
311
+ const dialog = indexes.dialogsById.get(target.saved_peer_id);
312
+ if (!dialog) {
313
+ throw new TgsmError({
314
+ code: "DIALOG_NOT_FOUND",
315
+ message: `Saved dialog ${target.saved_peer_id} was not found.`,
316
+ retryable: false
317
+ });
318
+ }
319
+ const chronological = indexes.messagesByDialog.get(target.saved_peer_id) ?? [];
320
+ const targetIndex = chronological.findIndex(
321
+ (message) => message.message_id === target.message_id && message.saved_peer_id === target.saved_peer_id
322
+ );
323
+ const maxEachSide = Math.floor(DEFAULT_CHRONOLOGY_LIMIT / 2);
324
+ const before = chronological.slice(Math.max(0, targetIndex - maxEachSide), targetIndex);
325
+ const after = chronological.slice(targetIndex + 1, targetIndex + 1 + maxEachSide);
326
+ const replyParent = target.reply_to_message_id ? indexes.messagesByKey.get(
327
+ makeMessageKey(target.reply_to_saved_peer_id ?? target.saved_peer_id, target.reply_to_message_id)
328
+ ) ?? null : null;
329
+ const directBackreplies = indexes.backreplyIndex.get(makeMessageKey(target.saved_peer_id, target.message_id)) ?? [];
330
+ const notes = [];
331
+ if (target.reply_to_message_id !== null && !replyParent) {
332
+ notes.push(`Reply target #${target.reply_to_message_id} could not be resolved in cache.`);
333
+ }
334
+ const contexts = /* @__PURE__ */ new Map();
335
+ const pushContext = (message, role) => {
336
+ const key = makeMessageKey(message.saved_peer_id, message.message_id);
337
+ const existing = contexts.get(key);
338
+ if (existing) {
339
+ if (!existing.context_roles.includes(role)) {
340
+ existing.context_roles.push(role);
341
+ }
342
+ return;
343
+ }
344
+ contexts.set(key, {
345
+ message: this.toEnvelope(message, indexes),
346
+ context_roles: [role]
347
+ });
348
+ };
349
+ for (const message of before) pushContext(message, "chronology_before");
350
+ if (replyParent) pushContext(replyParent, "reply_parent");
351
+ pushContext(target, "target");
352
+ for (const message of directBackreplies) pushContext(message, "backreply_child");
353
+ for (const message of after) pushContext(message, "chronology_after");
354
+ const contextMessages = [...contexts.values()].sort((left, right) => {
355
+ const rank = (roles) => {
356
+ if (roles.includes("chronology_before")) return 1;
357
+ if (roles.includes("reply_parent")) return 2;
358
+ if (roles.includes("target")) return 3;
359
+ if (roles.includes("backreply_child")) return 4;
360
+ return 5;
361
+ };
362
+ return rank(left.context_roles) - rank(right.context_roles) || left.message.date.localeCompare(right.message.date) || left.message.message_id - right.message.message_id;
363
+ });
364
+ return {
365
+ target: this.toEnvelope(target, indexes),
366
+ dialog,
367
+ context_messages: contextMessages,
368
+ window: {
369
+ chronology_total_limit: DEFAULT_CHRONOLOGY_LIMIT,
370
+ chronology_before_count: before.length,
371
+ chronology_after_count: after.length,
372
+ direct_reply_ancestor_included: Boolean(replyParent),
373
+ direct_backreply_count_included: directBackreplies.length
374
+ },
375
+ notes
376
+ };
377
+ }
378
+ toMessageListItem(message, indexes) {
379
+ const dialog = indexes.dialogsById.get(message.saved_peer_id);
380
+ const directBackreplyCount = indexes.backreplyIndex.get(makeMessageKey(message.saved_peer_id, message.message_id))?.length ?? 0;
381
+ return {
382
+ message_id: message.message_id,
383
+ saved_peer_id: message.saved_peer_id,
384
+ dialog_title: dialog?.title ?? message.saved_peer_id,
385
+ date: message.date,
386
+ text_preview: previewText(message.text),
387
+ from_self: message.from_self,
388
+ forwarded: message.forwarded,
389
+ reply_to_message_id: message.reply_to_message_id,
390
+ direct_backreply_count: directBackreplyCount,
391
+ queued_for_delete: message.queued_for_delete
392
+ };
393
+ }
394
+ toEnvelope(message, indexes) {
395
+ const replyTarget = message.reply_to_message_id !== null ? indexes.messagesByKey.get(
396
+ makeMessageKey(message.reply_to_saved_peer_id ?? message.saved_peer_id, message.reply_to_message_id)
397
+ ) ?? null : null;
398
+ const directBackreplies = indexes.backreplyIndex.get(makeMessageKey(message.saved_peer_id, message.message_id)) ?? [];
399
+ const reply = message.reply_to_message_id === null ? {
400
+ exists: false,
401
+ target: null,
402
+ status: "resolved"
403
+ } : {
404
+ exists: true,
405
+ target: replyTarget ? this.toMessageRef(replyTarget, "reply_to") : {
406
+ message_id: message.reply_to_message_id,
407
+ saved_peer_id: message.reply_to_saved_peer_id ?? message.saved_peer_id,
408
+ text_preview: "(missing from cache)",
409
+ date: "",
410
+ relationship: "reply_to"
411
+ },
412
+ status: replyTarget ? "resolved" : "missing"
413
+ };
414
+ const backreplies = directBackreplies.map((child) => ({
415
+ message: this.toMessageRef(child, "backreply"),
416
+ thread_depth_from_target: 1,
417
+ subtree_size_hint: this.countDescendants(child, indexes)
418
+ }));
419
+ return {
420
+ message_id: message.message_id,
421
+ saved_peer_id: message.saved_peer_id,
422
+ date: message.date,
423
+ edit_date: message.edit_date,
424
+ text: message.text,
425
+ text_preview: previewText(message.text),
426
+ from_self: message.from_self,
427
+ forwarded: message.forwarded,
428
+ forward_origin: message.forward_origin,
429
+ reply,
430
+ backreplies,
431
+ thread: {
432
+ ancestors_known: this.countAncestors(message, indexes),
433
+ direct_backreply_count: backreplies.length,
434
+ descendant_count_hint: this.countDescendants(message, indexes),
435
+ max_known_depth: this.maxDepth(message, indexes)
436
+ },
437
+ links: message.links,
438
+ media_summary: message.media_summary,
439
+ queued_for_delete: message.queued_for_delete
440
+ };
441
+ }
442
+ toMessageRef(message, relationship) {
443
+ return {
444
+ message_id: message.message_id,
445
+ saved_peer_id: message.saved_peer_id,
446
+ text_preview: previewText(message.text),
447
+ date: message.date,
448
+ relationship
449
+ };
450
+ }
451
+ findThreadRoot(message, indexes) {
452
+ let current = message;
453
+ while (current.reply_to_message_id !== null) {
454
+ const parent = indexes.messagesByKey.get(
455
+ makeMessageKey(current.reply_to_saved_peer_id ?? current.saved_peer_id, current.reply_to_message_id)
456
+ );
457
+ if (!parent || parent.saved_peer_id !== current.saved_peer_id) {
458
+ break;
459
+ }
460
+ current = parent;
461
+ }
462
+ return current;
463
+ }
464
+ buildThreadNode(message, indexes, depth) {
465
+ const children = indexes.backreplyIndex.get(makeMessageKey(message.saved_peer_id, message.message_id)) ?? [];
466
+ return {
467
+ message: this.toEnvelope(message, indexes),
468
+ depth,
469
+ children: children.map((child) => this.buildThreadNode(child, indexes, depth + 1))
470
+ };
471
+ }
472
+ countAncestors(message, indexes) {
473
+ let count = 0;
474
+ let current = message;
475
+ while (current.reply_to_message_id !== null) {
476
+ const parent = indexes.messagesByKey.get(
477
+ makeMessageKey(current.reply_to_saved_peer_id ?? current.saved_peer_id, current.reply_to_message_id)
478
+ );
479
+ if (!parent) break;
480
+ count += 1;
481
+ current = parent;
482
+ }
483
+ return count;
484
+ }
485
+ countDescendants(message, indexes) {
486
+ const children = indexes.backreplyIndex.get(makeMessageKey(message.saved_peer_id, message.message_id)) ?? [];
487
+ return children.reduce((count, child) => count + 1 + this.countDescendants(child, indexes), 0);
488
+ }
489
+ maxDepth(message, indexes) {
490
+ const children = indexes.backreplyIndex.get(makeMessageKey(message.saved_peer_id, message.message_id)) ?? [];
491
+ if (children.length === 0) return 0;
492
+ return 1 + Math.max(...children.map((child) => this.maxDepth(child, indexes)));
493
+ }
494
+ };
495
+ function makeMessageKey(savedPeerId, messageId) {
496
+ return `${savedPeerId}:${messageId}`;
497
+ }
498
+ function encodeCursor(offset) {
499
+ return Buffer.from(String(offset), "utf8").toString("base64url");
500
+ }
501
+ function decodeCursor(cursor) {
502
+ if (!cursor) return 0;
503
+ try {
504
+ const value = Number(Buffer.from(cursor, "base64url").toString("utf8"));
505
+ return Number.isFinite(value) && value >= 0 ? value : 0;
506
+ } catch {
507
+ return 0;
508
+ }
509
+ }
510
+ function compareByDateAsc(left, right) {
511
+ return left.date.localeCompare(right.date) || left.message_id - right.message_id;
512
+ }
513
+ function compareByDateDesc(left, right) {
514
+ return right.date.localeCompare(left.date) || right.message_id - left.message_id;
515
+ }
516
+ function matchesSearch(message, query) {
517
+ const normalized = query.trim().toLowerCase();
518
+ if (!normalized) return true;
519
+ return [
520
+ message.text,
521
+ message.forward_origin?.title ?? "",
522
+ message.saved_peer_id
523
+ ].join("\n").toLowerCase().includes(normalized);
524
+ }
525
+ function previewText(text, limit = 80) {
526
+ const normalized = text.replace(/\s+/g, " ").trim();
527
+ if (!normalized) return "(no text)";
528
+ return normalized.length <= limit ? normalized : `${normalized.slice(0, limit - 1)}\u2026`;
529
+ }
530
+
531
+ // ../core/src/telegram.ts
532
+ import path3 from "path";
533
+ import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
534
+ import { TelegramClient, getMarkedPeerId } from "@mtcute/node";
535
+ var TelegramSource = class {
536
+ backend = "telegram";
537
+ async authLogin(accountDir, input) {
538
+ const config = {
539
+ apiId: input.apiId,
540
+ apiHash: input.apiHash,
541
+ phone: input.phone
542
+ };
543
+ await saveTelegramConfig(accountDir, config);
544
+ const client = new TelegramClient({
545
+ apiId: config.apiId,
546
+ apiHash: config.apiHash,
547
+ storage: path3.join(accountDir, "mtcute-session")
548
+ });
549
+ try {
550
+ const user = await client.start({
551
+ phone: input.phone,
552
+ code: input.code,
553
+ password: input.password || void 0
554
+ });
555
+ return {
556
+ authenticated: true,
557
+ user: {
558
+ id: String(user.id),
559
+ display_name: user.displayName
560
+ }
561
+ };
562
+ } catch (error) {
563
+ throw new TgsmError({
564
+ code: "AUTH_FAILED",
565
+ message: error instanceof Error ? error.message : "Telegram auth failed.",
566
+ retryable: false
567
+ });
568
+ } finally {
569
+ }
570
+ }
571
+ async authStatus(accountDir) {
572
+ const config = await loadTelegramConfig(accountDir);
573
+ if (!config) {
574
+ return {
575
+ authenticated: false,
576
+ user: null
577
+ };
578
+ }
579
+ const client = new TelegramClient({
580
+ apiId: config.apiId,
581
+ apiHash: config.apiHash,
582
+ storage: path3.join(accountDir, "mtcute-session")
583
+ });
584
+ try {
585
+ await client.start({});
586
+ const me = await client.getMe();
587
+ return {
588
+ authenticated: true,
589
+ user: {
590
+ id: String(me.id),
591
+ display_name: me.displayName
592
+ }
593
+ };
594
+ } catch {
595
+ return {
596
+ authenticated: false,
597
+ user: null
598
+ };
599
+ } finally {
600
+ }
601
+ }
602
+ async sync(accountDir) {
603
+ const config = await loadTelegramConfig(accountDir);
604
+ if (!config) {
605
+ throw new TgsmError({
606
+ code: "AUTH_REQUIRED",
607
+ message: "Telegram credentials are not configured.",
608
+ retryable: false,
609
+ suggestion: "Run `tgsm auth login` first."
610
+ });
611
+ }
612
+ const client = new TelegramClient({
613
+ apiId: config.apiId,
614
+ apiHash: config.apiHash,
615
+ storage: path3.join(accountDir, "mtcute-session")
616
+ });
617
+ try {
618
+ const me = await client.start({});
619
+ const syncedAt = (/* @__PURE__ */ new Date()).toISOString();
620
+ const dialogsResponse = await client.call({
621
+ _: "messages.getSavedDialogs",
622
+ excludePinned: false,
623
+ offsetDate: 0,
624
+ offsetId: 0,
625
+ offsetPeer: { _: "inputPeerEmpty" },
626
+ limit: 1e3,
627
+ hash: 0
628
+ });
629
+ if (dialogsResponse._ === "messages.savedDialogsNotModified") {
630
+ return {
631
+ backend: this.backend,
632
+ account: {
633
+ id: String(me.id),
634
+ display_name: me.displayName
635
+ },
636
+ dialogs: [],
637
+ messages: [],
638
+ synced_at: syncedAt
639
+ };
640
+ }
641
+ const lookup = {
642
+ users: /* @__PURE__ */ new Map(),
643
+ chats: /* @__PURE__ */ new Map(),
644
+ selfUserId: Number(me.id)
645
+ };
646
+ ingestEntities(lookup, dialogsResponse.users, dialogsResponse.chats);
647
+ const dialogs = [];
648
+ const messagesByKey = /* @__PURE__ */ new Map();
649
+ for (const dialog of dialogsResponse.dialogs) {
650
+ if (dialog._ !== "savedDialog") continue;
651
+ const savedPeerId = savedPeerIdFromPeer(dialog.peer, lookup.selfUserId);
652
+ const peerInput = await client.resolvePeer(getMarkedPeerId(dialog.peer));
653
+ const title = peerTitle(dialog.peer, lookup);
654
+ const messages2 = await fetchSavedHistory(client, peerInput, lookup);
655
+ const topMessage = messages2.find((message) => message.message_id === dialog.topMessage) ?? messages2[0] ?? null;
656
+ dialogs.push({
657
+ saved_peer_id: savedPeerId,
658
+ kind: peerKindFromPeer(dialog.peer, lookup.selfUserId),
659
+ title,
660
+ top_message_id: dialog.topMessage ?? topMessage?.message_id ?? null,
661
+ top_text_preview: topMessage ? previewText2(topMessage.text) : null,
662
+ message_count: messages2.length,
663
+ pinned: dialog.pinned ?? null,
664
+ last_synced_at: syncedAt
665
+ });
666
+ for (const message of messages2) {
667
+ messagesByKey.set(`${message.saved_peer_id}:${message.message_id}`, message);
668
+ }
669
+ }
670
+ return {
671
+ backend: this.backend,
672
+ account: {
673
+ id: String(me.id),
674
+ display_name: me.displayName
675
+ },
676
+ dialogs,
677
+ messages: [...messagesByKey.values()].sort(
678
+ (a, b) => a.saved_peer_id.localeCompare(b.saved_peer_id) || a.date.localeCompare(b.date) || a.message_id - b.message_id
679
+ ),
680
+ synced_at: syncedAt
681
+ };
682
+ } catch (error) {
683
+ throw new TgsmError({
684
+ code: "TELEGRAM_SYNC_FAILED",
685
+ message: error instanceof Error ? error.message : "Telegram sync failed.",
686
+ retryable: true
687
+ });
688
+ } finally {
689
+ }
690
+ }
691
+ };
692
+ async function fetchSavedHistory(client, peer, lookup) {
693
+ let offsetId = 0;
694
+ let offsetDate = 0;
695
+ const records = /* @__PURE__ */ new Map();
696
+ while (true) {
697
+ const history = await client.call({
698
+ _: "messages.getSavedHistory",
699
+ peer,
700
+ offsetId,
701
+ offsetDate,
702
+ addOffset: 0,
703
+ limit: 100,
704
+ maxId: 0,
705
+ minId: 0,
706
+ hash: 0
707
+ });
708
+ if (!("messages" in history)) {
709
+ break;
710
+ }
711
+ ingestEntities(lookup, history.users ?? [], history.chats ?? []);
712
+ const rawMessages = history.messages.filter(
713
+ (message) => message._ === "message" || message._ === "messageService"
714
+ );
715
+ if (rawMessages.length === 0) {
716
+ break;
717
+ }
718
+ for (const raw of rawMessages) {
719
+ const record = normalizeRawMessage(raw, lookup);
720
+ records.set(`${record.saved_peer_id}:${record.message_id}`, record);
721
+ }
722
+ const oldest = rawMessages[rawMessages.length - 1];
723
+ offsetId = oldest.id;
724
+ offsetDate = oldest.date;
725
+ if (rawMessages.length < 100) {
726
+ break;
727
+ }
728
+ }
729
+ return [...records.values()].sort((a, b) => a.date.localeCompare(b.date) || a.message_id - b.message_id);
730
+ }
731
+ function normalizeRawMessage(raw, lookup) {
732
+ const savedPeerId = savedPeerIdFromPeer(raw.peerId, lookup.selfUserId);
733
+ const replyHeader = "replyTo" in raw && raw.replyTo && raw.replyTo._ === "messageReplyHeader" ? raw.replyTo : null;
734
+ const replyPeer = replyHeader?.replyToPeerId ?? raw.peerId;
735
+ const fwdFrom = "fwdFrom" in raw ? raw.fwdFrom : void 0;
736
+ const editDate = "editDate" in raw ? raw.editDate : void 0;
737
+ const media = "media" in raw ? raw.media : void 0;
738
+ const text = "message" in raw ? raw.message : "";
739
+ return {
740
+ message_id: raw.id,
741
+ saved_peer_id: savedPeerId,
742
+ date: new Date(raw.date * 1e3).toISOString(),
743
+ edit_date: editDate ? new Date(editDate * 1e3).toISOString() : null,
744
+ text,
745
+ from_self: peerIsSelf("fromId" in raw ? raw.fromId : null, lookup.selfUserId),
746
+ forwarded: Boolean(fwdFrom),
747
+ forward_origin: fwdFrom ? forwardOriginFromHeader(fwdFrom, lookup) : null,
748
+ reply_to_message_id: replyHeader?.replyToMsgId ?? null,
749
+ reply_to_saved_peer_id: replyHeader?.replyToMsgId ? savedPeerIdFromPeer(replyPeer, lookup.selfUserId) : null,
750
+ links: extractLinks(text),
751
+ media_summary: mediaSummary(media),
752
+ queued_for_delete: false
753
+ };
754
+ }
755
+ function forwardOriginFromHeader(header, lookup) {
756
+ if (header.savedFromPeer) {
757
+ return {
758
+ saved_peer_id: savedPeerIdFromPeer(header.savedFromPeer, lookup.selfUserId),
759
+ title: peerTitle(header.savedFromPeer, lookup),
760
+ message_id: header.savedFromMsgId ?? null
761
+ };
762
+ }
763
+ if (header.fromId) {
764
+ return {
765
+ saved_peer_id: savedPeerIdFromPeer(header.fromId, lookup.selfUserId),
766
+ title: header.fromName ?? peerTitle(header.fromId, lookup),
767
+ message_id: header.savedFromMsgId ?? null
768
+ };
769
+ }
770
+ return {
771
+ saved_peer_id: null,
772
+ title: header.fromName ?? header.savedFromName ?? null,
773
+ message_id: header.savedFromMsgId ?? null
774
+ };
775
+ }
776
+ function peerIsSelf(peer, selfUserId) {
777
+ return Boolean(peer && peer._ === "peerUser" && peer.userId === selfUserId);
778
+ }
779
+ function peerKindFromPeer(peer, selfUserId) {
780
+ if (peer._ === "peerUser" && peer.userId === selfUserId) return "self";
781
+ if (peer._ === "peerUser" || peer._ === "peerChat") return "peer";
782
+ if (peer._ === "peerChannel") return "channel";
783
+ return "unknown";
784
+ }
785
+ function savedPeerIdFromPeer(peer, selfUserId) {
786
+ if (peer._ === "peerUser" && peer.userId === selfUserId) {
787
+ return "self";
788
+ }
789
+ if (peer._ === "peerUser") return `user:${peer.userId}`;
790
+ if (peer._ === "peerChat") return `chat:${peer.chatId}`;
791
+ if (peer._ === "peerChannel") return `channel:${peer.channelId}`;
792
+ return `unknown:${getMarkedPeerId(peer)}`;
793
+ }
794
+ function peerTitle(peer, lookup) {
795
+ if (peer._ === "peerUser") {
796
+ if (peer.userId === lookup.selfUserId) return "Self";
797
+ const user = lookup.users.get(peer.userId);
798
+ const firstName = user && user._ === "user" ? user.firstName ?? "" : "";
799
+ const lastName = user && user._ === "user" ? user.lastName ?? "" : "";
800
+ const username = user && user._ === "user" ? user.username ?? "" : "";
801
+ const parts = [firstName, lastName].filter(Boolean);
802
+ return parts.join(" ").trim() || username || `User ${peer.userId}`;
803
+ }
804
+ const entity = lookup.chats.get(peer._ === "peerChat" ? peer.chatId : peer.channelId);
805
+ if (entity && "title" in entity) {
806
+ return entity.title;
807
+ }
808
+ if (peer._ === "peerChat") return `Chat ${peer.chatId}`;
809
+ if (peer._ === "peerChannel") return `Channel ${peer.channelId}`;
810
+ return "Unknown";
811
+ }
812
+ function ingestEntities(lookup, users, chats) {
813
+ for (const user of users) {
814
+ if ("id" in user) {
815
+ lookup.users.set(Number(user.id), user);
816
+ }
817
+ }
818
+ for (const chat of chats) {
819
+ if ("id" in chat) {
820
+ lookup.chats.set(Number(chat.id), chat);
821
+ }
822
+ }
823
+ }
824
+ function extractLinks(text) {
825
+ const matches = text.matchAll(/https?:\/\/[^\s)]+/g);
826
+ const seen = /* @__PURE__ */ new Set();
827
+ const links = [];
828
+ for (const match of matches) {
829
+ const url = match[0];
830
+ if (seen.has(url)) continue;
831
+ seen.add(url);
832
+ links.push({ url });
833
+ }
834
+ return links;
835
+ }
836
+ function mediaSummary(media) {
837
+ if (!media) return null;
838
+ return media._;
839
+ }
840
+ function previewText2(text, limit = 80) {
841
+ const normalized = text.replace(/\s+/g, " ").trim();
842
+ if (!normalized) return "(no text)";
843
+ return normalized.length <= limit ? normalized : `${normalized.slice(0, limit - 1)}\u2026`;
844
+ }
845
+ async function loadTelegramConfig(accountDir) {
846
+ try {
847
+ const raw = await readFile3(path3.join(accountDir, "telegram.json"), "utf8");
848
+ return JSON.parse(raw);
849
+ } catch (error) {
850
+ if (error.code === "ENOENT") return null;
851
+ throw error;
852
+ }
853
+ }
854
+ async function saveTelegramConfig(accountDir, config) {
855
+ await writeFile2(
856
+ path3.join(accountDir, "telegram.json"),
857
+ `${JSON.stringify(config, null, 2)}
858
+ `,
859
+ "utf8"
860
+ );
861
+ }
862
+
863
+ // src/format.ts
864
+ function formatSyncResult(result) {
865
+ return [
866
+ `sync backend=${result.backend}`,
867
+ `synced_at=${result.synced_at}`,
868
+ `dialogs=${result.synced_dialogs}`,
869
+ `messages=${result.synced_messages}`
870
+ ].join("\n");
871
+ }
872
+ function formatAuthStatus(status) {
873
+ if (!status.authenticated || !status.user) {
874
+ return "authenticated: false";
875
+ }
876
+ return [`authenticated: true`, `user: ${status.user.display_name}`, `id: ${status.user.id}`].join("\n");
877
+ }
878
+ function formatSavedDialogs(dialogs) {
879
+ if (dialogs.length === 0) return "No saved dialogs found.";
880
+ return dialogs.map(
881
+ (dialog) => [
882
+ `${dialog.saved_peer_id} (${dialog.kind})`,
883
+ `title: ${dialog.title}`,
884
+ `messages: ${dialog.message_count}`,
885
+ `top_message_id: ${dialog.top_message_id ?? "n/a"}`,
886
+ `top_text: ${dialog.top_text_preview ?? "(none)"}`
887
+ ].join("\n")
888
+ ).join("\n\n");
889
+ }
890
+ function formatMessagesPage(page) {
891
+ if (page.items.length === 0) return "No messages found.";
892
+ const header = page.scope === "saved_dialog" ? `messages scope=${page.saved_peer_id} total=${page.result_count}` : `messages scope=all_saved_dialogs total=${page.result_count}`;
893
+ const blocks = page.items.map(
894
+ (item) => [
895
+ `#${item.message_id} ${item.date}`,
896
+ `dialog: ${item.saved_peer_id} (${item.dialog_title})`,
897
+ `from_self: ${item.from_self} forwarded: ${item.forwarded}`,
898
+ `reply_to: ${item.reply_to_message_id ?? "none"} backreplies: ${item.direct_backreply_count}`,
899
+ `text: ${item.text_preview}`
900
+ ].join("\n")
901
+ );
902
+ return [header, ...blocks, page.next_cursor ? `next_cursor: ${page.next_cursor}` : ""].filter(Boolean).join("\n\n");
903
+ }
904
+ function formatContextBundle(bundle) {
905
+ const lines = [
906
+ `MESSAGE #${bundle.target.message_id}`,
907
+ `dialog: ${bundle.dialog.saved_peer_id} (${bundle.dialog.title})`,
908
+ `date: ${bundle.target.date}`,
909
+ `from_self: ${bundle.target.from_self}`,
910
+ `thread: direct_backreplies=${bundle.target.thread.direct_backreply_count} descendant_hint=${bundle.target.thread.descendant_count_hint ?? 0}`,
911
+ "",
912
+ "text:",
913
+ bundle.target.text || "(no text)"
914
+ ];
915
+ const replySection = bundle.target.reply.exists ? bundle.target.reply.target ? [``, "reply_to:", `- #${bundle.target.reply.target.message_id} ${bundle.target.reply.target.text_preview}`] : [``, "reply_to:", "- (missing from cache)"] : [];
916
+ const backreplySection = bundle.target.backreplies.length > 0 ? [
917
+ "",
918
+ "backreplies:",
919
+ ...bundle.target.backreplies.map(
920
+ (item) => `- #${item.message.message_id} ${item.message.text_preview}`
921
+ )
922
+ ] : [];
923
+ const before = bundle.context_messages.filter((item) => item.context_roles.includes("chronology_before"));
924
+ const after = bundle.context_messages.filter((item) => item.context_roles.includes("chronology_after"));
925
+ if (before.length > 0) {
926
+ lines.push("", "chronology_before:");
927
+ for (const item of before) lines.push(`- #${item.message.message_id} ${item.message.text_preview}`);
928
+ }
929
+ if (after.length > 0) {
930
+ lines.push("", "chronology_after:");
931
+ for (const item of after) lines.push(`- #${item.message.message_id} ${item.message.text_preview}`);
932
+ }
933
+ lines.push(...replySection, ...backreplySection);
934
+ if (bundle.notes.length > 0) {
935
+ lines.push("", "notes:");
936
+ for (const note of bundle.notes) lines.push(`- ${note}`);
937
+ }
938
+ return lines.join("\n");
939
+ }
940
+ function formatThread(result) {
941
+ const lines = [`THREAD #${result.root.message_id}`, `dialog: ${result.dialog.saved_peer_id} (${result.dialog.title})`, ""];
942
+ const root = result.nodes[0];
943
+ if (!root) return lines.join("\n");
944
+ renderNode(root, "", true, lines);
945
+ return lines.join("\n");
946
+ }
947
+ function renderNode(node, prefix, isLast, lines) {
948
+ const branch = prefix ? `${prefix}${isLast ? "\u2514\u2500\u2500 " : "\u251C\u2500\u2500 "}` : "";
949
+ lines.push(`${branch}#${node.message.message_id} ${node.message.text_preview}`);
950
+ const nextPrefix = prefix ? `${prefix}${isLast ? " " : "\u2502 "}` : "";
951
+ node.children.forEach((child, index) => {
952
+ renderNode(child, nextPrefix, index === node.children.length - 1, lines);
953
+ });
954
+ }
955
+
956
+ // src/index.ts
957
+ var program = new Command();
958
+ program.name("tgsm").description("Retrieval-first Telegram Saved Messages CLI").option("--json", "Emit JSON instead of default text output").option("--backend <backend>", "telegram or fixture", "telegram").option("--fixture <path>", "Fixture path for the fixture backend").option("--home <path>", "Override TGSM home directory").option("--account <name>", "Account namespace", "default");
959
+ program.command("auth").description("Telegram auth commands").addCommand(
960
+ new Command("login").action(async (_, command) => {
961
+ await withService(command.optsWithGlobals(), async (service, options) => {
962
+ if (options.backend !== "telegram") {
963
+ throw new TgsmError({
964
+ code: "AUTH_UNSUPPORTED",
965
+ message: "Auth login is only supported with the telegram backend.",
966
+ retryable: false
967
+ });
968
+ }
969
+ const rl = createInterface({
970
+ input: process2.stdin,
971
+ output: process2.stderr
972
+ });
973
+ try {
974
+ const apiId = Number(await rl.question("API ID: "));
975
+ const apiHash = await rl.question("API Hash: ");
976
+ const phone = await rl.question("Phone: ");
977
+ const code = await rl.question("Code: ");
978
+ const password = await rl.question("2FA password (optional): ");
979
+ const result = await service.authLogin({
980
+ apiId,
981
+ apiHash,
982
+ phone,
983
+ code,
984
+ password: password || void 0
985
+ });
986
+ emit(result, options);
987
+ } finally {
988
+ rl.close();
989
+ }
990
+ });
991
+ })
992
+ ).addCommand(
993
+ new Command("status").action(async (_, command) => {
994
+ await withService(command.optsWithGlobals(), async (service, options) => {
995
+ emit(await service.authStatus(), options, formatAuthStatus);
996
+ });
997
+ })
998
+ );
999
+ program.command("sync").action(async (_, command) => {
1000
+ await withService(command.optsWithGlobals(), async (service, options) => {
1001
+ emit(await service.sync(), options, formatSyncResult);
1002
+ });
1003
+ });
1004
+ program.command("saved-dialogs").description("Saved dialog commands").addCommand(
1005
+ new Command("list").action(async (_, command) => {
1006
+ await withService(command.optsWithGlobals(), async (service, options) => {
1007
+ emit(await service.listSavedDialogs(), options, formatSavedDialogs);
1008
+ });
1009
+ })
1010
+ );
1011
+ var messages = program.command("messages").description("Message commands");
1012
+ messages.command("list").option("--dialog <savedPeerId>").option("--search <query>").option("--limit <number>", "Page size", "20").option("--cursor <cursor>").action(async (commandOptions, command) => {
1013
+ await withService(command.optsWithGlobals(), async (service, options) => {
1014
+ const result = await service.listMessages({
1015
+ dialog: commandOptions.dialog,
1016
+ search: commandOptions.search,
1017
+ limit: Number(commandOptions.limit),
1018
+ cursor: commandOptions.cursor
1019
+ });
1020
+ emit(result, options, formatMessagesPage);
1021
+ });
1022
+ });
1023
+ messages.command("get").argument("<id>").option("--dialog <savedPeerId>").action(async (id, commandOptions, command) => {
1024
+ await withService(command.optsWithGlobals(), async (service, options) => {
1025
+ const result = await service.getMessage(Number(id), {
1026
+ dialog: commandOptions.dialog
1027
+ });
1028
+ emit(result, options, formatContextBundle);
1029
+ });
1030
+ });
1031
+ messages.command("context").argument("<id>").option("--dialog <savedPeerId>").action(async (id, commandOptions, command) => {
1032
+ await withService(command.optsWithGlobals(), async (service, options) => {
1033
+ const result = await service.getContext(Number(id), {
1034
+ dialog: commandOptions.dialog
1035
+ });
1036
+ emit(result, options, formatContextBundle);
1037
+ });
1038
+ });
1039
+ program.command("threads").description("Thread commands").addCommand(
1040
+ new Command("inspect").argument("<id>").option("--dialog <savedPeerId>").action(async (id, commandOptions, command) => {
1041
+ await withService(command.optsWithGlobals(), async (service, options) => {
1042
+ const result = await service.inspectThread(Number(id), {
1043
+ dialog: commandOptions.dialog
1044
+ });
1045
+ emit(result, options, formatThread);
1046
+ });
1047
+ })
1048
+ );
1049
+ program.parseAsync(process2.argv).catch((error) => {
1050
+ const tgsmError = error instanceof TgsmError ? error : new TgsmError({
1051
+ code: "UNEXPECTED_ERROR",
1052
+ message: error instanceof Error ? error.message : "Unexpected error",
1053
+ retryable: false
1054
+ });
1055
+ const options = program.opts();
1056
+ if (options.json) {
1057
+ process2.stdout.write(`${JSON.stringify(tgsmError.toJSON(), null, 2)}
1058
+ `);
1059
+ } else {
1060
+ process2.stderr.write(`error: ${tgsmError.message}
1061
+ `);
1062
+ if (tgsmError.suggestion) {
1063
+ process2.stderr.write(`suggestion: ${tgsmError.suggestion}
1064
+ `);
1065
+ }
1066
+ }
1067
+ process2.exitCode = errorCode(tgsmError.code);
1068
+ });
1069
+ async function withService(options, fn) {
1070
+ const normalized = {
1071
+ json: Boolean(options.json),
1072
+ backend: options.backend ?? "telegram",
1073
+ fixture: options.fixture ?? "",
1074
+ home: options.home ?? "",
1075
+ account: options.account ?? "default"
1076
+ };
1077
+ await ensureAccountDir({
1078
+ homeDir: normalized.home || void 0,
1079
+ account: normalized.account
1080
+ });
1081
+ const cachePath = getCachePath({
1082
+ homeDir: normalized.home || void 0,
1083
+ account: normalized.account
1084
+ });
1085
+ const source = resolveSource(normalized);
1086
+ const service = new TgsmService({
1087
+ cachePath,
1088
+ source
1089
+ });
1090
+ await fn(service, normalized);
1091
+ }
1092
+ function resolveSource(options) {
1093
+ if (options.backend === "fixture") {
1094
+ if (!options.fixture) {
1095
+ throw new TgsmError({
1096
+ code: "FIXTURE_REQUIRED",
1097
+ message: "The fixture backend requires --fixture <path>.",
1098
+ retryable: false
1099
+ });
1100
+ }
1101
+ return new FixtureSource(options.fixture);
1102
+ }
1103
+ return new TelegramSource();
1104
+ }
1105
+ function emit(value, options, format) {
1106
+ if (options.json) {
1107
+ process2.stdout.write(`${JSON.stringify(value, null, 2)}
1108
+ `);
1109
+ return;
1110
+ }
1111
+ process2.stdout.write(`${format ? format(value) : String(value)}
1112
+ `);
1113
+ }
1114
+ function errorCode(code) {
1115
+ if (code.startsWith("AUTH")) return 3;
1116
+ if (code.includes("SYNC") || code.includes("TELEGRAM")) return 2;
1117
+ return 1;
1118
+ }
1119
+ //# sourceMappingURL=index.js.map