discord-html-transcripts-fix 1.1.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
@@ -1,28 +1,77 @@
1
- # `discord-html-transcripts` (Bugfix Fork)
1
+ # `discord-html-transcripts-fix`
2
2
 
3
- This is a fork of [discord-html-transcripts](https://github.com/ItzDerock/discord-html-transcripts) with bug fixes for compatibility with the latest discord.js version and Components v2.
3
+ A nicely formatted HTML transcript generator for discord.js with full **Components V2** support, an **interactive viewer** (search, lightbox, mention popups, i18n) and **hardened security**.
4
+
5
+ Forked from [discord-html-transcripts](https://github.com/ItzDerock/discord-html-transcripts).
4
6
 
5
7
  ## Changes compared to the original
6
8
 
7
- - Fixed compatibility with the latest discord.js version
8
- - Added support for Discord Components v2
9
- - **Bug fix**: defensive timestamp parsing — invalid `<t:...>` markers in messages no longer throw `RangeError: Invalid time value` and abort the whole transcript
10
- - **Bug fix**: removed `process.exit(1)` on discord.js version mismatch — the library now only warns, so the host bot keeps running
11
- - **Bug fix**: render error boundaries at AST-node and message level — a single broken message can no longer kill the entire transcript; it falls back to a "[message failed to render]" placeholder
12
- - **Bug fix**: `parseDiscordEmoji` no longer crashes on deleted / unknown reactions where `emoji.name` is `null`
13
- - **New**: clickable user / role / channel mentions — clicking opens a Discord-style popup with avatar, display name, username, roles (as colored pills), join date, account creation date, role color/position/member count, channel topic, IDs, etc.
14
- - **New**: `buildExtendedContext` collects full user / role / channel details and ships them in `window.$discordMessage` for the popups (also includes mentioned-but-silent users)
15
- - **New**: stickers in messages are rendered (image, name)
16
- - **New**: polls are rendered with answer bars, vote counts, percentages, expiry time
17
- - **New**: more reliable reaction rendering — handles missing emoji name / unknown custom emojis gracefully
18
- - **New**: many system message types that were previously stubbed (`undefined`) are now rendered — channel name change, channel icon change, thread created, slash-command used, context-menu command, call started, channel-follow added, recipient left, AutoMod block, guild incident reports, poll result
19
- - **New**: floating "back-to-top" button that fades in after scrolling
20
- - **New**: `dht-mention` hover effect on all clickable mentions
21
- - **DX**: `react`, `react-dom` and `debug` moved into regular `dependencies`, so users no longer have to install them manually (`debug` was actually a missing runtime dependency in the original — `images.js` requires it)
9
+ ### Security
10
+ - **Critical fix**: `</script>` breakout via inlined JSON is prevented (`<`, `>`, `&`, U+2028/2029 are escaped)
11
+ - **Critical fix**: markdown links with `javascript:`, `data:`, `vbscript:` and similar dangerous URI schemes are rewritten to `#`
12
+ - **Hardened**: inline `style="color:…"` sinks in the mention popup are hex-validated to block CSS injection
13
+ - **Hardened**: `data:` URI MIME types from the image downloader are restricted to image types only (no `text/html` smuggling)
14
+ - **Fixed**: `process.exit(1)` on discord.js version mismatch removed — library no longer kills the host bot
15
+
16
+ ### Robustness
17
+ - **Fix**: invalid Discord timestamp markers (`<t:abc:F>`, oversized values) no longer abort the transcript with `RangeError`
18
+ - **Fix**: per-AST-node error boundary in `MessageSingleASTNode` and per-message error boundary in `DiscordMessage` — one broken message can never kill the whole render any more
19
+ - **Fix**: `parseDiscordEmoji` no longer throws on deleted reactions with `emoji.name === null`
20
+ - **Fix**: `formatBytes(null/undefined/NaN)` no longer returns `NaN undefined`
21
+ - **Fix**: `createTranscript` slice uses the resolved limit instead of the raw `limit`
22
+ - **Fix**: embed fields are rendered through a proper async component (was an inline `async` arrow inside `.map()` — undefined React behaviour)
23
+ - **Fix**: `JoinMessage` text is now deterministic per message ID — re-rendering the same channel always yields the same join line
24
+ - **Fix**: random `console.log` calls in production paths replaced by the `debug` namespace
25
+
26
+ ### New: interactive viewer
27
+ - **Clickable mentions** — clicking a user / role / channel mention opens a Discord-style popup with avatar, display name, username, all roles (colored pills), server-since, account-since, color, member count, channel topic, etc.
28
+ - **Search bar** — Ctrl+F is intercepted, live highlighting with "x of n matches", prev / next, Enter / Shift+Enter to navigate, Escape to close
29
+ - **Image lightbox** — click any image to open fullscreen, arrow keys to nav, Escape to close
30
+ - **Participant sidebar (TOC)** — toggle button (top-left), sorted list of every participant, click to jump to their first message
31
+ - **Back-to-top** floating button fades in after scrolling
32
+ - **Date separators** between messages — `— Tuesday, May 13 2026 —` style, automatically inserted on day boundaries
33
+ - **Stats footer** — `324 messages · 12 participants · 5 images` (auto-computed)
34
+ - **Language switcher** (i18n) — built-in English and German dictionaries, switch live in the viewer. Pass your own via `options.i18n: { de: { … } }` to extend
35
+
36
+ ### New: more Discord content rendered
37
+ - **Stickers** — image, name, alt
38
+ - **Polls** — question, answer bars with vote counts and percentages, expiry
39
+ - **Forwarded messages (`messageSnapshots`)** — quoted-block style with original author
40
+ - **Voice messages** — `🎤` indicator, inline SVG waveform from `attachment.waveform`, duration
41
+ - **Edit timestamp tooltip** on `(edited)` marker
42
+ - **Optional edit history** — pass `message.editHistory = [{content, editedAt}, …]` and the viewer shows a collapsible `<details>` block
43
+ - **Pinned indicator** (`📌` badge on pinned messages)
44
+ - **App badge** for application/bot messages with `applicationId`
45
+ - **Suppressed embeds flag** is honored — when set, embeds aren't rendered (and a small `(embeds hidden)` note is shown instead)
46
+ - **Cross-guild replies** are no longer silently dropped — they show a "Message from another server" pill
47
+ - **Burst / super-reactions** are flagged
48
+ - **Thread state badges** — `Archived`, `Locked`
49
+ - **GIFV / animated GIFs** are rendered with `<video autoplay loop muted>` like in Discord
50
+ - **Attachment description (alt text)** is used as `alt`/`title`
51
+ - **`<id:guide>`, `<id:browse>`, `<id:customize>`** etc. pseudo-channels show as styled pills with proper labels
52
+ - **`</cmd:id>` slash command mentions** render as blue pills
53
+ - **Embed video** link and **embed provider** ("YouTube" etc.) are shown
54
+ - **`RoleSubscriptionPurchase`** system message renders properly
55
+ - **`AutoModerationAction`** shows the matched rule name and blocked content excerpt
56
+ - **Container component** with `accent_color` (left border) and `spoiler` support
57
+ - **Media gallery** per-item spoiler support
58
+ - Many additional system message types (`ChannelNameChange`, `ChannelIconChange`, `ThreadCreated`, `ChatInputCommand`, `ContextMenuCommand`, `Call`, `ChannelFollowAdd`, `RecipientRemove`, guild incident reports, poll result)
59
+
60
+ ### Performance
61
+ - **Single-pass profile collector** — `buildAllContext` builds profiles + extended dicts in one walk over the message list
62
+ - **Image downloader is concurrent** — bounded pool (default 6, configurable via `withConcurrency`) instead of sequential
63
+ - **`@skyra/discord-components-core` version is pinned** to an exact resolved version → CDN cacheable
64
+ - **Inline JSON** is shipped via `<script type="application/json">` so it doesn't block HTML parse
65
+ - **Emoji URL resolution** is memoized
66
+ - **`returnType: 'stream'`** option streams the rendered HTML out instead of buffering — usable for 5,000+ message tickets
67
+
68
+ ### DX
69
+ - **`react`, `react-dom`, `debug` moved into regular dependencies** so users don't install them manually (`debug` was actually a missing runtime dep in the original — `images.js` requires it)
70
+ - TypeScript build target unchanged; only `discord.js` remains a peer dependency
22
71
 
23
72
  ## Requirements
24
73
 
25
- Only [`discord.js`](https://discord.js.org/) is required as a peer dependency (you already have it in your bot). `react`, `react-dom`, and `debug` are installed automatically. Just run:
74
+ Only `discord.js` as a peer dependency. Everything else is auto-installed:
26
75
 
27
76
  ```bash
28
77
  npm install discord-html-transcripts-fix
@@ -30,76 +79,35 @@ npm install discord-html-transcripts-fix
30
79
 
31
80
  ## Usage
32
81
 
33
- ### Example usage using the built in message fetcher.
34
-
35
82
  ```js
36
- const discordTranscripts = require('discord-html-transcripts');
37
- // or (if using typescript) import * as discordTranscripts from 'discord-html-transcripts';
38
-
39
- const channel = message.channel; // or however you get your TextChannel
40
-
41
- // Must be awaited
42
- const attachment = await discordTranscripts.createTranscript(channel);
43
-
44
- channel.send({
45
- files: [attachment],
83
+ const { createTranscript } = require('discord-html-transcripts-fix');
84
+
85
+ const attachment = await createTranscript(channel, {
86
+ // existing options
87
+ limit: -1,
88
+ saveImages: false,
89
+ poweredBy: true,
90
+ // new options
91
+ language: 'de', // 'en' (default) or 'de' — also exposed live in the viewer
92
+ i18n: { de: { joined: 'kam an' } }, // optional overrides per language
93
+ returnType: 'stream', // 'buffer' | 'string' | 'attachment' (default) | 'stream'
46
94
  });
47
- ```
48
-
49
- ### Or if you prefer, you can pass in your own messages.
50
-
51
- ```js
52
- const discordTranscripts = require('discord-html-transcripts');
53
- // or (if using typescript) import * as discordTranscripts from 'discord-html-transcripts';
54
95
 
55
- const messages = someWayToGetMessages(); // Must be Collection<string, Message> or Message[]
56
- const channel = someWayToGetChannel(); // Used for ticket name, guild icon, and guild name
57
-
58
- // Must be awaited
59
- const attachment = await discordTranscripts.generateFromMessages(messages, channel);
60
-
61
- channel.send({
62
- files: [attachment],
63
- });
96
+ channel.send({ files: [attachment] });
64
97
  ```
65
98
 
66
- ## Configuration
99
+ ### Hooking edit history (optional)
67
100
 
68
- Both methods of generating a transcript allow for an option object as the last parameter.
69
- **All configuration options are optional!**
70
-
71
- ### Built in Message Fetcher
101
+ If your bot tracks message edits, attach the history to the message before passing it to the renderer:
72
102
 
73
103
  ```js
74
- const attachment = await discordTranscripts.createTranscript(channel, {
75
- limit: -1, // Max amount of messages to fetch. `-1` recursively fetches.
76
- returnType: 'attachment', // Valid options: 'buffer' | 'string' | 'attachment' Default: 'attachment' OR use the enum ExportReturnType
77
- filename: 'transcript.html', // Only valid with returnType is 'attachment'. Name of attachment.
78
- saveImages: false, // Download all images and include the image data in the HTML (allows viewing the image even after it has been deleted) (! WILL INCREASE FILE SIZE !)
79
- footerText: "Exported {number} message{s}", // Change text at footer, don't forget to put {number} to show how much messages got exported, and {s} for plural
80
- callbacks: {
81
- // register custom callbacks for the following:
82
- resolveChannel: (channelId: string) => Awaitable<Channel | null>,
83
- resolveUser: (userId: string) => Awaitable<User | null>,
84
- resolveRole: (roleId: string) => Awaitable<Role | null>,
85
- resolveImageSrc: (
86
- attachment: APIAttachment,
87
- message: APIMessage
88
- ) => Awaitable<string | null | undefined>
89
- },
90
- poweredBy: true, // Whether to include the "Powered by discord-html-transcripts" footer
91
- hydrate: true, // Whether to hydrate the html server-side
92
- filter: (message) => true // Filter messages, e.g. (message) => !message.author.bot
93
- });
104
+ message.editHistory = [
105
+ { content: 'first version', editedAt: new Date('2025-05-13T11:02Z') },
106
+ { content: 'corrected version', editedAt: new Date('2025-05-13T11:05Z') },
107
+ ];
94
108
  ```
95
109
 
96
- ### Providing your own messages
97
-
98
- ```js
99
- const attachment = await discordTranscripts.generateFromMessages(messages, channel, {
100
- // Same as createTranscript, except no limit or filter
101
- });
102
- ```
110
+ The viewer will then render a collapsible `<details>` block next to the `(edited)` marker.
103
111
 
104
112
  ## Credits
105
113
 
@@ -1,36 +1,7 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
2
  var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
3
+ var ownKeys = function(o){ownKeys = Object.getOwnPropertyNames || function (o){var ar=[];for (var k in o) if (Object.prototype.hasOwnProperty.call(o,k)) ar[ar.length]=k;return ar;};return ownKeys(o);};
4
+ return function (mod){if (mod && mod.__esModule) return mod;var result={};if (mod != null) for (var k=ownKeys(mod), i=0; i<k.length; i++) if (k[i] !== "default") Object.defineProperty(result,k[i],{get:function(){return mod[k[i]];}});Object.defineProperty(result,"default",{value:mod,enumerable:true});return result;};
34
5
  })();
35
6
  var __importDefault = (this && this.__importDefault) || function (mod) {
36
7
  return (mod && mod.__esModule) ? mod : { "default": mod };
@@ -39,76 +10,80 @@ Object.defineProperty(exports, "__esModule", { value: true });
39
10
  exports.TranscriptImageDownloader = void 0;
40
11
  const undici_1 = require("undici");
41
12
  const debug_1 = __importDefault(require("debug"));
42
- /**
43
- * Builder to build a image saving callback.
44
- */
13
+ const utils_1 = require("../utils/utils");
14
+
15
+ // Small inline concurrency limiter — no extra dep
16
+ function createLimiter(max) {
17
+ let active = 0;
18
+ const queue = [];
19
+ const next = () => {
20
+ if (active >= max || queue.length === 0) return;
21
+ active++;
22
+ const { fn, resolve, reject } = queue.shift();
23
+ fn().then((v) => { active--; resolve(v); next(); }, (e) => { active--; reject(e); next(); });
24
+ };
25
+ return (fn) => new Promise((resolve, reject) => { queue.push({ fn, resolve, reject }); next(); });
26
+ }
27
+
45
28
  class TranscriptImageDownloader {
46
29
  constructor() {
47
30
  this.log = TranscriptImageDownloader.log;
31
+ this.maxConcurrent = 6;
32
+ this._sharpModule = null;
33
+ this._limit = null;
48
34
  }
49
- /**
50
- * Sets the maximum file size for *each* individual image.
51
- * @param size The maximum file size in kilobytes
52
- */
53
35
  withMaxSize(size) {
54
36
  this.maxFileSize = size;
55
37
  return this;
56
38
  }
57
- /**
58
- * Sets the compression quality for each image. This requires `sharp` to be installed.
59
- * Optionally, images can be converted to WebP format which is smaller in size.
60
- * @param quality The quality of the image (1 lowest - 100 highest). Lower quality means smaller file size.
61
- * @param convertToWebP Whether to convert the image to WebP format
62
- */
39
+ withConcurrency(n) {
40
+ this.maxConcurrent = Math.max(1, Number(n) || 1);
41
+ return this;
42
+ }
63
43
  withCompression(quality = 80, convertToWebP = false, options = {}) {
64
- if (quality < 1 || quality > 100)
65
- throw new Error('Quality must be between 1 and 100');
66
- // try and import sharp
67
- Promise.resolve().then(() => __importStar(require('sharp'))).catch((err) => {
68
- console.error(err);
69
- console.error(`[discord-html-transcripts] Failed to import 'sharp'. Image compression requires the 'sharp' package to be installed. Either install sharp or remove the compression options.`);
70
- });
44
+ if (quality < 1 || quality > 100) throw new Error('Quality must be between 1 and 100');
45
+ // Hoist the sharp import so we don't pay it per image
46
+ Promise.resolve()
47
+ .then(() => __importStar(require('sharp')))
48
+ .then((s) => { this._sharpModule = s; })
49
+ .catch((err) => {
50
+ this.log('sharp import failed: %s', err && err.message ? err.message : err);
51
+ });
71
52
  this.compression = { quality, convertToWebP, options };
72
53
  return this;
73
54
  }
74
- /**
75
- * Builds the image saving callback.
76
- */
77
55
  build() {
56
+ if (!this._limit) this._limit = createLimiter(this.maxConcurrent);
78
57
  return async (attachment) => {
79
- // if the attachment is not an image, return null
80
- if (!attachment.width || !attachment.height)
81
- return undefined;
82
- // if the max file size is set, check if the file size is within the limit
83
- if (this.maxFileSize && attachment.size > this.maxFileSize * 1024)
84
- return undefined;
85
- // fetch the image
86
- this.log(`Fetching attachment ${attachment.id}: ${attachment.url}`);
87
- const response = await (0, undici_1.request)(attachment.url).catch((err) => {
88
- console.error(`[discord-html-transcripts] Failed to download image for transcript: `, err);
89
- return null;
90
- });
91
- if (!response)
92
- return undefined;
93
- const mimetype = response.headers['content-type'];
94
- const buffer = await response.body.arrayBuffer().then((res) => Buffer.from(res));
95
- this.log(`Finished fetching ${attachment.id} (${buffer.length} bytes)`);
96
- // if the compression options are set, compress the image
97
- if (this.compression) {
98
- const sharp = await Promise.resolve().then(() => __importStar(require('sharp')));
99
- this.log(`Compressing ${attachment.id} with 'sharp'`);
100
- const sharpbuf = await sharp
101
- .default(buffer)
102
- .webp(Object.assign({ quality: this.compression.quality, force: this.compression.convertToWebP, effort: 2 }, this.compression.options))
103
- .toBuffer({ resolveWithObject: true });
104
- this.log(`Finished compressing ${attachment.id} (${sharpbuf.info.size} bytes)`);
105
- return `data:image/${sharpbuf.info.format};base64,${sharpbuf.data.toString('base64')}`;
106
- }
107
- // return the base64 string
108
- return `data:${mimetype};base64,${buffer.toString('base64')}`;
58
+ if (!attachment.width || !attachment.height) return undefined;
59
+ if (this.maxFileSize && attachment.size > this.maxFileSize * 1024) return undefined;
60
+ return this._limit(() => this._fetchOne(attachment));
109
61
  };
110
62
  }
63
+ async _fetchOne(attachment) {
64
+ this.log('fetch %s %s', attachment.id, attachment.url);
65
+ const response = await (0, undici_1.request)(attachment.url).catch((err) => {
66
+ this.log('download failed for %s: %s', attachment.id, err && err.message ? err.message : err);
67
+ return null;
68
+ });
69
+ if (!response) return undefined;
70
+ const rawMime = response.headers['content-type'];
71
+ const mime = (0, utils_1.safeImageMime)(typeof rawMime === 'string' ? rawMime : '', 'image/png');
72
+ const arr = await response.body.arrayBuffer();
73
+ const buffer = Buffer.from(arr);
74
+ this.log('fetched %s (%d bytes, mime=%s)', attachment.id, buffer.length, mime);
75
+ if (this.compression) {
76
+ const sharp = this._sharpModule || (this._sharpModule = await __importStar(require('sharp')));
77
+ this.log('compressing %s with sharp', attachment.id);
78
+ const sharpbuf = await sharp
79
+ .default(buffer)
80
+ .webp(Object.assign({ quality: this.compression.quality, force: this.compression.convertToWebP, effort: 2 }, this.compression.options))
81
+ .toBuffer({ resolveWithObject: true });
82
+ return `data:image/${sharpbuf.info.format};base64,${sharpbuf.data.toString('base64')}`;
83
+ }
84
+ return `data:${mime};base64,${buffer.toString('base64')}`;
85
+ }
111
86
  }
112
87
  exports.TranscriptImageDownloader = TranscriptImageDownloader;
113
88
  TranscriptImageDownloader.log = (0, debug_1.default)('discord-html-transcripts:TranscriptImageDownloader');
114
- //# sourceMappingURL=images.js.map
89
+ //# sourceMappingURL=images.js.map