@gryt/core 0.28.0 → 0.30.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/dist/import/discordImportJob.d.ts +7 -1
- package/dist/import/discordImportJob.js +101 -71
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mls/content.d.ts +10 -0
- package/dist/mls/content.js +20 -2
- package/package.json +1 -1
|
@@ -34,6 +34,12 @@ export interface ImportDeps {
|
|
|
34
34
|
newId: () => string;
|
|
35
35
|
/** Downloads and uploads running together. A phone holding each file in memory wants fewer. */
|
|
36
36
|
transfersAtOnce?: number;
|
|
37
|
+
/** Send a big import's text as one sealed upload. Only for apps that read `import_bundle`. */
|
|
38
|
+
bundles?: boolean;
|
|
39
|
+
/** Whether this device's history already holds an entry, so a second run skips it. */
|
|
40
|
+
alreadyHas?: (entryId: string) => Promise<boolean>;
|
|
41
|
+
/** The server's file size limit, so a file over it is never sealed and sent to be refused. */
|
|
42
|
+
maxFileBytes?: number | null;
|
|
37
43
|
}
|
|
38
44
|
export interface ImportProgress {
|
|
39
45
|
messages: {
|
|
@@ -61,5 +67,5 @@ export interface ImportControl {
|
|
|
61
67
|
/** For a 429's Retry-After header. Only the seconds form: anything else gets the default wait. */
|
|
62
68
|
export declare function retryAfterMs(header: string | null): number;
|
|
63
69
|
export declare const importId: (discordId: string) => string;
|
|
64
|
-
/**
|
|
70
|
+
/** Text first, then 20 messages at a time: their files go straight through, then those messages go. */
|
|
65
71
|
export declare function runDiscordImport(messages: DiscordPackageMessage[], deps: ImportDeps, control: ImportControl, report: (p: ImportProgress) => void): Promise<ImportProgress>;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { importBatches } from "../mls/content.js";
|
|
1
|
+
import { encodeImportBundle, importBatches } from "../mls/content.js";
|
|
2
2
|
const TRANSFERS_AT_ONCE = 16;
|
|
3
3
|
/** `mls:send` allows about one send every two seconds sustained (RL_SEND in the server). */
|
|
4
4
|
const SEND_GAP_MS = 2_000;
|
|
5
5
|
const FILE_BATCH = 20;
|
|
6
|
+
/* A batch holds up to 200, and one send is the point where a bundle starts saving time. */
|
|
7
|
+
const BUNDLE_ABOVE = 200;
|
|
6
8
|
const DOWNLOAD_RETRIES = 5;
|
|
7
9
|
/* A server allows 30 uploads a minute with a ban after, so a big import waits many times over. */
|
|
8
10
|
const UPLOAD_RETRIES = 200;
|
|
@@ -22,12 +24,16 @@ function nameOf(url) {
|
|
|
22
24
|
return "file";
|
|
23
25
|
}
|
|
24
26
|
}
|
|
25
|
-
/**
|
|
27
|
+
/** Text first, then 20 messages at a time: their files go straight through, then those messages go. */
|
|
26
28
|
export async function runDiscordImport(messages, deps, control, report) {
|
|
27
|
-
const
|
|
29
|
+
const fresh = [];
|
|
30
|
+
for (const m of messages)
|
|
31
|
+
if (!(await deps.alreadyHas?.(importId(m.discordId))))
|
|
32
|
+
fresh.push(m);
|
|
33
|
+
const withFiles = fresh.filter((m) => m.attachments.length > 0);
|
|
28
34
|
const urls = [...new Set(withFiles.flatMap((m) => m.attachments))];
|
|
29
35
|
const p = {
|
|
30
|
-
messages: { done:
|
|
36
|
+
messages: { done: messages.length - fresh.length, total: messages.length },
|
|
31
37
|
downloaded: { done: 0, total: urls.length },
|
|
32
38
|
sent: { done: 0, total: urls.length },
|
|
33
39
|
missing: 0,
|
|
@@ -41,6 +47,32 @@ export async function runDiscordImport(messages, deps, control, report) {
|
|
|
41
47
|
while (control.paused() && !control.stopped())
|
|
42
48
|
await deps.sleep(500);
|
|
43
49
|
};
|
|
50
|
+
let uploadClearAt = 0;
|
|
51
|
+
const uploadFile = async (file) => {
|
|
52
|
+
for (let attempt = 0; attempt <= UPLOAD_RETRIES && !control.stopped(); attempt++) {
|
|
53
|
+
const wait = uploadClearAt - Date.now();
|
|
54
|
+
if (wait > 0)
|
|
55
|
+
await deps.sleep(wait);
|
|
56
|
+
const got = await deps.upload(file);
|
|
57
|
+
if (!("retryAfterMs" in got))
|
|
58
|
+
return got;
|
|
59
|
+
uploadClearAt = Math.max(uploadClearAt, Date.now() + Math.min(got.retryAfterMs, MAX_RATE_LIMIT_WAIT_MS));
|
|
60
|
+
}
|
|
61
|
+
if (control.stopped())
|
|
62
|
+
return null;
|
|
63
|
+
throw new Error("The server kept refusing uploads.");
|
|
64
|
+
};
|
|
65
|
+
/* False when the bundle can't go up, so the batches carry it. */
|
|
66
|
+
const sendBundle = async (entries) => {
|
|
67
|
+
await hold();
|
|
68
|
+
const up = await uploadFile({ name: "discord-import.json", type: "application/json", bytes: encodeImportBundle(entries) });
|
|
69
|
+
if (!up || "tooLarge" in up)
|
|
70
|
+
return false;
|
|
71
|
+
await deps.send({ type: "import_bundle", id: deps.newId(), source: "discord", attachments: { [up.id]: up.key } });
|
|
72
|
+
p.messages.done += entries.length;
|
|
73
|
+
tick();
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
44
76
|
const sendAll = async (entries) => {
|
|
45
77
|
for (const batch of importBatches(entries, deps.newId)) {
|
|
46
78
|
await hold();
|
|
@@ -52,7 +84,9 @@ export async function runDiscordImport(messages, deps, control, report) {
|
|
|
52
84
|
await deps.sleep(SEND_GAP_MS);
|
|
53
85
|
}
|
|
54
86
|
};
|
|
55
|
-
|
|
87
|
+
const textOnly = fresh.filter((m) => m.attachments.length === 0).map((m) => ({ id: importId(m.discordId), sentAt: m.sentAt, text: m.text }));
|
|
88
|
+
if (!(deps.bundles && textOnly.length > BUNDLE_ABOVE && !control.stopped() && (await sendBundle(textOnly))))
|
|
89
|
+
await sendAll(textOnly);
|
|
56
90
|
const missing = new Set();
|
|
57
91
|
let clearAt = 0;
|
|
58
92
|
const downloadFile = async (url) => {
|
|
@@ -68,89 +102,85 @@ export async function runDiscordImport(messages, deps, control, report) {
|
|
|
68
102
|
}
|
|
69
103
|
return null;
|
|
70
104
|
};
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (!(await deps.store.has(url))) {
|
|
84
|
-
const file = await downloadFile(url);
|
|
85
|
-
if (file) {
|
|
105
|
+
const uploaded = new Map();
|
|
106
|
+
const tooLarge = new Set();
|
|
107
|
+
const finished = new Set();
|
|
108
|
+
/* The file is only kept if it can't go up now, so a stop or a failure never downloads it twice. */
|
|
109
|
+
const transfer = async (url) => {
|
|
110
|
+
if (finished.has(url))
|
|
111
|
+
return;
|
|
112
|
+
finished.add(url);
|
|
113
|
+
let file = await deps.store.take(url);
|
|
114
|
+
if (!file) {
|
|
115
|
+
file = await downloadFile(url);
|
|
116
|
+
if (file)
|
|
86
117
|
p.bytes += file.bytes.length;
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
else {
|
|
118
|
+
else if (!control.stopped()) {
|
|
90
119
|
missing.add(url);
|
|
91
120
|
p.missing = missing.size;
|
|
92
121
|
}
|
|
93
122
|
}
|
|
123
|
+
if (control.stopped()) {
|
|
124
|
+
if (file)
|
|
125
|
+
await deps.store.put(url, file);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
94
128
|
p.downloaded.done++;
|
|
95
129
|
tick();
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
return
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
if (control.stopped())
|
|
111
|
-
return null;
|
|
112
|
-
throw new Error("The server kept refusing uploads.");
|
|
113
|
-
};
|
|
114
|
-
await eachAtOnce(async (url) => {
|
|
115
|
-
const stored = missing.has(url) ? null : await deps.store.take(url);
|
|
116
|
-
if (stored) {
|
|
117
|
-
const up = await uploadFile({ name: nameOf(url), ...stored });
|
|
118
|
-
if (up && "tooLarge" in up) {
|
|
130
|
+
if (file) {
|
|
131
|
+
let up;
|
|
132
|
+
try {
|
|
133
|
+
up = deps.maxFileBytes && file.bytes.length > deps.maxFileBytes ? { tooLarge: true } : await uploadFile({ name: nameOf(url), ...file });
|
|
134
|
+
}
|
|
135
|
+
catch (e) {
|
|
136
|
+
await deps.store.put(url, file);
|
|
137
|
+
throw e;
|
|
138
|
+
}
|
|
139
|
+
if (!up) {
|
|
140
|
+
await deps.store.put(url, file);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if ("tooLarge" in up) {
|
|
119
144
|
tooLarge.add(url);
|
|
120
145
|
p.tooLarge = tooLarge.size;
|
|
121
146
|
}
|
|
122
|
-
else
|
|
147
|
+
else
|
|
123
148
|
uploaded.set(url, up);
|
|
124
|
-
|
|
125
|
-
if (up)
|
|
126
|
-
await deps.store.drop(url);
|
|
149
|
+
await deps.store.drop(url);
|
|
127
150
|
}
|
|
128
151
|
p.sent.done++;
|
|
129
152
|
tick();
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
for (
|
|
153
|
+
};
|
|
154
|
+
const atOnce = deps.transfersAtOnce ?? TRANSFERS_AT_ONCE;
|
|
155
|
+
for (let i = 0; i < withFiles.length && !control.stopped(); i += FILE_BATCH) {
|
|
156
|
+
const chunk = withFiles.slice(i, i + FILE_BATCH);
|
|
157
|
+
const todo = [...new Set(chunk.flatMap((m) => m.attachments))];
|
|
158
|
+
let next = 0;
|
|
159
|
+
await Promise.all(Array.from({ length: atOnce }, async () => {
|
|
160
|
+
while (!control.stopped()) {
|
|
161
|
+
await hold();
|
|
162
|
+
if (next >= todo.length)
|
|
163
|
+
break;
|
|
164
|
+
await transfer(todo[next++]);
|
|
165
|
+
}
|
|
166
|
+
}));
|
|
133
167
|
if (control.stopped())
|
|
134
168
|
break;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
pending = [];
|
|
150
|
-
}
|
|
169
|
+
await sendAll(chunk.map((m) => {
|
|
170
|
+
const attachments = {};
|
|
171
|
+
for (const url of m.attachments) {
|
|
172
|
+
const up = uploaded.get(url);
|
|
173
|
+
if (up)
|
|
174
|
+
attachments[up.id] = up.key;
|
|
175
|
+
}
|
|
176
|
+
const big = m.attachments.filter((u) => tooLarge.has(u)).length;
|
|
177
|
+
const lost = m.attachments.length - Object.keys(attachments).length - big;
|
|
178
|
+
const lostNote = lost ? `${lost === 1 ? "A file" : `${lost} files`} from Discord couldn’t be downloaded.` : "";
|
|
179
|
+
const bigNote = big ? `${big === 1 ? "A file" : `${big} files`} from Discord ${big === 1 ? "was" : "were"} too big for this server.` : "";
|
|
180
|
+
const text = [m.text, lostNote, bigNote].filter(Boolean).join("\n");
|
|
181
|
+
return { id: importId(m.discordId), sentAt: m.sentAt, text, ...(Object.keys(attachments).length ? { attachments } : {}) };
|
|
182
|
+
}));
|
|
151
183
|
}
|
|
152
|
-
if (pending.length)
|
|
153
|
-
await sendAll(pending);
|
|
154
184
|
tick();
|
|
155
185
|
return p;
|
|
156
186
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_
|
|
|
13
13
|
export { createMlsDmDriver, MlsDriverError, type MlsDriverErrorCode } from "./mls/dmDriver.js";
|
|
14
14
|
export { mlsPinsFromPeerPins } from "./mls/pins.js";
|
|
15
15
|
export { reencryptedId, reencryptMine, type ReencryptCandidate, type ReencryptDeps } from "./mls/reencrypt.js";
|
|
16
|
-
export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, type MlsDmContent, type MlsHistoryEntry, type MlsHistoryReaction, type MlsImportEntry, readMlsDmContent, } from "./mls/content.js";
|
|
16
|
+
export { decodeMlsDmContent, encodeImportBundle, readImportBundle, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, type MlsDmContent, type MlsHistoryEntry, type MlsHistoryReaction, type MlsImportEntry, readMlsDmContent, } from "./mls/content.js";
|
|
17
17
|
export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
|
|
18
18
|
export { type DiscordPackageDm, type DiscordPackageMessage, isDiscordPackage, linksExpireAt, listDiscordDms, type PackageFiles, readDiscordDm, } from "./import/discordPackage.js";
|
|
19
19
|
export { inflateRaw, openZipBytes } from "./import/unzip.js";
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,7 @@ export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_
|
|
|
15
15
|
export { createMlsDmDriver, MlsDriverError } from "./mls/dmDriver.js";
|
|
16
16
|
export { mlsPinsFromPeerPins } from "./mls/pins.js";
|
|
17
17
|
export { reencryptedId, reencryptMine } from "./mls/reencrypt.js";
|
|
18
|
-
export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
|
|
18
|
+
export { decodeMlsDmContent, encodeImportBundle, readImportBundle, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
|
|
19
19
|
export { applyMlsReaction, mlsReactionAction } from "./mls/reactions.js";
|
|
20
20
|
export { isDiscordPackage, linksExpireAt, listDiscordDms, readDiscordDm, } from "./import/discordPackage.js";
|
|
21
21
|
export { inflateRaw, openZipBytes } from "./import/unzip.js";
|
package/dist/mls/content.d.ts
CHANGED
|
@@ -30,6 +30,13 @@ export type MlsDmContent = {
|
|
|
30
30
|
id: string;
|
|
31
31
|
source: "discord";
|
|
32
32
|
entries: MlsImportEntry[];
|
|
33
|
+
} | {
|
|
34
|
+
/** An import too big for messages: its entries sit in one sealed upload, read with `readImportBundle`. */
|
|
35
|
+
type: "import_bundle";
|
|
36
|
+
id: string;
|
|
37
|
+
source: "discord";
|
|
38
|
+
/** Upload id to its key. Exactly one. */
|
|
39
|
+
attachments: Record<string, SealedAttachmentKey>;
|
|
33
40
|
} | {
|
|
34
41
|
type: "history";
|
|
35
42
|
/** For the batch itself, so a repeat is the same message. */
|
|
@@ -82,6 +89,9 @@ export declare function acceptImport(content: Extract<MlsDmContent, {
|
|
|
82
89
|
senderId: string;
|
|
83
90
|
at: number;
|
|
84
91
|
}): MlsHistoryEntry[];
|
|
92
|
+
export declare function encodeImportBundle(entries: MlsImportEntry[]): Uint8Array;
|
|
93
|
+
/** A bundle's entries, or null for junk. A peer wrote it, so each entry is checked like a message's. */
|
|
94
|
+
export declare function readImportBundle(bytes: Uint8Array): MlsImportEntry[] | null;
|
|
85
95
|
/** Null for anything this version can't read: a newer version, a new type, or junk. */
|
|
86
96
|
export declare function decodeMlsDmContent(bytes: Uint8Array): MlsDmContent | null;
|
|
87
97
|
/**
|
package/dist/mls/content.js
CHANGED
|
@@ -84,7 +84,10 @@ function readAttachments(v) {
|
|
|
84
84
|
/** The same bytes the phone writes: `v` first, then the content's fields in this order. */
|
|
85
85
|
export function encodeMlsDmContent(content) {
|
|
86
86
|
let body;
|
|
87
|
-
if (content.type === "
|
|
87
|
+
if (content.type === "import_bundle") {
|
|
88
|
+
body = { type: content.type, id: content.id, source: content.source, attachments: content.attachments };
|
|
89
|
+
}
|
|
90
|
+
else if (content.type === "history") {
|
|
88
91
|
body = { type: content.type, id: content.id, entries: content.entries.map(historyBody) };
|
|
89
92
|
}
|
|
90
93
|
else if (content.type === "import") {
|
|
@@ -213,6 +216,17 @@ function readImportEntry(v) {
|
|
|
213
216
|
}
|
|
214
217
|
return out;
|
|
215
218
|
}
|
|
219
|
+
export function encodeImportBundle(entries) {
|
|
220
|
+
return new TextEncoder().encode(JSON.stringify({ v: MLS_DM_CONTENT_VERSION, entries: entries.map(importBody) }));
|
|
221
|
+
}
|
|
222
|
+
/** A bundle's entries, or null for junk. A peer wrote it, so each entry is checked like a message's. */
|
|
223
|
+
export function readImportBundle(bytes) {
|
|
224
|
+
const raw = parse(bytes);
|
|
225
|
+
if (!isObject(raw) || raw.v !== MLS_DM_CONTENT_VERSION || !Array.isArray(raw.entries))
|
|
226
|
+
return null;
|
|
227
|
+
const entries = raw.entries.map(readImportEntry);
|
|
228
|
+
return entries.every((e) => e !== null) ? entries : null;
|
|
229
|
+
}
|
|
216
230
|
/** Null for anything this version can't read: a newer version, a new type, or junk. */
|
|
217
231
|
export function decodeMlsDmContent(bytes) {
|
|
218
232
|
return fromRaw(parse(bytes));
|
|
@@ -225,7 +239,7 @@ export function readMlsDmContent(bytes) {
|
|
|
225
239
|
const raw = parse(bytes);
|
|
226
240
|
return fromRaw(raw) ?? (isNewer(raw) ? "newer" : null);
|
|
227
241
|
}
|
|
228
|
-
const KNOWN_TYPES = new Set(["message", "edit", "delete", "reaction", "history", "import"]);
|
|
242
|
+
const KNOWN_TYPES = new Set(["message", "edit", "delete", "reaction", "history", "import", "import_bundle"]);
|
|
229
243
|
const TYPE_NAME = /^[a-z][a-z0-9_]{0,31}$/;
|
|
230
244
|
/** A later version, or a type this one doesn't know that names its message the way ours do. */
|
|
231
245
|
function isNewer(raw) {
|
|
@@ -259,6 +273,10 @@ function fromRaw(raw) {
|
|
|
259
273
|
const entries = raw.entries.map(readImportEntry);
|
|
260
274
|
return entries.every((e) => e !== null) ? { type, id, source: raw.source, entries } : null;
|
|
261
275
|
}
|
|
276
|
+
if (type === "import_bundle") {
|
|
277
|
+
const attachments = raw.source === "discord" ? readAttachments(raw.attachments) : null;
|
|
278
|
+
return attachments && Object.keys(attachments).length === 1 ? { type, id, source: "discord", attachments } : null;
|
|
279
|
+
}
|
|
262
280
|
if (type === "message" && isText(text)) {
|
|
263
281
|
const out = { type, id, text };
|
|
264
282
|
if (raw.replyTo !== undefined) {
|
package/package.json
CHANGED