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.
- package/README.md +96 -30
- package/dist/generator/index.js +44 -57
- package/dist/generator/renderers/attachment.js +15 -3
- 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.js +6 -3
- package/dist/generator/renderers/content.js +1 -1
- package/dist/generator/renderers/embed.js +24 -9
- package/dist/generator/renderers/message.js +73 -25
- package/dist/generator/renderers/reply.js +18 -5
- package/dist/generator/renderers/systemMessage.js +19 -4
- package/dist/index.js +7 -0
- package/dist/static/client.js +10 -0
- package/dist/types.d.ts +36 -4
- package/dist/utils/buildProfiles.js +75 -17
- package/dist/utils/selfContained.js +545 -0
- package/dist/utils/utils.js +96 -2
- package/package.json +8 -5
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
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
|
}
|
|
@@ -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:
|
|
@@ -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
|
/**
|
|
@@ -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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
144
|
+
const profile = {
|
|
107
145
|
author: member?.nickname ?? author.displayName ?? author.username,
|
|
108
|
-
avatar: member
|
|
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
|
-
|
|
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
|
|
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
|
|
140
|
-
createdAt: author.createdAt
|
|
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
|
-
|
|
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
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|