@gryt/core 0.23.0 → 0.25.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 +6 -2
- package/dist/import/discordImportJob.js +23 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -1
- package/dist/mls/content.d.ts +7 -0
- package/dist/mls/content.js +23 -0
- package/dist/mls/reencrypt.d.ts +43 -0
- package/dist/mls/reencrypt.js +60 -0
- package/package.json +1 -1
|
@@ -16,8 +16,10 @@ export interface ImportFileStore {
|
|
|
16
16
|
/** What the job needs from the app. Each app supplies its own, and a test supplies fakes. */
|
|
17
17
|
export interface ImportDeps {
|
|
18
18
|
send: (content: MlsDmContent) => Promise<void>;
|
|
19
|
-
/** Null when Discord won't hand it over (expired, gone, refused). */
|
|
20
|
-
download: (url: string) => Promise<Omit<ImportFile, "name"> |
|
|
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>;
|
|
21
23
|
upload: (file: ImportFile) => Promise<{
|
|
22
24
|
id: string;
|
|
23
25
|
key: SealedAttachmentKey;
|
|
@@ -47,6 +49,8 @@ export interface ImportControl {
|
|
|
47
49
|
paused: () => boolean;
|
|
48
50
|
stopped: () => boolean;
|
|
49
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;
|
|
50
54
|
export declare const importId: (discordId: string) => string;
|
|
51
55
|
/** Messages without files first, then every file downloaded to the store, then the messages that carry files. */
|
|
52
56
|
export declare function runDiscordImport(messages: DiscordPackageMessage[], deps: ImportDeps, control: ImportControl, report: (p: ImportProgress) => void): Promise<ImportProgress>;
|
|
@@ -3,6 +3,14 @@ const DOWNLOADS_AT_ONCE = 4;
|
|
|
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
|
+
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
|
+
}
|
|
6
14
|
export const importId = (discordId) => `discord-${discordId}`;
|
|
7
15
|
function nameOf(url) {
|
|
8
16
|
try {
|
|
@@ -44,6 +52,20 @@ export async function runDiscordImport(messages, deps, control, report) {
|
|
|
44
52
|
await sendAll(messages.filter((m) => m.attachments.length === 0).map((m) => ({ id: importId(m.discordId), sentAt: m.sentAt, text: m.text })));
|
|
45
53
|
const missing = new Set();
|
|
46
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
|
+
};
|
|
47
69
|
const worker = async () => {
|
|
48
70
|
while (!control.stopped()) {
|
|
49
71
|
await hold();
|
|
@@ -51,7 +73,7 @@ export async function runDiscordImport(messages, deps, control, report) {
|
|
|
51
73
|
break;
|
|
52
74
|
const url = urls[next++];
|
|
53
75
|
if (!(await deps.store.has(url))) {
|
|
54
|
-
const file = await
|
|
76
|
+
const file = await downloadFile(url);
|
|
55
77
|
if (file) {
|
|
56
78
|
p.bytes += file.bytes.length;
|
|
57
79
|
await deps.store.put(url, file);
|
package/dist/index.d.ts
CHANGED
|
@@ -12,11 +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 {
|
|
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
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";
|
|
19
|
-
export { type ImportControl, type ImportDeps, type ImportFile, type ImportFileStore, importId, type ImportProgress, runDiscordImport, } from "./import/discordImportJob.js";
|
|
20
|
+
export { type ImportControl, type ImportDeps, type ImportFile, type ImportFileStore, importId, type ImportProgress, retryAfterMs, runDiscordImport, } from "./import/discordImportJob.js";
|
|
20
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";
|
|
21
22
|
export { createApproverPairing, type ApproverOptions, type ApproverPairing, type ApproverState, } from "./pairing/approver.js";
|
|
22
23
|
export { createNewDevicePairing, PAIRING_APPROVAL_MS, type NewDeviceOptions, type NewDevicePairing, type NewDeviceState, } from "./pairing/newDevice.js";
|
package/dist/index.js
CHANGED
|
@@ -14,11 +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";
|
|
21
|
-
export { importId, runDiscordImport, } from "./import/discordImportJob.js";
|
|
22
|
+
export { importId, retryAfterMs, runDiscordImport, } from "./import/discordImportJob.js";
|
|
22
23
|
export { createApproverPairing, } from "./pairing/approver.js";
|
|
23
24
|
export { createNewDevicePairing, PAIRING_APPROVAL_MS, } from "./pairing/newDevice.js";
|
|
24
25
|
export { createPairingRelay, PairingRelayError } from "./pairing/relayClient.js";
|
package/dist/mls/content.d.ts
CHANGED
|
@@ -9,6 +9,13 @@ export interface MlsHistoryEntry {
|
|
|
9
9
|
text: string;
|
|
10
10
|
replyTo?: string;
|
|
11
11
|
attachments?: Record<string, SealedAttachmentKey>;
|
|
12
|
+
/** Who reacted, as the sender's record has it. A claim, like the rest of a shared copy. */
|
|
13
|
+
reactions?: MlsHistoryReaction[];
|
|
14
|
+
}
|
|
15
|
+
export interface MlsHistoryReaction {
|
|
16
|
+
/** A Unicode emoji or `:name:`, what `chat:react` carries. */
|
|
17
|
+
emoji: string;
|
|
18
|
+
users: string[];
|
|
12
19
|
}
|
|
13
20
|
/** One message from the sender's own Discord data. Who wrote it is never in here: the reader takes the MLS sender. */
|
|
14
21
|
export interface MlsImportEntry {
|
package/dist/mls/content.js
CHANGED
|
@@ -17,6 +17,8 @@ const isText = (v) => typeof v === "string" && v.length <= MAX_TEXT;
|
|
|
17
17
|
const isCount = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
18
18
|
const isLabel = (v) => typeof v === "string" && v.length <= MAX_LABEL;
|
|
19
19
|
const MAX_HISTORY_ENTRIES = 200;
|
|
20
|
+
const MAX_HISTORY_REACTIONS = 20;
|
|
21
|
+
const MAX_REACTION_USERS = 50;
|
|
20
22
|
/** Under what one `mls:send` takes once sealed and padded. */
|
|
21
23
|
export const MAX_HISTORY_BATCH_BYTES = 48 * 1024;
|
|
22
24
|
/** One emoji's worth of emoji characters, so a peer can't put a sentence on a message as a reaction. */
|
|
@@ -115,6 +117,8 @@ function historyBody(e) {
|
|
|
115
117
|
out.replyTo = e.replyTo;
|
|
116
118
|
if (e.attachments && Object.keys(e.attachments).length > 0)
|
|
117
119
|
out.attachments = e.attachments;
|
|
120
|
+
if (e.reactions?.length)
|
|
121
|
+
out.reactions = e.reactions;
|
|
118
122
|
return out;
|
|
119
123
|
}
|
|
120
124
|
function importBody(e) {
|
|
@@ -176,6 +180,25 @@ function readHistoryEntry(v) {
|
|
|
176
180
|
return null;
|
|
177
181
|
out.attachments = attachments;
|
|
178
182
|
}
|
|
183
|
+
if (v.reactions !== undefined) {
|
|
184
|
+
const reactions = readHistoryReactions(v.reactions);
|
|
185
|
+
if (!reactions)
|
|
186
|
+
return null;
|
|
187
|
+
out.reactions = reactions;
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
function readHistoryReactions(v) {
|
|
192
|
+
if (!Array.isArray(v) || v.length === 0 || v.length > MAX_HISTORY_REACTIONS)
|
|
193
|
+
return null;
|
|
194
|
+
const out = [];
|
|
195
|
+
for (const r of v) {
|
|
196
|
+
if (!isObject(r) || !isReactionEmoji(r.emoji) || !Array.isArray(r.users))
|
|
197
|
+
return null;
|
|
198
|
+
if (r.users.length === 0 || r.users.length > MAX_REACTION_USERS || !r.users.every(isId))
|
|
199
|
+
return null;
|
|
200
|
+
out.push({ emoji: r.emoji, users: r.users });
|
|
201
|
+
}
|
|
179
202
|
return out;
|
|
180
203
|
}
|
|
181
204
|
function readImportEntry(v) {
|
|
@@ -0,0 +1,43 @@
|
|
|
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 {
|
|
15
|
+
src: string;
|
|
16
|
+
users?: readonly string[];
|
|
17
|
+
}[] | null;
|
|
18
|
+
poll?: object | null;
|
|
19
|
+
pinned_at?: string | Date | null;
|
|
20
|
+
thread_id?: string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface ReencryptDeps {
|
|
23
|
+
fetchOlder: (before: string | undefined) => Promise<{
|
|
24
|
+
items: ReencryptCandidate[];
|
|
25
|
+
hasMore: boolean;
|
|
26
|
+
}>;
|
|
27
|
+
send: (content: Extract<MlsDmContent, {
|
|
28
|
+
type: "history";
|
|
29
|
+
}>) => Promise<void>;
|
|
30
|
+
/** This device's own copy, kept before the plaintext goes. */
|
|
31
|
+
keep: (entries: MlsHistoryEntry[], receivedAt: number) => Promise<void>;
|
|
32
|
+
remove: (messageId: string) => void;
|
|
33
|
+
sleep: (ms: number) => Promise<void>;
|
|
34
|
+
now: () => number;
|
|
35
|
+
newId: () => string;
|
|
36
|
+
stopped: () => boolean;
|
|
37
|
+
report: (done: number, total: number) => void;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Sends this person's plaintext as history over MLS, and removes each one only after its
|
|
41
|
+
* batch went. New ids, so re-running repeats nothing and `chat:deleted` can't take the copy.
|
|
42
|
+
*/
|
|
43
|
+
export declare function reencryptMine(deps: ReencryptDeps, me: string): Promise<number>;
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
/* Files stay: an MLS file is deleted after the server's retention, a plaintext one isn't.
|
|
7
|
+
Polls and pins live on the server's row. */
|
|
8
|
+
function canMove(m, me) {
|
|
9
|
+
return m.sender_server_id === me && !!m.text && !m.mls && !m.sealed && !m.mls_placeholder
|
|
10
|
+
&& !m.attachments?.length && !m.poll && !m.pinned_at && !m.thread_id;
|
|
11
|
+
}
|
|
12
|
+
async function plaintextOfMine(deps, me) {
|
|
13
|
+
const out = [];
|
|
14
|
+
let before;
|
|
15
|
+
for (;;) {
|
|
16
|
+
const { items, hasMore } = await deps.fetchOlder(before);
|
|
17
|
+
out.push(...items.filter((m) => canMove(m, me)));
|
|
18
|
+
if (!hasMore || !items.length || deps.stopped())
|
|
19
|
+
return out;
|
|
20
|
+
before = items.map((m) => new Date(m.created_at).toISOString()).sort()[0];
|
|
21
|
+
await deps.sleep(FETCH_GAP_MS);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Sends this person's plaintext as history over MLS, and removes each one only after its
|
|
26
|
+
* batch went. New ids, so re-running repeats nothing and `chat:deleted` can't take the copy.
|
|
27
|
+
*/
|
|
28
|
+
export async function reencryptMine(deps, me) {
|
|
29
|
+
const mine = await plaintextOfMine(deps, me);
|
|
30
|
+
const ids = new Set(mine.map((m) => m.message_id));
|
|
31
|
+
const originals = new Map(mine.map((m) => [reencryptedId(m.message_id), m.message_id]));
|
|
32
|
+
const entries = mine.map((m) => {
|
|
33
|
+
const reply = m.reply_to_message_id;
|
|
34
|
+
const reactions = (m.reactions ?? []).flatMap((r) => (r.users?.length ? [{ emoji: r.src, users: [...r.users] }] : []));
|
|
35
|
+
return {
|
|
36
|
+
id: reencryptedId(m.message_id),
|
|
37
|
+
senderId: me,
|
|
38
|
+
sentAt: new Date(m.created_at).getTime(),
|
|
39
|
+
text: m.text ?? "",
|
|
40
|
+
...(reply ? { replyTo: ids.has(reply) ? reencryptedId(reply) : reply } : {}),
|
|
41
|
+
...(reactions.length ? { reactions } : {}),
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
const batches = historyBatches(entries, deps.newId);
|
|
45
|
+
const total = batches.reduce((n, b) => n + b.entries.length, 0);
|
|
46
|
+
let done = 0;
|
|
47
|
+
deps.report(done, total);
|
|
48
|
+
for (const batch of batches) {
|
|
49
|
+
if (deps.stopped())
|
|
50
|
+
return done;
|
|
51
|
+
await deps.send(batch);
|
|
52
|
+
await deps.keep(batch.entries, deps.now());
|
|
53
|
+
for (const e of batch.entries) {
|
|
54
|
+
deps.remove(originals.get(e.id));
|
|
55
|
+
deps.report(++done, total);
|
|
56
|
+
await deps.sleep(DELETE_GAP_MS);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return done;
|
|
60
|
+
}
|
package/package.json
CHANGED