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.
This commit is contained in:
TheOnlyMace
2026-07-22 22:30:46 +02:00
parent e7a3162494
commit 95eed45ec4
19 changed files with 500 additions and 146 deletions

View File

@@ -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;
}

View File

@@ -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] };

View File

@@ -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');
}

View File

@@ -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') {

View File

@@ -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 }) ?? ''
};
}