discord-html-transcripts-fix 1.3.0 → 1.6.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.
@@ -134,7 +134,6 @@ async function render(_a) {
134
134
  (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.searchBarStyles } }),
135
135
  (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.lightboxStyles } }),
136
136
  (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.tocStyles } }),
137
- (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.i18nStyles } }),
138
137
  (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.dateSeparatorStyles } }),
139
138
  (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.markdownStyles } }),
140
139
  (0, jsx_runtime_1.jsx)("style", { dangerouslySetInnerHTML: { __html: client_1.filterPanelStyles } }),
@@ -155,16 +154,13 @@ async function render(_a) {
155
154
  ] }),
156
155
  (0, jsx_runtime_1.jsxs)("body", { style: { margin: 0, minHeight: '100vh' }, children: [
157
156
  (0, jsx_runtime_1.jsx)(transcript_1.default, Object.assign({ messages, channel, callbacks }, options, renderContext)),
158
- (0, jsx_runtime_1.jsx)(StatsFooter, { stats, lang, i18n }),
157
+ renderStatsFooter(stats, lang, i18n, options.statsFooter),
159
158
  (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.mentionPopup } }),
160
- (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.backToTop } }),
161
159
  (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.searchBar } }),
162
160
  (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.lightbox } }),
163
161
  (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.toc } }),
164
- (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.i18nSwitcher } }),
165
162
  (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.filterPanel } }),
166
163
  (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.codeCopyButton } }),
167
- (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.permalinkButton } }),
168
164
  options.hydrate && (0, jsx_runtime_1.jsx)("script", { dangerouslySetInnerHTML: { __html: client_1.revealSpoiler } }),
169
165
  ] })
170
166
  ] });
@@ -200,27 +196,63 @@ function formatSpan(ms, lang) {
200
196
  if (mins > 0) return `${mins}m`;
201
197
  return '<1m';
202
198
  }
203
- function StatsFooter({ stats, lang, i18n }) {
204
- const dict = (i18n && i18n[lang]) || (i18n && i18n.en) || {};
199
+ // Renders the bottom stats footer. Caller-controlled via options.statsFooter:
200
+ // false → no footer
201
+ // { enabled: false } → no footer
202
+ // { template: '{messages} ...' } → render with custom template
203
+ // Placeholders: {messages} {participants}
204
+ // {images} {from} {to} {span}
205
+ // Default: en template "X messages · Y participants · Z images · from → to · span"
206
+ function renderStatsFooter(stats, lang, i18n, opts) {
207
+ if (opts === false) return null;
208
+ const config = (opts && typeof opts === 'object') ? opts : {};
209
+ if (config.enabled === false) return null;
210
+
205
211
  const locale = lang === 'de' ? 'de-DE' : 'en-US';
206
- const fmtDate = (ms) => new Date(ms).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' });
207
- return (0, jsx_runtime_1.jsxs)("footer", { className: "dht-stats", children: [
208
- (0, jsx_runtime_1.jsx)("span", { children: (dict.statsMessages || '{n} messages').replace('{n}', stats.messageCount) }),
209
- ' · ',
210
- (0, jsx_runtime_1.jsx)("span", { children: (dict.statsParticipants || '{n} participants').replace('{n}', stats.participantCount) }),
211
- ' · ',
212
- (0, jsx_runtime_1.jsx)("span", { children: (dict.statsImages || '{n} images').replace('{n}', stats.imageCount) }),
213
- stats.firstTs && stats.lastTs && (0, jsx_runtime_1.jsxs)("span", { children: [' · ', fmtDate(stats.firstTs), ' → ', fmtDate(stats.lastTs)] }),
214
- stats.spanMs && (0, jsx_runtime_1.jsxs)("span", { children: [' · ', formatSpan(stats.spanMs)] }),
215
- ] });
212
+ const fmtDate = (ms) => ms ? new Date(ms).toLocaleString(locale, { dateStyle: 'medium', timeStyle: 'short' }) : '';
213
+ const span = stats.spanMs ? formatSpan(stats.spanMs) : '';
214
+
215
+ const values = {
216
+ messages: String(stats.messageCount),
217
+ participants: String(stats.participantCount),
218
+ images: String(stats.imageCount),
219
+ from: fmtDate(stats.firstTs),
220
+ to: fmtDate(stats.lastTs),
221
+ span,
222
+ };
223
+
224
+ if (typeof config.template === 'string' && config.template.length > 0) {
225
+ const rendered = config.template.replace(/\{(messages|participants|images|from|to|span)\}/g, (_, k) => values[k] || '');
226
+ return (0, jsx_runtime_1.jsx)("footer", { className: "dht-stats", children: rendered });
227
+ }
228
+
229
+ const dict = (i18n && i18n[lang]) || (i18n && i18n.en) || {};
230
+ const parts = [
231
+ (dict.statsMessages || '{n} messages').replace('{n}', values.messages),
232
+ (dict.statsParticipants || '{n} participants').replace('{n}', values.participants),
233
+ (dict.statsImages || '{n} images').replace('{n}', values.images),
234
+ ];
235
+ if (values.from && values.to) parts.push(values.from + ' → ' + values.to);
236
+ if (values.span) parts.push(values.span);
237
+ return (0, jsx_runtime_1.jsx)("footer", { className: "dht-stats", children: parts.join(' · ') });
216
238
  }
217
239
 
218
240
  const extraStyles = `
219
241
  /* Ensure the host establishes a positioning context for the absolute pin indicator,
220
242
  and clamp it inside the message box on narrow viewports. */
243
+ /* Action-row buttons — Discord-like horizontal spacing */
244
+ discord-action-row{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:8px 0}
245
+ .dht-v2-container discord-action-row,.dht-section-content discord-action-row{margin:6px 0}
246
+ /* V2 container — Discord-like spacing inside block flow */
247
+ .dht-v2-container .dht-heading,.dht-section-content .dht-heading{margin:8px 0 4px}
248
+ .dht-v2-container .dht-heading:first-child,.dht-section-content .dht-heading:first-child{margin-top:0}
249
+ .dht-v2-container .dht-v2-separator,.dht-section-content .dht-v2-separator{margin:8px 0}
250
+ .dht-v2-container strong,.dht-section-content strong{display:inline;font-weight:700;color:#fff}
251
+ .dht-v2-container .dht-mention,.dht-section-content .dht-mention{display:inline-block;vertical-align:baseline}
252
+ /* System-message author — clickable like a user mention */
253
+ .dht-sys-author{cursor:pointer;font-style:normal;font-weight:500}
254
+ .dht-sys-author:hover{text-decoration:underline}
221
255
  .dht-msg-pinned{position:relative}
222
- .dht-pinned-indicator{position:absolute;top:4px;left:4px;font-size:12px;opacity:.75;pointer-events:none}
223
- @media (max-width:640px){.dht-pinned-indicator{position:static;display:inline-block;margin-right:4px}}
224
256
  .dht-app-badge,.dht-popup-badge{background:#5865F2;color:#fff;font-size:10px;padding:1px 6px;border-radius:4px;margin-left:6px;vertical-align:middle;font-weight:600}
225
257
  .dht-edit-marker{color:#949ba4;font-size:11px;margin-left:4px;cursor:help}
226
258
  .dht-suppressed{color:#6e727a;font-size:11px;font-style:italic}
@@ -19,12 +19,17 @@ function DiscordContainer({ children, accentColor, spoiler }) {
19
19
  // string can't smuggle CSS into the inline borderLeft.
20
20
  const raw = colorToHex(accentColor);
21
21
  const hex = raw ? (0, utils_1.safeColor)(raw, null) : null;
22
+ // FIX: previous layout was display:flex;flex-direction:column;gap:8px which
23
+ // forced every direct child — including inline <strong>, <br>, mention
24
+ // <span> and text nodes — to become its own vertical flex item. That broke
25
+ // sentences across multiple lines and made role mentions fill the entire
26
+ // row. Use normal block flow instead so inline content lays out the way
27
+ // Discord renders it.
22
28
  const style = {
23
- display: 'flex',
29
+ display: 'block',
24
30
  width: '100%',
25
31
  maxWidth: '500px',
26
32
  minWidth: 0,
27
- flexDirection: 'column',
28
33
  backgroundColor: '#3f4248',
29
34
  padding: '16px',
30
35
  borderLeft: hex ? `4px solid ${hex}` : '1px solid #4f5359',
@@ -34,10 +39,13 @@ function DiscordContainer({ children, accentColor, spoiler }) {
34
39
  marginTop: '2px',
35
40
  marginBottom: '2px',
36
41
  borderRadius: '10px',
37
- gap: '8px',
38
42
  boxSizing: 'border-box',
43
+ lineHeight: 1.4,
44
+ color: '#dbdee1',
45
+ wordBreak: 'break-word',
46
+ overflowWrap: 'anywhere',
39
47
  };
40
- const node = (0, jsx_runtime_1.jsx)("div", { style, children });
48
+ const node = (0, jsx_runtime_1.jsx)("div", { className: "dht-v2-container", style, children });
41
49
  if (spoiler) {
42
50
  return (0, jsx_runtime_1.jsx)("span", { className: "discord-spoiler", children: node });
43
51
  }
@@ -3,12 +3,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  const jsx_runtime_1 = require("react/jsx-runtime");
4
4
  function SectionContent({ children }) {
5
5
  return ((0, jsx_runtime_1.jsx)("div", {
6
+ className: "dht-section-content",
6
7
  style: {
7
- display: 'flex',
8
- flexDirection: 'column',
8
+ // Block flow so inline content (mentions, strong, br, text) flows
9
+ // naturally inside the section instead of becoming flex rows.
10
+ display: 'block',
9
11
  flex: 1,
10
- minWidth: 0, // allow text to shrink/wrap inside flex parent
12
+ minWidth: 0,
11
13
  overflowWrap: 'anywhere',
14
+ wordBreak: 'break-word',
15
+ lineHeight: 1.4,
12
16
  },
13
17
  children
14
18
  }));
@@ -195,62 +195,61 @@ async function renderASTNode(node, context) {
195
195
  const id = node.id;
196
196
  if (typeof id === 'string' && /^[a-z][a-z-]*$/.test(id)) {
197
197
  const label = GUIDE_LABELS[id] || id;
198
- return (0, jsx_runtime_1.jsxs)("span", { className: "dht-mention dht-mention--channel", children: ["#", label] });
198
+ return (0, jsx_runtime_1.jsx)("span", { className: "dht-mention dht-mention--channel", children: '#' + label });
199
199
  }
200
- const channel = await context.callbacks.resolveChannel(id).catch(() => null);
201
- const name = channel ? (channel.isDMBased() ? 'DM Channel' : channel.name) : `unknown-${id.slice(-6)}`;
202
- return (0, jsx_runtime_1.jsxs)("span", {
200
+ const channel = await Promise.resolve(context.callbacks.resolveChannel(id)).catch(() => null);
201
+ const name = channel ? (channel.isDMBased() ? 'DM Channel' : channel.name) : `unknown-${(id || '').slice(-6)}`;
202
+ return (0, jsx_runtime_1.jsx)("span", {
203
203
  className: "dht-mention dht-mention--channel",
204
204
  "data-mention-type": "channel",
205
205
  "data-mention-id": id,
206
- children: ["#", name]
206
+ children: '#' + name
207
207
  });
208
208
  }
209
209
 
210
210
  case 'role': {
211
211
  const id = node.id;
212
- const role = await context.callbacks.resolveRole(id).catch(() => null);
213
- const name = role?.name ?? `role-${id.slice(-6)}`;
212
+ const role = await Promise.resolve(context.callbacks.resolveRole(id)).catch(() => null);
213
+ const name = role?.name ?? `role-${(id || '').slice(-6)}`;
214
214
  const color = role?.hexColor && role.hexColor !== '#000000' ? (0, utils_1.safeColor)(role.hexColor, undefined) : undefined;
215
215
  const bg = color ? hexToRgba(color, 0.18) : undefined;
216
216
  const style = color ? { color, backgroundColor: bg, borderColor: hexToRgba(color, 0.45) } : undefined;
217
- return (0, jsx_runtime_1.jsxs)("span", {
217
+ return (0, jsx_runtime_1.jsx)("span", {
218
218
  className: "dht-mention dht-mention--role",
219
219
  "data-mention-type": "role",
220
220
  "data-mention-id": id,
221
221
  style,
222
- children: ["@", name]
222
+ children: '@' + name
223
223
  });
224
224
  }
225
225
 
226
226
  case 'user': {
227
227
  const id = node.id;
228
- const user = await context.callbacks.resolveUser(id).catch(() => null);
229
- const name = user ? (user.displayName ?? user.username ?? `user-${id.slice(-6)}`) : `user-${id.slice(-6)}`;
230
- return (0, jsx_runtime_1.jsxs)("span", {
228
+ const user = await Promise.resolve(context.callbacks.resolveUser(id)).catch(() => null);
229
+ const name = user ? (user.displayName ?? user.username ?? `user-${(id || '').slice(-6)}`) : `user-${(id || '').slice(-6)}`;
230
+ return (0, jsx_runtime_1.jsx)("span", {
231
231
  className: "dht-mention dht-mention--user",
232
232
  "data-mention-type": "user",
233
233
  "data-mention-id": id,
234
- children: ["@", name]
234
+ children: '@' + name
235
235
  });
236
236
  }
237
237
 
238
238
  case 'slashCommand':
239
239
  case 'command': {
240
240
  const name = node.name || node.content || 'command';
241
- return (0, jsx_runtime_1.jsxs)("span", { className: "dht-mention dht-mention--command", children: ["/", String(name)] });
241
+ return (0, jsx_runtime_1.jsx)("span", { className: "dht-mention dht-mention--command", children: '/' + String(name) });
242
242
  }
243
243
 
244
244
  case 'guildNavigation': {
245
- // discord-markdown-parser emits this for <id:guide> etc.
246
245
  const id = node.id || node.content || 'guide';
247
246
  const label = GUIDE_LABELS[id] || id;
248
- return (0, jsx_runtime_1.jsxs)("span", { className: "dht-mention dht-mention--channel", children: ["#", label] });
247
+ return (0, jsx_runtime_1.jsx)("span", { className: "dht-mention dht-mention--channel", children: '#' + label });
249
248
  }
250
249
 
251
250
  case 'here':
252
251
  case 'everyone':
253
- return (0, jsx_runtime_1.jsxs)("span", { className: "dht-mention dht-mention--role dht-mention--highlight", children: ["@", type] });
252
+ return (0, jsx_runtime_1.jsx)("span", { className: "dht-mention dht-mention--role dht-mention--highlight", children: '@' + type });
254
253
 
255
254
  case 'codeBlock':
256
255
  if (context.type !== RenderType.REPLY) {
@@ -80,9 +80,11 @@ async function DiscordMessage({ message, context }) {
80
80
  const authorName = (message.member?.nickname || message.author?.displayName || message.author?.username || '').toLowerCase();
81
81
  const ts = message.createdAt instanceof Date ? message.createdAt.getTime() : 0;
82
82
  const lowerText = (message.content || '').toLowerCase().slice(0, 4096);
83
- const hasImage = message.attachments && Array.from(message.attachments.values()).some(a => typeof a.contentType === 'string' && a.contentType.startsWith('image/'));
84
- const hasEmbed = message.embeds && message.embeds.length > 0;
85
- const hasAttachment = message.attachments && message.attachments.size > 0;
83
+ const mediaFlags = detectMediaFlags(message);
84
+ const hasImage = mediaFlags.hasImage;
85
+ const hasEmbed = mediaFlags.hasEmbed;
86
+ const hasAttachment = mediaFlags.hasAttachment;
87
+ const hasComponentV2 = mediaFlags.hasComponentV2;
86
88
  const stickers = message.stickers && message.stickers.size > 0
87
89
  ? Array.from(message.stickers.values()).map((s) => ({
88
90
  id: s.id,
@@ -107,11 +109,8 @@ async function DiscordMessage({ message, context }) {
107
109
  // Forwarded message snapshots (messageSnapshots) — render before the empty body
108
110
  const snapshots = Array.isArray(message.messageSnapshots) ? message.messageSnapshots : [];
109
111
 
110
- const isApp = !!(message.author?.bot && message.applicationId);
111
-
112
112
  const editedAtIso = message.editedAt instanceof Date ? message.editedAt.toISOString() : null;
113
113
  const pinned = !!message.pinned;
114
- const verifiedBot = !!message.author?.flags?.has?.(discord_js_1.UserFlags.VerifiedBot);
115
114
 
116
115
  // Forum applied tags — only on the first message of a forum thread (the post itself)
117
116
  let appliedTagPills = null;
@@ -134,15 +133,10 @@ async function DiscordMessage({ message, context }) {
134
133
  }
135
134
  }
136
135
 
137
- // Build a comma-separated badge list for additional flag rendering
138
- const slashCmdLine = renderSlashCommandLine(message, context);
139
-
140
- // Resolve author profile data for Skyra slot attributes — without these the
141
- // <discord-message> web component falls back to a generic display
142
- const authorMember = message.member;
143
- const authorDisplay = authorMember?.nickname ?? message.author?.displayName ?? message.author?.username;
144
- const authorAvatar = authorMember?.displayAvatarURL?.({ size: 64 }) ?? message.author?.displayAvatarURL?.({ size: 64 });
145
- const authorRoleHex = authorMember?.displayHexColor && authorMember.displayHexColor !== '#000000' ? authorMember.displayHexColor : undefined;
136
+ // Slash command interaction data is attached to <discord-command> as
137
+ // data attributes so the popup can show parameters Discord-style. We
138
+ // no longer render a separate "/cmd opt:val by user" line.
139
+ const slashData = buildSlashCommandData(message);
146
140
 
147
141
  return ((0, jsx_runtime_1.jsxs)("discord-message", {
148
142
  id: `m-${message.id}`,
@@ -151,11 +145,6 @@ async function DiscordMessage({ message, context }) {
151
145
  server: (isCrossGuildReply || isCrosspost || isCrossposted) ? true : undefined,
152
146
  highlight: message.mentions.everyone || pinned,
153
147
  profile: message.author.id,
154
- author: authorDisplay,
155
- avatar: authorAvatar,
156
- roleColor: authorRoleHex,
157
- bot: message.author?.bot && !message.webhookId ? true : undefined,
158
- verified: verifiedBot ? true : undefined,
159
148
  className: pinned ? 'dht-msg dht-msg-pinned' : 'dht-msg',
160
149
  "data-author-id": message.author.id,
161
150
  "data-author-name": authorName,
@@ -166,13 +155,20 @@ async function DiscordMessage({ message, context }) {
166
155
  "data-has-image": hasImage ? 'true' : undefined,
167
156
  "data-has-embed": hasEmbed ? 'true' : undefined,
168
157
  "data-has-attachment": hasAttachment ? 'true' : undefined,
158
+ "data-has-component-v2": hasComponentV2 ? 'true' : undefined,
169
159
  children: [
170
- pinned && (0, jsx_runtime_1.jsx)("div", { className: "dht-pinned-indicator", title: "Pinned message", children: "📌" }),
171
160
  appliedTagPills,
172
161
  (0, jsx_runtime_1.jsx)(reply_1.default, { message: message, context: context }),
173
- message.interaction && ((0, jsx_runtime_1.jsx)("discord-command", { slot: "reply", profile: message.interaction.user.id, command: '/' + message.interaction.commandName })),
174
- slashCmdLine,
175
- isApp && (0, jsx_runtime_1.jsxs)("span", { className: "dht-app-badge", "data-i18n": "appBadge", children: [t(context, 'appBadge', 'APP'), verifiedBot && (0, jsx_runtime_1.jsx)("span", { className: "dht-verified-check", title: "Verified Bot", children: '✓' })] }),
162
+ message.interaction && ((0, jsx_runtime_1.jsx)("discord-command", {
163
+ slot: "reply",
164
+ profile: message.interaction.user.id,
165
+ command: (slashData && slashData.cmd) || ('/' + message.interaction.commandName),
166
+ className: "dht-slash-clickable",
167
+ "data-slash-cmd": slashData ? slashData.cmd : ('/' + message.interaction.commandName),
168
+ "data-slash-by": slashData ? slashData.by : '',
169
+ "data-slash-by-id": slashData ? slashData.byId : message.interaction.user.id,
170
+ "data-slash-options": JSON.stringify((slashData && slashData.opts) || []),
171
+ })),
176
172
  isCrossposted && (0, jsx_runtime_1.jsx)("span", { className: "dht-badge dht-badge-crosspost", title: "Published from announcement channel", children: '📣 Published' }),
177
173
  isCrosspost && (0, jsx_runtime_1.jsx)("span", { className: "dht-badge dht-badge-crosspost", title: "Follows another channel", children: '📡 Follow' }),
178
174
  isSilent && (0, jsx_runtime_1.jsx)("span", { className: "dht-badge dht-badge-silent", title: "Silent message (no notification)", children: '🔕 Silent' }),
@@ -389,40 +385,108 @@ function renderSnapshots(snapshots, context, depth = 0) {
389
385
  });
390
386
  }
391
387
 
392
- function renderSlashCommandLine(message, context) {
388
+ const COMPONENT_TYPE_ACTION_ROW = 1;
389
+ const COMPONENT_TYPE_SECTION = 9;
390
+ const COMPONENT_TYPE_THUMBNAIL = 11;
391
+ const COMPONENT_TYPE_MEDIA_GALLERY = 12;
392
+ const COMPONENT_TYPE_FILE = 13;
393
+ const COMPONENT_TYPE_CONTAINER = 17;
394
+ const FLAG_IS_COMPONENTS_V2 = 1 << 15;
395
+
396
+ // Walks message.embeds and message.components to determine whether the message
397
+ // carries media or V2 containers — used to drive the filter checkboxes
398
+ // (data-has-image, data-has-attachment, data-has-embed, data-has-component-v2).
399
+ function detectMediaFlags(message) {
400
+ const out = { hasImage: false, hasAttachment: false, hasEmbed: false, hasComponentV2: false };
401
+
402
+ // Direct attachments
403
+ if (message.attachments && typeof message.attachments.values === 'function') {
404
+ for (const a of message.attachments.values()) {
405
+ out.hasAttachment = true;
406
+ const ct = typeof a.contentType === 'string' ? a.contentType : '';
407
+ if (ct.startsWith('image/') || ct.startsWith('video/')) out.hasImage = true;
408
+ }
409
+ }
410
+
411
+ // Classic embeds
412
+ if (Array.isArray(message.embeds)) {
413
+ for (const e of message.embeds) {
414
+ if (!e) continue;
415
+ out.hasEmbed = true;
416
+ if (e.image || e.thumbnail) out.hasImage = true;
417
+ if (e.video && (e.video.url || e.video.proxyURL || e.video.proxy_url)) out.hasImage = true;
418
+ }
419
+ }
420
+
421
+ // Components V2 — message-level flag and recursive walk
422
+ const flags = (typeof message.flags?.bitfield === 'number') ? message.flags.bitfield : 0;
423
+ if (flags & FLAG_IS_COMPONENTS_V2) out.hasComponentV2 = true;
424
+
425
+ const walk = (comp) => {
426
+ if (!comp || typeof comp.type !== 'number') return;
427
+ switch (comp.type) {
428
+ case COMPONENT_TYPE_CONTAINER:
429
+ out.hasComponentV2 = true;
430
+ if (Array.isArray(comp.components)) for (const c of comp.components) walk(c);
431
+ break;
432
+ case COMPONENT_TYPE_SECTION:
433
+ if (Array.isArray(comp.components)) for (const c of comp.components) walk(c);
434
+ if (comp.accessory) walk(comp.accessory);
435
+ break;
436
+ case COMPONENT_TYPE_ACTION_ROW:
437
+ if (Array.isArray(comp.components)) for (const c of comp.components) walk(c);
438
+ break;
439
+ case COMPONENT_TYPE_MEDIA_GALLERY:
440
+ if (Array.isArray(comp.items) && comp.items.length > 0) out.hasImage = true;
441
+ break;
442
+ case COMPONENT_TYPE_THUMBNAIL:
443
+ if (comp.media && (comp.media.url || comp.media.proxy_url || comp.media.proxyURL)) out.hasImage = true;
444
+ break;
445
+ case COMPONENT_TYPE_FILE:
446
+ out.hasAttachment = true;
447
+ if (comp.file && typeof comp.file.url === 'string' && /\.(png|jpe?g|gif|webp|bmp|svg|mp4|webm|mov)(\?|$)/i.test(comp.file.url)) out.hasImage = true;
448
+ break;
449
+ default:
450
+ if (Array.isArray(comp.components)) for (const c of comp.components) walk(c);
451
+ break;
452
+ }
453
+ };
454
+ if (Array.isArray(message.components)) for (const c of message.components) walk(c);
455
+
456
+ return out;
457
+ }
458
+
459
+ function buildSlashCommandData(message) {
393
460
  const interaction = message?.interaction;
394
461
  const metadata = message?.interactionMetadata;
395
462
  const optsRaw = message?.interactionOptions || interaction?.options;
396
- // Build command path: /cmd group? subcommand? plus key:value options
397
- if (!optsRaw && !metadata) return null;
463
+ if (!interaction && !metadata) return null;
398
464
 
399
- const opts = Array.isArray(optsRaw) ? optsRaw : [];
400
- let cmdParts = [interaction?.commandName || '?'];
401
- let valueOpts = opts;
465
+ let cmdParts = [interaction?.commandName || metadata?.name || '?'];
466
+ let valueOpts = Array.isArray(optsRaw) ? optsRaw : [];
402
467
 
403
- // Detect subcommand groups: nested option structure { type:2/1 (group/subcommand), name, options }
468
+ // Walk subcommand-group → subcommand nesting.
404
469
  for (let i = 0; i < 2; i++) {
405
470
  if (valueOpts.length === 1 && valueOpts[0] && (valueOpts[0].type === 1 || valueOpts[0].type === 2)) {
406
471
  cmdParts.push(valueOpts[0].name);
407
472
  valueOpts = Array.isArray(valueOpts[0].options) ? valueOpts[0].options : [];
408
473
  }
409
474
  }
410
- // Filter out autocomplete-only `focused` options
411
475
  valueOpts = valueOpts.filter((o) => o && !o.focused);
412
476
 
413
- if (!valueOpts.length && cmdParts.length === 1 && !metadata) return null;
414
-
415
- const triggeredBy = metadata?.user || interaction?.user;
477
+ const triggeredBy = metadata?.user || interaction?.user || null;
478
+ const opts = valueOpts.map((o) => ({
479
+ name: String(o.name ?? ''),
480
+ value: o.value == null ? '' : String(o.value),
481
+ type: typeof o.type === 'number' ? o.type : null,
482
+ }));
416
483
 
417
- return (0, jsx_runtime_1.jsxs)("div", { className: "dht-slash-options", children: [
418
- (0, jsx_runtime_1.jsx)("span", { className: "dht-slash-cmd-name", children: '/' + cmdParts.filter(Boolean).join(' ') }),
419
- valueOpts.map((o, i) => (0, jsx_runtime_1.jsxs)("span", { className: "dht-slash-opt", children: [
420
- (0, jsx_runtime_1.jsxs)("span", { className: "dht-slash-opt-name", children: [String(o.name), ':'] }),
421
- ' ',
422
- (0, jsx_runtime_1.jsx)("span", { className: "dht-slash-opt-val", children: String(o.value ?? '') })
423
- ] }, i)),
424
- triggeredBy && (0, jsx_runtime_1.jsxs)("span", { className: "dht-slash-opt", children: ['by ', (0, jsx_runtime_1.jsx)("strong", { children: triggeredBy.displayName || triggeredBy.username || triggeredBy.id })] })
425
- ] });
484
+ return {
485
+ cmd: '/' + cmdParts.filter(Boolean).join(' '),
486
+ opts,
487
+ by: triggeredBy ? (triggeredBy.displayName || triggeredBy.username || triggeredBy.id || '') : '',
488
+ byId: triggeredBy ? (triggeredBy.id || '') : '',
489
+ };
426
490
  }
427
491
 
428
492
  function renderEditHistory(history, context) {