@gryt/core 0.20.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.
- package/dist/import/discordPackage.d.ts +36 -0
- package/dist/import/discordPackage.js +206 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/mls/content.d.ts +27 -0
- package/dist/mls/content.js +54 -7
- package/package.json +1 -1
|
@@ -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";
|
package/dist/mls/content.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/mls/content.js
CHANGED
|
@@ -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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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 (!
|
|
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(
|
|
134
|
+
out.push(batch);
|
|
127
135
|
batch = [];
|
|
128
136
|
}
|
|
129
137
|
batch.push(e);
|
|
130
138
|
}
|
|
131
139
|
if (batch.length)
|
|
132
|
-
out.push(
|
|
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