@will-17173/telegram-cli 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +241 -61
- package/README.zh-CN.md +250 -73
- package/dist/account/account-authenticator.js +95 -0
- package/dist/account/account-authenticator.js.map +1 -0
- package/dist/account/account-context.js +7 -0
- package/dist/account/account-context.js.map +1 -1
- package/dist/account/account-name.js +17 -0
- package/dist/account/account-name.js.map +1 -0
- package/dist/account/account-presets.js +20 -3
- package/dist/account/account-presets.js.map +1 -1
- package/dist/account/account-store.js +277 -37
- package/dist/account/account-store.js.map +1 -1
- package/dist/cli/app.js +12 -1
- package/dist/cli/app.js.map +1 -1
- package/dist/cli/output.js +21 -6
- package/dist/cli/output.js.map +1 -1
- package/dist/cli/secure-input.js +408 -0
- package/dist/cli/secure-input.js.map +1 -0
- package/dist/commands/account-options.js +37 -7
- package/dist/commands/account-options.js.map +1 -1
- package/dist/commands/account.js +192 -66
- package/dist/commands/account.js.map +1 -1
- package/dist/commands/archive.js +151 -0
- package/dist/commands/archive.js.map +1 -0
- package/dist/commands/config.js +119 -12
- package/dist/commands/config.js.map +1 -1
- package/dist/commands/contact.js +58 -0
- package/dist/commands/contact.js.map +1 -0
- package/dist/commands/data.js +14 -7
- package/dist/commands/data.js.map +1 -1
- package/dist/commands/dialog.js +156 -0
- package/dist/commands/dialog.js.map +1 -0
- package/dist/commands/folder.js +113 -0
- package/dist/commands/folder.js.map +1 -0
- package/dist/commands/group-write.js +128 -0
- package/dist/commands/group-write.js.map +1 -0
- package/dist/commands/group.js +43 -7
- package/dist/commands/group.js.map +1 -1
- package/dist/commands/notification.js +76 -0
- package/dist/commands/notification.js.map +1 -0
- package/dist/commands/query.js +22 -15
- package/dist/commands/query.js.map +1 -1
- package/dist/commands/telegram-runner.js +40 -18
- package/dist/commands/telegram-runner.js.map +1 -1
- package/dist/commands/telegram.js +172 -109
- package/dist/commands/telegram.js.map +1 -1
- package/dist/commands/time-range.js +39 -0
- package/dist/commands/time-range.js.map +1 -0
- package/dist/commands/types.js +3 -2
- package/dist/commands/types.js.map +1 -1
- package/dist/config/credential-store.js +24 -4
- package/dist/config/credential-store.js.map +1 -1
- package/dist/config/env.js +4 -1
- package/dist/config/env.js.map +1 -1
- package/dist/group-commands/catalog.js +79 -0
- package/dist/group-commands/catalog.js.map +1 -0
- package/dist/group-commands/executor.js +102 -0
- package/dist/group-commands/executor.js.map +1 -0
- package/dist/group-commands/parser.js +268 -0
- package/dist/group-commands/parser.js.map +1 -0
- package/dist/group-commands/tokenize.js +51 -0
- package/dist/group-commands/tokenize.js.map +1 -0
- package/dist/group-commands/types.js +2 -0
- package/dist/group-commands/types.js.map +1 -0
- package/dist/index.js +0 -0
- package/dist/listen-commands/catalog.js +61 -0
- package/dist/listen-commands/catalog.js.map +1 -0
- package/dist/listen-commands/dispatch.js +46 -0
- package/dist/listen-commands/dispatch.js.map +1 -0
- package/dist/listen-commands/match.js +113 -0
- package/dist/listen-commands/match.js.map +1 -0
- package/dist/presenters/group.js +40 -0
- package/dist/presenters/group.js.map +1 -1
- package/dist/presenters/human.js +135 -0
- package/dist/presenters/human.js.map +1 -1
- package/dist/presenters/ink/display-width.js +45 -7
- package/dist/presenters/ink/display-width.js.map +1 -1
- package/dist/presenters/ink/group-command-confirm.js +13 -0
- package/dist/presenters/ink/group-command-confirm.js.map +1 -0
- package/dist/presenters/ink/group-command-result.js +23 -0
- package/dist/presenters/ink/group-command-result.js.map +1 -0
- package/dist/presenters/ink/listen-command-menu.js +38 -0
- package/dist/presenters/ink/listen-command-menu.js.map +1 -0
- package/dist/presenters/ink/listen-scroll.js +9 -4
- package/dist/presenters/ink/listen-scroll.js.map +1 -1
- package/dist/presenters/ink/listen.js +572 -52
- package/dist/presenters/ink/listen.js.map +1 -1
- package/dist/presenters/ink/mouse-scroll.js +12 -0
- package/dist/presenters/ink/mouse-scroll.js.map +1 -1
- package/dist/presenters/ink/secure-input.js +68 -0
- package/dist/presenters/ink/secure-input.js.map +1 -0
- package/dist/presenters/ink/use-group-command.js +186 -0
- package/dist/presenters/ink/use-group-command.js.map +1 -0
- package/dist/presenters/listen-message.js +23 -6
- package/dist/presenters/listen-message.js.map +1 -1
- package/dist/presenters/logical-message.js +78 -0
- package/dist/presenters/logical-message.js.map +1 -0
- package/dist/presenters/markdown.js +54 -0
- package/dist/presenters/markdown.js.map +1 -0
- package/dist/presenters/structured.js +8 -3
- package/dist/presenters/structured.js.map +1 -1
- package/dist/services/account-session-service.js +241 -0
- package/dist/services/account-session-service.js.map +1 -0
- package/dist/services/archive-layout.js +61 -0
- package/dist/services/archive-layout.js.map +1 -0
- package/dist/services/archive-manifest.js +187 -0
- package/dist/services/archive-manifest.js.map +1 -0
- package/dist/services/archive-markdown.js +383 -0
- package/dist/services/archive-markdown.js.map +1 -0
- package/dist/services/archive-service.js +718 -0
- package/dist/services/archive-service.js.map +1 -0
- package/dist/services/archive-types.js +2 -0
- package/dist/services/archive-types.js.map +1 -0
- package/dist/services/attachment-download.js +7 -2
- package/dist/services/attachment-download.js.map +1 -1
- package/dist/services/auto-download-coordinator.js +2 -4
- package/dist/services/auto-download-coordinator.js.map +1 -1
- package/dist/services/contact-service.js +78 -0
- package/dist/services/contact-service.js.map +1 -0
- package/dist/services/dialog-service.js +140 -0
- package/dist/services/dialog-service.js.map +1 -0
- package/dist/services/folder-service.js +200 -0
- package/dist/services/folder-service.js.map +1 -0
- package/dist/services/group-service.js.map +1 -1
- package/dist/services/group-write-service.js +225 -0
- package/dist/services/group-write-service.js.map +1 -0
- package/dist/services/listen-album-aggregator.js +9 -29
- package/dist/services/listen-album-aggregator.js.map +1 -1
- package/dist/services/listen-attachment.js +23 -6
- package/dist/services/listen-attachment.js.map +1 -1
- package/dist/services/listen-composer-command.js +102 -0
- package/dist/services/listen-composer-command.js.map +1 -0
- package/dist/services/listen-reply-resolver.js +126 -0
- package/dist/services/listen-reply-resolver.js.map +1 -0
- package/dist/services/message-service.js +17 -1
- package/dist/services/message-service.js.map +1 -1
- package/dist/services/notification-service.js +142 -0
- package/dist/services/notification-service.js.map +1 -0
- package/dist/services/query-service.js +59 -3
- package/dist/services/query-service.js.map +1 -1
- package/dist/services/reply-context.js +29 -0
- package/dist/services/reply-context.js.map +1 -0
- package/dist/services/write-access-policy.js +19 -0
- package/dist/services/write-access-policy.js.map +1 -0
- package/dist/storage/message-db.js +241 -5
- package/dist/storage/message-db.js.map +1 -1
- package/dist/telegram/archive-types.js +2 -0
- package/dist/telegram/archive-types.js.map +1 -0
- package/dist/telegram/client-factory.js +51 -11
- package/dist/telegram/client-factory.js.map +1 -1
- package/dist/telegram/contact-types.js +10 -0
- package/dist/telegram/contact-types.js.map +1 -0
- package/dist/telegram/dialog-types.js +2 -0
- package/dist/telegram/dialog-types.js.map +1 -0
- package/dist/telegram/errors.js +14 -0
- package/dist/telegram/errors.js.map +1 -0
- package/dist/telegram/fake-group-management.js +122 -0
- package/dist/telegram/fake-group-management.js.map +1 -1
- package/dist/telegram/folder-types.js +13 -0
- package/dist/telegram/folder-types.js.map +1 -0
- package/dist/telegram/group-types.js +40 -0
- package/dist/telegram/group-types.js.map +1 -1
- package/dist/telegram/group-write-types.js +29 -0
- package/dist/telegram/group-write-types.js.map +1 -0
- package/dist/telegram/mtcute-archive.js +190 -0
- package/dist/telegram/mtcute-archive.js.map +1 -0
- package/dist/telegram/mtcute-client.js +73 -12
- package/dist/telegram/mtcute-client.js.map +1 -1
- package/dist/telegram/mtcute-contacts.js +90 -0
- package/dist/telegram/mtcute-contacts.js.map +1 -0
- package/dist/telegram/mtcute-dialogs.js +186 -0
- package/dist/telegram/mtcute-dialogs.js.map +1 -0
- package/dist/telegram/mtcute-folders.js +318 -0
- package/dist/telegram/mtcute-folders.js.map +1 -0
- package/dist/telegram/mtcute-group-helpers.js +56 -0
- package/dist/telegram/mtcute-group-helpers.js.map +1 -0
- package/dist/telegram/mtcute-group-invites.js +82 -0
- package/dist/telegram/mtcute-group-invites.js.map +1 -0
- package/dist/telegram/mtcute-group-management.js +60 -31
- package/dist/telegram/mtcute-group-management.js.map +1 -1
- package/dist/telegram/mtcute-group-members.js +158 -0
- package/dist/telegram/mtcute-group-members.js.map +1 -0
- package/dist/telegram/mtcute-group-settings.js +146 -0
- package/dist/telegram/mtcute-group-settings.js.map +1 -0
- package/dist/telegram/mtcute-group-topics.js +63 -0
- package/dist/telegram/mtcute-group-topics.js.map +1 -0
- package/dist/telegram/mtcute-message-normalizer.js +63 -0
- package/dist/telegram/mtcute-message-normalizer.js.map +1 -0
- package/dist/telegram/mtcute-notifications.js +101 -0
- package/dist/telegram/mtcute-notifications.js.map +1 -0
- package/dist/telegram/notification-types.js +11 -0
- package/dist/telegram/notification-types.js.map +1 -0
- package/dist/telegram/raw-message.js +46 -0
- package/dist/telegram/raw-message.js.map +1 -0
- package/dist/telegram/types.js +6 -1
- package/dist/telegram/types.js.map +1 -1
- package/package.json +20 -10
|
@@ -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
|