@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/LICENSE +21 -0
- package/README.md +46 -0
- package/dist/index.js +1119 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
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
|