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:
89
apps/bot/src/lib/embed-payload.ts
Normal file
89
apps/bot/src/lib/embed-payload.ts
Normal 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;
|
||||
}
|
||||
@@ -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] };
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 }) ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
|
||||
@@ -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<string, string>): 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, string>): 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<string, string>;
|
||||
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<WelcomeEmbed>) {
|
||||
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({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={advancedOpen}
|
||||
aria-controls={advancedId}
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
<span>{t('embedBuilder.advancedToggle')}</span>
|
||||
<ChevronDown className={cn('size-4 shrink-0 transition-transform', advancedOpen && 'rotate-180')} />
|
||||
</button>
|
||||
{advancedOpen ? (
|
||||
<div id={advancedId} className="space-y-4 border-t border-border px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-foreground">{t('embedBuilder.authorSection')}</p>
|
||||
<Input
|
||||
value={draft.authorName ?? ''}
|
||||
maxLength={256}
|
||||
placeholder={t('embedBuilder.authorNamePlaceholder')}
|
||||
onChange={(event) => patch({ authorName: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
value={draft.authorIconUrl ?? ''}
|
||||
placeholder={t('embedBuilder.authorIconPlaceholder')}
|
||||
onChange={(event) => patch({ authorIconUrl: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
value={draft.authorUrl ?? ''}
|
||||
placeholder={t('embedBuilder.authorUrlPlaceholder')}
|
||||
onChange={(event) => patch({ authorUrl: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-foreground">{t('embedBuilder.mediaSection')}</p>
|
||||
<Input
|
||||
value={draft.thumbnailUrl ?? ''}
|
||||
placeholder={t('embedBuilder.thumbnailPlaceholder')}
|
||||
onChange={(event) => patch({ thumbnailUrl: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
value={draft.imageUrl ?? ''}
|
||||
placeholder={t('embedBuilder.imagePlaceholder')}
|
||||
onChange={(event) => patch({ imageUrl: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
value={draft.url ?? ''}
|
||||
placeholder={t('embedBuilder.titleUrlPlaceholder')}
|
||||
onChange={(event) => patch({ url: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-foreground">{t('embedBuilder.footerSection')}</p>
|
||||
<Input
|
||||
value={draft.footerText ?? ''}
|
||||
maxLength={2048}
|
||||
placeholder={t('embedBuilder.footerTextPlaceholder')}
|
||||
onChange={(event) => patch({ footerText: event.target.value })}
|
||||
/>
|
||||
<Input
|
||||
value={draft.footerIconUrl ?? ''}
|
||||
placeholder={t('embedBuilder.footerIconPlaceholder')}
|
||||
onChange={(event) => patch({ footerIconUrl: event.target.value })}
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||
<Label htmlFor="embed-timestamp">{t('embedBuilder.timestamp')}</Label>
|
||||
<Switch
|
||||
id="embed-timestamp"
|
||||
checked={Boolean(draft.timestamp)}
|
||||
onCheckedChange={(checked) => patch({ timestamp: checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t('embedBuilder.mediaHint')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showClearHint ? (
|
||||
<p className="text-xs text-muted-foreground">{t('embedBuilder.clearHint')}</p>
|
||||
) : null}
|
||||
@@ -173,29 +304,63 @@ export function DiscordEmbedBuilder({
|
||||
<Label>{t('embedBuilder.preview')}</Label>
|
||||
<div className="rounded-md border border-[#1e1f22] bg-[#313338] p-3">
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden rounded-[4px] bg-[#2b2d31]',
|
||||
!hasContent && 'opacity-70'
|
||||
)}
|
||||
className={cn('overflow-hidden rounded-[4px] bg-[#2b2d31]', !hasContent && 'opacity-70')}
|
||||
style={{ borderLeft: `4px solid ${colorHex}` }}
|
||||
>
|
||||
<div className="space-y-2 px-3 py-2">
|
||||
<p
|
||||
className={cn(
|
||||
'text-base font-semibold leading-5',
|
||||
hasContent && draft.title?.trim() ? 'text-white' : 'text-[#b5bac1]'
|
||||
)}
|
||||
>
|
||||
{previewTitle || '\u00a0'}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'whitespace-pre-wrap text-sm leading-5',
|
||||
hasContent && draft.description?.trim() ? 'text-[#dbdee1]' : 'text-[#949ba4]'
|
||||
)}
|
||||
>
|
||||
{previewDescription || '\u00a0'}
|
||||
</p>
|
||||
{previewAuthor ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{authorIcon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={authorIcon} alt="" className="size-6 rounded-full object-cover" />
|
||||
) : null}
|
||||
<p className="text-sm font-medium text-white">{previewAuthor}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cn('flex gap-3', thumbnail && 'items-start')}>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p
|
||||
className={cn(
|
||||
'text-base font-semibold leading-5',
|
||||
draft.title?.trim() ? 'text-white' : 'text-[#b5bac1]'
|
||||
)}
|
||||
>
|
||||
{previewTitle || '\u00a0'}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
'whitespace-pre-wrap text-sm leading-5',
|
||||
draft.description?.trim() ? 'text-[#dbdee1]' : 'text-[#949ba4]'
|
||||
)}
|
||||
>
|
||||
{previewDescription || '\u00a0'}
|
||||
</p>
|
||||
</div>
|
||||
{thumbnail ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={thumbnail} alt="" className="size-16 shrink-0 rounded object-cover" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{image ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={image} alt="" className="mt-1 w-full rounded object-cover" />
|
||||
) : null}
|
||||
|
||||
{previewFooter || draft.timestamp ? (
|
||||
<div className="flex items-center gap-2 border-t border-[#3f4147] pt-2">
|
||||
{footerIcon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={footerIcon} alt="" className="size-5 rounded-full object-cover" />
|
||||
) : null}
|
||||
<p className="text-xs text-[#949ba4]">
|
||||
{[previewFooter, draft.timestamp ? t('embedBuilder.timestampPreview') : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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 {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<ReturnType<typeof ensureWelcomeConfig>>): WelcomeConfigDashboard {
|
||||
|
||||
@@ -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<PlaceholderPresetId, PlaceholderDefinit
|
||||
{ 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: '{args}', descriptionKey: 'placeholders.desc.args' },
|
||||
{ token: '{random:a|b|c}', descriptionKey: 'placeholders.desc.random' },
|
||||
...CHANNEL_MENTIONS
|
||||
|
||||
@@ -33,7 +33,22 @@
|
||||
"previewTitlePlaceholder": "Embed-Titel",
|
||||
"previewDescriptionPlaceholder": "Embed-Beschreibung erscheint hier…",
|
||||
"clear": "Embed leeren",
|
||||
"clearHint": "Leere Felder speichern kein Embed."
|
||||
"clearHint": "Leere Felder speichern kein Embed.",
|
||||
"advancedToggle": "Erweiterte Embed-Optionen (Author, Bilder, Footer)",
|
||||
"authorSection": "Author (Kopfzeile)",
|
||||
"authorNamePlaceholder": "Author-Name, z. B. {server}",
|
||||
"authorIconPlaceholder": "Author-Icon-URL oder {server.icon}",
|
||||
"authorUrlPlaceholder": "Author-Link (https://…)",
|
||||
"mediaSection": "Medien",
|
||||
"thumbnailPlaceholder": "Thumbnail-URL oder {user.avatar}",
|
||||
"imagePlaceholder": "Großes Bild / Banner-URL (https://…)",
|
||||
"titleUrlPlaceholder": "Titel-Link (https://…)",
|
||||
"footerSection": "Footer",
|
||||
"footerTextPlaceholder": "Footer-Text, z. B. Nexumi",
|
||||
"footerIconPlaceholder": "Footer-Icon-URL oder {server.icon}",
|
||||
"timestamp": "Zeitstempel anzeigen",
|
||||
"timestampPreview": "gerade eben",
|
||||
"mediaHint": "Bild-URLs müssen mit http(s) beginnen. Für Welcome kannst du {user.avatar} und {server.icon} nutzen."
|
||||
},
|
||||
"placeholders": {
|
||||
"toggle": "Verfügbare Platzhalter anzeigen",
|
||||
@@ -59,7 +74,9 @@
|
||||
"feedTitle": "Titel des Feed-Eintrags",
|
||||
"feedUrl": "URL des Feed-Eintrags",
|
||||
"channelBracket": "Kanal-Erwähnung (Dashboard-Schreibweise, ID ersetzen)",
|
||||
"channelNative": "Kanal-Erwähnung (Discord-Syntax, ID ersetzen)"
|
||||
"channelNative": "Kanal-Erwähnung (Discord-Syntax, ID ersetzen)",
|
||||
"userAvatar": "Avatar-URL des Mitglieds (für Thumbnail/Icons)",
|
||||
"serverIcon": "Server-Icon-URL (für Author/Footer)"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
|
||||
@@ -33,7 +33,22 @@
|
||||
"previewTitlePlaceholder": "Embed title",
|
||||
"previewDescriptionPlaceholder": "Embed description appears here…",
|
||||
"clear": "Clear embed",
|
||||
"clearHint": "Empty fields do not save an embed."
|
||||
"clearHint": "Empty fields do not save an embed.",
|
||||
"advancedToggle": "Advanced embed options (author, images, footer)",
|
||||
"authorSection": "Author (header)",
|
||||
"authorNamePlaceholder": "Author name, e.g. {server}",
|
||||
"authorIconPlaceholder": "Author icon URL or {server.icon}",
|
||||
"authorUrlPlaceholder": "Author link (https://…)",
|
||||
"mediaSection": "Media",
|
||||
"thumbnailPlaceholder": "Thumbnail URL or {user.avatar}",
|
||||
"imagePlaceholder": "Large image / banner URL (https://…)",
|
||||
"titleUrlPlaceholder": "Title link (https://…)",
|
||||
"footerSection": "Footer",
|
||||
"footerTextPlaceholder": "Footer text, e.g. Nexumi",
|
||||
"footerIconPlaceholder": "Footer icon URL or {server.icon}",
|
||||
"timestamp": "Show timestamp",
|
||||
"timestampPreview": "just now",
|
||||
"mediaHint": "Image URLs must start with http(s). For Welcome you can use {user.avatar} and {server.icon}."
|
||||
},
|
||||
"placeholders": {
|
||||
"toggle": "Show available placeholders",
|
||||
@@ -59,7 +74,9 @@
|
||||
"feedTitle": "Feed item title",
|
||||
"feedUrl": "Feed item URL",
|
||||
"channelBracket": "Channel mention (dashboard syntax — replace the ID)",
|
||||
"channelNative": "Channel mention (Discord syntax — replace the ID)"
|
||||
"channelNative": "Channel mention (Discord syntax — replace the ID)",
|
||||
"userAvatar": "Member avatar URL (for thumbnail/icons)",
|
||||
"serverIcon": "Server icon URL (for author/footer)"
|
||||
}
|
||||
},
|
||||
"nav": {
|
||||
|
||||
@@ -130,16 +130,18 @@
|
||||
|
||||
### Abgeschlossen (Code)
|
||||
|
||||
- Wiederverwendbare `DiscordEmbedBuilder`-Komponente (Titel, Beschreibung, Farbe) inkl. Live-Vorschau
|
||||
- Welcome/Leave: JSON-Textareas durch Embed-Builder ersetzt
|
||||
- Tags: Embed-Felder bei Antworttyp EMBED (Schema + Persistenz)
|
||||
- Scheduler: optionales Embed beim Erstellen (Schema + Persistenz)
|
||||
- Wiederverwendbare `DiscordEmbedBuilder`-Komponente inkl. Live-Vorschau
|
||||
- Basis: Titel, Beschreibung, Farbe
|
||||
- Erweitert: Author (Name/Icon/URL), Thumbnail, großes Bild, Footer (Text/Icon), Titel-URL, Timestamp
|
||||
- Welcome/Leave, Tags (EMBED) und Scheduler nutzen denselben Builder + `buildEmbedFromPayload`
|
||||
- Platzhalter in Text-/URL-Feldern inkl. `{user.avatar}` / `{server.icon}` (Welcome/Tags)
|
||||
|
||||
### Manuell testen
|
||||
|
||||
- [ ] Welcome/Leave-Embed speichern und in Discord prüfen
|
||||
- [ ] Tag mit Antworttyp Embed erstellen/bearbeiten
|
||||
- [ ] Geplante Nachricht mit Embed (optional mit Text) anlegen
|
||||
- [ ] Welcome/Leave-Embed mit Author, Thumbnail, Banner und Footer speichern und in Discord prüfen
|
||||
- [ ] Tag mit Antworttyp Embed (auch nur Bild/Author ohne Titel) erstellen/bearbeiten
|
||||
- [ ] Geplante Nachricht mit erweitertem Embed anlegen
|
||||
- [ ] Einfaches Embed (nur Titel/Beschreibung/Farbe) funktioniert weiter wie zuvor
|
||||
|
||||
## Post-Phase – Logging Multi-Event pro Kanal (Status: implementiert)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { WelcomeEmbedSchema } from './phase2.js';
|
||||
import { embedHasContent, WelcomeEmbedSchema } from './phase2.js';
|
||||
import {
|
||||
SelfRoleBehaviorSchema,
|
||||
SelfRoleEntrySchema,
|
||||
@@ -406,9 +406,9 @@ export type TagDashboard = z.infer<typeof TagDashboardSchema>;
|
||||
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<typeof TagDashboardCreateSchema>;
|
||||
|
||||
@@ -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<typeof ScheduledMessageDashboardCreateSchema>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -131,12 +131,73 @@ export const WelcomeMessageTypeSchema = z.enum(['TEXT', 'EMBED', 'IMAGE']);
|
||||
export type WelcomeMessageType = z.infer<typeof WelcomeMessageTypeSchema>;
|
||||
|
||||
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<typeof WelcomeEmbedSchema>;
|
||||
|
||||
/** 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<typeof VerificationModeSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user