@gryt/core 0.22.0 → 0.23.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.
@@ -0,0 +1,52 @@
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). */
20
+ download: (url: string) => Promise<Omit<ImportFile, "name"> | null>;
21
+ upload: (file: ImportFile) => Promise<{
22
+ id: string;
23
+ key: SealedAttachmentKey;
24
+ }>;
25
+ store: ImportFileStore;
26
+ sleep: (ms: number) => Promise<void>;
27
+ newId: () => string;
28
+ }
29
+ export interface ImportProgress {
30
+ messages: {
31
+ done: number;
32
+ total: number;
33
+ };
34
+ downloaded: {
35
+ done: number;
36
+ total: number;
37
+ };
38
+ sent: {
39
+ done: number;
40
+ total: number;
41
+ };
42
+ missing: number;
43
+ bytes: number;
44
+ startedAt: number;
45
+ }
46
+ export interface ImportControl {
47
+ paused: () => boolean;
48
+ stopped: () => boolean;
49
+ }
50
+ export declare const importId: (discordId: string) => string;
51
+ /** Messages without files first, then every file downloaded to the store, then the messages that carry files. */
52
+ export declare function runDiscordImport(messages: DiscordPackageMessage[], deps: ImportDeps, control: ImportControl, report: (p: ImportProgress) => void): Promise<ImportProgress>;
@@ -0,0 +1,102 @@
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
+ export const importId = (discordId) => `discord-${discordId}`;
7
+ function nameOf(url) {
8
+ try {
9
+ return decodeURIComponent(new URL(url).pathname.split("/").pop() || "file");
10
+ }
11
+ catch {
12
+ return "file";
13
+ }
14
+ }
15
+ /** Messages without files first, then every file downloaded to the store, then the messages that carry files. */
16
+ export async function runDiscordImport(messages, deps, control, report) {
17
+ const withFiles = messages.filter((m) => m.attachments.length > 0);
18
+ const urls = [...new Set(withFiles.flatMap((m) => m.attachments))];
19
+ const p = {
20
+ messages: { done: 0, total: messages.length },
21
+ downloaded: { done: 0, total: urls.length },
22
+ sent: { done: 0, total: urls.length },
23
+ missing: 0,
24
+ bytes: 0,
25
+ startedAt: Date.now(),
26
+ };
27
+ const tick = () => report({ ...p, messages: { ...p.messages }, downloaded: { ...p.downloaded }, sent: { ...p.sent } });
28
+ tick();
29
+ const hold = async () => {
30
+ while (control.paused() && !control.stopped())
31
+ await deps.sleep(500);
32
+ };
33
+ const sendAll = async (entries) => {
34
+ for (const batch of importBatches(entries, deps.newId)) {
35
+ await hold();
36
+ if (control.stopped())
37
+ return;
38
+ await deps.send(batch);
39
+ p.messages.done += batch.entries.length;
40
+ tick();
41
+ await deps.sleep(SEND_GAP_MS);
42
+ }
43
+ };
44
+ await sendAll(messages.filter((m) => m.attachments.length === 0).map((m) => ({ id: importId(m.discordId), sentAt: m.sentAt, text: m.text })));
45
+ const missing = new Set();
46
+ let next = 0;
47
+ const worker = async () => {
48
+ while (!control.stopped()) {
49
+ await hold();
50
+ if (next >= urls.length)
51
+ break;
52
+ const url = urls[next++];
53
+ if (!(await deps.store.has(url))) {
54
+ const file = await deps.download(url).catch(() => null);
55
+ if (file) {
56
+ p.bytes += file.bytes.length;
57
+ await deps.store.put(url, file);
58
+ }
59
+ else {
60
+ missing.add(url);
61
+ p.missing = missing.size;
62
+ }
63
+ }
64
+ p.downloaded.done++;
65
+ tick();
66
+ }
67
+ };
68
+ await Promise.all(Array.from({ length: DOWNLOADS_AT_ONCE }, worker));
69
+ const uploaded = new Map();
70
+ let pending = [];
71
+ for (const m of withFiles) {
72
+ if (control.stopped())
73
+ break;
74
+ const attachments = {};
75
+ for (const url of m.attachments) {
76
+ await hold();
77
+ let up = uploaded.get(url);
78
+ const stored = up || missing.has(url) ? null : await deps.store.take(url);
79
+ if (stored) {
80
+ up = await deps.upload({ name: nameOf(url), ...stored });
81
+ uploaded.set(url, up);
82
+ await deps.store.drop(url);
83
+ }
84
+ if (up)
85
+ attachments[up.id] = up.key;
86
+ p.sent.done++;
87
+ tick();
88
+ }
89
+ const lost = m.attachments.length - Object.keys(attachments).length;
90
+ const note = lost ? `${lost === 1 ? "A file" : `${lost} files`} from Discord couldn’t be downloaded.` : "";
91
+ const text = [m.text, note].filter(Boolean).join("\n");
92
+ pending.push({ id: importId(m.discordId), sentAt: m.sentAt, text, ...(Object.keys(attachments).length ? { attachments } : {}) });
93
+ if (pending.length >= FILE_BATCH) {
94
+ await sendAll(pending);
95
+ pending = [];
96
+ }
97
+ }
98
+ if (pending.length)
99
+ await sendAll(pending);
100
+ tick();
101
+ return p;
102
+ }
package/dist/index.d.ts CHANGED
@@ -16,6 +16,7 @@ export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, i
16
16
  export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
17
17
  export { type DiscordPackageDm, type DiscordPackageMessage, isDiscordPackage, linksExpireAt, listDiscordDms, type PackageFiles, readDiscordDm, } from "./import/discordPackage.js";
18
18
  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";
19
20
  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
21
  export { createApproverPairing, type ApproverOptions, type ApproverPairing, type ApproverState, } from "./pairing/approver.js";
21
22
  export { createNewDevicePairing, PAIRING_APPROVAL_MS, type NewDeviceOptions, type NewDevicePairing, type NewDeviceState, } from "./pairing/newDevice.js";
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, i
18
18
  export { applyMlsReaction, mlsReactionAction } from "./mls/reactions.js";
19
19
  export { isDiscordPackage, linksExpireAt, listDiscordDms, readDiscordDm, } from "./import/discordPackage.js";
20
20
  export { inflateRaw, openZipBytes } from "./import/unzip.js";
21
+ export { importId, runDiscordImport, } from "./import/discordImportJob.js";
21
22
  export { createApproverPairing, } from "./pairing/approver.js";
22
23
  export { createNewDevicePairing, PAIRING_APPROVAL_MS, } from "./pairing/newDevice.js";
23
24
  export { createPairingRelay, PairingRelayError } from "./pairing/relayClient.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.22.0",
3
+ "version": "0.23.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": {