From 95eed45ec4ca15ad5a98c52374fcde221ee29fb1 Mon Sep 17 00:00:00 2001 From: TheOnlyMace <0815cracky@gmail.com> Date: Wed, 22 Jul 2026 22:30:46 +0200 Subject: [PATCH] Refactor embed handling and enhance user experience across components - Integrated the new `buildEmbedFromPayload` function in Scheduler, Tags, and Welcome components to streamline embed creation and management. - Updated validation schemas to require embed content checks, ensuring robust data integrity for embeds. - Enhanced localization files to include new placeholder descriptions and improve user guidance for embed fields. - Refactored embed parsing logic to utilize the `WelcomeEmbedSchema`, improving consistency and validation across the application. --- apps/bot/src/lib/embed-payload.ts | 89 +++++++ apps/bot/src/modules/scheduler/service.ts | 21 +- apps/bot/src/modules/tags/service.ts | 29 +- apps/bot/src/modules/welcome/renderer.ts | 20 +- apps/bot/src/modules/welcome/service.ts | 4 +- .../components/modules/scheduler-manager.tsx | 11 +- .../src/components/modules/tags-manager.tsx | 6 +- .../src/components/modules/welcome-form.tsx | 2 + .../components/ui/discord-embed-builder.tsx | 249 +++++++++++++++--- .../webui/src/lib/module-configs/scheduler.ts | 20 +- apps/webui/src/lib/module-configs/tags.ts | 21 +- apps/webui/src/lib/module-configs/welcome.ts | 20 +- apps/webui/src/lib/placeholder-presets.ts | 4 + apps/webui/src/messages/de.json | 21 +- apps/webui/src/messages/en.json | 21 +- docs/PHASE-TRACKING.md | 16 +- packages/shared/src/dashboard.ts | 12 +- packages/shared/src/phase2.test.ts | 13 + packages/shared/src/phase2.ts | 67 ++++- 19 files changed, 500 insertions(+), 146 deletions(-) create mode 100644 apps/bot/src/lib/embed-payload.ts diff --git a/apps/bot/src/lib/embed-payload.ts b/apps/bot/src/lib/embed-payload.ts new file mode 100644 index 0000000..4160743 --- /dev/null +++ b/apps/bot/src/lib/embed-payload.ts @@ -0,0 +1,89 @@ +import { EmbedBuilder } from 'discord.js'; +import { + embedHasContent, + mapEmbedTextFields, + type WelcomeEmbed +} from '@nexumi/shared'; + +function isHttpUrl(value: string | undefined): value is string { + if (!value) { + return false; + } + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +export interface ApplyEmbedOptions { + /** Called for every text/URL field before applying to the builder. */ + renderText: (value: string) => string; + /** + * Used when `thumbnailUrl` is unset (Welcome default: member avatar). + * Pass `null` to force no thumbnail. + */ + defaultThumbnailUrl?: string | null; +} + +/** + * Builds a discord.js EmbedBuilder from our shared WelcomeEmbed payload. + * Invalid/non-http URLs are skipped so bad dashboard input never crashes send. + */ +export function buildEmbedFromPayload( + raw: WelcomeEmbed | null | undefined, + options: ApplyEmbedOptions +): EmbedBuilder | null { + if (!raw || (!embedHasContent(raw) && raw.color === undefined)) { + return null; + } + + const data = mapEmbedTextFields(raw, options.renderText); + const embed = new EmbedBuilder(); + + if (data.color !== undefined) { + embed.setColor(data.color); + } + if (data.title) { + embed.setTitle(data.title); + } + if (data.description) { + embed.setDescription(data.description); + } + if (isHttpUrl(data.url)) { + embed.setURL(data.url); + } + if (data.authorName) { + embed.setAuthor({ + name: data.authorName, + iconURL: isHttpUrl(data.authorIconUrl) ? data.authorIconUrl : undefined, + url: isHttpUrl(data.authorUrl) ? data.authorUrl : undefined + }); + } + + const thumbnail = + isHttpUrl(data.thumbnailUrl) + ? data.thumbnailUrl + : options.defaultThumbnailUrl === null + ? undefined + : options.defaultThumbnailUrl; + if (isHttpUrl(thumbnail)) { + embed.setThumbnail(thumbnail); + } + + if (isHttpUrl(data.imageUrl)) { + embed.setImage(data.imageUrl); + } + if (data.footerText) { + embed.setFooter({ + text: data.footerText, + iconURL: isHttpUrl(data.footerIconUrl) ? data.footerIconUrl : undefined + }); + } + if (data.timestamp) { + embed.setTimestamp(new Date()); + } + + return embed; +} diff --git a/apps/bot/src/modules/scheduler/service.ts b/apps/bot/src/modules/scheduler/service.ts index 9ed8d00..5b1bb57 100644 --- a/apps/bot/src/modules/scheduler/service.ts +++ b/apps/bot/src/modules/scheduler/service.ts @@ -1,5 +1,4 @@ import { - EmbedBuilder, PermissionFlagsBits, type GuildTextBasedChannel, type MessageCreateOptions, @@ -8,6 +7,7 @@ import { import { WelcomeEmbedSchema, expandBracketChannelMentions, t, tf } from '@nexumi/shared'; import type { ScheduledMessage } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; +import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { logger } from '../../logger.js'; import { scheduleQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; @@ -94,18 +94,9 @@ export function buildAnnouncementPayload(schedule: { rolePingId: string | null; }): MessageCreateOptions { const embedData = parseStoredEmbed(schedule.embed); - const embeds: EmbedBuilder[] = []; - - if (embedData) { - const embed = new EmbedBuilder().setColor(embedData.color ?? 0x6366f1); - if (embedData.title) { - embed.setTitle(expandBracketChannelMentions(embedData.title)); - } - if (embedData.description) { - embed.setDescription(expandBracketChannelMentions(embedData.description)); - } - embeds.push(embed); - } + const embed = buildEmbedFromPayload(embedData, { + renderText: expandBracketChannelMentions + }); const baseContent = expandBracketChannelMentions(schedule.content?.trim() ?? ''); const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : ''; @@ -115,8 +106,8 @@ export function buildAnnouncementPayload(schedule: { if (content) { payload.content = content; } - if (embeds.length > 0) { - payload.embeds = embeds; + if (embed) { + payload.embeds = [embed]; } if (schedule.rolePingId) { payload.allowedMentions = { roles: [schedule.rolePingId] }; diff --git a/apps/bot/src/modules/tags/service.ts b/apps/bot/src/modules/tags/service.ts index e900ab9..13e708e 100644 --- a/apps/bot/src/modules/tags/service.ts +++ b/apps/bot/src/modules/tags/service.ts @@ -1,12 +1,15 @@ -import { EmbedBuilder, type Guild, type GuildMember } from 'discord.js'; +import type { EmbedBuilder, Guild, GuildMember } from 'discord.js'; import { applyTagPlaceholders, + embedHasContent, TagResponseTypeSchema, WelcomeEmbedSchema, - type TagResponseType + type TagResponseType, + type WelcomeEmbed } from '@nexumi/shared'; import type { Tag } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; +import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import type { BotContext } from '../../types.js'; import { assertPremiumLimit, PremiumLimitError } from '../../premium.js'; @@ -50,7 +53,9 @@ export function buildTagPlaceholderVars( 'user.name': member?.displayName ?? '', 'user.tag': member?.user.tag ?? '', 'user.id': member?.id ?? '', + 'user.avatar': member?.user.displayAvatarURL({ size: 256 }) ?? '', server: guild.name, + 'server.icon': guild.iconURL({ size: 128 }) ?? '', args }; } @@ -81,14 +86,10 @@ export function buildTagReply( if (responseType === 'EMBED') { const embedData = parseTagEmbed(tag.embed); - const embed = new EmbedBuilder().setColor(embedData?.color ?? 0x6366f1); - if (embedData?.title) { - embed.setTitle(renderTagContent(embedData.title, vars)); - } - if (embedData?.description) { - embed.setDescription(renderTagContent(embedData.description, vars)); - } - return { embeds: [embed] }; + const embed = buildEmbedFromPayload(embedData, { + renderText: (value) => renderTagContent(value, vars) + }); + return embed ? { embeds: [embed] } : { content: '—' }; } const content = renderTagContent(tag.content ?? '', vars); @@ -132,7 +133,7 @@ export async function createTag( name: string; responseType: TagResponseType; content?: string | null; - embed?: { title?: string; description?: string; color?: number } | null; + embed?: WelcomeEmbed | null; triggerWord?: string | null; allowedRoleIds?: string[]; allowedChannelIds?: string[]; @@ -150,7 +151,7 @@ export async function createTag( throw new TagError('content_required'); } - if (params.responseType === 'EMBED' && !params.embed?.title && !params.embed?.description) { + if (params.responseType === 'EMBED' && !embedHasContent(params.embed) && params.embed?.color === undefined) { throw new TagError('embed_required'); } @@ -187,7 +188,7 @@ export async function updateTag( newName?: string; responseType?: TagResponseType; content?: string | null; - embed?: { title?: string; description?: string; color?: number } | null; + embed?: WelcomeEmbed | null; triggerWord?: string | null; allowedRoleIds?: string[]; allowedChannelIds?: string[]; @@ -206,7 +207,7 @@ export async function updateTag( throw new TagError('content_required'); } - if (responseType === 'EMBED' && !embed?.title && !embed?.description) { + if (responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) { throw new TagError('embed_required'); } diff --git a/apps/bot/src/modules/welcome/renderer.ts b/apps/bot/src/modules/welcome/renderer.ts index 9e26d6f..6451644 100644 --- a/apps/bot/src/modules/welcome/renderer.ts +++ b/apps/bot/src/modules/welcome/renderer.ts @@ -7,6 +7,7 @@ import { type TextBasedChannel } from 'discord.js'; import { createCanvas, loadImage } from '@napi-rs/canvas'; +import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import type { WelcomePayload } from './service.js'; import { renderWelcomeText } from './service.js'; @@ -16,19 +17,12 @@ export async function buildWelcomeMessage( guild: Guild ): Promise<{ content?: string; embeds?: EmbedBuilder[]; files?: AttachmentBuilder[] }> { if (payload.type === 'EMBED') { - const embed = new EmbedBuilder(); - const data = payload.embed; - if (data?.title) { - embed.setTitle(renderWelcomeText(data.title, member, guild)); - } - if (data?.description) { - embed.setDescription(renderWelcomeText(data.description, member, guild)); - } - if (data?.color !== undefined) { - embed.setColor(data.color); - } - embed.setThumbnail(member.user.displayAvatarURL({ size: 256 })); - return { embeds: [embed] }; + const embed = buildEmbedFromPayload(payload.embed, { + renderText: (value) => renderWelcomeText(value, member, guild), + // Keep previous Welcome behavior when no custom thumbnail is set. + defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 }) + }); + return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) }; } if (payload.type === 'IMAGE') { diff --git a/apps/bot/src/modules/welcome/service.ts b/apps/bot/src/modules/welcome/service.ts index 7ab4e11..60254ca 100644 --- a/apps/bot/src/modules/welcome/service.ts +++ b/apps/bot/src/modules/welcome/service.ts @@ -19,8 +19,10 @@ export function buildPlaceholderVars(member: GuildMember, guild: Guild) { 'user.name': member.displayName, 'user.tag': member.user.tag, 'user.id': member.id, + 'user.avatar': member.user.displayAvatarURL({ size: 256 }), server: guild.name, - memberCount: guild.memberCount + memberCount: guild.memberCount, + 'server.icon': guild.iconURL({ size: 128 }) ?? '' }; } diff --git a/apps/webui/src/components/modules/scheduler-manager.tsx b/apps/webui/src/components/modules/scheduler-manager.tsx index a0e2ce7..446de6f 100644 --- a/apps/webui/src/components/modules/scheduler-manager.tsx +++ b/apps/webui/src/components/modules/scheduler-manager.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { ScheduledMessageDashboard, WelcomeEmbed } from '@nexumi/shared'; +import { embedHasContent, type ScheduledMessageDashboard, type WelcomeEmbed } from '@nexumi/shared'; import { Plus, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -48,6 +48,12 @@ function schedulePreview(schedule: ScheduledMessageDashboard, t: (key: string) = if (schedule.embed?.description?.trim()) { return schedule.embed.description; } + if (schedule.embed?.authorName?.trim()) { + return schedule.embed.authorName; + } + if (schedule.embed?.footerText?.trim()) { + return schedule.embed.footerText; + } return t('modulePages.scheduler.noContent'); } @@ -64,7 +70,8 @@ export function SchedulerManager({ guildId, initialSchedules }: SchedulerManager async function handleCreate() { const embed = normalizeEmbed(draft.embed); - const hasContent = Boolean(draft.content.trim()) || Boolean(embed?.title) || Boolean(embed?.description); + const hasContent = + Boolean(draft.content.trim()) || embedHasContent(embed) || embed?.color !== undefined; if (!draft.channelId.trim() || !hasContent || (!draft.cron.trim() && !draft.runAt)) { toast.error(t('modulePages.scheduler.formIncomplete')); return; diff --git a/apps/webui/src/components/modules/tags-manager.tsx b/apps/webui/src/components/modules/tags-manager.tsx index 3ac9077..8ff1382 100644 --- a/apps/webui/src/components/modules/tags-manager.tsx +++ b/apps/webui/src/components/modules/tags-manager.tsx @@ -1,6 +1,6 @@ 'use client'; -import type { TagDashboard, TagResponseType, WelcomeEmbed } from '@nexumi/shared'; +import { embedHasContent, type TagDashboard, type TagResponseType, type WelcomeEmbed } from '@nexumi/shared'; import { Plus, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { toast } from 'sonner'; @@ -57,7 +57,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) { return; } const embed = draft.responseType === 'EMBED' ? normalizeEmbed(draft.embed) : null; - if (draft.responseType === 'EMBED' && !embed?.title && !embed?.description) { + if (draft.responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) { toast.error(t('modulePages.tags.embedRequired')); return; } @@ -99,7 +99,7 @@ export function TagsManager({ guildId, initialTags }: TagsManagerProps) { async function handleSave(tag: LocalTag) { const embed = tag.responseType === 'EMBED' ? normalizeEmbed(tag.embed ?? null) : null; - if (tag.responseType === 'EMBED' && !embed?.title && !embed?.description) { + if (tag.responseType === 'EMBED' && !embedHasContent(embed) && embed?.color === undefined) { toast.error(t('modulePages.tags.embedRequired')); return; } diff --git a/apps/webui/src/components/modules/welcome-form.tsx b/apps/webui/src/components/modules/welcome-form.tsx index 414121a..32e2aea 100644 --- a/apps/webui/src/components/modules/welcome-form.tsx +++ b/apps/webui/src/components/modules/welcome-form.tsx @@ -25,7 +25,9 @@ const WELCOME_PREVIEW_VARS = { 'user.name': 'Alex', 'user.tag': 'Alex', 'user.id': '123456789012345678', + 'user.avatar': 'https://cdn.discordapp.com/embed/avatars/0.png', server: 'Nexumi', + 'server.icon': 'https://cdn.discordapp.com/embed/avatars/1.png', memberCount: '128' }; diff --git a/apps/webui/src/components/ui/discord-embed-builder.tsx b/apps/webui/src/components/ui/discord-embed-builder.tsx index a10e9b1..82e19a4 100644 --- a/apps/webui/src/components/ui/discord-embed-builder.tsx +++ b/apps/webui/src/components/ui/discord-embed-builder.tsx @@ -1,21 +1,38 @@ 'use client'; -import type { WelcomeEmbed } from '@nexumi/shared'; +import { embedHasContent, type WelcomeEmbed } from '@nexumi/shared'; +import { ChevronDown } from 'lucide-react'; +import { useId, useState } from 'react'; import { useTranslations } from '@/components/locale-provider'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { PlaceholderHelp } from '@/components/ui/placeholder-help'; +import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import type { PlaceholderPresetId } from '@/lib/placeholder-presets'; import { cn } from '@/lib/utils'; export const DEFAULT_EMBED_COLOR = 0x6366f1; +function trimOrUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + export function emptyEmbed(): WelcomeEmbed { return { title: '', description: '', - color: DEFAULT_EMBED_COLOR + color: DEFAULT_EMBED_COLOR, + url: '', + authorName: '', + authorIconUrl: '', + authorUrl: '', + thumbnailUrl: '', + imageUrl: '', + footerText: '', + footerIconUrl: '', + timestamp: false }; } @@ -23,13 +40,24 @@ export function normalizeEmbed(value: WelcomeEmbed | null | undefined): WelcomeE if (!value) { return null; } - const title = value.title?.trim() || undefined; - const description = value.description?.trim() || undefined; - const color = typeof value.color === 'number' && Number.isFinite(value.color) ? value.color : undefined; - if (!title && !description && color === undefined) { + const normalized: WelcomeEmbed = { + title: trimOrUndefined(value.title), + description: trimOrUndefined(value.description), + url: trimOrUndefined(value.url), + color: typeof value.color === 'number' && Number.isFinite(value.color) ? value.color : undefined, + authorName: trimOrUndefined(value.authorName), + authorIconUrl: trimOrUndefined(value.authorIconUrl), + authorUrl: trimOrUndefined(value.authorUrl), + thumbnailUrl: trimOrUndefined(value.thumbnailUrl), + imageUrl: trimOrUndefined(value.imageUrl), + footerText: trimOrUndefined(value.footerText), + footerIconUrl: trimOrUndefined(value.footerIconUrl), + timestamp: value.timestamp ? true : undefined + }; + if (!embedHasContent(normalized) && normalized.color === undefined) { return null; } - return { title, description, color }; + return normalized; } export function embedFromUnknown(raw: unknown): WelcomeEmbed | null { @@ -40,7 +68,16 @@ export function embedFromUnknown(raw: unknown): WelcomeEmbed | null { return normalizeEmbed({ title: typeof record.title === 'string' ? record.title : undefined, description: typeof record.description === 'string' ? record.description : undefined, - color: typeof record.color === 'number' ? record.color : undefined + url: typeof record.url === 'string' ? record.url : undefined, + color: typeof record.color === 'number' ? record.color : undefined, + authorName: typeof record.authorName === 'string' ? record.authorName : undefined, + authorIconUrl: typeof record.authorIconUrl === 'string' ? record.authorIconUrl : undefined, + authorUrl: typeof record.authorUrl === 'string' ? record.authorUrl : undefined, + thumbnailUrl: typeof record.thumbnailUrl === 'string' ? record.thumbnailUrl : undefined, + imageUrl: typeof record.imageUrl === 'string' ? record.imageUrl : undefined, + footerText: typeof record.footerText === 'string' ? record.footerText : undefined, + footerIconUrl: typeof record.footerIconUrl === 'string' ? record.footerIconUrl : undefined, + timestamp: typeof record.timestamp === 'boolean' ? record.timestamp : undefined }); } @@ -60,22 +97,28 @@ function applyPreviewVars(template: string, vars: Record): strin (acc, [key, value]) => acc.replaceAll(`{${key}}`, value), template ); - // Show bracket channel mentions as #id in the dashboard preview. return withVars.replace(/\["(\d{17,20})"\]/g, '#$1').replace(/<#(\d{17,20})>/g, '#$1'); } +function previewUrl(value: string | undefined, vars: Record): string | undefined { + if (!value?.trim()) { + return undefined; + } + const rendered = applyPreviewVars(value, vars).trim(); + if (!/^https?:\/\//i.test(rendered)) { + return undefined; + } + return rendered; +} + interface DiscordEmbedBuilderProps { value: WelcomeEmbed | null; onChange: (next: WelcomeEmbed | null) => void; className?: string; - /** Sample values for preview placeholder substitution (e.g. user → @Alex). */ previewVars?: Record; showClearHint?: boolean; - /** Which placeholders apply to this embed context. */ placeholderPreset?: PlaceholderPresetId; - /** Override default embed title input placeholder. */ titlePlaceholder?: string; - /** Override default embed description input placeholder. */ descriptionPlaceholder?: string; } @@ -90,23 +133,31 @@ export function DiscordEmbedBuilder({ descriptionPlaceholder }: DiscordEmbedBuilderProps) { const t = useTranslations(); + const advancedId = useId(); + const [advancedOpen, setAdvancedOpen] = useState(false); const draft = value ?? emptyEmbed(); + const vars = previewVars ?? {}; const colorHex = colorToHex(draft.color); + + const previewAuthor = draft.authorName ? applyPreviewVars(draft.authorName, vars) : ''; const previewTitle = draft.title - ? applyPreviewVars(draft.title, previewVars ?? {}) + ? applyPreviewVars(draft.title, vars) : t('embedBuilder.previewTitlePlaceholder'); const previewDescription = draft.description - ? applyPreviewVars(draft.description, previewVars ?? {}) + ? applyPreviewVars(draft.description, vars) : t('embedBuilder.previewDescriptionPlaceholder'); - const hasContent = Boolean(draft.title?.trim() || draft.description?.trim()); + const previewFooter = draft.footerText ? applyPreviewVars(draft.footerText, vars) : ''; + const authorIcon = previewUrl(draft.authorIconUrl, vars); + const thumbnail = previewUrl(draft.thumbnailUrl, vars) ?? previewUrl(vars['user.avatar'], {}); + const image = previewUrl(draft.imageUrl, vars); + const footerIcon = previewUrl(draft.footerIconUrl, vars); + const hasContent = embedHasContent(draft) || Boolean(draft.color !== undefined); function patch(next: Partial) { - const merged: WelcomeEmbed = { - title: next.title !== undefined ? next.title : draft.title, - description: next.description !== undefined ? next.description : draft.description, - color: next.color !== undefined ? next.color : draft.color - }; - onChange(merged); + onChange({ + ...draft, + ...next + }); } return ( @@ -156,6 +207,86 @@ export function DiscordEmbedBuilder({ /> + +
+ + {advancedOpen ? ( +
+
+

{t('embedBuilder.authorSection')}

+ patch({ authorName: event.target.value })} + /> + patch({ authorIconUrl: event.target.value })} + /> + patch({ authorUrl: event.target.value })} + /> +
+ +
+

{t('embedBuilder.mediaSection')}

+ patch({ thumbnailUrl: event.target.value })} + /> + patch({ imageUrl: event.target.value })} + /> + patch({ url: event.target.value })} + /> +
+ +
+

{t('embedBuilder.footerSection')}

+ patch({ footerText: event.target.value })} + /> + patch({ footerIconUrl: event.target.value })} + /> +
+ + patch({ timestamp: checked })} + /> +
+
+

{t('embedBuilder.mediaHint')}

+
+ ) : null} +
+ {showClearHint ? (

{t('embedBuilder.clearHint')}

) : null} @@ -173,29 +304,63 @@ export function DiscordEmbedBuilder({
-

- {previewTitle || '\u00a0'} -

-

- {previewDescription || '\u00a0'} -

+ {previewAuthor ? ( +
+ {authorIcon ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +

{previewAuthor}

+
+ ) : null} + +
+
+

+ {previewTitle || '\u00a0'} +

+

+ {previewDescription || '\u00a0'} +

+
+ {thumbnail ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +
+ + {image ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} + + {previewFooter || draft.timestamp ? ( +
+ {footerIcon ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : null} +

+ {[previewFooter, draft.timestamp ? t('embedBuilder.timestampPreview') : null] + .filter(Boolean) + .join(' · ')} +

+
+ ) : null}
diff --git a/apps/webui/src/lib/module-configs/scheduler.ts b/apps/webui/src/lib/module-configs/scheduler.ts index 6b143e2..8285765 100644 --- a/apps/webui/src/lib/module-configs/scheduler.ts +++ b/apps/webui/src/lib/module-configs/scheduler.ts @@ -1,4 +1,9 @@ -import type { ScheduledMessageDashboard, ScheduledMessageDashboardCreate, WelcomeEmbed } from '@nexumi/shared'; +import { + WelcomeEmbedSchema, + type ScheduledMessageDashboard, + type ScheduledMessageDashboardCreate, + type WelcomeEmbed +} from '@nexumi/shared'; import { Prisma, type ScheduledMessage } from '@prisma/client'; import { prisma } from '../prisma'; import { getScheduleQueue } from '../queues'; @@ -10,17 +15,8 @@ export class SchedulerValidationError extends Error { } function parseEmbed(raw: unknown): WelcomeEmbed | null { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return null; - } - const record = raw as Record; - const title = typeof record.title === 'string' ? record.title : undefined; - const description = typeof record.description === 'string' ? record.description : undefined; - const color = typeof record.color === 'number' ? record.color : undefined; - if (!title && !description && color === undefined) { - return null; - } - return { title, description, color }; + const parsed = WelcomeEmbedSchema.safeParse(raw); + return parsed.success ? parsed.data : null; } function toDashboard(schedule: ScheduledMessage): ScheduledMessageDashboard { diff --git a/apps/webui/src/lib/module-configs/tags.ts b/apps/webui/src/lib/module-configs/tags.ts index 16cecdb..02e5e6a 100644 --- a/apps/webui/src/lib/module-configs/tags.ts +++ b/apps/webui/src/lib/module-configs/tags.ts @@ -1,4 +1,10 @@ -import type { TagDashboard, TagDashboardCreate, TagDashboardUpdate, WelcomeEmbed } from '@nexumi/shared'; +import { + WelcomeEmbedSchema, + type TagDashboard, + type TagDashboardCreate, + type TagDashboardUpdate, + type WelcomeEmbed +} from '@nexumi/shared'; import { Prisma, type Tag } from '@prisma/client'; import { prisma } from '../prisma'; import { assertPremiumLimit, PremiumLimitError } from '../premium'; @@ -16,17 +22,8 @@ export class TagPremiumError extends Error { } function parseEmbed(raw: unknown): WelcomeEmbed | null { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return null; - } - const record = raw as Record; - const title = typeof record.title === 'string' ? record.title : undefined; - const description = typeof record.description === 'string' ? record.description : undefined; - const color = typeof record.color === 'number' ? record.color : undefined; - if (!title && !description && color === undefined) { - return null; - } - return { title, description, color }; + const parsed = WelcomeEmbedSchema.safeParse(raw); + return parsed.success ? parsed.data : null; } function toDashboard(tag: Tag): TagDashboard { diff --git a/apps/webui/src/lib/module-configs/welcome.ts b/apps/webui/src/lib/module-configs/welcome.ts index d19fcf8..bbd2775 100644 --- a/apps/webui/src/lib/module-configs/welcome.ts +++ b/apps/webui/src/lib/module-configs/welcome.ts @@ -1,4 +1,9 @@ -import type { WelcomeConfigDashboard, WelcomeConfigDashboardPatch, WelcomeEmbed } from '@nexumi/shared'; +import { + WelcomeEmbedSchema, + type WelcomeConfigDashboard, + type WelcomeConfigDashboardPatch, + type WelcomeEmbed +} from '@nexumi/shared'; import { Prisma } from '@prisma/client'; import { prisma } from '../prisma'; @@ -12,17 +17,8 @@ async function ensureWelcomeConfig(guildId: string) { } function parseEmbed(raw: unknown): WelcomeEmbed | null { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - return null; - } - const record = raw as Record; - const title = typeof record.title === 'string' ? record.title : undefined; - const description = typeof record.description === 'string' ? record.description : undefined; - const color = typeof record.color === 'number' ? record.color : undefined; - if (!title && !description && color === undefined) { - return null; - } - return { title, description, color }; + const parsed = WelcomeEmbedSchema.safeParse(raw); + return parsed.success ? parsed.data : null; } function toDashboard(config: Awaited>): WelcomeConfigDashboard { diff --git a/apps/webui/src/lib/placeholder-presets.ts b/apps/webui/src/lib/placeholder-presets.ts index 4868f46..5182b63 100644 --- a/apps/webui/src/lib/placeholder-presets.ts +++ b/apps/webui/src/lib/placeholder-presets.ts @@ -33,7 +33,9 @@ const USER_SERVER: PlaceholderDefinition[] = [ { token: '{user.name}', descriptionKey: 'placeholders.desc.userName' }, { token: '{user.tag}', descriptionKey: 'placeholders.desc.userTag' }, { token: '{user.id}', descriptionKey: 'placeholders.desc.userId' }, + { token: '{user.avatar}', descriptionKey: 'placeholders.desc.userAvatar' }, { token: '{server}', descriptionKey: 'placeholders.desc.server' }, + { token: '{server.icon}', descriptionKey: 'placeholders.desc.serverIcon' }, { token: '{memberCount}', descriptionKey: 'placeholders.desc.memberCount' } ]; @@ -49,7 +51,9 @@ export const PLACEHOLDER_PRESETS: Record; export const TagDashboardCreateSchema = TagDashboardSchema.omit({ id: true }).refine( (value) => value.responseType !== 'EMBED' || - Boolean(value.embed?.title?.trim()) || - Boolean(value.embed?.description?.trim()), - { message: 'Embed title or description is required for EMBED tags', path: ['embed'] } + embedHasContent(value.embed) || + value.embed?.color !== undefined, + { message: 'Embed content is required for EMBED tags', path: ['embed'] } ); export type TagDashboardCreate = z.infer; @@ -587,8 +587,8 @@ export const ScheduledMessageDashboardCreateSchema = z .refine( (value) => Boolean(value.content?.trim()) || - Boolean(value.embed?.title?.trim()) || - Boolean(value.embed?.description?.trim()), + embedHasContent(value.embed) || + value.embed?.color !== undefined, { message: 'Content or embed is required' } ); export type ScheduledMessageDashboardCreate = z.infer; diff --git a/packages/shared/src/phase2.test.ts b/packages/shared/src/phase2.test.ts index b56577b..77aa2da 100644 --- a/packages/shared/src/phase2.test.ts +++ b/packages/shared/src/phase2.test.ts @@ -27,6 +27,19 @@ describe('phase2 helpers', () => { expect(result).toBe('Join <#123456789012345678> please'); }); + it('accepts extended embed fields', async () => { + const { WelcomeEmbedSchema, embedHasContent } = await import('./phase2.js'); + const parsed = WelcomeEmbedSchema.parse({ + title: 'Hi', + authorName: 'Nexumi', + imageUrl: 'https://example.com/banner.png', + footerText: 'Nexumi', + timestamp: true + }); + expect(parsed.authorName).toBe('Nexumi'); + expect(embedHasContent(parsed)).toBe(true); + }); + it('detects discord invites', () => { expect(isDiscordInvite('join https://discord.gg/test')).toBe(true); expect(isDiscordInvite('hello world')).toBe(false); diff --git a/packages/shared/src/phase2.ts b/packages/shared/src/phase2.ts index ee67642..9fe8fc2 100644 --- a/packages/shared/src/phase2.ts +++ b/packages/shared/src/phase2.ts @@ -131,12 +131,73 @@ export const WelcomeMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'IMAGE']); export type WelcomeMessageType = z.infer; export const WelcomeEmbedSchema = z.object({ - title: z.string().optional(), - description: z.string().optional(), - color: z.number().int().optional() + title: z.string().max(256).optional(), + description: z.string().max(4096).optional(), + /** Clickable title URL. */ + url: z.string().max(2048).optional(), + color: z.number().int().optional(), + authorName: z.string().max(256).optional(), + authorIconUrl: z.string().max(2048).optional(), + authorUrl: z.string().max(2048).optional(), + thumbnailUrl: z.string().max(2048).optional(), + imageUrl: z.string().max(2048).optional(), + footerText: z.string().max(2048).optional(), + footerIconUrl: z.string().max(2048).optional(), + /** When true, Discord shows the current time in the footer area. */ + timestamp: z.boolean().optional() }); export type WelcomeEmbed = z.infer; +/** True when the embed has any user-facing content beyond an optional color. */ +export function embedHasContent(embed: WelcomeEmbed | null | undefined): boolean { + if (!embed) { + return false; + } + return Boolean( + embed.title?.trim() || + embed.description?.trim() || + embed.url?.trim() || + embed.authorName?.trim() || + embed.authorIconUrl?.trim() || + embed.authorUrl?.trim() || + embed.thumbnailUrl?.trim() || + embed.imageUrl?.trim() || + embed.footerText?.trim() || + embed.footerIconUrl?.trim() || + embed.timestamp + ); +} + +/** + * Applies a string renderer to every text/URL field of an embed payload. + * Used by Welcome/Tags/Scheduler before handing data to discord.js. + */ +export function mapEmbedTextFields( + embed: WelcomeEmbed, + render: (value: string) => string +): WelcomeEmbed { + const map = (value: string | undefined): string | undefined => { + if (!value?.trim()) { + return undefined; + } + return render(value); + }; + return { + title: map(embed.title), + description: map(embed.description), + url: map(embed.url), + color: embed.color, + authorName: map(embed.authorName), + authorIconUrl: map(embed.authorIconUrl), + authorUrl: map(embed.authorUrl), + thumbnailUrl: map(embed.thumbnailUrl), + imageUrl: map(embed.imageUrl), + footerText: map(embed.footerText), + footerIconUrl: map(embed.footerIconUrl), + timestamp: embed.timestamp + }; +} + export const VerificationModeSchema = z.enum(['BUTTON', 'CAPTCHA']); export type VerificationMode = z.infer;