@will-17173/telegram-cli 0.3.0 → 0.4.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.
Files changed (145) hide show
  1. package/README.md +73 -335
  2. package/README.zh-CN.md +72 -333
  3. package/dist/account/account-authenticator.js +95 -0
  4. package/dist/account/account-authenticator.js.map +1 -0
  5. package/dist/account/account-context.js +7 -0
  6. package/dist/account/account-context.js.map +1 -1
  7. package/dist/account/account-name.js +17 -0
  8. package/dist/account/account-name.js.map +1 -0
  9. package/dist/account/account-presets.js +20 -3
  10. package/dist/account/account-presets.js.map +1 -1
  11. package/dist/account/account-store.js +277 -37
  12. package/dist/account/account-store.js.map +1 -1
  13. package/dist/cli/app.js +12 -1
  14. package/dist/cli/app.js.map +1 -1
  15. package/dist/cli/output.js +21 -6
  16. package/dist/cli/output.js.map +1 -1
  17. package/dist/cli/secure-input.js +408 -0
  18. package/dist/cli/secure-input.js.map +1 -0
  19. package/dist/commands/account-options.js +37 -7
  20. package/dist/commands/account-options.js.map +1 -1
  21. package/dist/commands/account.js +144 -62
  22. package/dist/commands/account.js.map +1 -1
  23. package/dist/commands/archive.js +151 -0
  24. package/dist/commands/archive.js.map +1 -0
  25. package/dist/commands/config.js +119 -12
  26. package/dist/commands/config.js.map +1 -1
  27. package/dist/commands/contact.js +58 -0
  28. package/dist/commands/contact.js.map +1 -0
  29. package/dist/commands/data.js +14 -7
  30. package/dist/commands/data.js.map +1 -1
  31. package/dist/commands/dialog.js +156 -0
  32. package/dist/commands/dialog.js.map +1 -0
  33. package/dist/commands/folder.js +113 -0
  34. package/dist/commands/folder.js.map +1 -0
  35. package/dist/commands/group-write.js +45 -5
  36. package/dist/commands/group-write.js.map +1 -1
  37. package/dist/commands/group.js +23 -1
  38. package/dist/commands/group.js.map +1 -1
  39. package/dist/commands/notification.js +76 -0
  40. package/dist/commands/notification.js.map +1 -0
  41. package/dist/commands/query.js +22 -15
  42. package/dist/commands/query.js.map +1 -1
  43. package/dist/commands/telegram-runner.js +40 -18
  44. package/dist/commands/telegram-runner.js.map +1 -1
  45. package/dist/commands/telegram.js +82 -38
  46. package/dist/commands/telegram.js.map +1 -1
  47. package/dist/commands/time-range.js +39 -0
  48. package/dist/commands/time-range.js.map +1 -0
  49. package/dist/commands/types.js +3 -2
  50. package/dist/commands/types.js.map +1 -1
  51. package/dist/config/credential-store.js +24 -4
  52. package/dist/config/credential-store.js.map +1 -1
  53. package/dist/config/env.js +4 -1
  54. package/dist/config/env.js.map +1 -1
  55. package/dist/group-commands/catalog.js +10 -0
  56. package/dist/group-commands/catalog.js.map +1 -1
  57. package/dist/group-commands/executor.js +13 -4
  58. package/dist/group-commands/executor.js.map +1 -1
  59. package/dist/presenters/group.js +16 -0
  60. package/dist/presenters/group.js.map +1 -1
  61. package/dist/presenters/human.js +113 -0
  62. package/dist/presenters/human.js.map +1 -1
  63. package/dist/presenters/ink/group-command-result.js +2 -0
  64. package/dist/presenters/ink/group-command-result.js.map +1 -1
  65. package/dist/presenters/ink/listen-scroll.js +4 -3
  66. package/dist/presenters/ink/listen-scroll.js.map +1 -1
  67. package/dist/presenters/ink/listen.js +245 -57
  68. package/dist/presenters/ink/listen.js.map +1 -1
  69. package/dist/presenters/ink/mouse-scroll.js +12 -0
  70. package/dist/presenters/ink/mouse-scroll.js.map +1 -1
  71. package/dist/presenters/ink/secure-input.js +68 -0
  72. package/dist/presenters/ink/secure-input.js.map +1 -0
  73. package/dist/presenters/ink/use-group-command.js +78 -17
  74. package/dist/presenters/ink/use-group-command.js.map +1 -1
  75. package/dist/presenters/markdown.js +54 -0
  76. package/dist/presenters/markdown.js.map +1 -0
  77. package/dist/presenters/structured.js +8 -3
  78. package/dist/presenters/structured.js.map +1 -1
  79. package/dist/services/account-session-service.js +241 -0
  80. package/dist/services/account-session-service.js.map +1 -0
  81. package/dist/services/archive-layout.js +61 -0
  82. package/dist/services/archive-layout.js.map +1 -0
  83. package/dist/services/archive-manifest.js +187 -0
  84. package/dist/services/archive-manifest.js.map +1 -0
  85. package/dist/services/archive-markdown.js +383 -0
  86. package/dist/services/archive-markdown.js.map +1 -0
  87. package/dist/services/archive-service.js +718 -0
  88. package/dist/services/archive-service.js.map +1 -0
  89. package/dist/services/archive-types.js +2 -0
  90. package/dist/services/archive-types.js.map +1 -0
  91. package/dist/services/attachment-download.js +7 -2
  92. package/dist/services/attachment-download.js.map +1 -1
  93. package/dist/services/auto-download-coordinator.js +2 -4
  94. package/dist/services/auto-download-coordinator.js.map +1 -1
  95. package/dist/services/contact-service.js +78 -0
  96. package/dist/services/contact-service.js.map +1 -0
  97. package/dist/services/dialog-service.js +140 -0
  98. package/dist/services/dialog-service.js.map +1 -0
  99. package/dist/services/folder-service.js +200 -0
  100. package/dist/services/folder-service.js.map +1 -0
  101. package/dist/services/group-write-service.js +78 -18
  102. package/dist/services/group-write-service.js.map +1 -1
  103. package/dist/services/message-service.js +17 -1
  104. package/dist/services/message-service.js.map +1 -1
  105. package/dist/services/notification-service.js +142 -0
  106. package/dist/services/notification-service.js.map +1 -0
  107. package/dist/services/write-access-policy.js +19 -0
  108. package/dist/services/write-access-policy.js.map +1 -0
  109. package/dist/telegram/archive-types.js +2 -0
  110. package/dist/telegram/archive-types.js.map +1 -0
  111. package/dist/telegram/client-factory.js +51 -11
  112. package/dist/telegram/client-factory.js.map +1 -1
  113. package/dist/telegram/contact-types.js +10 -0
  114. package/dist/telegram/contact-types.js.map +1 -0
  115. package/dist/telegram/dialog-types.js +2 -0
  116. package/dist/telegram/dialog-types.js.map +1 -0
  117. package/dist/telegram/errors.js +14 -0
  118. package/dist/telegram/errors.js.map +1 -0
  119. package/dist/telegram/fake-group-management.js +5 -0
  120. package/dist/telegram/fake-group-management.js.map +1 -1
  121. package/dist/telegram/folder-types.js +13 -0
  122. package/dist/telegram/folder-types.js.map +1 -0
  123. package/dist/telegram/group-write-types.js +28 -1
  124. package/dist/telegram/group-write-types.js.map +1 -1
  125. package/dist/telegram/mtcute-archive.js +190 -0
  126. package/dist/telegram/mtcute-archive.js.map +1 -0
  127. package/dist/telegram/mtcute-client.js +72 -11
  128. package/dist/telegram/mtcute-client.js.map +1 -1
  129. package/dist/telegram/mtcute-contacts.js +90 -0
  130. package/dist/telegram/mtcute-contacts.js.map +1 -0
  131. package/dist/telegram/mtcute-dialogs.js +186 -0
  132. package/dist/telegram/mtcute-dialogs.js.map +1 -0
  133. package/dist/telegram/mtcute-folders.js +318 -0
  134. package/dist/telegram/mtcute-folders.js.map +1 -0
  135. package/dist/telegram/mtcute-group-members.js +43 -3
  136. package/dist/telegram/mtcute-group-members.js.map +1 -1
  137. package/dist/telegram/mtcute-message-normalizer.js +63 -0
  138. package/dist/telegram/mtcute-message-normalizer.js.map +1 -0
  139. package/dist/telegram/mtcute-notifications.js +101 -0
  140. package/dist/telegram/mtcute-notifications.js.map +1 -0
  141. package/dist/telegram/notification-types.js +11 -0
  142. package/dist/telegram/notification-types.js.map +1 -0
  143. package/dist/telegram/types.js +6 -1
  144. package/dist/telegram/types.js.map +1 -1
  145. package/package.json +2 -2
@@ -0,0 +1,718 @@
1
+ import { closeSync, copyFileSync, constants, createReadStream, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, realpathSync, renameSync, unlinkSync, writeFileSync, } from 'node:fs';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
4
+ import { isTelegramAuthSessionError } from '../telegram/errors.js';
5
+ import { archiveChatFile, archiveMediaFile } from './archive-layout.js';
6
+ import { readArchiveManifest, validateArchiveAccount, writeArchiveManifest, } from './archive-manifest.js';
7
+ import { renderArchiveHeader, renderArchiveMessage, scanArchiveRecovery, } from './archive-markdown.js';
8
+ const DEFAULT_HISTORY_MS = 7 * 86_400_000;
9
+ const MANIFEST_FILE = 'archive-manifest.json';
10
+ const BLOCK_SEPARATOR = '\n\n---\n\n';
11
+ class ArchiveOperationError extends Error {
12
+ fatal;
13
+ constructor(message, fatal = false) {
14
+ super(message);
15
+ this.fatal = fatal;
16
+ }
17
+ }
18
+ export class ArchiveService {
19
+ source;
20
+ writeManifest;
21
+ writeArchive;
22
+ restoreManifest;
23
+ transaction;
24
+ constructor(source, dependencies = {}) {
25
+ this.source = source;
26
+ this.writeManifest = dependencies.writeManifest ?? writeArchiveManifest;
27
+ this.writeArchive = dependencies.writeArchive ?? writeArchiveFile;
28
+ this.restoreManifest = dependencies.restoreManifest ?? restoreManifestSnapshot;
29
+ const sync = dependencies.transaction?.syncDirectory ?? syncDirectory;
30
+ this.transaction = {
31
+ backupDestination: dependencies.transaction?.backupDestination
32
+ ?? ((source, backup) => backupFile(source, backup, sync)),
33
+ replaceDestination: dependencies.transaction?.replaceDestination
34
+ ?? ((temporary, destination) => {
35
+ renameSync(temporary, destination);
36
+ sync(dirname(destination));
37
+ }),
38
+ rollbackDestination: dependencies.transaction?.rollbackDestination
39
+ ?? ((destination, backup) => {
40
+ if (backup == null) {
41
+ try {
42
+ unlinkSync(destination);
43
+ }
44
+ catch (error) {
45
+ if (error.code !== 'ENOENT')
46
+ throw error;
47
+ }
48
+ }
49
+ else {
50
+ renameSync(backup, destination);
51
+ }
52
+ sync(dirname(destination));
53
+ }),
54
+ cleanupBackup: dependencies.transaction?.cleanupBackup
55
+ ?? ((backup) => {
56
+ unlinkSync(backup);
57
+ sync(dirname(backup));
58
+ }),
59
+ syncDirectory: sync,
60
+ };
61
+ }
62
+ async archive(input) {
63
+ validateInput(input);
64
+ const now = input.now ?? new Date();
65
+ const timestamp = now.toISOString();
66
+ const manifestPath = join(input.output, MANIFEST_FILE);
67
+ const existing = readArchiveManifest(manifestPath);
68
+ if (existing != null)
69
+ validateArchiveAccount(existing, input.account);
70
+ let manifest = existing ?? {
71
+ schema_version: 1,
72
+ account_name: input.account.name,
73
+ account_user_id: input.account.userId,
74
+ created_at: timestamp,
75
+ updated_at: timestamp,
76
+ chats: {},
77
+ };
78
+ let manifestPersisted = existing != null;
79
+ const chats = await this.source.resolveChats({
80
+ chats: input.all ? undefined : input.chats,
81
+ all: input.all,
82
+ });
83
+ mkdirSync(input.output, { recursive: true });
84
+ const result = {
85
+ manifest: manifestPath,
86
+ completed: [],
87
+ failed: [],
88
+ warnings: [],
89
+ };
90
+ for (const chat of chats) {
91
+ const previous = manifest.chats[String(chat.id)];
92
+ try {
93
+ const range = effectiveRange(input, previous, now);
94
+ validateEffectiveRange(range);
95
+ const archived = await this.archiveChat(chat, input.output, range, now, previous, input.rebuild === true, input.media === true);
96
+ const candidate = {
97
+ ...manifest,
98
+ account_name: input.account.name,
99
+ updated_at: timestamp,
100
+ chats: {
101
+ ...manifest.chats,
102
+ [String(chat.id)]: archived.state,
103
+ },
104
+ };
105
+ try {
106
+ this.writeManifest(manifestPath, candidate);
107
+ }
108
+ catch {
109
+ const failures = ['archive_manifest_commit_failed'];
110
+ let fatal = false;
111
+ try {
112
+ this.restoreManifest(manifestPath, manifestPersisted ? manifest : null);
113
+ }
114
+ catch {
115
+ failures.push('archive_manifest_recovery_failed');
116
+ fatal = true;
117
+ }
118
+ try {
119
+ archived.rollback();
120
+ }
121
+ catch {
122
+ failures.push('archive_rollback_failed');
123
+ fatal = true;
124
+ }
125
+ throw new ArchiveOperationError(failures.join(';'), fatal);
126
+ }
127
+ manifest = candidate;
128
+ manifestPersisted = true;
129
+ let cleanupFailed = false;
130
+ try {
131
+ archived.finalize();
132
+ }
133
+ catch {
134
+ cleanupFailed = true;
135
+ }
136
+ result.completed.push({
137
+ chat_id: chat.id,
138
+ title: chat.title,
139
+ file: archived.state.file,
140
+ messages_archived: archived.messages,
141
+ media_archived: archived.media,
142
+ });
143
+ result.warnings.push(...archived.warnings);
144
+ if (cleanupFailed) {
145
+ result.warnings.push({
146
+ chat_id: chat.id,
147
+ code: 'archive_backup_cleanup_failed',
148
+ message: 'Archive committed, but recovery-backup cleanup could not be confirmed.',
149
+ });
150
+ }
151
+ }
152
+ catch (error) {
153
+ if (isTelegramAuthSessionError(error))
154
+ throw error;
155
+ const failure = publicArchiveFailure(error);
156
+ result.failed.push({
157
+ chat_id: chat.id,
158
+ title: chat.title,
159
+ error: failure.message,
160
+ });
161
+ if (failure.fatal)
162
+ break;
163
+ }
164
+ }
165
+ if (result.failed.length === 0 && result.warnings.length === 0) {
166
+ return { ok: true, data: result };
167
+ }
168
+ return {
169
+ ok: false,
170
+ error: {
171
+ code: 'archive_partial_failure',
172
+ message: 'Archive completed with one or more chat or attachment failures.',
173
+ details: {
174
+ completed: result.completed,
175
+ failed: result.failed,
176
+ warnings: result.warnings,
177
+ },
178
+ },
179
+ };
180
+ }
181
+ async archiveChat(chat, output, range, now, previous, rebuild = false, downloadMedia = false) {
182
+ const file = previous?.file ?? archiveChatFile(chat.id, chat.title);
183
+ const destination = join(output, file);
184
+ const token = `${process.pid}-${randomBytes(8).toString('hex')}`;
185
+ const ownedPaths = [];
186
+ const segments = [];
187
+ let backup;
188
+ let replacementAttempted = false;
189
+ let newest;
190
+ let messages = 0;
191
+ let media = 0;
192
+ const warnings = [];
193
+ try {
194
+ const incremental = previous != null && !rebuild && existsSync(destination);
195
+ const recovery = incremental
196
+ ? await scanArchiveRecovery(createReadStream(destination), {
197
+ expectedChatId: chat.id,
198
+ onMedia: downloadMedia ? async (link) => {
199
+ const downloaded = await this.downloadMediaFile(output, chat, link.messageId, link.path, token);
200
+ if (downloaded.archived && !downloaded.reused)
201
+ media += 1;
202
+ if (downloaded.warning != null)
203
+ warnings.push(downloaded.warning);
204
+ } : undefined,
205
+ })
206
+ : { maxId: null, maxTimestamp: null };
207
+ const effectiveMinId = incremental
208
+ ? maxDefined(previous.last_message_id, recovery.maxId)
209
+ : undefined;
210
+ const fetchedIds = new Set();
211
+ let pageNumber = 0;
212
+ for await (const page of this.source.iterHistoryPages({
213
+ chat: chat.id,
214
+ since: range.since,
215
+ until: range.until,
216
+ minId: effectiveMinId,
217
+ })) {
218
+ if (page.length === 0)
219
+ continue;
220
+ const unseen = page.filter((item) => {
221
+ if (effectiveMinId != null && item.msg_id <= effectiveMinId)
222
+ return false;
223
+ if (fetchedIds.has(item.msg_id))
224
+ return false;
225
+ fetchedIds.add(item.msg_id);
226
+ return true;
227
+ });
228
+ if (unseen.length === 0)
229
+ continue;
230
+ const segment = join(output, `.${basename(file)}.${token}.${pageNumber}.segment`);
231
+ pageNumber += 1;
232
+ ownedPaths.push(segment);
233
+ const chronological = [...unseen].reverse();
234
+ const rendered = [];
235
+ for (const item of chronological) {
236
+ const downloaded = downloadMedia
237
+ ? await this.downloadAttachment(output, chat, item, token)
238
+ : { path: undefined, archived: false, warning: undefined };
239
+ if (downloaded.archived)
240
+ media += 1;
241
+ if (downloaded.warning != null)
242
+ warnings.push(downloaded.warning);
243
+ rendered.push(renderArchiveMessage(item, downloaded.path));
244
+ }
245
+ writeExclusive(segment, rendered.join(BLOCK_SEPARATOR));
246
+ segments.push(segment);
247
+ messages += chronological.length;
248
+ for (const item of unseen) {
249
+ if (newest == null || item.msg_id > newest.msg_id)
250
+ newest = item;
251
+ }
252
+ }
253
+ const temporary = join(output, `.${basename(file)}.${token}.tmp`);
254
+ ownedPaths.push(temporary);
255
+ await this.writeArchive(temporary, renderArchiveHeader(chat, now), segments.reverse(), incremental ? destination : undefined);
256
+ removeOwned(segments);
257
+ backup = existsSync(destination)
258
+ ? join(output, `.${basename(file)}.${token}.backup`)
259
+ : undefined;
260
+ if (backup != null) {
261
+ this.transaction.backupDestination(destination, backup);
262
+ }
263
+ replacementAttempted = true;
264
+ this.transaction.replaceDestination(temporary, destination);
265
+ return {
266
+ messages,
267
+ media,
268
+ warnings,
269
+ finalize: () => {
270
+ if (backup != null)
271
+ this.transaction.cleanupBackup(backup);
272
+ },
273
+ rollback: () => this.transaction.rollbackDestination(destination, backup),
274
+ state: {
275
+ title: chat.title,
276
+ file,
277
+ initial_since: incremental
278
+ ? previous.initial_since
279
+ : range.since?.toISOString() ?? null,
280
+ initial_until: incremental
281
+ ? previous.initial_until
282
+ : range.until?.toISOString() ?? null,
283
+ full_history: incremental ? previous.full_history : range.full,
284
+ last_message_id: newest?.msg_id ?? effectiveMinId ?? null,
285
+ last_message_date: newest?.timestamp == null
286
+ ? recoveredCursorTimestamp(previous, recovery, effectiveMinId)
287
+ : new Date(newest.timestamp).toISOString(),
288
+ last_run: now.toISOString(),
289
+ },
290
+ };
291
+ }
292
+ catch (error) {
293
+ removeOwnedQuietly(ownedPaths);
294
+ if (replacementAttempted) {
295
+ try {
296
+ this.transaction.rollbackDestination(destination, backup);
297
+ }
298
+ catch {
299
+ throw new ArchiveOperationError('archive_persistence_failed;archive_rollback_failed', true);
300
+ }
301
+ throw new ArchiveOperationError('archive_persistence_failed');
302
+ }
303
+ if (backup != null) {
304
+ try {
305
+ this.transaction.cleanupBackup(backup);
306
+ }
307
+ catch {
308
+ throw new ArchiveOperationError('archive_backup_cleanup_failed');
309
+ }
310
+ }
311
+ throw error;
312
+ }
313
+ }
314
+ async downloadAttachment(output, chat, message, token) {
315
+ const attachment = message.attachment;
316
+ if (attachment == null || !attachment.downloadable)
317
+ return { archived: false };
318
+ const relativePath = archiveMediaFile(chat.id, message.msg_id, attachment.file_name ?? 'attachment');
319
+ return this.downloadMediaFile(output, chat, message.msg_id, relativePath, token);
320
+ }
321
+ async downloadMediaFile(output, chat, messageId, relativePath, token) {
322
+ let prepared;
323
+ try {
324
+ prepared = this.prepareMediaTarget(output, chat.id, messageId, relativePath);
325
+ }
326
+ catch {
327
+ return mediaDownloadFailure(chat.id, messageId, relativePath);
328
+ }
329
+ if (prepared.reused)
330
+ return { path: relativePath, archived: true, reused: true };
331
+ const { destination, directory, root } = prepared;
332
+ const temporary = join(root, `.media-stage.${token}.${chat.id}.${messageId}.tmp`);
333
+ let stagingOwned = false;
334
+ let stagingIdentity = '';
335
+ try {
336
+ stagingIdentity = createOwnedStagingFile(temporary);
337
+ stagingOwned = true;
338
+ const beforeDownload = this.prepareMediaTarget(output, chat.id, messageId, relativePath);
339
+ assertSameMediaTarget(prepared, beforeDownload);
340
+ if (beforeDownload.reused) {
341
+ removeOwnedStagingFile(temporary, stagingIdentity);
342
+ stagingOwned = false;
343
+ return { path: relativePath, archived: true, reused: true };
344
+ }
345
+ await this.source.downloadMedia({
346
+ chat: chat.id,
347
+ messageId,
348
+ destination: temporary,
349
+ });
350
+ assertOwnedRegularNonEmptyFile(temporary, stagingIdentity);
351
+ const descriptor = openSync(temporary, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
352
+ try {
353
+ const status = fstatSync(descriptor, { bigint: true });
354
+ if (!status.isFile()
355
+ || status.size === 0n
356
+ || fileIdentity(status) !== stagingIdentity) {
357
+ throw new Error('archive_unsafe_media_temp');
358
+ }
359
+ fsyncSync(descriptor);
360
+ }
361
+ finally {
362
+ closeSync(descriptor);
363
+ }
364
+ const current = this.prepareMediaTarget(output, chat.id, messageId, relativePath);
365
+ assertSameMediaTarget(prepared, current);
366
+ if (current.reused) {
367
+ removeOwnedStagingFile(temporary, stagingIdentity);
368
+ stagingOwned = false;
369
+ return { path: relativePath, archived: true, reused: true };
370
+ }
371
+ assertOwnedRegularNonEmptyFile(temporary, stagingIdentity);
372
+ // Node has no openat/renameat. Revalidation pins every parent identity immediately
373
+ // before this commit, but a same-user directory swap can still race the rename itself.
374
+ renameSync(temporary, destination);
375
+ stagingOwned = false;
376
+ this.transaction.syncDirectory(directory);
377
+ return { path: relativePath, archived: true };
378
+ }
379
+ catch (error) {
380
+ if (stagingOwned)
381
+ removeOwnedStagingFile(temporary, stagingIdentity);
382
+ if (isTelegramAuthSessionError(error))
383
+ throw error;
384
+ return mediaDownloadFailure(chat.id, messageId, relativePath);
385
+ }
386
+ }
387
+ prepareMediaTarget(output, chatId, messageId, relativePath) {
388
+ const expectedPrefix = `${messageId}-`;
389
+ const components = relativePath.split('/');
390
+ if (components.length !== 3
391
+ || components[0] !== 'media'
392
+ || components[1] !== String(chatId)
393
+ || !components[2]?.startsWith(expectedPrefix)
394
+ || archiveMediaFile(chatId, messageId, components[2].slice(expectedPrefix.length))
395
+ !== relativePath) {
396
+ throw new Error('archive_unsafe_media_path');
397
+ }
398
+ const root = realpathSync(output);
399
+ const rootStatus = lstatSync(root, { bigint: true });
400
+ if (!rootStatus.isDirectory())
401
+ throw new Error('archive_unsafe_media_root');
402
+ const identities = [fileIdentity(rootStatus)];
403
+ let directory = root;
404
+ for (const component of components.slice(0, -1)) {
405
+ const next = join(directory, component);
406
+ let status;
407
+ try {
408
+ status = lstatSync(next, { bigint: true });
409
+ }
410
+ catch (error) {
411
+ if (error.code !== 'ENOENT')
412
+ throw error;
413
+ mkdirSync(next, { mode: 0o700 });
414
+ this.transaction.syncDirectory(directory);
415
+ status = lstatSync(next, { bigint: true });
416
+ }
417
+ if (status.isSymbolicLink() || !status.isDirectory()) {
418
+ throw new Error('archive_unsafe_media_directory');
419
+ }
420
+ const realDirectory = realpathSync(next);
421
+ if (!pathIsWithin(root, realDirectory))
422
+ throw new Error('archive_unsafe_media_directory');
423
+ identities.push(fileIdentity(status));
424
+ directory = realDirectory;
425
+ }
426
+ const destination = join(directory, components.at(-1));
427
+ if (!pathIsWithin(root, destination))
428
+ throw new Error('archive_unsafe_media_destination');
429
+ try {
430
+ const status = lstatSync(destination, { bigint: true });
431
+ if (status.isSymbolicLink() || !status.isFile()) {
432
+ throw new Error('archive_unsafe_media_destination');
433
+ }
434
+ return { root, destination, directory, identities, reused: status.size > 0n };
435
+ }
436
+ catch (error) {
437
+ if (error.code !== 'ENOENT')
438
+ throw error;
439
+ return { root, destination, directory, identities, reused: false };
440
+ }
441
+ }
442
+ }
443
+ function validateInput(input) {
444
+ const chats = input.chats ?? [];
445
+ if (!input.all && chats.length === 0)
446
+ throw new Error('archive_scope_required');
447
+ if (input.all && chats.length > 0)
448
+ throw new Error('archive_scope_conflict');
449
+ if (input.full && input.range?.since != null)
450
+ throw new Error('archive_full_range_conflict');
451
+ const now = input.now ?? new Date();
452
+ const since = input.range?.since;
453
+ const until = input.range?.until;
454
+ const effectiveSince = input.full || (input.rebuild && since == null && until == null)
455
+ ? since
456
+ : since ?? new Date(now.getTime() - DEFAULT_HISTORY_MS);
457
+ if (!validDate(now)
458
+ || (since != null && !validDate(since))
459
+ || (until != null && !validDate(until))
460
+ || (effectiveSince != null && until != null && effectiveSince.getTime() >= until.getTime())) {
461
+ throw new Error('archive_invalid_time_range');
462
+ }
463
+ }
464
+ function effectiveRange(input, previous, now) {
465
+ const explicitRange = input.range?.since != null || input.range?.until != null;
466
+ if (input.rebuild && previous != null && !input.full && !explicitRange) {
467
+ return {
468
+ since: previous.initial_since == null ? undefined : new Date(previous.initial_since),
469
+ until: previous.initial_until == null ? undefined : new Date(previous.initial_until),
470
+ full: previous.full_history,
471
+ };
472
+ }
473
+ return {
474
+ since: input.full
475
+ ? undefined
476
+ : input.range?.since ?? new Date(now.getTime() - DEFAULT_HISTORY_MS),
477
+ until: input.range?.until,
478
+ full: input.full === true,
479
+ };
480
+ }
481
+ function validDate(value) {
482
+ return value instanceof Date && Number.isFinite(value.getTime());
483
+ }
484
+ function maxDefined(left, right) {
485
+ if (left == null)
486
+ return right ?? undefined;
487
+ if (right == null)
488
+ return left;
489
+ return Math.max(left, right);
490
+ }
491
+ function recoveredCursorTimestamp(previous, recovery, effectiveMinId) {
492
+ if (effectiveMinId == null)
493
+ return null;
494
+ if (recovery.maxId === effectiveMinId)
495
+ return recovery.maxTimestamp;
496
+ if (previous?.last_message_id === effectiveMinId)
497
+ return previous.last_message_date;
498
+ return null;
499
+ }
500
+ function pathIsWithin(root, candidate) {
501
+ const fromRoot = relative(root, candidate);
502
+ return fromRoot !== ''
503
+ && fromRoot !== '..'
504
+ && !fromRoot.startsWith(`..${sep}`)
505
+ && !isAbsolute(fromRoot);
506
+ }
507
+ function createOwnedStagingFile(path) {
508
+ const descriptor = openSync(path, constants.O_CREAT
509
+ | constants.O_EXCL
510
+ | constants.O_WRONLY
511
+ | (constants.O_NOFOLLOW ?? 0), 0o600);
512
+ try {
513
+ const status = fstatSync(descriptor, { bigint: true });
514
+ if (!status.isFile())
515
+ throw new Error('archive_unsafe_media_temp');
516
+ return fileIdentity(status);
517
+ }
518
+ finally {
519
+ closeSync(descriptor);
520
+ }
521
+ }
522
+ function assertSameMediaTarget(expected, actual) {
523
+ if (expected.root !== actual.root
524
+ || expected.destination !== actual.destination
525
+ || expected.directory !== actual.directory
526
+ || expected.identities.length !== actual.identities.length
527
+ || expected.identities.some((identity, index) => identity !== actual.identities[index])) {
528
+ throw new Error('archive_unsafe_media_destination');
529
+ }
530
+ }
531
+ function fileIdentity(status) {
532
+ if (status.ino <= 0n || status.dev < 0n)
533
+ throw new Error('archive_unusable_file_identity');
534
+ return `${status.dev}:${status.ino}`;
535
+ }
536
+ function assertOwnedRegularNonEmptyFile(path, identity) {
537
+ const status = lstatSync(path, { bigint: true });
538
+ if (status.isSymbolicLink()
539
+ || !status.isFile()
540
+ || status.size === 0n
541
+ || fileIdentity(status) !== identity) {
542
+ throw new Error('archive_unsafe_media_temp');
543
+ }
544
+ }
545
+ function removeOwnedStagingFile(path, identity) {
546
+ try {
547
+ const status = lstatSync(path, { bigint: true });
548
+ if (status.isSymbolicLink() || !status.isFile() || fileIdentity(status) !== identity)
549
+ return;
550
+ unlinkSync(path);
551
+ }
552
+ catch {
553
+ // Never follow or remove a staging path whose ownership can no longer be proven.
554
+ }
555
+ }
556
+ function mediaDownloadFailure(chatId, messageId, relativePath) {
557
+ const fileName = basename(relativePath).slice(`${messageId}-`.length);
558
+ return {
559
+ path: relativePath,
560
+ archived: false,
561
+ warning: {
562
+ chat_id: chatId,
563
+ code: 'archive_media_failed',
564
+ message: `Media download failed for message #${messageId} attachment ${fileName}.`,
565
+ },
566
+ };
567
+ }
568
+ function validateEffectiveRange(range) {
569
+ if ((range.since != null && !validDate(range.since))
570
+ || (range.until != null && !validDate(range.until))
571
+ || (range.since != null
572
+ && range.until != null
573
+ && range.since.getTime() >= range.until.getTime())) {
574
+ throw new ArchiveOperationError('archive_invalid_time_range');
575
+ }
576
+ }
577
+ function publicArchiveFailure(error) {
578
+ if (error instanceof ArchiveOperationError) {
579
+ return { message: error.message, fatal: error.fatal };
580
+ }
581
+ return { message: 'archive_chat_failed', fatal: false };
582
+ }
583
+ function writeExclusive(path, value) {
584
+ let descriptor = null;
585
+ try {
586
+ descriptor = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
587
+ writeFileSync(descriptor, value, 'utf8');
588
+ fsyncSync(descriptor);
589
+ }
590
+ finally {
591
+ if (descriptor != null)
592
+ closeSync(descriptor);
593
+ }
594
+ }
595
+ function backupFile(source, backup, sync) {
596
+ try {
597
+ linkSync(source, backup);
598
+ }
599
+ catch {
600
+ copyFileSync(source, backup, constants.COPYFILE_EXCL);
601
+ const descriptor = openSync(backup, constants.O_RDONLY);
602
+ try {
603
+ fsyncSync(descriptor);
604
+ }
605
+ finally {
606
+ closeSync(descriptor);
607
+ }
608
+ }
609
+ sync(dirname(backup));
610
+ }
611
+ function restoreManifestSnapshot(path, manifest) {
612
+ let failure;
613
+ try {
614
+ if (manifest == null) {
615
+ try {
616
+ unlinkSync(path);
617
+ syncDirectory(dirname(path));
618
+ }
619
+ catch (error) {
620
+ if (error.code !== 'ENOENT')
621
+ throw error;
622
+ }
623
+ }
624
+ else {
625
+ writeArchiveManifest(path, manifest);
626
+ }
627
+ }
628
+ catch (error) {
629
+ failure = error;
630
+ }
631
+ try {
632
+ const restored = readArchiveManifest(path);
633
+ if (JSON.stringify(restored) === JSON.stringify(manifest))
634
+ return;
635
+ }
636
+ catch {
637
+ // Report the recovery failure below without exposing filesystem paths.
638
+ }
639
+ throw failure ?? new Error('archive_manifest_recovery_failed');
640
+ }
641
+ const UNSUPPORTED_DIRECTORY_SYNC_CODES = new Set([
642
+ 'EACCES',
643
+ 'EINVAL',
644
+ 'EISDIR',
645
+ 'ENOSYS',
646
+ 'ENOTSUP',
647
+ 'EPERM',
648
+ ]);
649
+ function syncDirectory(path) {
650
+ let descriptor = null;
651
+ try {
652
+ descriptor = openSync(path, constants.O_RDONLY);
653
+ fsyncSync(descriptor);
654
+ }
655
+ catch (error) {
656
+ const code = error.code;
657
+ if (!code || !UNSUPPORTED_DIRECTORY_SYNC_CODES.has(code))
658
+ throw error;
659
+ }
660
+ finally {
661
+ if (descriptor != null)
662
+ closeSync(descriptor);
663
+ }
664
+ }
665
+ async function writeArchiveFile(path, header, segments, source) {
666
+ let descriptor = null;
667
+ try {
668
+ descriptor = openSync(path, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
669
+ if (source == null) {
670
+ writeFileSync(descriptor, header, 'utf8');
671
+ }
672
+ else {
673
+ for await (const chunk of createReadStream(source)) {
674
+ writeFileSync(descriptor, chunk);
675
+ }
676
+ }
677
+ for (const segment of segments) {
678
+ writeFileSync(descriptor, BLOCK_SEPARATOR, 'utf8');
679
+ for await (const chunk of createReadStream(segment)) {
680
+ writeFileSync(descriptor, chunk);
681
+ }
682
+ }
683
+ if (segments.length > 0 || source == null)
684
+ writeFileSync(descriptor, '\n', 'utf8');
685
+ fsyncSync(descriptor);
686
+ const completed = descriptor;
687
+ descriptor = null;
688
+ closeSync(completed);
689
+ }
690
+ finally {
691
+ if (descriptor != null)
692
+ closeSync(descriptor);
693
+ }
694
+ }
695
+ function removeOwned(paths) {
696
+ let firstError;
697
+ for (const path of paths) {
698
+ try {
699
+ unlinkSync(path);
700
+ }
701
+ catch (error) {
702
+ if (error.code !== 'ENOENT' && firstError == null) {
703
+ firstError = error;
704
+ }
705
+ }
706
+ }
707
+ if (firstError != null)
708
+ throw firstError;
709
+ }
710
+ function removeOwnedQuietly(paths) {
711
+ try {
712
+ removeOwned(paths);
713
+ }
714
+ catch {
715
+ // Preserve the archive failure after attempting every owned path.
716
+ }
717
+ }
718
+ //# sourceMappingURL=archive-service.js.map