discord-html-transcripts-fix 2.1.0 → 2.3.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.
@@ -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
@@ -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
@@ -197,7 +202,14 @@ async function DiscordMessage({ message, context }) {
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
  }
@@ -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:
@@ -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
  /**
@@ -71,15 +72,46 @@ export type GenerateFromMessagesOptions<T extends ExportReturnType> = Partial<{
71
72
  enabled?: boolean;
72
73
  template?: string;
73
74
  };
75
+ /**
76
+ * Embed the third-party assets the transcript would otherwise load from a CDN
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.
79
+ *
80
+ * Without this the file needs internet access whenever it is *opened*: offline,
81
+ * behind a CDN-blocking network or after a CDN outage the transcript renders
82
+ * unstyled. It also means every viewer's IP reaches those CDNs, which may
83
+ * matter for GDPR-sensitive ticket archives.
84
+ *
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.
90
+ *
91
+ * Never fails the export: if an asset cannot be fetched, the CDN reference is
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.
94
+ * @default false
95
+ */
96
+ inlineAssets: boolean;
97
+ /**
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.
101
+ * @default 30000
102
+ */
103
+ inlineAssetsTimeout: number;
74
104
  /**
75
105
  * Date order used whenever a message timestamp is older than yesterday
76
- * (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.
77
108
  * @default 'dd/mm/yyyy'
78
109
  */
79
110
  dateFormat: 'dd/mm/yyyy' | 'mm/dd/yyyy';
80
111
  /**
81
112
  * Clock format for message timestamps — `'24h'` renders `07:16`,
82
- * `'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.
83
115
  * @default '24h'
84
116
  */
85
117
  timeFormat: '24h' | '12h';
@@ -4,6 +4,7 @@ exports.buildProfiles = buildProfiles;
4
4
  exports.buildExtendedContext = buildExtendedContext;
5
5
  exports.buildAllContext = buildAllContext;
6
6
  const discord_js_1 = require("discord.js");
7
+ const utils_1 = require("./utils");
7
8
 
8
9
  /**
9
10
  * Backwards compatible: returns the profile dict used by <discord-mention> web components.
@@ -37,7 +38,8 @@ async function buildAllContext(messages, channel) {
37
38
  ? channel.guild
38
39
  : null;
39
40
 
40
- const seenUserIds = new Set();
41
+ // id -> 'member' | 'bare': whether the entry was built from the guild member.
42
+ const userState = new Map();
41
43
  const seenRoleIds = new Set();
42
44
  const seenChannelIds = new Set();
43
45
 
@@ -73,12 +75,48 @@ async function buildAllContext(messages, channel) {
73
75
  };
74
76
  };
75
77
 
78
+ // Plain-object input may carry dates as strings; discord.js gives Dates.
79
+ const isoDate = (value) => {
80
+ const date = value instanceof Date ? value
81
+ : (typeof value === 'string' || typeof value === 'number') ? new Date(value) : null;
82
+ return date && !Number.isNaN(date.getTime()) ? date.toISOString() : null;
83
+ };
84
+
76
85
  const includeUser = (member, author) => {
77
- if (!author) return;
78
- const id = author.id;
79
- if (seenUserIds.has(id)) return;
80
- seenUserIds.add(id);
86
+ const id = author?.id;
87
+ if (!id) return;
88
+ // The first sighting must not decide: a user seen first without their
89
+ // member (a mention, a command, a forward) would otherwise keep a profile
90
+ // without nickname, roles and color for the whole transcript.
91
+ const state = userState.get(id);
92
+ if (state === 'member' || (state === 'bare' && !member)) return;
93
+ userState.set(id, member ? 'member' : 'bare');
94
+ try {
95
+ buildUser(id, member, author);
96
+ return;
97
+ }
98
+ catch (err) {
99
+ // One malformed user must not cost every other profile in the transcript.
100
+ console.warn(`[discord-html-transcripts-fix] could not build the profile of user ${id}${member ? ' from their guild member' : ''}:`, err);
101
+ }
102
+ // A broken member — discord.js' role getters throw when the @everyone role
103
+ // is not cached, for one — still leaves the user's own name and avatar.
104
+ if (member && !profiles[id]) {
105
+ try {
106
+ buildUser(id, null, author);
107
+ }
108
+ catch (err) {
109
+ console.warn(`[discord-html-transcripts-fix] could not build the profile of user ${id}:`, err);
110
+ }
111
+ }
112
+ };
81
113
 
114
+ // discord.js gives a method; a user serialised with toJSON() carries the URL itself.
115
+ const avatarOf = (subject, size) => typeof subject?.displayAvatarURL === 'function'
116
+ ? subject.displayAvatarURL({ size, forceStatic: true })
117
+ : (typeof subject?.displayAvatarURL === 'string' ? subject.displayAvatarURL : undefined);
118
+
119
+ const buildUser = (id, member, author) => {
82
120
  // Compute member roles first so we can derive the name color from the
83
121
  // highest *listed* role (top of the role list), matching how Discord
84
122
  // colors usernames. Falls back to displayHexColor (highest *colored*
@@ -103,9 +141,9 @@ async function buildAllContext(messages, channel) {
103
141
  // whose hash still carries the `a_` prefix). The static variant (.webp
104
142
  // or .png) always works, so render avatars as static in transcripts.
105
143
  // Avatars in a historical archive don't gain much from being animated.
106
- profiles[id] = {
144
+ const profile = {
107
145
  author: member?.nickname ?? author.displayName ?? author.username,
108
- avatar: member?.displayAvatarURL?.({ size: 64, forceStatic: true }) ?? author.displayAvatarURL?.({ size: 64, forceStatic: true }),
146
+ avatar: avatarOf(member, 64) ?? avatarOf(author, 64),
109
147
  roleColor: effectiveColor,
110
148
  roleIcon: member?.roles?.icon?.iconURL?.() ?? undefined,
111
149
  roleName: member?.roles?.hoist?.name ?? undefined,
@@ -125,19 +163,19 @@ async function buildAllContext(messages, channel) {
125
163
  }
126
164
  }
127
165
 
128
- users[id] = {
166
+ const user = {
129
167
  id,
130
168
  username: author.username,
131
169
  displayName: member?.nickname || author.displayName || author.username,
132
170
  globalName: author.globalName || null,
133
- avatar: member?.displayAvatarURL?.({ size: 128, forceStatic: true }) || author.displayAvatarURL?.({ size: 128, forceStatic: true }) || null,
171
+ avatar: avatarOf(member, 128) || avatarOf(author, 128) || null,
134
172
  bannerColor: effectiveColor,
135
173
  bot: !!author.bot,
136
174
  system: !!author.system,
137
175
  verifiedBot: flagNames.includes('VerifiedBot'),
138
176
  flags: flagNames,
139
- joinedAt: member?.joinedAt ? member.joinedAt.toISOString() : null,
140
- createdAt: author.createdAt ? author.createdAt.toISOString() : null,
177
+ joinedAt: isoDate(member?.joinedAt),
178
+ createdAt: isoDate(author.createdAt),
141
179
  roles: memberRoles.map((r) => ({
142
180
  id: r.id,
143
181
  name: r.name,
@@ -148,9 +186,12 @@ async function buildAllContext(messages, channel) {
148
186
  ? { name: memberRoles[0].name, color: memberRoles[0].hexColor }
149
187
  : null,
150
188
  };
189
+ // Both at once, so a failure above never leaves half an entry behind.
190
+ profiles[id] = profile;
191
+ users[id] = user;
151
192
  };
152
193
 
153
- for (const message of messages) {
194
+ const collect = (message) => {
154
195
  if (message.author) includeUser(message.member, message.author);
155
196
  if (message.interaction?.user) {
156
197
  // FIX: try to find a member object for this user from later messages (interaction user may have authored their own messages elsewhere).
@@ -174,11 +215,28 @@ async function buildAllContext(messages, channel) {
174
215
  for (const [cid] of message.mentions.channels) includeChannelFromGuild(cid);
175
216
  }
176
217
  }
177
- // also include authors of forwarded snapshots
178
- if (Array.isArray(message.messageSnapshots)) {
179
- for (const snap of message.messageSnapshots) {
180
- if (snap?.author) includeUser(null, snap.author);
181
- }
218
+ // Authors of forwarded snapshots — from plain-object input only, exactly
219
+ // as the renderer names them. Discord hides who wrote a forward, and a
220
+ // discord.js snapshot of a message the bot has cached is that cached
221
+ // original, author included (see renderSnapshots).
222
+ // messageSnapshots is a discord.js Collection, not an array.
223
+ const snaps = message.messageSnapshots;
224
+ const snapList = !snaps ? [] : Array.isArray(snaps) ? snaps : typeof snaps.values === 'function' ? Array.from(snaps.values()) : [];
225
+ for (const snap of snapList) {
226
+ // Raw payloads nest the snapshot under `message`, like the renderer reads it.
227
+ const snapMessage = snap?.message || snap;
228
+ if (!snapMessage || (0, utils_1.isLibraryStructure)(snapMessage)) continue;
229
+ includeUser(null, snapMessage.author);
230
+ }
231
+ };
232
+
233
+ for (const message of messages) {
234
+ try {
235
+ collect(message);
236
+ }
237
+ catch (err) {
238
+ // A malformed message costs its own entries, not every profile.
239
+ console.warn('[discord-html-transcripts-fix] could not collect the profiles of message', message?.id, err);
182
240
  }
183
241
  }
184
242