@gryt/core 0.23.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.
@@ -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"> | null>;
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 deps.download(url).catch(() => null);
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 { 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";
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";
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Gryt's shared application logic: one implementation of the things the desktop and mobile apps both do.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {