discord-html-transcripts-fix 2.2.0 → 2.3.1

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.
@@ -16,17 +16,28 @@ function t(context, key, fallback) {
16
16
  }
17
17
 
18
18
  async function DiscordEmbed({ embed, context }) {
19
- const type = embed.type || 'rich';
19
+ // discord.js' Embed class has no `type` getter; the type only lives on the raw
20
+ // payload in `embed.data`. Reading `embed.type` alone made every embed 'rich'
21
+ // in production, so link previews, GIFs and videos never got their own layout.
22
+ // Plain-object embeds without `data` keep working through the fallback.
23
+ const type = embed.data?.type ?? embed.type ?? 'rich';
20
24
  const url = embed.url ? (0, utils_1.safeHref)(embed.url) : undefined;
21
- const imageUrl = embed.image?.proxyURL ?? embed.image?.url;
25
+ // Image previews, GIFs and videos carry their picture in `thumbnail`; only rich
26
+ // embeds use `image` for it.
27
+ const preview = type === 'image' || type === 'gifv' || type === 'video' ? embed.thumbnail : null;
28
+ const ownImageUrl = embed.image?.proxyURL ?? embed.image?.url;
29
+ const imageUrl = ownImageUrl ?? preview?.proxyURL ?? preview?.url;
22
30
  const videoUrl = embed.video?.proxyURL ?? embed.video?.url;
23
31
  const provider = embed.provider?.name;
24
32
  const safeVid = videoUrl ? (0, utils_1.safeHref)(videoUrl) : null;
25
- const safeImg = imageUrl ? (0, utils_1.safeHref)(imageUrl) : null;
33
+ const safeImg = (0, utils_1.safeImageSrc)(imageUrl) ?? null;
34
+ // The rich card already shows `thumbnail` itself, so a preview that falls
35
+ // through to it (a GIF without a video, say) must not appear twice.
36
+ const safeOwnImg = (0, utils_1.safeImageSrc)(ownImageUrl) ?? null;
26
37
 
27
38
  // Image-only embeds (link previews): render the image alone with optional caption
28
39
  if (type === 'image' && safeImg) {
29
- return (0, jsx_runtime_1.jsx)("div", { className: "dht-embed dht-embed--image", children: (0, jsx_runtime_1.jsx)("a", { href: url || safeImg, target: "_blank", rel: "noreferrer", children: (0, jsx_runtime_1.jsx)("img", { src: safeImg, alt: embed.title || '', className: "dht-embed-image", loading: "lazy" }) }) });
40
+ return (0, jsx_runtime_1.jsx)("div", { className: "dht-embed dht-embed--image", children: (0, jsx_runtime_1.jsx)("a", { href: url || (0, utils_1.safeHref)(imageUrl), target: "_blank", rel: "noreferrer", children: (0, jsx_runtime_1.jsx)("img", { src: safeImg, alt: embed.title || '', className: "dht-embed-image", loading: "lazy" }) }) });
30
41
  }
31
42
  // GIFV — auto-play loop video
32
43
  if (type === 'gifv' && safeVid) {
@@ -35,7 +46,7 @@ async function DiscordEmbed({ embed, context }) {
35
46
  // Video embed (YouTube/Vimeo/etc.) — show thumbnail with a play overlay linking to original
36
47
  if (type === 'video' && (safeVid || safeImg)) {
37
48
  return (0, jsx_runtime_1.jsxs)("div", { className: "dht-embed dht-embed--video", children: [
38
- (0, jsx_runtime_1.jsxs)("a", { href: url || safeVid || safeImg, target: "_blank", rel: "noreferrer", className: "dht-embed-video-wrap", children: [
49
+ (0, jsx_runtime_1.jsxs)("a", { href: url || safeVid || (0, utils_1.safeHref)(imageUrl), target: "_blank", rel: "noreferrer", className: "dht-embed-video-wrap", children: [
39
50
  safeImg && (0, jsx_runtime_1.jsx)("img", { src: safeImg, alt: embed.title || '', className: "dht-embed-video-thumb", loading: "lazy" }),
40
51
  (0, jsx_runtime_1.jsx)("div", { className: "dht-embed-video-play", children: '▶' })
41
52
  ] }),
@@ -46,11 +57,12 @@ async function DiscordEmbed({ embed, context }) {
46
57
 
47
58
  // Default rich embed
48
59
  const title = embed.title ?? undefined;
49
- const authorImage = embed.author?.proxyIconURL ?? embed.author?.iconURL;
60
+ // The component renders these unchecked; only web and inline image URLs pass.
61
+ const authorImage = (0, utils_1.safeImageSrc)(embed.author?.proxyIconURL ?? embed.author?.iconURL);
50
62
  const authorName = embed.author?.name;
51
63
  const authorUrl = embed.author?.url ? (0, utils_1.safeHref)(embed.author.url) : undefined;
52
64
  const color = embed.hexColor ? (0, utils_1.safeColor)(embed.hexColor, undefined) : undefined;
53
- const thumb = embed.thumbnail?.proxyURL ?? embed.thumbnail?.url;
65
+ const thumb = (0, utils_1.safeImageSrc)(embed.thumbnail?.proxyURL ?? embed.thumbnail?.url);
54
66
 
55
67
  return ((0, jsx_runtime_1.jsxs)("discord-embed", {
56
68
  "embed-title": title,
@@ -59,7 +71,7 @@ async function DiscordEmbed({ embed, context }) {
59
71
  "author-name": authorName,
60
72
  "author-url": authorUrl,
61
73
  color: color,
62
- image: safeImg,
74
+ image: safeOwnImg,
63
75
  thumbnail: thumb,
64
76
  url: url,
65
77
  children: [
@@ -75,7 +87,10 @@ async function DiscordEmbed({ embed, context }) {
75
87
  safeVid && ((0, jsx_runtime_1.jsx)("div", { className: "dht-embed-video", children: (0, jsx_runtime_1.jsxs)("a", { href: safeVid, target: "_blank", rel: "noreferrer", children: ['▶ Open video', provider ? ` (${provider})` : ''] }) })),
76
88
  embed.footer && ((0, jsx_runtime_1.jsx)("discord-embed-footer", {
77
89
  slot: "footer",
78
- footerImage: embed.footer.proxyIconURL ?? embed.footer.iconURL,
90
+ // Attribute names are lowercased by HTML; `footerImage` arrived as
91
+ // `footerimage`, which the component does not observe, so footer
92
+ // icons never showed.
93
+ "footer-image": (0, utils_1.safeImageSrc)(embed.footer.proxyIconURL ?? embed.footer.iconURL),
79
94
  timestamp: (0, utils_1.formatMessageTimestamp)(embed.timestamp, context?.timestampFormat, t(context, 'yesterdayAt', 'Yesterday at {time}')),
80
95
  children: embed.footer.text
81
96
  }))
@@ -52,15 +52,8 @@ const FLAG_IS_CROSSPOST = 1 << 1;
52
52
  const FLAG_SUPPRESS_EMBEDS = 1 << 2;
53
53
  const FLAG_SUPPRESS_NOTIFICATIONS = 1 << 12;
54
54
  const FLAG_IS_VOICE_MESSAGE = 1 << 13;
55
- const FLAG_HAS_SNAPSHOT = 1 << 14;
56
55
  const ACTIVITY_TYPES = { 1: 'Join', 2: 'Spectate', 3: 'Listen', 5: 'Join Request' };
57
56
 
58
- function abbrCount(n) {
59
- if (n < 1000) return String(n);
60
- if (n < 1000000) return (n / 1000).toFixed(n < 10000 ? 1 : 0).replace(/\.0$/, '') + 'K';
61
- return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
62
- }
63
-
64
57
  function t(context, key, fallback) {
65
58
  const dict = context?.i18n?.[context?.lang] || context?.i18n?.en || {};
66
59
  return dict[key] || fallback || key;
@@ -71,7 +64,9 @@ async function DiscordMessage({ message, context }) {
71
64
  if (message.system)
72
65
  return (0, jsx_runtime_1.jsx)(systemMessage_1.default, { message: message, context: context });
73
66
 
74
- const isCrossGuildReply = message.reference && message.reference.guildId !== message.guild?.id;
67
+ // Only replies count here: a forward from another server is not a
68
+ // cross-server reply and must not get the server tag.
69
+ const isCrossGuildReply = message.reference && !(0, utils_1.isForwardMessage)(message) && message.reference.guildId !== message.guild?.id;
75
70
 
76
71
  // Data-attrs for client-side filtering (keyword/author/role/date/has-*).
77
72
  const memberRoleIds = message.member?.roles?.cache
@@ -79,7 +74,7 @@ async function DiscordMessage({ message, context }) {
79
74
  : '';
80
75
  const authorName = (message.member?.nickname || message.author?.displayName || message.author?.username || '').toLowerCase();
81
76
  const ts = message.createdAt instanceof Date ? message.createdAt.getTime() : 0;
82
- const lowerText = (message.content || '').toLowerCase().slice(0, 4096);
77
+ const lowerText = (0, utils_1.truncateText)((message.content || '').toLowerCase(), 4096);
83
78
  const mediaFlags = detectMediaFlags(message);
84
79
  const hasImage = mediaFlags.hasImage;
85
80
  const hasEmbed = mediaFlags.hasEmbed;
@@ -106,8 +101,18 @@ async function DiscordMessage({ message, context }) {
106
101
  partyId: message.activity.partyId,
107
102
  } : null;
108
103
 
109
- // Forwarded message snapshots (messageSnapshots) — render before the empty body
110
- const snapshots = Array.isArray(message.messageSnapshots) ? message.messageSnapshots : [];
104
+ // Forwarded message snapshots (messageSnapshots) — render before the empty body.
105
+ // discord.js exposes them as a Collection, never an array, so an Array.isArray
106
+ // check left every forward empty ("Message could not be loaded.").
107
+ const snapshots = toArray(message.messageSnapshots);
108
+ const isForward = (0, utils_1.isForwardMessage)(message);
109
+ // Discord hides who wrote a forwarded message (see renderSnapshots). Name the
110
+ // channel it was forwarded from instead — only from this server, so a
111
+ // transcript never reveals channel names of other servers the bot is in.
112
+ const forwardedFromChannel = isForward && message.reference?.guildId && message.reference.guildId === message.guild?.id
113
+ ? message.guild?.channels?.cache?.get(message.reference.channelId)
114
+ : null;
115
+ const forwardSource = forwardedFromChannel?.name ? '#' + forwardedFromChannel.name : null;
111
116
 
112
117
  const editedAtIso = message.editedAt instanceof Date ? message.editedAt.toISOString() : null;
113
118
  // Hover text for the "(edited)" marker — the ISO string stays in
@@ -193,11 +198,18 @@ async function DiscordMessage({ message, context }) {
193
198
  isSilent && (0, jsx_runtime_1.jsx)("span", { className: "dht-badge dht-badge-silent", title: "Silent message (no notification)", children: '🔕 Silent' }),
194
199
  activity && (0, jsx_runtime_1.jsxs)("div", { className: "dht-activity", children: [
195
200
  (0, jsx_runtime_1.jsx)("span", { className: "dht-activity-icon", children: '🎮' }),
196
- (0, jsx_runtime_1.jsxs)("span", { children: [activity.type, ' invite', activity.partyId ? ` · Party ${activity.partyId.slice(-6)}` : ''] })
201
+ (0, jsx_runtime_1.jsxs)("span", { children: [activity.type, ' invite', activity.partyId ? ` · Party ${Array.from(activity.partyId).slice(-6).join('')}` : ''] })
197
202
  ] }),
198
203
  editedAtIso && (0, jsx_runtime_1.jsx)("span", { className: "dht-edit-marker", title: editedAtTitle || editedAtIso, "data-edit-iso": editedAtIso, "data-i18n": "edited", "data-i18n-params": JSON.stringify({ time: editedAtLabel || editedAtIso }), children: '(' + t(context, 'edited', 'edited') + ')' }),
199
204
  Array.isArray(message.editHistory) && message.editHistory.length > 0 && renderEditHistory(message.editHistory, context),
200
- snapshots.length > 0 && renderSnapshots(snapshots, context),
205
+ snapshots.length > 0
206
+ ? renderSnapshots(snapshots, context, 0, forwardSource)
207
+ // A forward whose snapshot is missing still says what it is
208
+ // instead of rendering as an empty message.
209
+ : isForward && (0, jsx_runtime_1.jsx)("div", { className: "dht-forwarded-wrap", children: (0, jsx_runtime_1.jsx)("div", { className: "dht-forwarded", "data-depth": 0, children: (0, jsx_runtime_1.jsxs)("div", { className: "dht-forwarded-head", children: [
210
+ (0, jsx_runtime_1.jsx)("span", { className: "dht-forwarded-icon", children: '↪' }),
211
+ (0, jsx_runtime_1.jsx)("em", { "data-i18n": "forwardUnavailable", children: t(context, 'forwardUnavailable', 'Forwarded message is unavailable') }),
212
+ ] }) }) }),
201
213
  message.content && ((0, jsx_runtime_1.jsx)(content_1.default, { content: message.content, context: Object.assign({}, context, { type: message.webhookId ? content_1.RenderType.WEBHOOK : content_1.RenderType.NORMAL }) })),
202
214
  (0, jsx_runtime_1.jsx)(attachment_1.Attachments, { message: message, context: context }),
203
215
  isVoice && renderVoiceIndicator(message, context),
@@ -210,10 +222,14 @@ async function DiscordMessage({ message, context }) {
210
222
  slot: "reactions",
211
223
  children: Array.from(message.reactions.cache.values()).map((reaction, id) => {
212
224
  const total = reaction.count ?? 0;
213
- const burstCount = reaction.count_details?.burst || reaction.burst_count || 0;
225
+ // discord.js names these countDetails and burstColors. The snake_case
226
+ // reads only ever matched raw API JSON, so super reactions never
227
+ // showed in production; raw shapes still work as a fallback.
228
+ const burstCount = reaction.countDetails?.burst || reaction.count_details?.burst || reaction.burst_count || 0;
214
229
  const burst = burstCount > 0;
215
- const burstColors = Array.isArray(reaction.burst_colors) && reaction.burst_colors.length
216
- ? reaction.burst_colors.filter((c) => typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c))
230
+ const rawBurstColors = reaction.burstColors ?? reaction.burst_colors;
231
+ const burstColors = Array.isArray(rawBurstColors) && rawBurstColors.length
232
+ ? rawBurstColors.filter((c) => typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c))
217
233
  : [];
218
234
  const title = burst
219
235
  ? `${total} reactions (${burstCount} super)`
@@ -224,7 +240,9 @@ async function DiscordMessage({ message, context }) {
224
240
  return ((0, jsx_runtime_1.jsx)("discord-reaction", {
225
241
  name: reaction.emoji?.name || ':unknown:',
226
242
  emoji: (0, utils_1.parseDiscordEmoji)(reaction.emoji) || undefined,
227
- count: abbrCount(total),
243
+ // <discord-reaction> declares `count` as a Number and renders
244
+ // it as one: an abbreviated "1.5K" showed up as NaN.
245
+ count: total,
228
246
  title,
229
247
  "data-burst": burst ? 'true' : undefined,
230
248
  className: burst ? 'dht-reaction--burst' : undefined,
@@ -236,7 +254,7 @@ async function DiscordMessage({ message, context }) {
236
254
  ]
237
255
  }, message.id));
238
256
  } catch (err) {
239
- console.warn('[discord-html-transcripts-fix] Render failed', message?.id, err?.message || err);
257
+ console.warn('[discord-html-transcripts-fix] Render failed', message?.id, err);
240
258
  const safeAuthor = (message?.author?.displayName || message?.author?.username) || 'Unknown';
241
259
  return ((0, jsx_runtime_1.jsx)("discord-message", { id: `m-${message?.id}`, children: (0, jsx_runtime_1.jsx)("span", { style: { opacity: 0.6 }, children: `[message from ${safeAuthor} failed to render]` }) }, message?.id));
242
260
  }
@@ -252,7 +270,7 @@ function renderThread(thread, context) {
252
270
  return (0, jsx_runtime_1.jsxs)("discord-thread", { slot: "thread", name: thread.name, cta, children: [
253
271
  badges,
254
272
  thread.lastMessage
255
- ? (0, jsx_runtime_1.jsx)("discord-thread-message", { profile: thread.lastMessage.author.id, children: (0, jsx_runtime_1.jsx)(content_1.default, { content: thread.lastMessage.content.length > 128 ? thread.lastMessage.content.substring(0, 125) + '...' : thread.lastMessage.content, context: Object.assign({}, context, { type: content_1.RenderType.REPLY }) }) })
273
+ ? (0, jsx_runtime_1.jsx)("discord-thread-message", { profile: thread.lastMessage.author.id, children: (0, jsx_runtime_1.jsx)(content_1.default, { content: thread.lastMessage.content.length > 128 ? (0, utils_1.truncateText)(thread.lastMessage.content, 125) + '...' : thread.lastMessage.content, context: Object.assign({}, context, { type: content_1.RenderType.REPLY }) }) })
256
274
  : 'Thread messages not saved.'
257
275
  ] });
258
276
  }
@@ -321,7 +339,8 @@ function renderVoiceIndicator(message, context) {
321
339
  if (typeof a.contentType === 'string' && a.contentType.startsWith('audio/')) { audio = a; break; }
322
340
  }
323
341
  }
324
- const duration = audio?.duration_secs || audio?.durationSecs;
342
+ // discord.js exposes the raw `duration_secs` as `duration`.
343
+ const duration = audio?.duration || audio?.duration_secs || audio?.durationSecs;
325
344
  const waveformB64 = audio?.waveform;
326
345
  let waveformRects = null;
327
346
  let waveformWidth = 0;
@@ -360,18 +379,29 @@ function toArray(maybeColl) {
360
379
  return [];
361
380
  }
362
381
 
363
- function renderSnapshots(snapshots, context, depth = 0) {
382
+ function renderSnapshots(snapshots, context, depth = 0, source = null) {
364
383
  const arr = toArray(snapshots);
365
384
  if (arr.length === 0 || depth >= 5) return null; // hard-cap at 5 levels
366
385
  return (0, jsx_runtime_1.jsx)("div", {
367
386
  className: "dht-forwarded-wrap",
368
387
  children: arr.map((snap, i) => {
369
388
  const m = snap.message || snap;
370
- const author = m?.author?.displayName || m?.author?.username || 'Unknown';
389
+ // Discord deliberately hides who wrote a forwarded message, and the API
390
+ // snapshot has no author. discord.js still builds each snapshot in the
391
+ // source channel's message cache, so when the bot has the original
392
+ // cached, the snapshot IS the original — author included, even from
393
+ // another server. Only plain-object input, whose author the caller
394
+ // supplied on purpose, may name one.
395
+ const fromLibrary = (0, utils_1.isLibraryStructure)(m);
396
+ const author = fromLibrary ? null : (m?.author?.displayName || m?.author?.username || null);
397
+ const origin = author || source;
371
398
  const text = m?.content || '';
372
399
  const attachments = toArray(m?.attachments);
373
400
  const nested = toArray(m?.messageSnapshots);
374
401
  const embeds = toArray(m?.embeds);
402
+ // Raw component shapes are not what the component renderer is written
403
+ // for, so plain-object input keeps showing only what it showed before.
404
+ const components = fromLibrary ? toArray(m?.components) : [];
375
405
  const stickers = toArray(m?.stickers).map((s) => ({
376
406
  id: s.id,
377
407
  name: s.name,
@@ -381,11 +411,19 @@ function renderSnapshots(snapshots, context, depth = 0) {
381
411
  return (0, jsx_runtime_1.jsxs)("div", { className: "dht-forwarded", "data-depth": depth, children: [
382
412
  (0, jsx_runtime_1.jsxs)("div", { className: "dht-forwarded-head", children: [
383
413
  (0, jsx_runtime_1.jsx)("span", { className: "dht-forwarded-icon", children: '↪' }),
384
- (0, jsx_runtime_1.jsx)("span", { "data-i18n": "forwardedFrom", children: t(context, 'forwardedFrom', 'Forwarded from') }),
385
- (0, jsx_runtime_1.jsx)("strong", { children: ' ' + author }),
414
+ // Without a known origin just say "Forwarded" rather than an
415
+ // unlocalised "Forwarded from Unknown".
416
+ origin
417
+ ? (0, jsx_runtime_1.jsx)("span", { "data-i18n": "forwardedFrom", children: t(context, 'forwardedFrom', 'Forwarded from') })
418
+ : (0, jsx_runtime_1.jsx)("span", { "data-i18n": "forwardedMessage", children: t(context, 'forwardedMessage', 'Forwarded') }),
419
+ origin && (0, jsx_runtime_1.jsx)("strong", { children: ' ' + origin }),
386
420
  ] }),
387
421
  text && (0, jsx_runtime_1.jsx)("div", { className: "dht-forwarded-body", children: (0, jsx_runtime_1.jsx)(content_1.default, { content: text, context: Object.assign({}, context, { type: content_1.RenderType.NORMAL }) }) }),
388
- attachments.length > 0 && (0, jsx_runtime_1.jsxs)("div", { className: "dht-forwarded-attachments", children: [
422
+ // discord.js attachments take the same path as a message's own, so
423
+ // saveImages and a custom resolveImageSrc archive forwarded images too
424
+ // instead of leaving them on Discord's expiring CDN links.
425
+ fromLibrary && attachments.length > 0 && (0, jsx_runtime_1.jsx)("div", { className: "dht-forwarded-media", children: (0, jsx_runtime_1.jsx)(attachment_1.Attachments, { message: m, context: context }) }),
426
+ !fromLibrary && attachments.length > 0 && (0, jsx_runtime_1.jsxs)("div", { className: "dht-forwarded-attachments", children: [
389
427
  (0, jsx_runtime_1.jsxs)("strong", { children: ['📎 ', attachments.length, ' attachment', attachments.length !== 1 ? 's' : '', ':'] }),
390
428
  (0, jsx_runtime_1.jsx)("ul", { children: attachments.map((a, ai) => {
391
429
  const safeUrl = (0, utils_1.safeHref)(a.url);
@@ -397,7 +435,10 @@ function renderSnapshots(snapshots, context, depth = 0) {
397
435
  }) })
398
436
  ] }),
399
437
  stickers.length > 0 && renderStickers(stickers),
400
- embeds.length > 0 && (0, jsx_runtime_1.jsxs)("div", { className: "dht-forwarded-embeds", children: ['📑 ', embeds.length, ' embed', embeds.length !== 1 ? 's' : ''] }),
438
+ // Forwarded embeds and components render in full, like Discord shows them.
439
+ fromLibrary && embeds.length > 0 && (0, jsx_runtime_1.jsx)("div", { className: "dht-forwarded-media", children: embeds.map((embed, ei) => (0, jsx_runtime_1.jsx)(embed_1.DiscordEmbed, { embed: embed, context: Object.assign({}, context, { index: ei, message: m }) }, ei)) }),
440
+ !fromLibrary && embeds.length > 0 && (0, jsx_runtime_1.jsxs)("div", { className: "dht-forwarded-embeds", children: ['📑 ', embeds.length, ' embed', embeds.length !== 1 ? 's' : ''] }),
441
+ components.length > 0 && (0, jsx_runtime_1.jsx)("div", { className: "dht-forwarded-media", children: (0, jsx_runtime_1.jsx)("discord-attachments", { children: components.map((component, ci) => (0, jsx_runtime_1.jsx)(components_1.default, { id: ci, component: component, context: context }, ci)) }) }),
401
442
  nested.length > 0 && renderSnapshots(nested, context, depth + 1),
402
443
  ] }, i);
403
444
  })
@@ -415,8 +456,7 @@ const FLAG_IS_COMPONENTS_V2 = 1 << 15;
415
456
  // Walks message.embeds and message.components to determine whether the message
416
457
  // carries media or V2 containers — used to drive the filter checkboxes
417
458
  // (data-has-image, data-has-attachment, data-has-embed, data-has-component-v2).
418
- function detectMediaFlags(message) {
419
- const out = { hasImage: false, hasAttachment: false, hasEmbed: false, hasComponentV2: false };
459
+ function detectMediaFlags(message, out = { hasImage: false, hasAttachment: false, hasEmbed: false, hasComponentV2: false }, depth = 0) {
420
460
 
421
461
  // Direct attachments
422
462
  if (message.attachments && typeof message.attachments.values === 'function') {
@@ -472,6 +512,14 @@ function detectMediaFlags(message) {
472
512
  };
473
513
  if (Array.isArray(message.components)) for (const c of message.components) walk(c);
474
514
 
515
+ // What a forward carries counts for the "has image/attachment/embed" filters too.
516
+ if (depth < 5) {
517
+ for (const snap of toArray(message.messageSnapshots)) {
518
+ const inner = snap?.message || snap;
519
+ if (inner) detectMediaFlags(inner, out, depth + 1);
520
+ }
521
+ }
522
+
475
523
  return out;
476
524
  }
477
525
 
@@ -8,6 +8,7 @@ exports.default = MessageReply;
8
8
  const jsx_runtime_1 = require("react/jsx-runtime");
9
9
  const discord_js_1 = require("discord.js");
10
10
  const content_1 = __importStar(require("./content"));
11
+ const utils_1 = require("../../utils/utils");
11
12
 
12
13
  function t(context, key, fallback) {
13
14
  const dict = context?.i18n?.[context?.lang] || context?.i18n?.en || {};
@@ -15,7 +16,10 @@ function t(context, key, fallback) {
15
16
  }
16
17
 
17
18
  async function MessageReply({ message, context }) {
18
- if (!message.reference) return null;
19
+ // A forward carries a message reference too (type Forward). It is rendered from
20
+ // its snapshots, not as a reply; treating it as one put a "Message could not be
21
+ // loaded." reply bar on every forwarded message.
22
+ if (!message.reference || (0, utils_1.isForwardMessage)(message)) return null;
19
23
 
20
24
  const isCrossGuild = message.reference.guildId !== message.guild?.id;
21
25
 
@@ -32,16 +36,22 @@ async function MessageReply({ message, context }) {
32
36
  if (!referencedMessage)
33
37
  return (0, jsx_runtime_1.jsx)("discord-reply", { slot: "reply", children: "Message could not be loaded." });
34
38
 
35
- const isCrossPost = referencedMessage.reference && referencedMessage.reference.guildId !== message.guild?.id;
39
+ const isCrossPost = referencedMessage.reference && !(0, utils_1.isForwardMessage)(referencedMessage) && referencedMessage.reference.guildId !== message.guild?.id;
36
40
  const isCommand = !!referencedMessage.interaction;
37
41
  // Special-case: replying to a forwarded-only / Components-V2-only message has empty content
38
42
  const hasContent = !!referencedMessage.content;
39
- const hasSnapshot = Array.isArray(referencedMessage.messageSnapshots) && referencedMessage.messageSnapshots.length > 0;
43
+ // messageSnapshots is a discord.js Collection (size), not an array (length).
44
+ // discord.js before 14.16 has none at all; the forward flag still says what it is.
45
+ const snapshotCount = referencedMessage.messageSnapshots?.size ?? referencedMessage.messageSnapshots?.length ?? 0;
46
+ const hasSnapshot = snapshotCount > 0 || (0, utils_1.isForwardMessage)(referencedMessage);
40
47
  const hasComponents = Array.isArray(referencedMessage.components) && referencedMessage.components.length > 0;
41
48
  const hasSticker = referencedMessage.stickers?.size > 0;
42
49
  const author = referencedMessage.member?.nickname ?? referencedMessage.author.displayName ?? referencedMessage.author.username;
43
50
  const avatar = referencedMessage.author.avatarURL?.({ size: 32 }) ?? undefined;
44
- const roleColor = referencedMessage.member?.displayHexColor ?? undefined;
51
+ // displayHexColor is '#000000' for members without a colored role; passed on,
52
+ // that would paint the name black on the dark background.
53
+ const memberHex = referencedMessage.member?.displayHexColor;
54
+ const roleColor = memberHex && memberHex !== '#000000' ? memberHex : undefined;
45
55
  const op = referencedMessage.author.id === message?.channel?.ownerId && message?.channel?.isThread?.();
46
56
 
47
57
  return ((0, jsx_runtime_1.jsx)("discord-reply", {
@@ -50,7 +60,10 @@ async function MessageReply({ message, context }) {
50
60
  attachment: referencedMessage.attachments.size > 0,
51
61
  author: author,
52
62
  avatar: avatar,
53
- roleColor: roleColor,
63
+ // Props become HTML attributes, whose names HTML lowercases: `roleColor`
64
+ // arrived as `rolecolor`, which the component does not observe, so reply
65
+ // authors never got their role color.
66
+ "role-color": roleColor,
54
67
  bot: !isCrossPost && referencedMessage.author.bot,
55
68
  verified: referencedMessage.author.flags?.has?.(discord_js_1.UserFlags.VerifiedBot),
56
69
  op: op,
@@ -6,6 +6,7 @@ exports.JoinMessage = JoinMessage;
6
6
  const jsx_runtime_1 = require("react/jsx-runtime");
7
7
  const discord_js_1 = require("discord.js");
8
8
  const utils_1 = require("../../utils/utils");
9
+ const content_1 = require("./content");
9
10
 
10
11
  function t(context, key, fallback) {
11
12
  const dict = context?.i18n?.[context?.lang] || context?.i18n?.en || {};
@@ -145,14 +146,19 @@ async function SystemMessage({ message, context }) {
145
146
  const matchedKeyword = fields.find((f) => /matched_keyword|keyword/i.test(f.name))?.value || '';
146
147
  const action = fields.find((f) => /action_type|action/i.test(f.name))?.value || '';
147
148
  const blocked = e?.description || '';
148
- const channelId = fields.find((f) => /channel/i.test(f.name))?.value;
149
+ const channelField = fields.find((f) => /channel/i.test(f.name))?.value;
150
+ // A bare id in today's payloads; tolerate the <#id> mention form as well.
151
+ const channelId = channelField ? String(channelField).match(/\d{17,20}/)?.[0] ?? String(channelField) : null;
152
+ // The raw `<#id>` mention syntax used to be printed verbatim; name the
153
+ // channel when this server knows it.
154
+ const automodChannel = channelId ? message.guild?.channels?.cache?.get(channelId) : null;
149
155
  return ((0, jsx_runtime_1.jsxs)("discord-system-message", { id: `m-${message.id}`, timestamp: ts, type: "boost", children: [
150
156
  (0, jsx_runtime_1.jsx)("span", { "data-i18n": "autoModBlocked", children: t(ctx, 'autoModBlocked', 'AutoMod blocked a message.') }),
151
157
  ruleName && (0, jsx_runtime_1.jsxs)("span", { className: "dht-automod-rule", children: [' · Rule: ', (0, jsx_runtime_1.jsx)("strong", { children: ruleName })] }),
152
158
  action && (0, jsx_runtime_1.jsxs)("span", { className: "dht-automod-rule", children: [' · Action: ', action] }),
153
159
  matchedKeyword && (0, jsx_runtime_1.jsxs)("span", { className: "dht-automod-rule", children: [' · Matched: ', (0, jsx_runtime_1.jsx)("code", { children: matchedKeyword })] }),
154
160
  blocked && (0, jsx_runtime_1.jsx)("blockquote", { className: "dht-automod-content", children: blocked }),
155
- channelId && (0, jsx_runtime_1.jsxs)("span", { className: "dht-automod-rule", children: [' · In <#', channelId, '>'] }),
161
+ channelId && (0, jsx_runtime_1.jsxs)("span", { className: "dht-automod-rule", children: [' · In #', automodChannel?.name || channelId] }),
156
162
  ] }, message.id));
157
163
  }
158
164
  case discord_js_1.MessageType.StageStart:
@@ -195,7 +201,7 @@ async function SystemMessage({ message, context }) {
195
201
  return ((0, jsx_runtime_1.jsxs)("discord-system-message", { id: `m-${message.id}`, timestamp: ts, type: "boost", children: [
196
202
  (0, jsx_runtime_1.jsx)(Highlight, { color, userId: authorId, children: authorName }),
197
203
  ' ',
198
- t(ctx, 'roleSubPurchased', 'subscribed to {role}!').replace('{role}', roleName),
204
+ t(ctx, 'roleSubPurchased', 'subscribed to {role}!').replace('{role}', () => roleName),
199
205
  ] }, message.id));
200
206
  }
201
207
 
@@ -213,8 +219,17 @@ async function SystemMessage({ message, context }) {
213
219
  case discord_js_1.MessageType.Reply:
214
220
  return undefined;
215
221
 
216
- default:
217
- return undefined;
222
+ // A type without its own wording here — Server Discovery notices, invite
223
+ // reminders, premium upsells, purchase notifications, or anything Discord adds
224
+ // later. Dropping it left a silent gap in the record; a neutral line keeps it,
225
+ // with whatever text Discord sent, and names the type on hover.
226
+ default: {
227
+ const typeName = typeof discord_js_1.MessageType[message.type] === 'string' ? discord_js_1.MessageType[message.type] : null;
228
+ return ((0, jsx_runtime_1.jsxs)("discord-system-message", { id: `m-${message.id}`, timestamp: ts, type: "alert", title: typeName ? `${typeName} (${message.type})` : `type ${message.type}`, "data-system-type": message.type, children: [
229
+ (0, jsx_runtime_1.jsx)("span", { "data-i18n": "systemMessage", children: t(ctx, 'systemMessage', 'System message') }),
230
+ message.content && (0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [': ', (0, jsx_runtime_1.jsx)(content_1.default, { content: message.content, context: Object.assign({}, ctx, { type: content_1.RenderType.NORMAL }) })] }),
231
+ ] }, message.id));
232
+ }
218
233
  }
219
234
  }
220
235
 
package/dist/index.js CHANGED
@@ -85,6 +85,13 @@ async function generateFromMessages(messages, channel, options = {}) {
85
85
  hydrate: options.hydrate ?? false,
86
86
  language: options.language ?? 'en',
87
87
  i18n: options.i18n ?? undefined,
88
+ // These four were missing from this hand-maintained list when they were
89
+ // introduced, so the public API silently dropped them and only the
90
+ // defaults ever applied. Anything the generator reads must be listed here.
91
+ dateFormat: options.dateFormat,
92
+ timeFormat: options.timeFormat,
93
+ inlineAssets: options.inlineAssets ?? false,
94
+ inlineAssetsTimeout: options.inlineAssetsTimeout,
88
95
  stream: options.returnType === 'stream' || options.stream === true,
89
96
  returnType: options.returnType,
90
97
  });
@@ -75,9 +75,11 @@ const defaultDicts = {
75
75
  left: 'left.',
76
76
  autoModBlocked: 'AutoMod blocked a message.',
77
77
  serverAlert: 'Server safety alert.',
78
+ systemMessage: 'System message',
78
79
  pollEnded: 'A poll ended.',
79
80
  roleSubPurchased: 'subscribed to {role}!',
80
81
  forwardedFrom: 'Forwarded from',
82
+ forwardUnavailable: 'Forwarded message is unavailable',
81
83
  searchPlaceholder: 'Search transcript…',
82
84
  ofMatches: 'of',
83
85
  noMatches: 'no matches',
@@ -86,6 +88,9 @@ const defaultDicts = {
86
88
  statsMessages: '{n} messages',
87
89
  statsParticipants: '{n} participants',
88
90
  statsImages: '{n} images',
91
+ statsMessage: '{n} message',
92
+ statsParticipant: '{n} participant',
93
+ statsImage: '{n} image',
89
94
  crossServerReply: 'Message from another server',
90
95
  appBadge: 'APP',
91
96
  botBadge: 'BOT',
@@ -129,9 +134,11 @@ const defaultDicts = {
129
134
  left: 'hat den Channel verlassen.',
130
135
  autoModBlocked: 'AutoMod hat eine Nachricht blockiert.',
131
136
  serverAlert: 'Sicherheitswarnung.',
137
+ systemMessage: 'Systemnachricht',
132
138
  pollEnded: 'Eine Umfrage wurde beendet.',
133
139
  roleSubPurchased: 'hat {role} abonniert!',
134
140
  forwardedFrom: 'Weitergeleitet von',
141
+ forwardUnavailable: 'Weitergeleitete Nachricht ist nicht verfügbar',
135
142
  searchPlaceholder: 'Im Transcript suchen…',
136
143
  ofMatches: 'von',
137
144
  noMatches: 'keine Treffer',
@@ -140,6 +147,9 @@ const defaultDicts = {
140
147
  statsMessages: '{n} Nachrichten',
141
148
  statsParticipants: '{n} Teilnehmer',
142
149
  statsImages: '{n} Bilder',
150
+ statsMessage: '{n} Nachricht',
151
+ statsParticipant: '{n} Teilnehmer',
152
+ statsImage: '{n} Bild',
143
153
  crossServerReply: 'Nachricht von einem anderen Server',
144
154
  appBadge: 'APP',
145
155
  botBadge: 'BOT',
package/dist/types.d.ts CHANGED
@@ -53,8 +53,9 @@ export type GenerateFromMessagesOptions<T extends ExportReturnType> = Partial<{
53
53
  */
54
54
  favicon: 'guild' | string;
55
55
  /**
56
- * Whether to hydrate the html server-side
57
- * @default false - the returned html will be hydrated client-side
56
+ * Adds the client-side script that reveals spoilers on click.
57
+ * The `<discord-*>` elements hydrate in the browser either way.
58
+ * @default false
58
59
  */
59
60
  hydrate: boolean;
60
61
  /**
@@ -73,36 +74,44 @@ export type GenerateFromMessagesOptions<T extends ExportReturnType> = Partial<{
73
74
  };
74
75
  /**
75
76
  * Embed the third-party assets the transcript would otherwise load from a CDN
76
- * at view time — the `<discord-*>` web component runtime (from jsDelivr) and
77
- * the Twemoji SVGs (from cdnjs) — directly into the HTML.
77
+ * at view time — the `<discord-*>` web component runtime and the gg sans font
78
+ * (from jsDelivr) and the Twemoji SVGs (from cdnjs) — directly into the HTML.
78
79
  *
79
80
  * Without this the file needs internet access whenever it is *opened*: offline,
80
81
  * behind a CDN-blocking network or after a CDN outage the transcript renders
81
82
  * unstyled. It also means every viewer's IP reaches those CDNs, which may
82
83
  * matter for GDPR-sensitive ticket archives.
83
84
  *
84
- * Costs roughly +550 kB per file and one download at generation time (cached
85
- * per process). Discord's own CDN is untouched — use `saveImages` for that.
85
+ * Costs roughly +890 kB per file and one download at generation time (cached
86
+ * per process, shared between concurrent exports). Discord's own CDN
87
+ * (cdn.discordapp.com, media.discordapp.net) is not a third-party CDN and stays
88
+ * in use: avatars always load from it, attachment images unless `saveImages` is
89
+ * set, and embed images, stickers and custom emoji.
86
90
  *
87
91
  * Never fails the export: if an asset cannot be fetched, the CDN reference is
88
- * kept and a warning is logged.
92
+ * kept and a warning naming the cause is logged. Combined with a stream return
93
+ * type the document is buffered first, so streaming saves no memory then.
89
94
  * @default false
90
95
  */
91
96
  inlineAssets: boolean;
92
97
  /**
93
- * Per-request timeout in ms while downloading the assets for `inlineAssets`.
98
+ * Timeout in milliseconds for each download made by `inlineAssets`, including
99
+ * connection setup. A numeric string such as `'5000'` is accepted; anything
100
+ * that is not a positive whole number falls back to the default with a warning.
94
101
  * @default 30000
95
102
  */
96
103
  inlineAssetsTimeout: number;
97
104
  /**
98
105
  * Date order used whenever a message timestamp is older than yesterday
99
- * (e.g. `11/08/2026 07:16`).
106
+ * (e.g. `11/08/2026 07:16`). Matched case-insensitively; an unknown value falls
107
+ * back to the default with a one-time warning.
100
108
  * @default 'dd/mm/yyyy'
101
109
  */
102
110
  dateFormat: 'dd/mm/yyyy' | 'mm/dd/yyyy';
103
111
  /**
104
112
  * Clock format for message timestamps — `'24h'` renders `07:16`,
105
- * `'12h'` renders `07:16 AM`.
113
+ * `'12h'` renders `07:16 AM`. Matched case-insensitively; an unknown value
114
+ * falls back to the default with a one-time warning.
106
115
  * @default '24h'
107
116
  */
108
117
  timeFormat: '24h' | '12h';