@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.
@@ -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 { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, type MlsDmContent, type MlsHistoryEntry, type MlsImportEntry, readMlsDmContent, } from "./mls/content.js";
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";
@@ -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 {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.23.0",
3
+ "version": "0.25.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": {