discord-message-transcript 1.2.0-dev.1.2.0.12.0 → 1.2.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/README.md CHANGED
@@ -50,6 +50,7 @@ This project provides two packages:
50
50
  - Single-file HTML output
51
51
  - Lightweight JSON format
52
52
  - Optional image embedding
53
+ - Optional CDN Upload (Can be custom)
53
54
  - Markdown + syntax highlighting
54
55
  - No tracking or telemetry
55
56
 
@@ -1,2 +1,5 @@
1
1
  import { CDNOptions } from "../types/types.js";
2
- export declare function cdnResolver(url: string, cdnOptions: CDNOptions<unknown>): Promise<string>;
2
+ import { TranscriptOptionsBase } from "discord-message-transcript-base";
3
+ export declare function cdnResolver(url: string, options: TranscriptOptionsBase, cdnOptions: CDNOptions): Promise<string>;
4
+ export declare function uploadCareResolver(url: string, publicKey: string, cdnDomain: string): Promise<string>;
5
+ export declare function cloudinaryResolver(url: string, fileName: string, cloudName: string, apiKey: string, apiSecret: string): Promise<string>;
@@ -1,56 +1,197 @@
1
1
  import https from 'https';
2
2
  import http from 'http';
3
3
  import { CustomWarn } from "discord-message-transcript-base";
4
- export async function cdnResolver(url, cdnOptions) {
5
- return new Promise((resolve, reject) => {
6
- const client = url.startsWith('https') ? https : http;
7
- const request = client.get(url, { headers: { "User-Agent": "discord-message-transcript" } }, async (response) => {
8
- if (response.statusCode !== 200) {
4
+ import crypto from 'crypto';
5
+ import { getCDNLimiter } from "./limiter.js";
6
+ export async function cdnResolver(url, options, cdnOptions) {
7
+ const limit = getCDNLimiter();
8
+ return limit(async () => {
9
+ return new Promise((resolve, reject) => {
10
+ const client = url.startsWith('https') ? https : http;
11
+ const request = client.request(url, {
12
+ method: 'HEAD',
13
+ headers: { "User-Agent": "discord-message-transcript" }
14
+ }, async (response) => {
15
+ if (response.statusCode !== 200) {
16
+ response.destroy();
17
+ CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of uploading to CDN.
18
+ Failed to fetch attachment with status code: ${response.statusCode} from ${url}.`);
19
+ return resolve(url);
20
+ }
21
+ const contentType = response.headers["content-type"];
22
+ const splitContentType = contentType ? contentType?.split('/') : [];
23
+ if (!contentType || splitContentType.length != 2 || splitContentType[0].length == 0 || splitContentType[1].length == 0) {
24
+ response.destroy();
25
+ CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of uploading to CDN.
26
+ Failed to receive a valid content-type from ${url}.`);
27
+ return resolve(url);
28
+ }
9
29
  response.destroy();
10
- CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of uploading to CDN.\nFailed to fetch attachment with status code: ${response.statusCode} from ${url}.`);
30
+ const isImage = contentType.startsWith('image/') && contentType !== 'image/gif';
31
+ const isAudio = contentType.startsWith('audio/');
32
+ const isVideo = contentType.startsWith('video/') || contentType === 'image/gif';
33
+ if ((cdnOptions.includeImage && isImage) ||
34
+ (cdnOptions.includeAudio && isAudio) ||
35
+ (cdnOptions.includeVideo && isVideo) ||
36
+ (cdnOptions.includeOthers && !isAudio && !isImage && !isVideo)) {
37
+ return resolve(await cdnRedirectType(url, options, contentType, cdnOptions));
38
+ }
11
39
  return resolve(url);
12
- }
13
- const contentType = response.headers["content-type"];
14
- const splitContentType = contentType ? contentType?.split('/') : [];
15
- if (!contentType || splitContentType.length != 2 || splitContentType[0].length == 0 || splitContentType[1].length == 0) {
16
- response.destroy();
17
- CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of uploading to CDN.\nFailed to receive a valid content-type from ${url}.`);
40
+ });
41
+ request.on('error', (err) => {
42
+ CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of uploading to CDN.
43
+ Error: ${err.message}`);
18
44
  return resolve(url);
19
- }
20
- response.destroy();
21
- const isImage = contentType.startsWith('image/') && contentType !== 'image/gif';
22
- const isAudio = contentType.startsWith('audio/');
23
- const isVideo = contentType.startsWith('video/') || contentType === 'image/gif';
24
- if ((cdnOptions.includeImage && isImage) ||
25
- (cdnOptions.includeAudio && isAudio) ||
26
- (cdnOptions.includeVideo && isVideo) ||
27
- (cdnOptions.includeOthers && !isAudio && !isImage && !isVideo)) {
28
- return resolve(await cdnRedirectType(url, contentType, cdnOptions));
29
- }
30
- return resolve(url);
45
+ });
46
+ request.setTimeout(15000, () => {
47
+ request.destroy();
48
+ CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of uploading to CDN.
49
+ Request timeout for ${url}.`);
50
+ resolve(url);
51
+ });
52
+ request.end();
31
53
  });
32
- request.on('error', (err) => {
33
- CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of uploading to CDN.\nError message: ${err.message}`);
34
- return resolve(url);
35
- });
36
- request.setTimeout(15000, () => {
37
- request.destroy();
38
- CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of uploading to CDN.\nRequest timeout for ${url}. Using original URL.`);
39
- resolve(url);
40
- });
41
- request.end();
42
54
  });
43
55
  }
44
- async function cdnRedirectType(url, contentType, cdnOptions) {
45
- switch (cdnOptions.type) {
56
+ async function cdnRedirectType(url, options, contentType, cdnOptions) {
57
+ switch (cdnOptions.provider) {
46
58
  case "CUSTOM": {
47
- return await cdnOptions.customCdnResolver(url, contentType, cdnOptions.other);
59
+ try {
60
+ return await cdnOptions.resolver(url, contentType, cdnOptions.customData);
61
+ }
62
+ catch (error) {
63
+ CustomWarn(`Custom CDN resolver threw an error. Falling back to original URL.
64
+ This is most likely an issue in the custom CDN implementation provided by the user.
65
+ URL: ${url}
66
+ Error: ${error?.message ?? error}`);
67
+ return url;
68
+ }
69
+ }
70
+ case "CLOUDINARY": {
71
+ return await cloudinaryResolver(url, options.fileName, cdnOptions.cloudName, cdnOptions.apiKey, cdnOptions.apiSecret);
72
+ ;
48
73
  }
49
- case "CLOUDFLARE_R2": {
50
- return await cdnCloudflareR2();
74
+ case "UPLOADCARE": {
75
+ return await uploadCareResolver(url, cdnOptions.publicKey, cdnOptions.cdnDomain);
51
76
  }
52
77
  }
53
78
  }
54
- async function cdnCloudflareR2() {
55
- return ''; // TODO: Implement cloudflareR2 cdn
79
+ function sleep(ms) {
80
+ return new Promise(r => setTimeout(r, ms));
81
+ }
82
+ export async function uploadCareResolver(url, publicKey, cdnDomain) {
83
+ try {
84
+ const form = new FormData();
85
+ form.append("pub_key", publicKey);
86
+ form.append("source_url", url);
87
+ form.append("store", "1");
88
+ form.append("check_URL_duplicates", "1");
89
+ form.append("save_URL_duplicates", "1");
90
+ const res = await fetch("https://upload.uploadcare.com/from_url/", {
91
+ method: "POST",
92
+ body: form,
93
+ headers: {
94
+ "User-Agent": "discord-message-transcript"
95
+ }
96
+ });
97
+ if (!res.ok) {
98
+ switch (res.status) {
99
+ case 400:
100
+ throw new Error(`Uploadcare initial request failed with status code ${res.status} - Request failed input parameters validation.`);
101
+ case 403:
102
+ throw new Error(`Uploadcare initial request failed with status code ${res.status} - Request was not allowed.`);
103
+ case 429:
104
+ throw new Error(`Uploadcare initial request failed with status code ${res.status} - Request was throttled.`);
105
+ default:
106
+ throw new Error(`Uploadcare initial request failed with status code ${res.status}`);
107
+ }
108
+ }
109
+ const json = await res.json();
110
+ if (json.uuid) {
111
+ return `https://${cdnDomain}/${json.uuid}/`;
112
+ }
113
+ let delay = 200;
114
+ let maxDelay = 2000;
115
+ if (json.token) {
116
+ for (let i = 0; i < 10; i++) {
117
+ await sleep(delay);
118
+ delay = Math.min(delay * 2, maxDelay);
119
+ const resToken = await fetch(`https://upload.uploadcare.com/from_url/status/?token=${json.token}&pub_key=${publicKey}`, { headers: { "User-Agent": "discord-message-transcript" } });
120
+ if (!resToken.ok)
121
+ throw new Error(`Uploadcare status failed with status code ${resToken.status}`);
122
+ const jsonToken = await resToken.json();
123
+ if (jsonToken.status === "success" && jsonToken.file_id) {
124
+ return `https://${cdnDomain}/${jsonToken.file_id}/`;
125
+ }
126
+ if (jsonToken.status === "error") {
127
+ throw new Error(jsonToken.error || "Uploadcare failed");
128
+ }
129
+ }
130
+ throw new Error("Uploadcare polling timeout");
131
+ }
132
+ return url;
133
+ }
134
+ catch (error) {
135
+ CustomWarn(`Uploadcare CDN upload failed. Using original URL as fallback.
136
+ Check Uploadcare public key, CDN domain, project settings, rate limits, and network access.
137
+ URL: ${url}
138
+ Error: ${error?.message ?? error}`);
139
+ return url;
140
+ }
141
+ }
142
+ export async function cloudinaryResolver(url, fileName, cloudName, apiKey, apiSecret) {
143
+ try {
144
+ const paramsToSign = {
145
+ folder: `discord-message-transcript/${fileName}`,
146
+ timestamp: Math.floor(Date.now() / 1000).toString(),
147
+ unique_filename: "true",
148
+ use_filename: "true",
149
+ };
150
+ const stringToSign = Object.keys(paramsToSign).sort().map(k => `${k}=${paramsToSign[k]}`).join("&");
151
+ // signature SHA1
152
+ const signature = crypto
153
+ .createHash("sha1")
154
+ .update(stringToSign + apiSecret)
155
+ .digest("hex");
156
+ const form = new FormData();
157
+ form.append("folder", paramsToSign.folder);
158
+ form.append("file", url);
159
+ form.append("api_key", apiKey);
160
+ form.append("timestamp", paramsToSign.timestamp);
161
+ form.append("signature", signature);
162
+ form.append("use_filename", paramsToSign.use_filename);
163
+ form.append("unique_filename", paramsToSign.unique_filename);
164
+ const res = await fetch(`https://api.cloudinary.com/v1_1/${cloudName}/auto/upload`, {
165
+ method: "POST",
166
+ body: form,
167
+ headers: {
168
+ "User-Agent": "discord-message-transcript"
169
+ }
170
+ });
171
+ if (!res.ok) {
172
+ switch (res.status) {
173
+ case 400:
174
+ throw new Error(`Cloudinary upload failed with status code ${res.status} - Bad request / invalid params.`);
175
+ case 403:
176
+ throw new Error(`Cloudinary upload failed with status code ${res.status} - Invalid credentials or unauthorized.`);
177
+ case 429:
178
+ throw new Error(`Cloudinary upload failed with status code ${res.status} - Rate limited.`);
179
+ default:
180
+ throw new Error(`Cloudinary upload failed with status code ${res.status}.`);
181
+ }
182
+ }
183
+ const json = await res.json();
184
+ if (!json.secure_url) {
185
+ throw new Error("Cloudinary response missing secure_url");
186
+ }
187
+ return json.secure_url;
188
+ }
189
+ catch (error) {
190
+ CustomWarn(`Failed to upload asset to Cloudinary CDN. Using original URL as fallback.
191
+ Check Cloudinary configuration (cloud name, API key, API secret) and network access.
192
+ URL: ${url}
193
+ Error: ${error?.message ?? error}`);
194
+ return url;
195
+ }
56
196
  }
197
+ // Note: for debug use ${JSON.stringify(await res.json())} to understand the error
@@ -1,4 +1,4 @@
1
1
  import { TopLevelComponent } from "discord.js";
2
- import { JsonTopLevelComponent, TranscriptOptionsBase } from "discord-message-transcript-base";
3
- import { CDNOptions } from "../types/types.js";
4
- export declare function componentsToJson(components: TopLevelComponent[], options: TranscriptOptionsBase, cdnOptions: CDNOptions<unknown> | null): Promise<JsonTopLevelComponent[]>;
2
+ import { JsonTopLevelComponent, JsonComponentInContainer, TranscriptOptionsBase } from "discord-message-transcript-base";
3
+ export declare function componentsToJson(components: TopLevelComponent[], options: TranscriptOptionsBase): Promise<JsonTopLevelComponent[]>;
4
+ export declare function isJsonComponentInContainer(component: JsonTopLevelComponent): component is JsonComponentInContainer;
@@ -1,8 +1,7 @@
1
1
  import { ComponentType } from "discord.js";
2
2
  import { mapButtonStyle, mapSelectorType, mapSeparatorSpacing } from "./mappers.js";
3
3
  import { JsonComponentType } from "discord-message-transcript-base";
4
- import { urlResolver } from "./urlResolver.js";
5
- export async function componentsToJson(components, options, cdnOptions) {
4
+ export async function componentsToJson(components, options) {
6
5
  const processedComponents = await Promise.all(components.filter(component => !(!options.includeV2Components && component.type != ComponentType.ActionRow))
7
6
  .map(async (component) => {
8
7
  switch (component.type) {
@@ -54,7 +53,7 @@ export async function componentsToJson(components, options, cdnOptions) {
54
53
  }
55
54
  case ComponentType.Container: {
56
55
  const newOptions = { ...options, includeComponents: true, includeButtons: true };
57
- const componentsJson = await componentsToJson(component.components, newOptions, cdnOptions);
56
+ const componentsJson = await componentsToJson(component.components, newOptions);
58
57
  return {
59
58
  type: JsonComponentType.Container,
60
59
  components: componentsJson.filter(isJsonComponentInContainer), // Input components that are container-safe must always produce container-safe output.
@@ -67,17 +66,17 @@ export async function componentsToJson(components, options, cdnOptions) {
67
66
  type: JsonComponentType.File,
68
67
  fileName: component.data.name ?? null,
69
68
  size: component.data.size ?? 0,
70
- url: await urlResolver(component.file.url, options, cdnOptions),
69
+ url: component.file.url,
71
70
  spoiler: component.spoiler,
72
71
  };
73
72
  }
74
73
  case ComponentType.MediaGallery: {
75
- const mediaItems = await Promise.all(component.items.map(async (item) => {
74
+ const mediaItems = component.items.map(item => {
76
75
  return {
77
- media: { url: await urlResolver(item.media.url, options, cdnOptions) },
76
+ media: { url: item.media.url },
78
77
  spoiler: item.spoiler,
79
78
  };
80
- }));
79
+ });
81
80
  return {
82
81
  type: JsonComponentType.MediaGallery,
83
82
  items: mediaItems,
@@ -99,7 +98,7 @@ export async function componentsToJson(components, options, cdnOptions) {
99
98
  accessoryJson = {
100
99
  type: JsonComponentType.Thumbnail,
101
100
  media: {
102
- url: await urlResolver(component.accessory.media.url, options, cdnOptions),
101
+ url: component.accessory.media.url,
103
102
  },
104
103
  spoiler: component.accessory.spoiler,
105
104
  };
@@ -135,7 +134,7 @@ export async function componentsToJson(components, options, cdnOptions) {
135
134
  }));
136
135
  return processedComponents.filter(c => c != null);
137
136
  }
138
- function isJsonComponentInContainer(component) {
137
+ export function isJsonComponentInContainer(component) {
139
138
  return (component.type == JsonComponentType.ActionRow ||
140
139
  component.type == JsonComponentType.File ||
141
140
  component.type == JsonComponentType.MediaGallery ||
@@ -1,7 +1,19 @@
1
1
  import { TextBasedChannel } from "discord.js";
2
2
  import { JsonAuthor, JsonMessage, TranscriptOptionsBase } from "discord-message-transcript-base";
3
- import { CDNOptions, MapMentions } from "../types/types.js";
4
- export declare function fetchMessages(channel: TextBasedChannel, options: TranscriptOptionsBase, cdnOptions: CDNOptions<unknown> | null, authors: Map<string, JsonAuthor>, mentions: MapMentions, after?: string): Promise<{
3
+ import { MapMentions } from "../types/types.js";
4
+ export declare function fetchMessages(ctx: FetchMessagesContext): Promise<{
5
5
  messages: JsonMessage[];
6
6
  end: boolean;
7
+ newLastMessageId: string | undefined;
7
8
  }>;
9
+ export type FetchMessagesContext = {
10
+ channel: TextBasedChannel;
11
+ options: TranscriptOptionsBase;
12
+ transcriptState: TranscriptState;
13
+ lastMessageId: string | undefined;
14
+ };
15
+ type TranscriptState = {
16
+ authors: Map<string, JsonAuthor>;
17
+ mentions: MapMentions;
18
+ };
19
+ export {};
@@ -1,45 +1,41 @@
1
1
  import { EmbedType } from "discord.js";
2
2
  import { componentsToJson } from "./componentToJson.js";
3
- import { urlResolver } from "./urlResolver.js";
4
3
  import { getMentions } from "./getMentions.js";
5
- export async function fetchMessages(channel, options, cdnOptions, authors, mentions, after) {
6
- const originalMessages = await channel.messages.fetch({ limit: 100, cache: false, after: after });
4
+ export async function fetchMessages(ctx) {
5
+ const { channel, options, transcriptState, lastMessageId } = ctx;
6
+ const { authors, mentions } = transcriptState;
7
+ const originalMessages = await channel.messages.fetch({ limit: 100, cache: false, before: lastMessageId });
7
8
  const rawMessages = await Promise.all(originalMessages.map(async (message) => {
8
- const attachments = await Promise.all(message.attachments.map(async (attachment) => {
9
+ const attachments = message.attachments.map(attachment => {
9
10
  return {
10
11
  contentType: attachment.contentType,
11
12
  name: attachment.name,
12
13
  size: attachment.size,
13
14
  spoiler: attachment.spoiler,
14
- url: await urlResolver(attachment.url, options, cdnOptions),
15
+ url: attachment.url,
15
16
  };
16
- }));
17
+ });
17
18
  // This only works because embeds with the type poll_result that are send when a poll end are marked as a message send by the system
18
- const embeds = message.system && message.embeds.length == 1 && message.embeds[0].data.type == EmbedType.PollResult && !options.includePolls ? []
19
- : await Promise.all(message.embeds.map(async (embed) => {
20
- const [authorIcon, thumbnailUrl, imageUrl, footerIcon] = await Promise.all([
21
- embed.author?.iconURL ? urlResolver(embed.author.iconURL, options, cdnOptions) : Promise.resolve(null),
22
- embed.thumbnail?.url ? urlResolver(embed.thumbnail.url, options, cdnOptions) : Promise.resolve(null),
23
- embed.image?.url ? urlResolver(embed.image.url, options, cdnOptions) : Promise.resolve(null),
24
- embed.footer?.iconURL ? urlResolver(embed.footer.iconURL, options, cdnOptions) : Promise.resolve(null),
25
- ]);
19
+ const embeds = message.system && message.embeds.length == 1 && message.embeds[0].data.type == EmbedType.PollResult && !options.includePolls
20
+ ? []
21
+ : message.embeds.map(embed => {
26
22
  return {
27
- author: embed.author ? { name: embed.author.name, url: embed.author.url ?? null, iconURL: authorIcon } : null,
23
+ author: embed.author ? { name: embed.author.name, url: embed.author.url ?? null, iconURL: embed.author?.iconURL ?? null } : null,
28
24
  description: embed.description ?? null,
29
25
  fields: embed.fields.map(field => ({ inline: field.inline ?? false, name: field.name, value: field.value })),
30
- footer: embed.footer ? { iconURL: footerIcon, text: embed.footer.text } : null,
26
+ footer: embed.footer ? { iconURL: embed.footer?.iconURL ?? null, text: embed.footer.text } : null,
31
27
  hexColor: embed.hexColor ?? null,
32
- image: imageUrl ? { url: imageUrl } : null,
33
- thumbnail: thumbnailUrl ? { url: thumbnailUrl } : null,
28
+ image: embed.image?.url ? { url: embed.image.url } : null,
29
+ thumbnail: embed.thumbnail?.url ? { url: embed.thumbnail.url } : null,
34
30
  timestamp: embed.timestamp,
35
31
  title: embed.title,
36
32
  type: embed.data.type ?? "rich",
37
33
  url: embed.url,
38
34
  };
39
- }));
35
+ });
40
36
  if (!authors.has(message.author.id)) {
41
37
  authors.set(message.author.id, {
42
- avatarURL: await urlResolver(message.author.displayAvatarURL(), options, cdnOptions),
38
+ avatarURL: message.author.displayAvatarURL(),
43
39
  bot: message.author.bot,
44
40
  displayName: message.author.displayName,
45
41
  guildTag: message.author.primaryGuild?.tag ?? null,
@@ -51,7 +47,7 @@ export async function fetchMessages(channel, options, cdnOptions, authors, menti
51
47
  system: message.author.system,
52
48
  });
53
49
  }
54
- const components = await componentsToJson(message.components, options, cdnOptions);
50
+ const components = await componentsToJson(message.components, options);
55
51
  await getMentions(message, mentions);
56
52
  return {
57
53
  attachments: options.includeAttachments ? attachments : [],
@@ -89,9 +85,10 @@ export async function fetchMessages(channel, options, cdnOptions, authors, menti
89
85
  system: message.system,
90
86
  };
91
87
  }));
88
+ const newLastMessageId = originalMessages.last()?.id;
92
89
  const messages = rawMessages.filter(m => !(!options.includeEmpty && m.attachments.length == 0 && m.components.length == 0 && m.content == "" && m.embeds.length == 0 && !m.poll));
93
90
  const end = originalMessages.size !== 100;
94
- return { messages, end };
91
+ return { messages, end, newLastMessageId };
95
92
  }
96
93
  function formatTimeLeftPoll(timestamp) {
97
94
  const now = new Date();
@@ -1,43 +1,53 @@
1
1
  import https from 'https';
2
2
  import http from 'http';
3
3
  import { CustomWarn } from 'discord-message-transcript-base';
4
+ import { getBase64Limiter } from './limiter.js';
4
5
  export async function imageToBase64(url) {
5
- return new Promise((resolve, reject) => {
6
- const client = url.startsWith('https') ? https : http;
7
- const request = client.get(url, { headers: { "User-Agent": "discord-message-transcript" } }, (response) => {
8
- if (response.statusCode !== 200) {
9
- response.destroy();
10
- CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of converting to base64.\nFailed to fetch image with status code: ${response.statusCode} from ${url}.`);
11
- return resolve(url);
12
- }
13
- const contentType = response.headers['content-type'];
14
- if (!contentType || !contentType.startsWith('image/') || contentType === 'image/gif') {
15
- response.destroy();
16
- return resolve(url);
17
- }
18
- const chunks = [];
19
- response.on('data', (chunk) => {
20
- chunks.push(chunk);
6
+ const limit = getBase64Limiter();
7
+ return limit(async () => {
8
+ return new Promise((resolve, reject) => {
9
+ const client = url.startsWith('https') ? https : http;
10
+ const request = client.get(url, { headers: { "User-Agent": "discord-message-transcript" } }, (response) => {
11
+ if (response.statusCode !== 200) {
12
+ response.destroy();
13
+ CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of converting to base64.
14
+ Failed to fetch image with status code: ${response.statusCode} from ${url}.`);
15
+ return resolve(url);
16
+ }
17
+ const contentType = response.headers['content-type'];
18
+ if (!contentType || !contentType.startsWith('image/') || contentType === 'image/gif') {
19
+ response.destroy();
20
+ return resolve(url);
21
+ }
22
+ const chunks = [];
23
+ response.on('data', (chunk) => {
24
+ chunks.push(chunk);
25
+ });
26
+ response.on('end', () => {
27
+ const buffer = Buffer.concat(chunks);
28
+ const base64 = buffer.toString('base64');
29
+ resolve(`data:${contentType};base64,${base64}`);
30
+ });
31
+ response.on('error', (err) => {
32
+ CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of converting to base64.
33
+ Stream error while fetching from ${url}.
34
+ Error: ${err.message}`);
35
+ resolve(url);
36
+ });
21
37
  });
22
- response.on('end', () => {
23
- const buffer = Buffer.concat(chunks);
24
- const base64 = buffer.toString('base64');
25
- resolve(`data:${contentType};base64,${base64}`);
38
+ request.on('error', (err) => {
39
+ CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of converting to base64.
40
+ Error fetching image from ${url}
41
+ Error: ${err.message}`);
42
+ return resolve(url);
26
43
  });
27
- response.on('error', (err) => {
28
- CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of converting to base64.\nStream error while fetching from ${url}.\nError message: ${err.message}`);
44
+ request.setTimeout(15000, () => {
45
+ request.destroy();
46
+ CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of converting to base64.
47
+ Request timeout for ${url}.`);
29
48
  resolve(url);
30
49
  });
50
+ request.end();
31
51
  });
32
- request.on('error', (err) => {
33
- CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of converting to base64.\nError fetching image from ${url}\nError message: ${err.message}`);
34
- return resolve(url);
35
- });
36
- request.setTimeout(15000, () => {
37
- request.destroy();
38
- CustomWarn(`This is not an issue with the package. Using the original URL as fallback instead of converting to base64.\nRequest timeout for ${url}. Using original URL.`);
39
- resolve(url);
40
- });
41
- request.end();
42
52
  });
43
53
  }
@@ -0,0 +1,5 @@
1
+ export declare function getCDNLimiter(): <T>(fn: () => Promise<T>) => Promise<T>;
2
+ export declare function getBase64Limiter(): <T>(fn: () => Promise<T>) => Promise<T>;
3
+ export declare function setCDNConcurrency(n: number): void;
4
+ export declare function setBase64Concurrency(n: number): void;
5
+ export declare function createLimiter(concurrency: number): <T>(fn: () => Promise<T>) => Promise<T>;
@@ -0,0 +1,47 @@
1
+ const globalLimiters = {
2
+ cdn: createLimiter(12),
3
+ base64: createLimiter(6)
4
+ };
5
+ export function getCDNLimiter() {
6
+ return globalLimiters.cdn;
7
+ }
8
+ export function getBase64Limiter() {
9
+ return globalLimiters.base64;
10
+ }
11
+ export function setCDNConcurrency(n) {
12
+ globalLimiters.cdn = createLimiter(n);
13
+ }
14
+ export function setBase64Concurrency(n) {
15
+ globalLimiters.base64 = createLimiter(n);
16
+ }
17
+ export function createLimiter(concurrency) {
18
+ if (concurrency <= 0) {
19
+ throw new Error("Limiter must be greater than 0");
20
+ }
21
+ let active = 0;
22
+ const queue = [];
23
+ const next = () => {
24
+ active = Math.max(0, active - 1);
25
+ if (queue.length > 0 && active < concurrency) {
26
+ const run = queue.shift();
27
+ run?.();
28
+ }
29
+ };
30
+ return function limit(fn) {
31
+ return new Promise((resolve, reject) => {
32
+ const run = () => {
33
+ active++;
34
+ Promise.resolve()
35
+ .then(fn)
36
+ .then(resolve, reject)
37
+ .finally(next);
38
+ };
39
+ if (active < concurrency) {
40
+ run();
41
+ }
42
+ else {
43
+ queue.push(run);
44
+ }
45
+ });
46
+ };
47
+ }
@@ -1,3 +1,5 @@
1
- import { TranscriptOptionsBase } from "discord-message-transcript-base";
1
+ import { JsonAuthor, JsonMessage, TranscriptOptionsBase } from "discord-message-transcript-base";
2
2
  import { CDNOptions } from "../types/types.js";
3
- export declare function urlResolver(url: string, options: TranscriptOptionsBase, cdnOptions: CDNOptions<unknown> | null): Promise<string>;
3
+ export declare function urlResolver(url: string, options: TranscriptOptionsBase, cdnOptions: CDNOptions | null, urlCache: Map<string, Promise<string>>): Promise<string>;
4
+ export declare function messagesUrlResolver(messages: JsonMessage[], options: TranscriptOptionsBase, cdnOptions: CDNOptions | null, urlCache: Map<string, Promise<string>>): Promise<JsonMessage[]>;
5
+ export declare function authorUrlResolver(authors: Map<string, JsonAuthor>, options: TranscriptOptionsBase, cdnOptions: CDNOptions | null, urlCache: Map<string, Promise<string>>): Promise<JsonAuthor[]>;
@@ -1,9 +1,101 @@
1
+ import { JsonComponentType } from "discord-message-transcript-base";
1
2
  import { cdnResolver } from "./cdnResolver.js";
2
3
  import { imageToBase64 } from "./imageToBase64.js";
3
- export async function urlResolver(url, options, cdnOptions) {
4
+ import { isJsonComponentInContainer } from "./componentToJson.js";
5
+ export async function urlResolver(url, options, cdnOptions, urlCache) {
6
+ if (urlCache.has(url)) {
7
+ const cache = urlCache.get(url);
8
+ if (cache)
9
+ return await cache;
10
+ }
11
+ let returnUrl;
4
12
  if (cdnOptions)
5
- return await cdnResolver(url, cdnOptions);
6
- if (options.saveImages)
7
- return await imageToBase64(url);
13
+ returnUrl = cdnResolver(url, options, cdnOptions);
14
+ else if (options.saveImages)
15
+ returnUrl = imageToBase64(url);
16
+ if (returnUrl) {
17
+ urlCache.set(url, returnUrl);
18
+ return await returnUrl;
19
+ }
8
20
  return url;
9
21
  }
22
+ export async function messagesUrlResolver(messages, options, cdnOptions, urlCache) {
23
+ return await Promise.all(messages.map(async (message) => {
24
+ const attachmentsPromise = Promise.all(message.attachments.map(async (attachment) => {
25
+ return {
26
+ ...attachment,
27
+ url: await urlResolver(attachment.url, options, cdnOptions, urlCache)
28
+ };
29
+ }));
30
+ const embedsPromise = Promise.all(message.embeds.map(async (embed) => {
31
+ return {
32
+ ...embed,
33
+ author: embed.author ? { ...embed.author, iconURL: embed.author.iconURL ? await urlResolver(embed.author.iconURL, options, cdnOptions, urlCache) : null } : null,
34
+ footer: embed.footer ? { ...embed.footer, iconURL: embed.footer.iconURL ? await urlResolver(embed.footer.iconURL, options, cdnOptions, urlCache) : null } : null,
35
+ image: embed.image?.url ? { url: await urlResolver(embed.image.url, options, cdnOptions, urlCache) } : null,
36
+ thumbnail: embed.thumbnail?.url ? { url: await urlResolver(embed.thumbnail.url, options, cdnOptions, urlCache) } : null,
37
+ };
38
+ }));
39
+ async function componentsFunction(components) {
40
+ return Promise.all(components.map(async (component) => {
41
+ if (component.type == JsonComponentType.Section) {
42
+ if (component.accessory.type == JsonComponentType.Thumbnail) {
43
+ return {
44
+ ...component,
45
+ accessory: {
46
+ ...component.accessory,
47
+ media: {
48
+ url: await urlResolver(component.accessory.media.url, options, cdnOptions, urlCache),
49
+ }
50
+ }
51
+ };
52
+ }
53
+ }
54
+ if (component.type == JsonComponentType.MediaGallery) {
55
+ return {
56
+ ...component,
57
+ items: await Promise.all(component.items.map(async (item) => {
58
+ return {
59
+ ...item,
60
+ media: { url: await urlResolver(item.media.url, options, cdnOptions, urlCache) },
61
+ };
62
+ }))
63
+ };
64
+ }
65
+ if (component.type == JsonComponentType.File) {
66
+ return {
67
+ ...component,
68
+ url: await urlResolver(component.url, options, cdnOptions, urlCache),
69
+ };
70
+ }
71
+ if (component.type == JsonComponentType.Container) {
72
+ return {
73
+ ...component,
74
+ components: (await componentsFunction(component.components)).filter(isJsonComponentInContainer), // Input components that are container-safe must always produce container-safe output.
75
+ };
76
+ }
77
+ return component;
78
+ }));
79
+ }
80
+ const componentsPromise = componentsFunction(message.components);
81
+ const [attachments, embeds, components] = await Promise.all([
82
+ attachmentsPromise,
83
+ embedsPromise,
84
+ componentsPromise
85
+ ]);
86
+ return {
87
+ ...message,
88
+ attachments: attachments,
89
+ embeds: embeds,
90
+ components: components,
91
+ };
92
+ }));
93
+ }
94
+ export async function authorUrlResolver(authors, options, cdnOptions, urlCache) {
95
+ return await Promise.all(Array.from(authors.values()).map(async (author) => {
96
+ return {
97
+ ...author,
98
+ avatarURL: await urlResolver(author.avatarURL, options, cdnOptions, urlCache),
99
+ };
100
+ }));
101
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { CreateTranscriptOptions, ConvertTranscriptOptions, TranscriptOptions, ReturnType, CDNOptions, MimeType } from "./types/types.js";
2
2
  export { ReturnFormat, LocalDate, TimeZone } from "discord-message-transcript-base";
3
+ export { setBase64Concurrency, setCDNConcurrency } from './core/limiter.js';
3
4
  import { TextBasedChannel } from "discord.js";
4
5
  import { ConvertTranscriptOptions, CreateTranscriptOptions, OutputType, ReturnType } from "./types/types.js";
5
6
  /**
package/dist/index.js CHANGED
@@ -1,12 +1,14 @@
1
1
  export { ReturnType } from "./types/types.js";
2
2
  export { ReturnFormat } from "discord-message-transcript-base";
3
+ export { setBase64Concurrency, setCDNConcurrency } from './core/limiter.js';
3
4
  import { AttachmentBuilder } from "discord.js";
4
5
  import { Json } from "./renderers/json/json.js";
5
6
  import { fetchMessages } from "./core/fetchMessages.js";
6
7
  import { ReturnType } from "./types/types.js";
7
8
  import { output } from "./core/output.js";
8
- import { ReturnTypeBase, ReturnFormat, outputBase, CustomError } from "discord-message-transcript-base";
9
+ import { ReturnTypeBase, ReturnFormat, outputBase, CustomError, CustomWarn } from "discord-message-transcript-base";
9
10
  import { returnTypeMapper } from "./core/mappers.js";
11
+ import { authorUrlResolver, messagesUrlResolver } from "./core/urlResolver.js";
10
12
  /**
11
13
  * Creates a transcript of a Discord channel's messages.
12
14
  * Depending on the `returnType` option, this function can return an `AttachmentBuilder`,
@@ -27,6 +29,11 @@ export async function createTranscript(channel, options = {}) {
27
29
  const artificialReturnType = options.returnType == ReturnType.Attachment ? ReturnTypeBase.Buffer : options.returnType ? returnTypeMapper(options.returnType) : ReturnTypeBase.Buffer;
28
30
  const { fileName = null, includeAttachments = true, includeButtons = true, includeComponents = true, includeEmpty = false, includeEmbeds = true, includePolls = true, includeReactions = true, includeV2Components = true, localDate = 'en-GB', quantity = 0, returnFormat = ReturnFormat.HTML, saveImages = false, selfContained = false, timeZone = 'UTC', watermark = true, } = options;
29
31
  const checkedFileName = (fileName ?? `Transcript-${channel.isDMBased() ? "DirectMessage" : channel.name}-${channel.id}`);
32
+ let validQuantity = true;
33
+ if (quantity < 0) {
34
+ CustomWarn("Quantity can't be a negative number, please use 0 for unlimited messages.\nUsing 0 as fallback!");
35
+ validQuantity = false;
36
+ }
30
37
  const internalOptions = {
31
38
  fileName: checkedFileName,
32
39
  includeAttachments,
@@ -38,7 +45,7 @@ export async function createTranscript(channel, options = {}) {
38
45
  includeReactions,
39
46
  includeV2Components,
40
47
  localDate,
41
- quantity,
48
+ quantity: validQuantity ? quantity : 0,
42
49
  returnFormat,
43
50
  returnType: artificialReturnType,
44
51
  saveImages,
@@ -46,26 +53,63 @@ export async function createTranscript(channel, options = {}) {
46
53
  timeZone,
47
54
  watermark
48
55
  };
49
- const jsonTranscript = channel.isDMBased() ? new Json(null, channel, internalOptions) : new Json(channel.guild, channel, internalOptions);
50
- let lastMessageID;
56
+ const urlCache = new Map();
51
57
  const authors = new Map();
52
58
  const mentions = {
53
59
  channels: new Map(),
54
60
  roles: new Map(),
55
61
  users: new Map(),
56
62
  };
63
+ const fetchMessageParameter = {
64
+ channel: channel,
65
+ options: internalOptions,
66
+ transcriptState: {
67
+ authors: authors,
68
+ mentions: mentions,
69
+ },
70
+ lastMessageId: undefined
71
+ };
72
+ const jsonTranscript = channel.isDMBased() ? new Json(null, channel, internalOptions, options.cdnOptions ?? null, urlCache) : new Json(channel.guild, channel, internalOptions, options.cdnOptions ?? null, urlCache);
57
73
  while (true) {
58
- const { messages, end } = await fetchMessages(channel, internalOptions, options.cdnOptions ?? null, authors, mentions, lastMessageID);
74
+ const { messages, end, newLastMessageId } = await fetchMessages(fetchMessageParameter);
59
75
  jsonTranscript.addMessages(messages);
60
- lastMessageID = messages[messages.length - 1]?.id;
61
- if (end || (jsonTranscript.messages.length >= quantity && quantity != 0)) {
76
+ fetchMessageParameter.lastMessageId = newLastMessageId;
77
+ if (end || (jsonTranscript.getMessages().length >= quantity && quantity != 0)) {
62
78
  break;
63
79
  }
64
80
  }
65
- jsonTranscript.sliceMessages(quantity);
66
- jsonTranscript.setAuthors(Array.from(authors.values()));
67
- jsonTranscript.setMentions({ channels: Array.from(mentions.channels.values()), roles: Array.from(mentions.roles.values()), users: Array.from(mentions.users.values()) });
68
- const result = await output(await jsonTranscript.toJson());
81
+ if (quantity > 0 && jsonTranscript.getMessages().length > quantity) {
82
+ jsonTranscript.sliceMessages(quantity);
83
+ }
84
+ if (options.cdnOptions) {
85
+ options.cdnOptions = {
86
+ includeAudio: true,
87
+ includeImage: true,
88
+ includeVideo: true,
89
+ includeOthers: true,
90
+ ...options.cdnOptions
91
+ };
92
+ }
93
+ if (options.cdnOptions || options.saveImages) {
94
+ await Promise.all([
95
+ (async () => {
96
+ jsonTranscript.setAuthors(await authorUrlResolver(authors, internalOptions, options.cdnOptions ?? null, urlCache));
97
+ authors.clear();
98
+ })(),
99
+ (() => {
100
+ jsonTranscript.setMentions({ channels: Array.from(mentions.channels.values()), roles: Array.from(mentions.roles.values()), users: Array.from(mentions.users.values()) });
101
+ mentions.channels.clear();
102
+ mentions.roles.clear();
103
+ mentions.users.clear();
104
+ })(),
105
+ (async () => {
106
+ jsonTranscript.setMessages(await messagesUrlResolver(jsonTranscript.getMessages(), internalOptions, options.cdnOptions ?? null, urlCache));
107
+ })()
108
+ ]);
109
+ }
110
+ const outputJson = await jsonTranscript.toJson();
111
+ urlCache.clear();
112
+ const result = await output(outputJson);
69
113
  if (!options.returnType || options.returnType == "attachment") {
70
114
  if (!(result instanceof Buffer)) {
71
115
  throw new CustomError("Expected buffer from output when *attachment* returnType is used.");
@@ -1,15 +1,20 @@
1
1
  import { Guild, TextBasedChannel } from "discord.js";
2
2
  import { ArrayMentions, JsonAuthor, JsonMessage, TranscriptOptionsBase, JsonData } from "discord-message-transcript-base";
3
+ import { CDNOptions } from "../../types/types.js";
3
4
  export declare class Json {
4
- guild: Guild | null;
5
- channel: TextBasedChannel;
6
- authors: JsonAuthor[];
7
- messages: JsonMessage[];
8
- options: TranscriptOptionsBase;
9
- mentions: ArrayMentions;
10
- constructor(guild: Guild | null, channel: TextBasedChannel, options: TranscriptOptionsBase);
5
+ private guild;
6
+ private channel;
7
+ private authors;
8
+ private messages;
9
+ private options;
10
+ private mentions;
11
+ private cdnOptions;
12
+ private urlCache;
13
+ constructor(guild: Guild | null, channel: TextBasedChannel, options: TranscriptOptionsBase, cdnOptions: CDNOptions | null, urlCache: Map<string, Promise<string>>);
11
14
  addMessages(messages: JsonMessage[]): void;
12
15
  sliceMessages(size: number): void;
16
+ setMessages(messages: JsonMessage[]): void;
17
+ getMessages(): JsonMessage[];
13
18
  setAuthors(authors: JsonAuthor[]): void;
14
19
  setMentions(mentions: ArrayMentions): void;
15
20
  toJson(): Promise<JsonData>;
@@ -1,4 +1,5 @@
1
1
  import { BaseGuildTextChannel, DMChannel } from "discord.js";
2
+ import { urlResolver } from "../../core/urlResolver.js";
2
3
  export class Json {
3
4
  guild;
4
5
  channel;
@@ -6,13 +7,17 @@ export class Json {
6
7
  messages;
7
8
  options;
8
9
  mentions;
9
- constructor(guild, channel, options) {
10
+ cdnOptions;
11
+ urlCache;
12
+ constructor(guild, channel, options, cdnOptions, urlCache) {
10
13
  this.guild = guild;
11
14
  this.channel = channel;
12
15
  this.messages = [];
13
16
  this.options = options;
14
17
  this.authors = [];
15
18
  this.mentions = { channels: [], roles: [], users: [] };
19
+ this.cdnOptions = cdnOptions;
20
+ this.urlCache = urlCache;
16
21
  }
17
22
  addMessages(messages) {
18
23
  this.messages.push(...messages);
@@ -21,7 +26,13 @@ export class Json {
21
26
  if (size > this.messages.length || size == 0) {
22
27
  return;
23
28
  }
24
- this.messages = this.messages.slice(0, size - 1);
29
+ this.messages = this.messages.slice(0, size);
30
+ }
31
+ setMessages(messages) {
32
+ this.messages = messages;
33
+ }
34
+ getMessages() {
35
+ return this.messages;
25
36
  }
26
37
  setAuthors(authors) {
27
38
  this.authors = authors;
@@ -31,11 +42,13 @@ export class Json {
31
42
  }
32
43
  async toJson() {
33
44
  const channel = await this.channel.fetch();
45
+ const channelImg = channel instanceof DMChannel ? channel.recipient?.displayAvatarURL() ?? "cdn.discordapp.com/embed/avatars/4.png" : channel.isDMBased() ? channel.iconURL() ?? (await channel.fetchOwner()).displayAvatarURL() : null;
34
46
  const guild = !channel.isDMBased() ? this.guild : null;
47
+ const guildIcon = guild?.iconURL();
35
48
  const guildJson = !guild ? null : {
36
49
  name: guild.name,
37
50
  id: guild.id,
38
- icon: guild.iconURL(),
51
+ icon: guildIcon ? await urlResolver(guildIcon, this.options, this.cdnOptions, this.urlCache) : null,
39
52
  };
40
53
  return {
41
54
  options: this.options,
@@ -45,7 +58,7 @@ export class Json {
45
58
  parent: channel.isDMBased() ? null : (channel.parent ? { name: channel.parent.name, id: channel.parent.id } : null),
46
59
  topic: (channel instanceof BaseGuildTextChannel) ? channel.topic : null,
47
60
  id: channel.id,
48
- img: channel instanceof DMChannel ? channel.recipient?.displayAvatarURL() ?? "cdn.discordapp.com/embed/avatars/4.png" : channel.isDMBased() ? channel.iconURL() ?? (await channel.fetchOwner()).displayAvatarURL() : null,
61
+ img: channelImg ? await urlResolver(channelImg, this.options, this.cdnOptions, this.urlCache) : null,
49
62
  },
50
63
  authors: this.authors,
51
64
  messages: this.messages.reverse(),
@@ -1,19 +1,51 @@
1
1
  import { JsonMessageMentionsChannels, JsonMessageMentionsRoles, JsonMessageMentionsUsers, LocalDate, TimeZone, Uploadable, ReturnFormat } from "discord-message-transcript-base";
2
2
  import { AttachmentBuilder } from "discord.js";
3
3
  import Stream from 'stream';
4
+ /**
5
+ * An enum-like object providing the possible return types for the transcript functions.
6
+ */
4
7
  export declare const ReturnType: {
8
+ /**
9
+ * Returns a `discord.js` AttachmentBuilder.
10
+ */
5
11
  readonly Attachment: "attachment";
12
+ /**
13
+ * Returns a `Buffer`.
14
+ */
6
15
  readonly Buffer: "buffer";
16
+ /**
17
+ * Returns a `Stream.Readable`.
18
+ * */
7
19
  readonly Stream: "stream";
20
+ /**
21
+ * Returns a `string`.
22
+ * */
8
23
  readonly String: "string";
24
+ /**
25
+ * Returns an `Uploadable` object with content, contentType, and fileName.
26
+ */
9
27
  readonly Uploadable: "uploadable";
10
28
  };
29
+ /**
30
+ * The type representing the possible values of the `ReturnType` enum.
31
+ */
11
32
  export type ReturnType = typeof ReturnType[keyof typeof ReturnType];
33
+ /**
34
+ * A conditional type that maps the `ReturnType` string literal to the actual TypeScript type returned by the function.
35
+ * @template T The `ReturnType` literal.
36
+ */
12
37
  export type OutputType<T extends ReturnType> = T extends typeof ReturnType.Buffer ? Buffer : T extends typeof ReturnType.Stream ? Stream : T extends typeof ReturnType.String ? string : T extends typeof ReturnType.Uploadable ? Uploadable : AttachmentBuilder;
38
+ /**
39
+ * Options for creating a transcript, with all properties being optional.
40
+ * @see TranscriptOptions
41
+ */
13
42
  export type CreateTranscriptOptions<T extends ReturnType> = Partial<TranscriptOptions<T>>;
43
+ /**
44
+ * Options for converting a JSON transcript to an HTML transcript.
45
+ */
14
46
  export type ConvertTranscriptOptions<T extends ReturnType> = Partial<{
15
47
  /**
16
- * The type of the returned value.
48
+ * The desired output type for the transcript.
17
49
  * - ReturnType.Attachment - The transcript content as a `Attachment`
18
50
  * - ReturnType.String - The transcript content as a string.
19
51
  * - ReturnType.Buffer - The transcript content as a `Buffer`.
@@ -23,24 +55,27 @@ export type ConvertTranscriptOptions<T extends ReturnType> = Partial<{
23
55
  */
24
56
  returnType: T;
25
57
  /**
26
- * Whether the generated HTML should be self-contained (CSS and JS in HTML).
58
+ * Whether the generated HTML should have its CSS and JS embedded directly in the file.
27
59
  * @default false
28
60
  */
29
61
  selfContained: boolean;
30
62
  /**
31
- * If you want to include the watermark.
63
+ * Whether to include the 'Generated with discord-message-transcript' watermark in the footer.
32
64
  * @default true
33
65
  */
34
66
  watermark: boolean;
35
67
  }>;
36
- export interface TranscriptOptions<T extends ReturnType, Other = unknown> {
68
+ /**
69
+ * Defines the complete set of options for creating a transcript.
70
+ */
71
+ export interface TranscriptOptions<T extends ReturnType> {
37
72
  /**
38
- * CDN Options
73
+ * Configuration for uploading attachments and other assets to a CDN.
39
74
  */
40
- cdnOptions: CDNOptions<Other>;
75
+ cdnOptions: CDNOptions;
41
76
  /**
42
- * The name of the file to be created.
43
- * Default depends if is DM or Guild
77
+ * The name of the generated file (without extension).
78
+ * @default `Transcript-channel-name-channel-id`
44
79
  */
45
80
  fileName: string;
46
81
  /**
@@ -49,17 +84,17 @@ export interface TranscriptOptions<T extends ReturnType, Other = unknown> {
49
84
  */
50
85
  includeAttachments: boolean;
51
86
  /**
52
- * Whether to include buttons in the transcript.
87
+ * Whether to include message component buttons in the transcript.
53
88
  * @default true
54
89
  */
55
90
  includeButtons: boolean;
56
91
  /**
57
- * Whether to include components in the transcript.
92
+ * Whether to include non-button message components (like select menus) in the transcript.
58
93
  * @default true
59
94
  */
60
95
  includeComponents: boolean;
61
96
  /**
62
- * Whether to include empty messages in the transcript.
97
+ * Whether to include messages that have no content.
63
98
  * @default false
64
99
  */
65
100
  includeEmpty: boolean;
@@ -74,23 +109,23 @@ export interface TranscriptOptions<T extends ReturnType, Other = unknown> {
74
109
  */
75
110
  includePolls: boolean;
76
111
  /**
77
- * Whether to include reactions in the transcript.
112
+ * Whether to include message reactions in the transcript.
78
113
  * @default true
79
114
  */
80
115
  includeReactions: boolean;
81
116
  /**
82
- * Whether to include V2 components in the transcript.
117
+ * Whether to include newer (V2) components like `Container`, `MediaGallery`, etc.
83
118
  * @default true
84
119
  */
85
120
  includeV2Components: boolean;
86
121
  /**
87
- * The locale to use for formatting dates.
88
- * Can be any BCP 47 language tag.
122
+ * The locale to use for formatting dates (e.g., 'en-US', 'pt-BR').
123
+ * Must be a valid BCP 47 language tag.
89
124
  * @default 'en-GB'
90
125
  */
91
126
  localDate: LocalDate;
92
127
  /**
93
- * The maximum number of messages to fetch. Set to 0 to fetch all messages.
128
+ * The maximum number of messages to fetch. Set to `0` to fetch all messages in the channel.
94
129
  * @default 0
95
130
  */
96
131
  quantity: number;
@@ -102,7 +137,7 @@ export interface TranscriptOptions<T extends ReturnType, Other = unknown> {
102
137
  */
103
138
  returnFormat: ReturnFormat;
104
139
  /**
105
- * The type of the returned value.
140
+ * The desired output type for the transcript.
106
141
  * - ReturnType.Attachment - The transcript content as a `Attachment`
107
142
  * - ReturnType.String - The transcript content as a string.
108
143
  * - ReturnType.Buffer - The transcript content as a `Buffer`.
@@ -112,48 +147,128 @@ export interface TranscriptOptions<T extends ReturnType, Other = unknown> {
112
147
  */
113
148
  returnType: T;
114
149
  /**
115
- * Whether to save images locally or use remote URLs.
150
+ * Whether to save images as base64 data directly in the transcript.
151
+ * This is an alternative to using a CDN and results in larger file sizes.
152
+ * Will not work if using CDN.
116
153
  * @default false
117
154
  */
118
155
  saveImages: boolean;
119
156
  /**
120
- * Whether the generated HTML should be self-contained.
121
- * Only matters if `returnFormat` is `HTML`.
157
+ * Whether the generated HTML should have its CSS and JS embedded directly in the file.
158
+ * Only applicable if `returnFormat` is `HTML`.
122
159
  * @default false
123
160
  */
124
161
  selfContained: boolean;
125
162
  /**
126
- * The timezone to use for formatting dates.
127
- * Can be any IANA time zone name.
163
+ * The timezone to use for formatting dates (e.g., 'UTC', 'America/New_York').
164
+ * Must be a valid IANA time zone name.
128
165
  * @default 'UTC'
129
166
  */
130
167
  timeZone: TimeZone;
131
168
  /**
132
- * If you want to include the watermark.
169
+ * Whether to include the 'Generated with discord-message-transcript' watermark in the footer.
133
170
  * @default true
134
171
  */
135
172
  watermark: boolean;
136
173
  }
174
+ /**
175
+ * Defines the structure for storing discovered mentions (users, roles, channels) during transcript creation.
176
+ * Uses Maps for efficient lookups.
177
+ */
137
178
  export interface MapMentions {
138
179
  channels: Map<string, JsonMessageMentionsChannels>;
139
180
  roles: Map<string, JsonMessageMentionsRoles>;
140
181
  users: Map<string, JsonMessageMentionsUsers>;
141
182
  }
183
+ /**
184
+ * A string template type for representing a MIME type (e.g., 'image/png', 'application/json').
185
+ */
142
186
  export type MimeType = `${string}/${string}`;
143
- export type CDNOptions<Other> = (Partial<{
187
+ /**
188
+ * Base options applicable to all CDN providers.
189
+ */
190
+ export type CDNBase = Partial<{
191
+ /**
192
+ * Whether to upload audio files to the CDN.
193
+ * @default true
194
+ */
144
195
  includeAudio: boolean;
196
+ /**
197
+ * Whether to upload image files (excluding GIFs) to the CDN.
198
+ * @default true
199
+ */
145
200
  includeImage: boolean;
201
+ /**
202
+ * Whether to upload video files (and GIFs) to the CDN.
203
+ * @default true
204
+ */
146
205
  includeVideo: boolean;
206
+ /**
207
+ * Whether to upload any other file types to the CDN.
208
+ * @default true
209
+ */
147
210
  includeOthers: boolean;
148
- }>) & ({
149
- type: "CUSTOM";
150
- customCdnResolver: (url: string, contentType: MimeType | null, other: Other) => Promise<string> | string;
151
- other: Other;
152
- } | {
153
- type: "CLOUDFLARE_R2";
154
- accountId: string;
155
- accessKey: string;
156
- secretKey: string;
157
- bucket: string;
158
- publicUrl: string;
159
- });
211
+ }>;
212
+ /**
213
+ * A discriminated union of all possible CDN configurations.
214
+ */
215
+ export type CDNOptions = (CDNBase & CDNOptionsCustom<any>) | (CDNBase & CDNOptionsCloudinary) | (CDNBase & CDNOptionsUploadcare);
216
+ /**
217
+ * Configuration for using a custom, user-provided CDN resolver function.
218
+ */
219
+ export type CDNOptionsCustom<T = unknown> = {
220
+ /** Specifies the use of a custom CDN resolver. */
221
+ provider: "CUSTOM";
222
+ /**
223
+ * An async function that takes a URL and returns a new URL.
224
+ * @param url The original Discord asset URL.
225
+ * @param contentType The MIME type of the asset.
226
+ * @param customData Any additional data you want to pass to your resolver.
227
+ * @returns The new URL of the asset on your CDN.
228
+ */
229
+ resolver: (url: string, contentType: MimeType | null, customData: T) => Promise<string> | string;
230
+ /**
231
+ * Any custom data you wish to make available within your resolver function.
232
+ */
233
+ customData: T;
234
+ };
235
+ /**
236
+ * Configuration for using Cloudinary as the CDN.
237
+ */
238
+ export type CDNOptionsCloudinary = {
239
+ /**
240
+ * Specifies the use of the built-in Cloudinary provider.
241
+ */
242
+ provider: "CLOUDINARY";
243
+ /**
244
+ * Your Cloudinary cloud name.
245
+ */
246
+ cloudName: string;
247
+ /**
248
+ * Your Cloudinary API key.
249
+ * */
250
+ apiKey: string;
251
+ /**
252
+ * Your Cloudinary API secret.
253
+ */
254
+ apiSecret: string;
255
+ };
256
+ /**
257
+ * Configuration for using Uploadcare as the CDN.
258
+ */
259
+ export type CDNOptionsUploadcare = {
260
+ /**
261
+ * Specifies the use of the built-in Uploadcare provider.
262
+ */
263
+ provider: "UPLOADCARE";
264
+ /**
265
+ * Your Uploadcare public key.
266
+ */
267
+ publicKey: string;
268
+ /**
269
+ * Your Uploadcare CDN domain.
270
+ * Example: "aaa111aaa1.ucarecd.net".
271
+ * DO NOT INCLUDE https://
272
+ */
273
+ cdnDomain: string;
274
+ };
@@ -1,7 +1,25 @@
1
+ /**
2
+ * An enum-like object providing the possible return types for the transcript functions.
3
+ */
1
4
  export const ReturnType = {
5
+ /**
6
+ * Returns a `discord.js` AttachmentBuilder.
7
+ */
2
8
  Attachment: "attachment",
9
+ /**
10
+ * Returns a `Buffer`.
11
+ */
3
12
  Buffer: "buffer",
13
+ /**
14
+ * Returns a `Stream.Readable`.
15
+ * */
4
16
  Stream: "stream",
17
+ /**
18
+ * Returns a `string`.
19
+ * */
5
20
  String: "string",
21
+ /**
22
+ * Returns an `Uploadable` object with content, contentType, and fileName.
23
+ */
6
24
  Uploadable: "uploadable"
7
25
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discord-message-transcript",
3
- "version": "1.2.0-dev.1.2.0.12.0",
3
+ "version": "1.2.0",
4
4
  "description": "",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -48,11 +48,14 @@
48
48
  "typescript": "^5.9.3"
49
49
  },
50
50
  "dependencies": {
51
- "discord-message-transcript-base": "1.2.0-dev.1.2.0.12.0"
51
+ "discord-message-transcript-base": "1.2.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "discord.js": ">=14.19.0 <15"
55
55
  },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ },
56
59
  "scripts": {
57
60
  "test": "echo \"Error: no test specified\" && exit 1",
58
61
  "build": "tsc"