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.
- package/README.md +79 -25
- package/dist/generator/index.js +32 -22
- package/dist/generator/renderers/attachment.js +15 -3
- package/dist/generator/renderers/components/DiscordHighlightedCode.js +30 -4
- package/dist/generator/renderers/components/Media Gallery.js +2 -1
- package/dist/generator/renderers/components/Select Menu.js +8 -2
- package/dist/generator/renderers/components/TranscriptHeader.js +22 -2
- package/dist/generator/renderers/components.js +6 -3
- package/dist/generator/renderers/content.js +45 -10
- package/dist/generator/renderers/embed.js +24 -9
- package/dist/generator/renderers/message.js +76 -28
- package/dist/generator/renderers/reply.js +18 -5
- package/dist/generator/renderers/systemMessage.js +20 -5
- package/dist/index.js +7 -0
- package/dist/static/client.js +10 -0
- package/dist/types.d.ts +19 -10
- package/dist/utils/buildProfiles.js +75 -17
- package/dist/utils/selfContained.js +402 -50
- package/dist/utils/utils.js +107 -2
- package/package.json +8 -3
|
@@ -16,17 +16,28 @@ function t(context, key, fallback) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
async function DiscordEmbed({ embed, context }) {
|
|
19
|
-
|
|
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
|
-
|
|
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 =
|
|
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 ||
|
|
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 ||
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
216
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
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
|
});
|
package/dist/static/client.js
CHANGED
|
@@ -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
|
-
*
|
|
57
|
-
*
|
|
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
|
|
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 +
|
|
85
|
-
* per process). Discord's own CDN
|
|
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
|
-
*
|
|
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';
|