@gryt/core 0.19.0 → 0.21.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,36 @@
1
+ /** The files in a package, however it was opened: a zip, a folder, a test. */
2
+ export interface PackageFiles {
3
+ names: string[];
4
+ read(name: string): Promise<string>;
5
+ }
6
+ export interface DiscordPackageDm {
7
+ channelId: string;
8
+ /** Their Discord name, from the package's own index. */
9
+ name: string;
10
+ userId: string | null;
11
+ /** Discord's CDN serves avatars by hash without a token. Only friends carry one. */
12
+ avatarUrl: string | null;
13
+ messages: number;
14
+ files: number;
15
+ firstAt: number;
16
+ lastAt: number;
17
+ }
18
+ export interface DiscordPackageMessage {
19
+ discordId: string;
20
+ sentAt: number;
21
+ text: string;
22
+ attachments: string[];
23
+ }
24
+ export declare function isDiscordPackage(names: string[]): boolean;
25
+ /** Like JSON.parse, but survives what Discord writes: raw newlines in strings, trailing commas. */
26
+ export declare function parseLooseJson(text: string): unknown;
27
+ /** RFC 4180: quoted fields may hold commas, quotes ("") and newlines. First row is the header. */
28
+ export declare function parseCsv(text: string): Record<string, string>[];
29
+ /** Discord writes "2023-04-12 21:14:05" in UTC, sometimes with fractions and an offset. */
30
+ export declare function parseDiscordTime(v: unknown): number | null;
31
+ /** Every one-to-one DM in the package that has at least one message, most messages first. */
32
+ export declare function listDiscordDms(files: PackageFiles): Promise<DiscordPackageDm[]>;
33
+ /** One DM's messages, oldest first. */
34
+ export declare function readDiscordDm(files: PackageFiles, channelId: string): Promise<DiscordPackageMessage[]>;
35
+ /** The earliest `ex` (hex Unix seconds) among signed CDN links, or null when none carry one. */
36
+ export declare function linksExpireAt(urls: string[]): number | null;
@@ -0,0 +1,206 @@
1
+ /* Reads the DMs out of Discord's own data package (GRYT-1965). The package only holds
2
+ messages its owner sent, and its JSON is not always valid, so every read is forgiving. */
3
+ function indexNames(names) {
4
+ const out = new Map();
5
+ const lower = names.map((n) => n.replace(/\\/g, "/").toLowerCase());
6
+ // A zip made by hand can wrap everything in one folder; Discord's own doesn't.
7
+ const roots = new Set(lower.map((n) => n.split("/")[0]));
8
+ const strip = roots.size === 1 && !lower.some((n) => n === "readme.txt") ? `${[...roots][0]}/` : "";
9
+ lower.forEach((n, i) => out.set(strip && n.startsWith(strip) ? n.slice(strip.length) : n, names[i]));
10
+ return out;
11
+ }
12
+ export function isDiscordPackage(names) {
13
+ const idx = indexNames(names);
14
+ return idx.has("messages/index.json") && idx.has("account/user.json");
15
+ }
16
+ const ESCAPES = { "\n": "\\n", "\r": "\\r", "\t": "\\t" };
17
+ /** Like JSON.parse, but survives what Discord writes: raw newlines in strings, trailing commas. */
18
+ export function parseLooseJson(text) {
19
+ try {
20
+ return JSON.parse(text);
21
+ }
22
+ catch {
23
+ let out = "";
24
+ let inString = false;
25
+ for (let i = 0; i < text.length; i++) {
26
+ const c = text[i];
27
+ if (inString) {
28
+ if (c === "\\") {
29
+ out += c + (text[++i] ?? "");
30
+ continue;
31
+ }
32
+ if (c === "\"")
33
+ inString = false;
34
+ out += ESCAPES[c] ?? c;
35
+ }
36
+ else {
37
+ if (c === "\"")
38
+ inString = true;
39
+ out += c;
40
+ }
41
+ }
42
+ return JSON.parse(out.replace(/,\s*([}\]])/g, "$1"));
43
+ }
44
+ }
45
+ /** RFC 4180: quoted fields may hold commas, quotes ("") and newlines. First row is the header. */
46
+ export function parseCsv(text) {
47
+ const rows = [];
48
+ let row = [];
49
+ let field = "";
50
+ let quoted = false;
51
+ for (let i = 0; i < text.length; i++) {
52
+ const c = text[i];
53
+ if (quoted) {
54
+ if (c === "\"" && text[i + 1] === "\"") {
55
+ field += "\"";
56
+ i++;
57
+ }
58
+ else if (c === "\"")
59
+ quoted = false;
60
+ else
61
+ field += c;
62
+ }
63
+ else if (c === "\"")
64
+ quoted = true;
65
+ else if (c === ",") {
66
+ row.push(field);
67
+ field = "";
68
+ }
69
+ else if (c === "\n" || c === "\r") {
70
+ if (c === "\r" && text[i + 1] === "\n")
71
+ i++;
72
+ row.push(field);
73
+ field = "";
74
+ if (row.some((f) => f !== ""))
75
+ rows.push(row);
76
+ row = [];
77
+ }
78
+ else
79
+ field += c;
80
+ }
81
+ row.push(field);
82
+ if (row.some((f) => f !== ""))
83
+ rows.push(row);
84
+ const [head, ...body] = rows;
85
+ if (!head)
86
+ return [];
87
+ return body.map((r) => Object.fromEntries(head.map((h, i) => [h.trim(), r[i] ?? ""])));
88
+ }
89
+ /** Discord writes "2023-04-12 21:14:05" in UTC, sometimes with fractions and an offset. */
90
+ export function parseDiscordTime(v) {
91
+ if (typeof v !== "string" || !v.trim())
92
+ return null;
93
+ let s = v.trim().replace(" ", "T");
94
+ if (!/(Z|[+-]\d\d:?\d\d)$/.test(s))
95
+ s += "Z";
96
+ const t = Date.parse(s);
97
+ return Number.isFinite(t) ? t : null;
98
+ }
99
+ const field = (row, name) => {
100
+ const key = Object.keys(row).find((k) => k.toLowerCase() === name);
101
+ return key === undefined ? undefined : row[key];
102
+ };
103
+ function attachmentsOf(v) {
104
+ let list = [];
105
+ if (Array.isArray(v))
106
+ list = v;
107
+ else if (typeof v === "string")
108
+ list = v.split(/\s+/);
109
+ return list.filter((u) => typeof u === "string" && /^https:\/\//.test(u));
110
+ }
111
+ function toMessage(row) {
112
+ if (typeof row !== "object" || row === null)
113
+ return null;
114
+ const r = row;
115
+ const id = field(r, "id");
116
+ const sentAt = parseDiscordTime(field(r, "timestamp"));
117
+ const text = field(r, "contents");
118
+ if ((typeof id !== "string" && typeof id !== "number") || sentAt === null)
119
+ return null;
120
+ return { discordId: String(id), sentAt, text: typeof text === "string" ? text : "", attachments: attachmentsOf(field(r, "attachments")) };
121
+ }
122
+ async function readJson(files, idx, path) {
123
+ const real = idx.get(path);
124
+ return real === undefined ? undefined : parseLooseJson(await files.read(real));
125
+ }
126
+ /** Newer packages name folders `c<id>`, older ones just `<id>`. */
127
+ function channelFolder(idx, channelId) {
128
+ for (const dir of [`messages/c${channelId}/`, `messages/${channelId}/`]) {
129
+ if (idx.has(`${dir}channel.json`))
130
+ return dir;
131
+ }
132
+ return null;
133
+ }
134
+ async function messagesIn(files, idx, dir) {
135
+ const json = idx.get(`${dir}messages.json`);
136
+ const csv = idx.get(`${dir}messages.csv`);
137
+ let rows = [];
138
+ if (json !== undefined) {
139
+ const parsed = parseLooseJson(await files.read(json));
140
+ rows = Array.isArray(parsed) ? parsed : [];
141
+ }
142
+ else if (csv !== undefined) {
143
+ rows = parseCsv(await files.read(csv));
144
+ }
145
+ return rows.map(toMessage).filter((m) => m !== null).sort((a, b) => a.sentAt - b.sentAt);
146
+ }
147
+ const displayName = (raw) => raw.replace(/^Direct Message with\s*/i, "").replace(/#\d+$/, "").trim();
148
+ /** Every one-to-one DM in the package that has at least one message, most messages first. */
149
+ export async function listDiscordDms(files) {
150
+ const idx = indexNames(files.names);
151
+ const user = (await readJson(files, idx, "account/user.json"));
152
+ const selfId = typeof user?.id === "string" ? user.id : null;
153
+ const avatars = new Map();
154
+ for (const rel of Array.isArray(user?.relationships) ? user.relationships : []) {
155
+ const u = rel?.user;
156
+ if (typeof u?.id === "string" && typeof u.avatar === "string" && /^(a_)?[0-9a-f]{32}$/i.test(u.avatar))
157
+ avatars.set(u.id, u.avatar);
158
+ }
159
+ const index = ((await readJson(files, idx, "messages/index.json")) ?? {});
160
+ const out = [];
161
+ for (const [channelId, label] of Object.entries(index)) {
162
+ const dir = channelFolder(idx, channelId);
163
+ if (!dir)
164
+ continue;
165
+ const channel = (await readJson(files, idx, `${dir}channel.json`));
166
+ const recipients = Array.isArray(channel?.recipients) ? channel.recipients.filter((r) => typeof r === "string") : [];
167
+ const isDm = channel?.type === 1 || channel?.type === "DM" || (!channel?.guild && recipients.length === 2);
168
+ if (!isDm)
169
+ continue;
170
+ const messages = await messagesIn(files, idx, dir);
171
+ if (messages.length === 0)
172
+ continue;
173
+ const userId = recipients.find((r) => r !== selfId) ?? null;
174
+ const hash = userId ? avatars.get(userId) : undefined;
175
+ out.push({
176
+ channelId,
177
+ name: typeof label === "string" && label ? displayName(label) : (userId ?? channelId),
178
+ userId,
179
+ avatarUrl: userId && hash ? `https://cdn.discordapp.com/avatars/${userId}/${hash}.png?size=64` : null,
180
+ messages: messages.length,
181
+ files: messages.reduce((n, m) => n + m.attachments.length, 0),
182
+ firstAt: messages[0].sentAt,
183
+ lastAt: messages[messages.length - 1].sentAt,
184
+ });
185
+ }
186
+ return out.sort((a, b) => b.messages - a.messages);
187
+ }
188
+ /** One DM's messages, oldest first. */
189
+ export async function readDiscordDm(files, channelId) {
190
+ const idx = indexNames(files.names);
191
+ const dir = channelFolder(idx, channelId);
192
+ return dir ? messagesIn(files, idx, dir) : [];
193
+ }
194
+ /** The earliest `ex` (hex Unix seconds) among signed CDN links, or null when none carry one. */
195
+ export function linksExpireAt(urls) {
196
+ let soonest = null;
197
+ for (const u of urls) {
198
+ const ex = /[?&]ex=([0-9a-f]+)/i.exec(u)?.[1];
199
+ if (!ex)
200
+ continue;
201
+ const at = parseInt(ex, 16) * 1000;
202
+ if (soonest === null || at < soonest)
203
+ soonest = at;
204
+ }
205
+ return soonest;
206
+ }
package/dist/index.d.ts CHANGED
@@ -12,8 +12,9 @@ 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, historyBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, type MlsDmContent, type MlsHistoryEntry, readMlsDmContent, } from "./mls/content.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";
16
16
  export { applyMlsReaction, mlsReactionAction, type MlsReaction } from "./mls/reactions.js";
17
+ export { type DiscordPackageDm, type DiscordPackageMessage, isDiscordPackage, linksExpireAt, listDiscordDms, type PackageFiles, readDiscordDm, } from "./import/discordPackage.js";
17
18
  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";
18
19
  export { createApproverPairing, type ApproverOptions, type ApproverPairing, type ApproverState, } from "./pairing/approver.js";
19
20
  export { createNewDevicePairing, PAIRING_APPROVAL_MS, type NewDeviceOptions, type NewDevicePairing, type NewDeviceState, } from "./pairing/newDevice.js";
package/dist/index.js CHANGED
@@ -14,8 +14,9 @@ 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 { decodeMlsDmContent, encodeMlsDmContent, historyBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
17
+ export { decodeMlsDmContent, encodeMlsDmContent, acceptImport, historyBatches, importBatches, MAX_HISTORY_BATCH_BYTES, MLS_DM_CONTENT_VERSION, readMlsDmContent, } from "./mls/content.js";
18
18
  export { applyMlsReaction, mlsReactionAction } from "./mls/reactions.js";
19
+ export { isDiscordPackage, linksExpireAt, listDiscordDms, readDiscordDm, } from "./import/discordPackage.js";
19
20
  export { createApproverPairing, } from "./pairing/approver.js";
20
21
  export { createNewDevicePairing, PAIRING_APPROVAL_MS, } from "./pairing/newDevice.js";
21
22
  export { createPairingRelay, PairingRelayError } from "./pairing/relayClient.js";
@@ -10,7 +10,20 @@ export interface MlsHistoryEntry {
10
10
  replyTo?: string;
11
11
  attachments?: Record<string, SealedAttachmentKey>;
12
12
  }
13
+ /** One message from the sender's own Discord data. Who wrote it is never in here: the reader takes the MLS sender. */
14
+ export interface MlsImportEntry {
15
+ id: string;
16
+ /** Milliseconds since the epoch, Discord's time for it. */
17
+ sentAt: number;
18
+ text: string;
19
+ attachments?: Record<string, SealedAttachmentKey>;
20
+ }
13
21
  export type MlsDmContent = {
22
+ type: "import";
23
+ id: string;
24
+ source: "discord";
25
+ entries: MlsImportEntry[];
26
+ } | {
14
27
  type: "history";
15
28
  /** For the batch itself, so a repeat is the same message. */
16
29
  id: string;
@@ -48,6 +61,20 @@ export declare function encodeMlsDmContent(content: MlsDmContent): Uint8Array;
48
61
  export declare function historyBatches(entries: MlsHistoryEntry[], newId: () => string): Extract<MlsDmContent, {
49
62
  type: "history";
50
63
  }>[];
64
+ /** An import as batches small enough to send, oldest first, the same way `historyBatches` splits. */
65
+ export declare function importBatches(entries: MlsImportEntry[], newId: () => string): Extract<MlsDmContent, {
66
+ type: "import";
67
+ }>[];
68
+ /**
69
+ * An import's entries as messages by `senderId`, the MLS sender, whatever the batch says.
70
+ * Dated before Discord existed or after the server got it (`at`) is dropped.
71
+ */
72
+ export declare function acceptImport(content: Extract<MlsDmContent, {
73
+ type: "import";
74
+ }>, { senderId, at }: {
75
+ senderId: string;
76
+ at: number;
77
+ }): MlsHistoryEntry[];
51
78
  /** Null for anything this version can't read: a newer version, a new type, or junk. */
52
79
  export declare function decodeMlsDmContent(bytes: Uint8Array): MlsDmContent | null;
53
80
  /**
@@ -85,6 +85,9 @@ export function encodeMlsDmContent(content) {
85
85
  if (content.type === "history") {
86
86
  body = { type: content.type, id: content.id, entries: content.entries.map(historyBody) };
87
87
  }
88
+ else if (content.type === "import") {
89
+ body = { type: content.type, id: content.id, source: content.source, entries: content.entries.map(importBody) };
90
+ }
88
91
  else if (content.type === "message") {
89
92
  body = { type: content.type, id: content.id, text: content.text };
90
93
  if (content.replyTo !== undefined)
@@ -114,24 +117,50 @@ function historyBody(e) {
114
117
  out.attachments = e.attachments;
115
118
  return out;
116
119
  }
117
- /** History as batches small enough to send, oldest first. One entry too big to send on its own is left out. */
118
- export function historyBatches(entries, newId) {
119
- const size = (batch) => new TextEncoder().encode(JSON.stringify({ v: MLS_DM_CONTENT_VERSION, type: "history", id: "x".repeat(MAX_ID), entries: batch.map(historyBody) })).length;
120
+ function importBody(e) {
121
+ const out = { id: e.id, sentAt: e.sentAt, text: e.text };
122
+ if (e.attachments && Object.keys(e.attachments).length > 0)
123
+ out.attachments = e.attachments;
124
+ return out;
125
+ }
126
+ function sizedBatches(entries, body, readable, head) {
127
+ const size = (batch) => new TextEncoder().encode(JSON.stringify({ v: MLS_DM_CONTENT_VERSION, ...head, id: "x".repeat(MAX_ID), entries: batch.map(body) })).length;
120
128
  const out = [];
121
129
  let batch = [];
122
130
  for (const e of [...entries].sort((a, b) => a.sentAt - b.sentAt)) {
123
- if (!readHistoryEntry(historyBody(e)) || size([e]) > MAX_HISTORY_BATCH_BYTES)
131
+ if (!readable(body(e)) || size([e]) > MAX_HISTORY_BATCH_BYTES)
124
132
  continue;
125
133
  if (batch.length === MAX_HISTORY_ENTRIES || size([...batch, e]) > MAX_HISTORY_BATCH_BYTES) {
126
- out.push({ type: "history", id: newId(), entries: batch });
134
+ out.push(batch);
127
135
  batch = [];
128
136
  }
129
137
  batch.push(e);
130
138
  }
131
139
  if (batch.length)
132
- out.push({ type: "history", id: newId(), entries: batch });
140
+ out.push(batch);
133
141
  return out;
134
142
  }
143
+ /** History as batches small enough to send, oldest first. One entry too big to send on its own is left out. */
144
+ export function historyBatches(entries, newId) {
145
+ return sizedBatches(entries, historyBody, (raw) => readHistoryEntry(raw) !== null, { type: "history" })
146
+ .map((batch) => ({ type: "history", id: newId(), entries: batch }));
147
+ }
148
+ /** An import as batches small enough to send, oldest first, the same way `historyBatches` splits. */
149
+ export function importBatches(entries, newId) {
150
+ return sizedBatches(entries, importBody, (raw) => readImportEntry(raw) !== null, { type: "import", source: "discord" })
151
+ .map((batch) => ({ type: "import", id: newId(), source: "discord", entries: batch }));
152
+ }
153
+ /** Discord's own epoch, 2015-01-01. Nothing from Discord is older. */
154
+ const DISCORD_EPOCH = 1_420_070_400_000;
155
+ /**
156
+ * An import's entries as messages by `senderId`, the MLS sender, whatever the batch says.
157
+ * Dated before Discord existed or after the server got it (`at`) is dropped.
158
+ */
159
+ export function acceptImport(content, { senderId, at }) {
160
+ return content.entries
161
+ .filter((e) => e.sentAt >= DISCORD_EPOCH && e.sentAt <= at)
162
+ .map((e) => (e.attachments ? { ...e, senderId } : { id: e.id, senderId, sentAt: e.sentAt, text: e.text }));
163
+ }
135
164
  function readHistoryEntry(v) {
136
165
  if (!isObject(v) || !isId(v.id) || !isId(v.senderId) || !isText(v.text) || !isCount(v.sentAt))
137
166
  return null;
@@ -149,6 +178,18 @@ function readHistoryEntry(v) {
149
178
  }
150
179
  return out;
151
180
  }
181
+ function readImportEntry(v) {
182
+ if (!isObject(v) || !isId(v.id) || !isText(v.text) || !isCount(v.sentAt))
183
+ return null;
184
+ const out = { id: v.id, sentAt: v.sentAt, text: v.text };
185
+ if (v.attachments !== undefined) {
186
+ const attachments = readAttachments(v.attachments);
187
+ if (!attachments)
188
+ return null;
189
+ out.attachments = attachments;
190
+ }
191
+ return out;
192
+ }
152
193
  /** Null for anything this version can't read: a newer version, a new type, or junk. */
153
194
  export function decodeMlsDmContent(bytes) {
154
195
  return fromRaw(parse(bytes));
@@ -161,7 +202,7 @@ export function readMlsDmContent(bytes) {
161
202
  const raw = parse(bytes);
162
203
  return fromRaw(raw) ?? (isNewer(raw) ? "newer" : null);
163
204
  }
164
- const KNOWN_TYPES = new Set(["message", "edit", "delete", "reaction", "history"]);
205
+ const KNOWN_TYPES = new Set(["message", "edit", "delete", "reaction", "history", "import"]);
165
206
  const TYPE_NAME = /^[a-z][a-z0-9_]{0,31}$/;
166
207
  /** A later version, or a type this one doesn't know that names its message the way ours do. */
167
208
  function isNewer(raw) {
@@ -189,6 +230,12 @@ function fromRaw(raw) {
189
230
  const entries = raw.entries.map(readHistoryEntry);
190
231
  return entries.every((e) => e !== null) ? { type, id, entries } : null;
191
232
  }
233
+ if (type === "import") {
234
+ if (raw.source !== "discord" || !Array.isArray(raw.entries) || raw.entries.length === 0 || raw.entries.length > MAX_HISTORY_ENTRIES)
235
+ return null;
236
+ const entries = raw.entries.map(readImportEntry);
237
+ return entries.every((e) => e !== null) ? { type, id, source: raw.source, entries } : null;
238
+ }
192
239
  if (type === "message" && isText(text)) {
193
240
  const out = { type, id, text };
194
241
  if (raw.replyTo !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gryt/core",
3
- "version": "0.19.0",
3
+ "version": "0.21.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": {
@@ -43,7 +43,7 @@
43
43
  "access": "public"
44
44
  },
45
45
  "dependencies": {
46
- "@gryt/crypto": "0.12.0",
46
+ "@gryt/crypto": "0.13.0",
47
47
  "@noble/hashes": "^2.3.0"
48
48
  }
49
49
  }