@gryt/core 0.26.0 → 0.28.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.
@@ -20,9 +20,14 @@ export interface ImportDeps {
20
20
  download: (url: string) => Promise<Omit<ImportFile, "name"> | {
21
21
  retryAfterMs: number;
22
22
  } | null>;
23
+ /** `retryAfterMs` when the server answers 429, `tooLarge` when it refuses the file's size. */
23
24
  upload: (file: ImportFile) => Promise<{
24
25
  id: string;
25
26
  key: SealedAttachmentKey;
27
+ } | {
28
+ retryAfterMs: number;
29
+ } | {
30
+ tooLarge: true;
26
31
  }>;
27
32
  store: ImportFileStore;
28
33
  sleep: (ms: number) => Promise<void>;
@@ -44,6 +49,8 @@ export interface ImportProgress {
44
49
  total: number;
45
50
  };
46
51
  missing: number;
52
+ /** Files this server won't take, each left out with a note on its message. */
53
+ tooLarge: number;
47
54
  bytes: number;
48
55
  startedAt: number;
49
56
  }
@@ -4,6 +4,8 @@ const TRANSFERS_AT_ONCE = 16;
4
4
  const SEND_GAP_MS = 2_000;
5
5
  const FILE_BATCH = 20;
6
6
  const DOWNLOAD_RETRIES = 5;
7
+ /* A server allows 30 uploads a minute with a ban after, so a big import waits many times over. */
8
+ const UPLOAD_RETRIES = 200;
7
9
  const MAX_RATE_LIMIT_WAIT_MS = 60_000;
8
10
  const DEFAULT_RATE_LIMIT_WAIT_MS = 5_000;
9
11
  /** For a 429's Retry-After header. Only the seconds form: anything else gets the default wait. */
@@ -29,6 +31,7 @@ export async function runDiscordImport(messages, deps, control, report) {
29
31
  downloaded: { done: 0, total: urls.length },
30
32
  sent: { done: 0, total: urls.length },
31
33
  missing: 0,
34
+ tooLarge: 0,
32
35
  bytes: 0,
33
36
  startedAt: Date.now(),
34
37
  };
@@ -92,11 +95,35 @@ export async function runDiscordImport(messages, deps, control, report) {
92
95
  tick();
93
96
  });
94
97
  const uploaded = new Map();
98
+ const tooLarge = new Set();
99
+ let uploadClearAt = 0;
100
+ const uploadFile = async (file) => {
101
+ for (let attempt = 0; attempt <= UPLOAD_RETRIES && !control.stopped(); attempt++) {
102
+ const wait = uploadClearAt - Date.now();
103
+ if (wait > 0)
104
+ await deps.sleep(wait);
105
+ const got = await deps.upload(file);
106
+ if (!("retryAfterMs" in got))
107
+ return got;
108
+ uploadClearAt = Math.max(uploadClearAt, Date.now() + Math.min(got.retryAfterMs, MAX_RATE_LIMIT_WAIT_MS));
109
+ }
110
+ if (control.stopped())
111
+ return null;
112
+ throw new Error("The server kept refusing uploads.");
113
+ };
95
114
  await eachAtOnce(async (url) => {
96
115
  const stored = missing.has(url) ? null : await deps.store.take(url);
97
116
  if (stored) {
98
- uploaded.set(url, await deps.upload({ name: nameOf(url), ...stored }));
99
- await deps.store.drop(url);
117
+ const up = await uploadFile({ name: nameOf(url), ...stored });
118
+ if (up && "tooLarge" in up) {
119
+ tooLarge.add(url);
120
+ p.tooLarge = tooLarge.size;
121
+ }
122
+ else if (up) {
123
+ uploaded.set(url, up);
124
+ }
125
+ if (up)
126
+ await deps.store.drop(url);
100
127
  }
101
128
  p.sent.done++;
102
129
  tick();
@@ -111,9 +138,11 @@ export async function runDiscordImport(messages, deps, control, report) {
111
138
  if (up)
112
139
  attachments[up.id] = up.key;
113
140
  }
114
- const lost = m.attachments.length - Object.keys(attachments).length;
115
- const note = lost ? `${lost === 1 ? "A file" : `${lost} files`} from Discord couldn’t be downloaded.` : "";
116
- const text = [m.text, note].filter(Boolean).join("\n");
141
+ const big = m.attachments.filter((u) => tooLarge.has(u)).length;
142
+ const lost = m.attachments.length - Object.keys(attachments).length - big;
143
+ const lostNote = lost ? `${lost === 1 ? "A file" : `${lost} files`} from Discord couldn’t be downloaded.` : "";
144
+ const bigNote = big ? `${big === 1 ? "A file" : `${big} files`} from Discord ${big === 1 ? "was" : "were"} too big for this server.` : "";
145
+ const text = [m.text, lostNote, bigNote].filter(Boolean).join("\n");
117
146
  pending.push({ id: importId(m.discordId), sentAt: m.sentAt, text, ...(Object.keys(attachments).length ? { attachments } : {}) });
118
147
  if (pending.length >= FILE_BATCH) {
119
148
  await sendAll(pending);
@@ -32,5 +32,7 @@ export declare function parseDiscordTime(v: unknown): number | null;
32
32
  export declare function listDiscordDms(files: PackageFiles): Promise<DiscordPackageDm[]>;
33
33
  /** One DM's messages, oldest first. */
34
34
  export declare function readDiscordDm(files: PackageFiles, channelId: string): Promise<DiscordPackageMessage[]>;
35
+ /** Discord's own markup as text a person can read. A custom emoji can't come over, so it keeps its name. */
36
+ export declare function readableDiscordText(text: string, people: ReadonlyMap<string, string>, channels: Readonly<Record<string, unknown>>): string;
35
37
  /** The earliest `ex` (hex Unix seconds) among signed CDN links, or null when none carry one. */
36
38
  export declare function linksExpireAt(urls: string[]): number | null;
@@ -189,7 +189,41 @@ export async function listDiscordDms(files) {
189
189
  export async function readDiscordDm(files, channelId) {
190
190
  const idx = indexNames(files.names);
191
191
  const dir = channelFolder(idx, channelId);
192
- return dir ? messagesIn(files, idx, dir) : [];
192
+ if (!dir)
193
+ return [];
194
+ const names = await peopleIn(files, idx, channelId);
195
+ const channels = ((await readJson(files, idx, "messages/index.json")) ?? {});
196
+ return (await messagesIn(files, idx, dir)).map((m) => ({ ...m, text: readableDiscordText(m.text, names, channels) }));
197
+ }
198
+ async function peopleIn(files, idx, channelId) {
199
+ const names = new Map();
200
+ const user = (await readJson(files, idx, "account/user.json"));
201
+ const add = (u) => {
202
+ const name = u?.global_name || u?.username;
203
+ if (typeof u?.id === "string" && typeof name === "string")
204
+ names.set(u.id, name);
205
+ };
206
+ add(user);
207
+ for (const rel of Array.isArray(user?.relationships) ? user.relationships : [])
208
+ add(rel?.user);
209
+ const channel = (await readJson(files, idx, `messages/c${channelId}/channel.json`));
210
+ const label = (await readJson(files, idx, "messages/index.json"))?.[channelId];
211
+ const peer = Array.isArray(channel?.recipients) ? channel.recipients.find((id) => id !== user?.id) : undefined;
212
+ if (typeof peer === "string" && typeof label === "string" && !names.has(peer))
213
+ names.set(peer, displayName(label));
214
+ return names;
215
+ }
216
+ /** Discord's own markup as text a person can read. A custom emoji can't come over, so it keeps its name. */
217
+ export function readableDiscordText(text, people, channels) {
218
+ return text
219
+ .replace(/<a?:(\w+):\d+>/g, ":$1:")
220
+ .replace(/<@!?(\d+)>/g, (_, id) => `@${people.get(id) ?? "someone"}`)
221
+ .replace(/<@&\d+>/g, "@role")
222
+ .replace(/<#(\d+)>/g, (_, id) => {
223
+ const label = channels[id];
224
+ const name = typeof label === "string" && !/^unknown/i.test(label) ? label.split(",")[0].trim() : "channel";
225
+ return `#${name}`;
226
+ });
193
227
  }
194
228
  /** The earliest `ex` (hex Unix seconds) among signed CDN links, or null when none carry one. */
195
229
  export function linksExpireAt(urls) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.26.0",
3
+ "version": "0.28.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": {