@gryt/core 0.22.0 → 0.24.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 +56 -0
- package/dist/import/discordImportJob.js +124 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/mls/reencrypt.d.ts +40 -0
- package/dist/mls/reencrypt.js +57 -0
- package/package.json +1 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { SealedAttachmentKey } from "@gryt/crypto";
|
|
2
|
+
import type { MlsDmContent } from "../mls/content.js";
|
|
3
|
+
import type { DiscordPackageMessage } from "./discordPackage.js";
|
|
4
|
+
export interface ImportFile {
|
|
5
|
+
name: string;
|
|
6
|
+
type: string;
|
|
7
|
+
bytes: Uint8Array;
|
|
8
|
+
}
|
|
9
|
+
/** Where downloads wait until they're uploaded: Cache Storage on the desktop, files on the phone. */
|
|
10
|
+
export interface ImportFileStore {
|
|
11
|
+
has(url: string): Promise<boolean>;
|
|
12
|
+
put(url: string, file: Omit<ImportFile, "name">): Promise<void>;
|
|
13
|
+
take(url: string): Promise<Omit<ImportFile, "name"> | null>;
|
|
14
|
+
drop(url: string): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
/** What the job needs from the app. Each app supplies its own, and a test supplies fakes. */
|
|
17
|
+
export interface ImportDeps {
|
|
18
|
+
send: (content: MlsDmContent) => Promise<void>;
|
|
19
|
+
/** Null when Discord won't hand it over (expired, gone, refused). `retryAfterMs` when it answers 429. */
|
|
20
|
+
download: (url: string) => Promise<Omit<ImportFile, "name"> | {
|
|
21
|
+
retryAfterMs: number;
|
|
22
|
+
} | null>;
|
|
23
|
+
upload: (file: ImportFile) => Promise<{
|
|
24
|
+
id: string;
|
|
25
|
+
key: SealedAttachmentKey;
|
|
26
|
+
}>;
|
|
27
|
+
store: ImportFileStore;
|
|
28
|
+
sleep: (ms: number) => Promise<void>;
|
|
29
|
+
newId: () => string;
|
|
30
|
+
}
|
|
31
|
+
export interface ImportProgress {
|
|
32
|
+
messages: {
|
|
33
|
+
done: number;
|
|
34
|
+
total: number;
|
|
35
|
+
};
|
|
36
|
+
downloaded: {
|
|
37
|
+
done: number;
|
|
38
|
+
total: number;
|
|
39
|
+
};
|
|
40
|
+
sent: {
|
|
41
|
+
done: number;
|
|
42
|
+
total: number;
|
|
43
|
+
};
|
|
44
|
+
missing: number;
|
|
45
|
+
bytes: number;
|
|
46
|
+
startedAt: number;
|
|
47
|
+
}
|
|
48
|
+
export interface ImportControl {
|
|
49
|
+
paused: () => boolean;
|
|
50
|
+
stopped: () => boolean;
|
|
51
|
+
}
|
|
52
|
+
/** For a 429's Retry-After header. Only the seconds form: anything else gets the default wait. */
|
|
53
|
+
export declare function retryAfterMs(header: string | null): number;
|
|
54
|
+
export declare const importId: (discordId: string) => string;
|
|
55
|
+
/** Messages without files first, then every file downloaded to the store, then the messages that carry files. */
|
|
56
|
+
export declare function runDiscordImport(messages: DiscordPackageMessage[], deps: ImportDeps, control: ImportControl, report: (p: ImportProgress) => void): Promise<ImportProgress>;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { importBatches } from "../mls/content.js";
|
|
2
|
+
const DOWNLOADS_AT_ONCE = 4;
|
|
3
|
+
/** `mls:send` allows about one send every two seconds sustained (RL_SEND in the server). */
|
|
4
|
+
const SEND_GAP_MS = 2_000;
|
|
5
|
+
const FILE_BATCH = 20;
|
|
6
|
+
const DOWNLOAD_RETRIES = 5;
|
|
7
|
+
const MAX_RATE_LIMIT_WAIT_MS = 60_000;
|
|
8
|
+
const DEFAULT_RATE_LIMIT_WAIT_MS = 5_000;
|
|
9
|
+
/** For a 429's Retry-After header. Only the seconds form: anything else gets the default wait. */
|
|
10
|
+
export function retryAfterMs(header) {
|
|
11
|
+
const seconds = Number(header);
|
|
12
|
+
return header && seconds >= 0 ? seconds * 1000 : DEFAULT_RATE_LIMIT_WAIT_MS;
|
|
13
|
+
}
|
|
14
|
+
export const importId = (discordId) => `discord-${discordId}`;
|
|
15
|
+
function nameOf(url) {
|
|
16
|
+
try {
|
|
17
|
+
return decodeURIComponent(new URL(url).pathname.split("/").pop() || "file");
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return "file";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Messages without files first, then every file downloaded to the store, then the messages that carry files. */
|
|
24
|
+
export async function runDiscordImport(messages, deps, control, report) {
|
|
25
|
+
const withFiles = messages.filter((m) => m.attachments.length > 0);
|
|
26
|
+
const urls = [...new Set(withFiles.flatMap((m) => m.attachments))];
|
|
27
|
+
const p = {
|
|
28
|
+
messages: { done: 0, total: messages.length },
|
|
29
|
+
downloaded: { done: 0, total: urls.length },
|
|
30
|
+
sent: { done: 0, total: urls.length },
|
|
31
|
+
missing: 0,
|
|
32
|
+
bytes: 0,
|
|
33
|
+
startedAt: Date.now(),
|
|
34
|
+
};
|
|
35
|
+
const tick = () => report({ ...p, messages: { ...p.messages }, downloaded: { ...p.downloaded }, sent: { ...p.sent } });
|
|
36
|
+
tick();
|
|
37
|
+
const hold = async () => {
|
|
38
|
+
while (control.paused() && !control.stopped())
|
|
39
|
+
await deps.sleep(500);
|
|
40
|
+
};
|
|
41
|
+
const sendAll = async (entries) => {
|
|
42
|
+
for (const batch of importBatches(entries, deps.newId)) {
|
|
43
|
+
await hold();
|
|
44
|
+
if (control.stopped())
|
|
45
|
+
return;
|
|
46
|
+
await deps.send(batch);
|
|
47
|
+
p.messages.done += batch.entries.length;
|
|
48
|
+
tick();
|
|
49
|
+
await deps.sleep(SEND_GAP_MS);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
await sendAll(messages.filter((m) => m.attachments.length === 0).map((m) => ({ id: importId(m.discordId), sentAt: m.sentAt, text: m.text })));
|
|
53
|
+
const missing = new Set();
|
|
54
|
+
let next = 0;
|
|
55
|
+
let clearAt = 0;
|
|
56
|
+
const downloadFile = async (url) => {
|
|
57
|
+
for (let attempt = 0; attempt <= DOWNLOAD_RETRIES && !control.stopped(); attempt++) {
|
|
58
|
+
const wait = clearAt - Date.now();
|
|
59
|
+
if (wait > 0)
|
|
60
|
+
await deps.sleep(wait);
|
|
61
|
+
const got = await deps.download(url).catch(() => null);
|
|
62
|
+
if (!got || !("retryAfterMs" in got))
|
|
63
|
+
return got;
|
|
64
|
+
// Every worker waits, or the other three spend the wait getting 429s too.
|
|
65
|
+
clearAt = Math.max(clearAt, Date.now() + Math.min(got.retryAfterMs, MAX_RATE_LIMIT_WAIT_MS));
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
const worker = async () => {
|
|
70
|
+
while (!control.stopped()) {
|
|
71
|
+
await hold();
|
|
72
|
+
if (next >= urls.length)
|
|
73
|
+
break;
|
|
74
|
+
const url = urls[next++];
|
|
75
|
+
if (!(await deps.store.has(url))) {
|
|
76
|
+
const file = await downloadFile(url);
|
|
77
|
+
if (file) {
|
|
78
|
+
p.bytes += file.bytes.length;
|
|
79
|
+
await deps.store.put(url, file);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
missing.add(url);
|
|
83
|
+
p.missing = missing.size;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
p.downloaded.done++;
|
|
87
|
+
tick();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
await Promise.all(Array.from({ length: DOWNLOADS_AT_ONCE }, worker));
|
|
91
|
+
const uploaded = new Map();
|
|
92
|
+
let pending = [];
|
|
93
|
+
for (const m of withFiles) {
|
|
94
|
+
if (control.stopped())
|
|
95
|
+
break;
|
|
96
|
+
const attachments = {};
|
|
97
|
+
for (const url of m.attachments) {
|
|
98
|
+
await hold();
|
|
99
|
+
let up = uploaded.get(url);
|
|
100
|
+
const stored = up || missing.has(url) ? null : await deps.store.take(url);
|
|
101
|
+
if (stored) {
|
|
102
|
+
up = await deps.upload({ name: nameOf(url), ...stored });
|
|
103
|
+
uploaded.set(url, up);
|
|
104
|
+
await deps.store.drop(url);
|
|
105
|
+
}
|
|
106
|
+
if (up)
|
|
107
|
+
attachments[up.id] = up.key;
|
|
108
|
+
p.sent.done++;
|
|
109
|
+
tick();
|
|
110
|
+
}
|
|
111
|
+
const lost = m.attachments.length - Object.keys(attachments).length;
|
|
112
|
+
const note = lost ? `${lost === 1 ? "A file" : `${lost} files`} from Discord couldn’t be downloaded.` : "";
|
|
113
|
+
const text = [m.text, note].filter(Boolean).join("\n");
|
|
114
|
+
pending.push({ id: importId(m.discordId), sentAt: m.sentAt, text, ...(Object.keys(attachments).length ? { attachments } : {}) });
|
|
115
|
+
if (pending.length >= FILE_BATCH) {
|
|
116
|
+
await sendAll(pending);
|
|
117
|
+
pending = [];
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (pending.length)
|
|
121
|
+
await sendAll(pending);
|
|
122
|
+
tick();
|
|
123
|
+
return p;
|
|
124
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -12,10 +12,12 @@ export { describeFolderRules, folderFollowNote, folderPhrase, } from "./permissi
|
|
|
12
12
|
export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_URL, TERMS_VERSION, } from "./terms/termsAgreement.js";
|
|
13
13
|
export { createMlsDmDriver, MlsDriverError, type MlsDriverErrorCode } from "./mls/dmDriver.js";
|
|
14
14
|
export { mlsPinsFromPeerPins } from "./mls/pins.js";
|
|
15
|
+
export { reencryptedId, reencryptMine, type ReencryptCandidate, type ReencryptDeps } from "./mls/reencrypt.js";
|
|
15
16
|
export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, type MlsDmContent, type MlsHistoryEntry, type MlsImportEntry, readMlsDmContent, } from "./mls/content.js";
|
|
16
17
|
export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
|
|
17
18
|
export { type DiscordPackageDm, type DiscordPackageMessage, isDiscordPackage, linksExpireAt, listDiscordDms, type PackageFiles, readDiscordDm, } from "./import/discordPackage.js";
|
|
18
19
|
export { inflateRaw, openZipBytes } from "./import/unzip.js";
|
|
20
|
+
export { type ImportControl, type ImportDeps, type ImportFile, type ImportFileStore, importId, type ImportProgress, retryAfterMs, runDiscordImport, } from "./import/discordImportJob.js";
|
|
19
21
|
export type { DmSealingMode, MlsAddOwnDeviceOptions, MlsClaimedKeyPackage, MlsDecryptedMessage, MlsDeviceRecord, MlsDeviceRef, MlsDmDriver, MlsDmDriverOptions, MlsDmEvents, MlsGroupPosition, MlsGroupRecord, MlsGroupView, MlsKeyPackageRecord, MlsListedDevice, MlsLogEntry, MlsDmDiagnostics, MlsOwnDevice, MlsOwnDeviceAdd, MlsPins, MlsRefusal, MlsReply, MlsServerCapability, MlsStateStore, MlsTransport, MlsWelcomeDelivery, } from "./mls/interfaces.js";
|
|
20
22
|
export { createApproverPairing, type ApproverOptions, type ApproverPairing, type ApproverState, } from "./pairing/approver.js";
|
|
21
23
|
export { createNewDevicePairing, PAIRING_APPROVAL_MS, type NewDeviceOptions, type NewDevicePairing, type NewDeviceState, } from "./pairing/newDevice.js";
|
package/dist/index.js
CHANGED
|
@@ -14,10 +14,12 @@ export { describeFolderRules, folderFollowNote, folderPhrase, } from "./permissi
|
|
|
14
14
|
export { agreementAt, createTermsGate, GUIDELINES_URL, TERMS_STORAGE_KEY, TERMS_URL, TERMS_VERSION, } from "./terms/termsAgreement.js";
|
|
15
15
|
export { createMlsDmDriver, MlsDriverError } from "./mls/dmDriver.js";
|
|
16
16
|
export { mlsPinsFromPeerPins } from "./mls/pins.js";
|
|
17
|
+
export { reencryptedId, reencryptMine } from "./mls/reencrypt.js";
|
|
17
18
|
export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
|
|
18
19
|
export { applyMlsReaction, mlsReactionAction } from "./mls/reactions.js";
|
|
19
20
|
export { isDiscordPackage, linksExpireAt, listDiscordDms, readDiscordDm, } from "./import/discordPackage.js";
|
|
20
21
|
export { inflateRaw, openZipBytes } from "./import/unzip.js";
|
|
22
|
+
export { importId, retryAfterMs, runDiscordImport, } from "./import/discordImportJob.js";
|
|
21
23
|
export { createApproverPairing, } from "./pairing/approver.js";
|
|
22
24
|
export { createNewDevicePairing, PAIRING_APPROVAL_MS, } from "./pairing/newDevice.js";
|
|
23
25
|
export { createPairingRelay, PairingRelayError } from "./pairing/relayClient.js";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { MlsDmContent, MlsHistoryEntry } from "./content.js";
|
|
2
|
+
export declare const reencryptedId: (messageId: string) => string;
|
|
3
|
+
/** The fields of a server message that decide whether it can move. */
|
|
4
|
+
export interface ReencryptCandidate {
|
|
5
|
+
message_id: string;
|
|
6
|
+
sender_server_id: string;
|
|
7
|
+
text?: string | null;
|
|
8
|
+
created_at: string | Date;
|
|
9
|
+
reply_to_message_id?: string | null;
|
|
10
|
+
mls?: boolean;
|
|
11
|
+
sealed?: string | null;
|
|
12
|
+
mls_placeholder?: object | null;
|
|
13
|
+
attachments?: readonly string[] | null;
|
|
14
|
+
reactions?: readonly object[] | null;
|
|
15
|
+
poll?: object | null;
|
|
16
|
+
pinned_at?: string | Date | null;
|
|
17
|
+
thread_id?: string | null;
|
|
18
|
+
}
|
|
19
|
+
export interface ReencryptDeps {
|
|
20
|
+
fetchOlder: (before: string | undefined) => Promise<{
|
|
21
|
+
items: ReencryptCandidate[];
|
|
22
|
+
hasMore: boolean;
|
|
23
|
+
}>;
|
|
24
|
+
send: (content: Extract<MlsDmContent, {
|
|
25
|
+
type: "history";
|
|
26
|
+
}>) => Promise<void>;
|
|
27
|
+
/** This device's own copy, kept before the plaintext goes. */
|
|
28
|
+
keep: (entries: MlsHistoryEntry[], receivedAt: number) => Promise<void>;
|
|
29
|
+
remove: (messageId: string) => void;
|
|
30
|
+
sleep: (ms: number) => Promise<void>;
|
|
31
|
+
now: () => number;
|
|
32
|
+
newId: () => string;
|
|
33
|
+
stopped: () => boolean;
|
|
34
|
+
report: (done: number, total: number) => void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Sends this person's plaintext as history over MLS, and removes each one only after its
|
|
38
|
+
* batch went. New ids, so re-running repeats nothing and `chat:deleted` can't take the copy.
|
|
39
|
+
*/
|
|
40
|
+
export declare function reencryptMine(deps: ReencryptDeps, me: string): Promise<number>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { historyBatches } from "./content.js";
|
|
2
|
+
/* `chat:delete` scores 1 and decays one per 3 s (RL_DELETE); `chat:fetch` allows 15 in 10 s. */
|
|
3
|
+
const DELETE_GAP_MS = 3_100;
|
|
4
|
+
const FETCH_GAP_MS = 700;
|
|
5
|
+
export const reencryptedId = (messageId) => `re-${messageId}`;
|
|
6
|
+
/** Plain text only: files, reactions, polls and pins can't travel in a history entry, so they stay. */
|
|
7
|
+
function canMove(m, me) {
|
|
8
|
+
return m.sender_server_id === me && !!m.text && !m.mls && !m.sealed && !m.mls_placeholder
|
|
9
|
+
&& !m.attachments?.length && !m.reactions?.length && !m.poll && !m.pinned_at && !m.thread_id;
|
|
10
|
+
}
|
|
11
|
+
async function plaintextOfMine(deps, me) {
|
|
12
|
+
const out = [];
|
|
13
|
+
let before;
|
|
14
|
+
for (;;) {
|
|
15
|
+
const { items, hasMore } = await deps.fetchOlder(before);
|
|
16
|
+
out.push(...items.filter((m) => canMove(m, me)));
|
|
17
|
+
if (!hasMore || !items.length || deps.stopped())
|
|
18
|
+
return out;
|
|
19
|
+
before = items.map((m) => new Date(m.created_at).toISOString()).sort()[0];
|
|
20
|
+
await deps.sleep(FETCH_GAP_MS);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Sends this person's plaintext as history over MLS, and removes each one only after its
|
|
25
|
+
* batch went. New ids, so re-running repeats nothing and `chat:deleted` can't take the copy.
|
|
26
|
+
*/
|
|
27
|
+
export async function reencryptMine(deps, me) {
|
|
28
|
+
const mine = await plaintextOfMine(deps, me);
|
|
29
|
+
const ids = new Set(mine.map((m) => m.message_id));
|
|
30
|
+
const originals = new Map(mine.map((m) => [reencryptedId(m.message_id), m.message_id]));
|
|
31
|
+
const entries = mine.map((m) => {
|
|
32
|
+
const reply = m.reply_to_message_id;
|
|
33
|
+
return {
|
|
34
|
+
id: reencryptedId(m.message_id),
|
|
35
|
+
senderId: me,
|
|
36
|
+
sentAt: new Date(m.created_at).getTime(),
|
|
37
|
+
text: m.text ?? "",
|
|
38
|
+
...(reply ? { replyTo: ids.has(reply) ? reencryptedId(reply) : reply } : {}),
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
const batches = historyBatches(entries, deps.newId);
|
|
42
|
+
const total = batches.reduce((n, b) => n + b.entries.length, 0);
|
|
43
|
+
let done = 0;
|
|
44
|
+
deps.report(done, total);
|
|
45
|
+
for (const batch of batches) {
|
|
46
|
+
if (deps.stopped())
|
|
47
|
+
return done;
|
|
48
|
+
await deps.send(batch);
|
|
49
|
+
await deps.keep(batch.entries, deps.now());
|
|
50
|
+
for (const e of batch.entries) {
|
|
51
|
+
deps.remove(originals.get(e.id));
|
|
52
|
+
deps.report(++done, total);
|
|
53
|
+
await deps.sleep(DELETE_GAP_MS);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return done;
|
|
57
|
+
}
|
package/package.json
CHANGED