Enhance welcome module with embed text rendering and autorole management
- Updated the welcome embed functionality to allow for user mentions in titles, authors, and footers, while maintaining clickable mentions in descriptions. - Implemented autorole assignment logic with checks for role manageability and hierarchy, including detailed logging for skipped roles. - Improved the WebUI to display real user and guild data in the welcome embed preview, enhancing user experience. - Refactored the welcome text rendering to support additional options for mention handling based on the context of the embed fields.
This commit is contained in:
@@ -2,6 +2,7 @@ import { EmbedBuilder } from 'discord.js';
|
|||||||
import {
|
import {
|
||||||
embedHasContent,
|
embedHasContent,
|
||||||
mapEmbedTextFields,
|
mapEmbedTextFields,
|
||||||
|
type EmbedTextField,
|
||||||
type WelcomeEmbed
|
type WelcomeEmbed
|
||||||
} from '@nexumi/shared';
|
} from '@nexumi/shared';
|
||||||
|
|
||||||
@@ -18,8 +19,12 @@ function isHttpUrl(value: string | undefined): value is string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ApplyEmbedOptions {
|
export interface ApplyEmbedOptions {
|
||||||
/** Called for every text/URL field before applying to the builder. */
|
/**
|
||||||
renderText: (value: string) => string;
|
* Called for every text/URL field before applying to the builder.
|
||||||
|
* `field` lets callers render `{user}` as a mention in the description
|
||||||
|
* but as a plain display name in title/author/footer.
|
||||||
|
*/
|
||||||
|
renderText: (value: string, field: EmbedTextField) => string;
|
||||||
/**
|
/**
|
||||||
* Used when `thumbnailUrl` is unset (Welcome default: member avatar).
|
* Used when `thumbnailUrl` is unset (Welcome default: member avatar).
|
||||||
* Pass `null` to force no thumbnail.
|
* Pass `null` to force no thumbnail.
|
||||||
|
|||||||
@@ -1,23 +1,73 @@
|
|||||||
import { PermissionFlagsBits, type GuildMember } from 'discord.js';
|
import { PermissionFlagsBits, type GuildMember, type Role } from 'discord.js';
|
||||||
import type { BotContext } from '../../types.js';
|
import type { BotContext } from '../../types.js';
|
||||||
import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js';
|
import { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js';
|
||||||
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
|
import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js';
|
||||||
import { logger } from '../../logger.js';
|
import { logger } from '../../logger.js';
|
||||||
|
|
||||||
|
async function resolveRole(member: GuildMember, roleId: string): Promise<Role | null> {
|
||||||
|
const cached = member.guild.roles.cache.get(roleId);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
return member.guild.roles.fetch(roleId).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
async function applyAutoroles(member: GuildMember, roleIds: string[]): Promise<void> {
|
async function applyAutoroles(member: GuildMember, roleIds: string[]): Promise<void> {
|
||||||
const me = member.guild.members.me;
|
const uniqueIds = [...new Set(roleIds.filter(Boolean))];
|
||||||
if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
if (uniqueIds.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const assignable = roleIds.filter((roleId) => {
|
|
||||||
const role = member.guild.roles.cache.get(roleId);
|
const me =
|
||||||
return role && role.position < me.roles.highest.position;
|
member.guild.members.me ?? (await member.guild.members.fetchMe().catch(() => null));
|
||||||
});
|
if (!me) {
|
||||||
|
logger.warn({ guildId: member.guild.id }, 'Autoroles skipped: bot member unavailable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) {
|
||||||
|
logger.warn({ guildId: member.guild.id }, 'Autoroles skipped: missing ManageRoles permission');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignable: string[] = [];
|
||||||
|
for (const roleId of uniqueIds) {
|
||||||
|
if (roleId === member.guild.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (member.roles.cache.has(roleId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await resolveRole(member, roleId);
|
||||||
|
if (!role) {
|
||||||
|
logger.warn({ guildId: member.guild.id, roleId }, 'Autorole skipped: role not found');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (role.managed) {
|
||||||
|
logger.warn({ guildId: member.guild.id, roleId }, 'Autorole skipped: managed role');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!role.editable) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
guildId: member.guild.id,
|
||||||
|
roleId,
|
||||||
|
rolePosition: role.position,
|
||||||
|
botHighest: me.roles.highest.position
|
||||||
|
},
|
||||||
|
'Autorole skipped: role above bot or not editable'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assignable.push(roleId);
|
||||||
|
}
|
||||||
|
|
||||||
if (assignable.length === 0) {
|
if (assignable.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await member.roles.add(assignable).catch((error) => {
|
|
||||||
logger.warn({ error, memberId: member.id }, 'Failed to assign autoroles');
|
await member.roles.add(assignable, 'Nexumi welcome autorole').catch((error) => {
|
||||||
|
logger.warn({ error, memberId: member.id, assignable }, 'Failed to assign autoroles');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { createCanvas, loadImage } from '@napi-rs/canvas';
|
|||||||
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
import { buildEmbedFromPayload } from '../../lib/embed-payload.js';
|
||||||
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js';
|
||||||
import type { LeavePayload, WelcomePayload } from './service.js';
|
import type { LeavePayload, WelcomePayload } from './service.js';
|
||||||
import { renderWelcomeText } from './service.js';
|
import { renderWelcomeEmbedText, renderWelcomeText } from './service.js';
|
||||||
|
|
||||||
export async function buildWelcomeMessage(
|
export async function buildWelcomeMessage(
|
||||||
payload: WelcomePayload,
|
payload: WelcomePayload,
|
||||||
@@ -31,7 +31,7 @@ export async function buildWelcomeMessage(
|
|||||||
|
|
||||||
if (payload.type === 'EMBED') {
|
if (payload.type === 'EMBED') {
|
||||||
const embed = buildEmbedFromPayload(payload.embed, {
|
const embed = buildEmbedFromPayload(payload.embed, {
|
||||||
renderText: (value) => renderWelcomeText(value, member, guild),
|
renderText: (value, field) => renderWelcomeEmbedText(value, member, guild, field),
|
||||||
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
|
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
|
||||||
});
|
});
|
||||||
return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) };
|
return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) };
|
||||||
@@ -77,14 +77,15 @@ async function renderWelcomeCard(
|
|||||||
const title = payload.imageTitle ?? 'Welcome!';
|
const title = payload.imageTitle ?? 'Welcome!';
|
||||||
const subtitle =
|
const subtitle =
|
||||||
payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`;
|
payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`;
|
||||||
|
const plain = { mentionUser: false as const };
|
||||||
|
|
||||||
ctx.fillStyle = '#f9fafb';
|
ctx.fillStyle = '#f9fafb';
|
||||||
ctx.font = 'bold 36px sans-serif';
|
ctx.font = 'bold 36px sans-serif';
|
||||||
ctx.fillText(renderWelcomeText(title, member, guild).slice(0, 40), 220, 130);
|
ctx.fillText(renderWelcomeText(title, member, guild, plain).slice(0, 40), 220, 130);
|
||||||
|
|
||||||
ctx.fillStyle = '#d1d5db';
|
ctx.fillStyle = '#d1d5db';
|
||||||
ctx.font = '24px sans-serif';
|
ctx.font = '24px sans-serif';
|
||||||
ctx.fillText(renderWelcomeText(subtitle, member, guild).slice(0, 60), 220, 180);
|
ctx.fillText(renderWelcomeText(subtitle, member, guild, plain).slice(0, 60), 220, 180);
|
||||||
|
|
||||||
return canvas.toBuffer('image/png');
|
return canvas.toBuffer('image/png');
|
||||||
}
|
}
|
||||||
@@ -119,7 +120,7 @@ export async function buildLeaveMessage(
|
|||||||
|
|
||||||
if (payload.type === 'EMBED') {
|
if (payload.type === 'EMBED') {
|
||||||
const embed = buildEmbedFromPayload(payload.embed, {
|
const embed = buildEmbedFromPayload(payload.embed, {
|
||||||
renderText: (value) => renderWelcomeText(value, member, guild),
|
renderText: (value, field) => renderWelcomeEmbedText(value, member, guild, field),
|
||||||
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
|
defaultThumbnailUrl: member.user.displayAvatarURL({ size: 256 })
|
||||||
});
|
});
|
||||||
if (embed) {
|
if (embed) {
|
||||||
|
|||||||
@@ -13,9 +13,23 @@ export async function getWelcomeConfig(prisma: PrismaClient, guildId: string) {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildPlaceholderVars(member: GuildMember, guild: Guild) {
|
export type WelcomePlaceholderOptions = {
|
||||||
|
/**
|
||||||
|
* When false, `{user}` becomes the member display name instead of `<@id>`.
|
||||||
|
* Required for embed title/author/footer and canvas text — Discord does not
|
||||||
|
* resolve mentions in those surfaces.
|
||||||
|
*/
|
||||||
|
mentionUser?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildPlaceholderVars(
|
||||||
|
member: GuildMember,
|
||||||
|
guild: Guild,
|
||||||
|
options: WelcomePlaceholderOptions = {}
|
||||||
|
) {
|
||||||
|
const mentionUser = options.mentionUser !== false;
|
||||||
return {
|
return {
|
||||||
user: `<@${member.id}>`,
|
user: mentionUser ? `<@${member.id}>` : member.displayName,
|
||||||
'user.name': member.displayName,
|
'user.name': member.displayName,
|
||||||
'user.tag': member.user.tag,
|
'user.tag': member.user.tag,
|
||||||
'user.id': member.id,
|
'user.id': member.id,
|
||||||
@@ -26,8 +40,27 @@ export function buildPlaceholderVars(member: GuildMember, guild: Guild) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderWelcomeText(template: string, member: GuildMember, guild: Guild): string {
|
export function renderWelcomeText(
|
||||||
return applyWelcomePlaceholders(template, buildPlaceholderVars(member, guild));
|
template: string,
|
||||||
|
member: GuildMember,
|
||||||
|
guild: Guild,
|
||||||
|
options: WelcomePlaceholderOptions = {}
|
||||||
|
): string {
|
||||||
|
return applyWelcomePlaceholders(template, buildPlaceholderVars(member, guild, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields where Discord will not turn `<@id>` into a clickable mention. */
|
||||||
|
const EMBED_PLAIN_USER_FIELDS = new Set(['title', 'authorName', 'footerText']);
|
||||||
|
|
||||||
|
export function renderWelcomeEmbedText(
|
||||||
|
template: string,
|
||||||
|
member: GuildMember,
|
||||||
|
guild: Guild,
|
||||||
|
field: string
|
||||||
|
): string {
|
||||||
|
return renderWelcomeText(template, member, guild, {
|
||||||
|
mentionUser: !EMBED_PLAIN_USER_FIELDS.has(field)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseWelcomeEmbed(raw: unknown): WelcomeEmbed | null {
|
export function parseWelcomeEmbed(raw: unknown): WelcomeEmbed | null {
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { WelcomeForm } from '@/components/modules/welcome-form';
|
import { notFound } from 'next/navigation';
|
||||||
|
import { buildWelcomePreviewVars, WelcomeForm } from '@/components/modules/welcome-form';
|
||||||
|
import { requireGuildAccessOrRedirect } from '@/lib/auth';
|
||||||
|
import { getManageableGuild } from '@/lib/guilds';
|
||||||
import { getLocale, t } from '@/lib/i18n';
|
import { getLocale, t } from '@/lib/i18n';
|
||||||
import { getWelcomeDashboard } from '@/lib/module-configs/welcome';
|
import { getWelcomeDashboard } from '@/lib/module-configs/welcome';
|
||||||
|
|
||||||
@@ -8,7 +11,16 @@ interface WelcomePageProps {
|
|||||||
|
|
||||||
export default async function WelcomePage({ params }: WelcomePageProps) {
|
export default async function WelcomePage({ params }: WelcomePageProps) {
|
||||||
const { guildId } = await params;
|
const { guildId } = await params;
|
||||||
const [locale, config] = await Promise.all([getLocale(), getWelcomeDashboard(guildId)]);
|
const session = await requireGuildAccessOrRedirect(guildId);
|
||||||
|
const [locale, config, guild] = await Promise.all([
|
||||||
|
getLocale(),
|
||||||
|
getWelcomeDashboard(guildId),
|
||||||
|
getManageableGuild(session, guildId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!guild) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -16,7 +28,11 @@ export default async function WelcomePage({ params }: WelcomePageProps) {
|
|||||||
<h1 className="text-2xl font-semibold">{t(locale, 'modules.welcome.label')}</h1>
|
<h1 className="text-2xl font-semibold">{t(locale, 'modules.welcome.label')}</h1>
|
||||||
<p className="text-sm text-muted-foreground">{t(locale, 'modules.welcome.description')}</p>
|
<p className="text-sm text-muted-foreground">{t(locale, 'modules.welcome.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<WelcomeForm guildId={guildId} initialValue={config} />
|
<WelcomeForm
|
||||||
|
guildId={guildId}
|
||||||
|
initialValue={config}
|
||||||
|
previewVars={buildWelcomePreviewVars(session.user, guild)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { MessageComponentsV2, WelcomeConfigDashboard, WelcomeEmbed } from '@nexumi/shared';
|
import type {
|
||||||
|
DashboardGuild,
|
||||||
|
MessageComponentsV2,
|
||||||
|
SessionUser,
|
||||||
|
WelcomeConfigDashboard,
|
||||||
|
WelcomeEmbed
|
||||||
|
} from '@nexumi/shared';
|
||||||
import { FieldAnchor } from '@/components/layout/field-anchor';
|
import { FieldAnchor } from '@/components/layout/field-anchor';
|
||||||
import { useTranslations } from '@/components/locale-provider';
|
import { useTranslations } from '@/components/locale-provider';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
@@ -25,16 +31,44 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
import type { SettingsSaveResult } from '@/components/settings/settings-form';
|
||||||
import { SettingsForm } from '@/components/settings/settings-form';
|
import { SettingsForm } from '@/components/settings/settings-form';
|
||||||
|
|
||||||
const WELCOME_PREVIEW_VARS = {
|
function discordDefaultAvatarUrl(userId: string): string {
|
||||||
user: '@Alex',
|
try {
|
||||||
'user.name': 'Alex',
|
const index = Number((BigInt(userId) >> 22n) % 6n);
|
||||||
'user.tag': 'Alex',
|
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
|
||||||
'user.id': '123456789012345678',
|
} catch {
|
||||||
'user.avatar': 'https://cdn.discordapp.com/embed/avatars/0.png',
|
return 'https://cdn.discordapp.com/embed/avatars/0.png';
|
||||||
server: 'Nexumi',
|
}
|
||||||
'server.icon': 'https://cdn.discordapp.com/embed/avatars/1.png',
|
}
|
||||||
memberCount: '128'
|
|
||||||
|
function userAvatarUrl(user: SessionUser): string {
|
||||||
|
return user.avatar
|
||||||
|
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png?size=256`
|
||||||
|
: discordDefaultAvatarUrl(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function guildIconUrl(guild: DashboardGuild): string {
|
||||||
|
return guild.icon
|
||||||
|
? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png?size=128`
|
||||||
|
: discordDefaultAvatarUrl(guild.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview placeholders from the logged-in dashboard user and current guild. */
|
||||||
|
export function buildWelcomePreviewVars(
|
||||||
|
user: SessionUser,
|
||||||
|
guild: DashboardGuild
|
||||||
|
): Record<string, string> {
|
||||||
|
const displayName = user.globalName?.trim() || user.username;
|
||||||
|
return {
|
||||||
|
user: `@${displayName}`,
|
||||||
|
'user.name': displayName,
|
||||||
|
'user.tag': user.username,
|
||||||
|
'user.id': user.id,
|
||||||
|
'user.avatar': userAvatarUrl(user),
|
||||||
|
server: guild.name,
|
||||||
|
'server.icon': guildIconUrl(guild),
|
||||||
|
memberCount: '—'
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface LocalWelcomeValue extends Omit<
|
interface LocalWelcomeValue extends Omit<
|
||||||
WelcomeConfigDashboard,
|
WelcomeConfigDashboard,
|
||||||
@@ -85,9 +119,10 @@ async function saveWelcome(guildId: string, value: LocalWelcomeValue): Promise<S
|
|||||||
interface WelcomeFormProps {
|
interface WelcomeFormProps {
|
||||||
guildId: string;
|
guildId: string;
|
||||||
initialValue: WelcomeConfigDashboard;
|
initialValue: WelcomeConfigDashboard;
|
||||||
|
previewVars: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
export function WelcomeForm({ guildId, initialValue, previewVars }: WelcomeFormProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -162,7 +197,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
<DiscordEmbedBuilder
|
<DiscordEmbedBuilder
|
||||||
value={value.welcomeEmbed}
|
value={value.welcomeEmbed}
|
||||||
onChange={(welcomeEmbed) => setValue((prev) => ({ ...prev, welcomeEmbed }))}
|
onChange={(welcomeEmbed) => setValue((prev) => ({ ...prev, welcomeEmbed }))}
|
||||||
previewVars={WELCOME_PREVIEW_VARS}
|
previewVars={previewVars}
|
||||||
placeholderPreset="welcome"
|
placeholderPreset="welcome"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,7 +211,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
guildId={guildId}
|
guildId={guildId}
|
||||||
value={value.welcomeComponents}
|
value={value.welcomeComponents}
|
||||||
onChange={(welcomeComponents) => setValue((prev) => ({ ...prev, welcomeComponents }))}
|
onChange={(welcomeComponents) => setValue((prev) => ({ ...prev, welcomeComponents }))}
|
||||||
previewVars={WELCOME_PREVIEW_VARS}
|
previewVars={previewVars}
|
||||||
placeholderPreset="welcome"
|
placeholderPreset="welcome"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,7 +347,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
<DiscordEmbedBuilder
|
<DiscordEmbedBuilder
|
||||||
value={value.leaveEmbed}
|
value={value.leaveEmbed}
|
||||||
onChange={(leaveEmbed) => setValue((prev) => ({ ...prev, leaveEmbed }))}
|
onChange={(leaveEmbed) => setValue((prev) => ({ ...prev, leaveEmbed }))}
|
||||||
previewVars={WELCOME_PREVIEW_VARS}
|
previewVars={previewVars}
|
||||||
placeholderPreset="welcome"
|
placeholderPreset="welcome"
|
||||||
titlePlaceholder={t('modulePages.welcome.leaveEmbedTitlePlaceholder')}
|
titlePlaceholder={t('modulePages.welcome.leaveEmbedTitlePlaceholder')}
|
||||||
descriptionPlaceholder={t('modulePages.welcome.leaveEmbedDescriptionPlaceholder')}
|
descriptionPlaceholder={t('modulePages.welcome.leaveEmbedDescriptionPlaceholder')}
|
||||||
@@ -328,7 +363,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
|||||||
guildId={guildId}
|
guildId={guildId}
|
||||||
value={value.leaveComponents}
|
value={value.leaveComponents}
|
||||||
onChange={(leaveComponents) => setValue((prev) => ({ ...prev, leaveComponents }))}
|
onChange={(leaveComponents) => setValue((prev) => ({ ...prev, leaveComponents }))}
|
||||||
previewVars={WELCOME_PREVIEW_VARS}
|
previewVars={previewVars}
|
||||||
placeholderPreset="welcome"
|
placeholderPreset="welcome"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -290,6 +290,22 @@
|
|||||||
- [ ] Original löschen/editieren → Starboard aktualisiert/entfernt
|
- [ ] Original löschen/editieren → Starboard aktualisiert/entfernt
|
||||||
- [ ] Dashboard: aktivieren ohne Kanal → Fehler
|
- [ ] Dashboard: aktivieren ohne Kanal → Fehler
|
||||||
|
|
||||||
|
## Post-Phase – Welcome Embed Mentions + Autorole + Preview (Status: implementiert)
|
||||||
|
|
||||||
|
### Abgeschlossen (Code)
|
||||||
|
|
||||||
|
- Embed `{user}` in Titel/Author/Footer als Displayname (Discord resolved Mentions dort nicht)
|
||||||
|
- Beschreibung/Content behalten `<@id>`-Mentions
|
||||||
|
- Autorole: Rollen nachladen, managed/@everyone filtern, `editable`-Check, Warn-Logs bei Hierarchy/Permission
|
||||||
|
- WebUI Embed-/Components-Vorschau: echte Session-User- + Guild-Daten statt Dummy `@Alex`
|
||||||
|
|
||||||
|
### Manuell testen
|
||||||
|
|
||||||
|
- [ ] Welcome-Embed Titel `Welcome {user}` → Anzeigename, nicht `<@id>`
|
||||||
|
- [ ] Beschreibung `{user}` → klickbare Mention
|
||||||
|
- [ ] User-Autorollen gesetzt, Bot-Rolle über Autorolle, Manage Roles → Rolle bei Join
|
||||||
|
- [ ] Dashboard Welcome-Embed-Vorschau zeigt eigenen Namen/Avatar und Servername/-icon
|
||||||
|
|
||||||
## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert)
|
## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert)
|
||||||
|
|
||||||
### Abgeschlossen (Code)
|
### Abgeschlossen (Code)
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ describe('phase2 helpers', () => {
|
|||||||
expect(result).toBe('Join <#123456789012345678> please');
|
expect(result).toBe('Join <#123456789012345678> please');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps embed text fields with field names', async () => {
|
||||||
|
const { mapEmbedTextFields } = await import('./phase2.js');
|
||||||
|
const mapped = mapEmbedTextFields(
|
||||||
|
{ title: 'T:{user}', description: 'D:{user}', footerText: 'F:{user}' },
|
||||||
|
(value, field) => `${field}:${value}`
|
||||||
|
);
|
||||||
|
expect(mapped.title).toBe('title:T:{user}');
|
||||||
|
expect(mapped.description).toBe('description:D:{user}');
|
||||||
|
expect(mapped.footerText).toBe('footerText:F:{user}');
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts extended embed fields', async () => {
|
it('accepts extended embed fields', async () => {
|
||||||
const { WelcomeEmbedSchema, embedHasContent } = await import('./phase2.js');
|
const { WelcomeEmbedSchema, embedHasContent } = await import('./phase2.js');
|
||||||
const parsed = WelcomeEmbedSchema.parse({
|
const parsed = WelcomeEmbedSchema.parse({
|
||||||
|
|||||||
@@ -171,32 +171,47 @@ export function embedHasContent(embed: WelcomeEmbed | null | undefined): boolean
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Text/URL fields of {@link WelcomeEmbed} that pass through {@link mapEmbedTextFields}. */
|
||||||
|
export type EmbedTextField =
|
||||||
|
| 'title'
|
||||||
|
| 'description'
|
||||||
|
| 'url'
|
||||||
|
| 'authorName'
|
||||||
|
| 'authorIconUrl'
|
||||||
|
| 'authorUrl'
|
||||||
|
| 'thumbnailUrl'
|
||||||
|
| 'imageUrl'
|
||||||
|
| 'footerText'
|
||||||
|
| 'footerIconUrl';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies a string renderer to every text/URL field of an embed payload.
|
* Applies a string renderer to every text/URL field of an embed payload.
|
||||||
* Used by Welcome/Tags/Scheduler before handing data to discord.js.
|
* Used by Welcome/Tags/Scheduler before handing data to discord.js.
|
||||||
|
* The second argument names the field so callers can e.g. avoid Discord
|
||||||
|
* mentions in title/author/footer (Discord does not resolve them there).
|
||||||
*/
|
*/
|
||||||
export function mapEmbedTextFields(
|
export function mapEmbedTextFields(
|
||||||
embed: WelcomeEmbed,
|
embed: WelcomeEmbed,
|
||||||
render: (value: string) => string
|
render: (value: string, field: EmbedTextField) => string
|
||||||
): WelcomeEmbed {
|
): WelcomeEmbed {
|
||||||
const map = (value: string | undefined): string | undefined => {
|
const map = (value: string | undefined, field: EmbedTextField): string | undefined => {
|
||||||
if (!value?.trim()) {
|
if (!value?.trim()) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return render(value);
|
return render(value, field);
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
title: map(embed.title),
|
title: map(embed.title, 'title'),
|
||||||
description: map(embed.description),
|
description: map(embed.description, 'description'),
|
||||||
url: map(embed.url),
|
url: map(embed.url, 'url'),
|
||||||
color: embed.color,
|
color: embed.color,
|
||||||
authorName: map(embed.authorName),
|
authorName: map(embed.authorName, 'authorName'),
|
||||||
authorIconUrl: map(embed.authorIconUrl),
|
authorIconUrl: map(embed.authorIconUrl, 'authorIconUrl'),
|
||||||
authorUrl: map(embed.authorUrl),
|
authorUrl: map(embed.authorUrl, 'authorUrl'),
|
||||||
thumbnailUrl: map(embed.thumbnailUrl),
|
thumbnailUrl: map(embed.thumbnailUrl, 'thumbnailUrl'),
|
||||||
imageUrl: map(embed.imageUrl),
|
imageUrl: map(embed.imageUrl, 'imageUrl'),
|
||||||
footerText: map(embed.footerText),
|
footerText: map(embed.footerText, 'footerText'),
|
||||||
footerIconUrl: map(embed.footerIconUrl),
|
footerIconUrl: map(embed.footerIconUrl, 'footerIconUrl'),
|
||||||
timestamp: embed.timestamp
|
timestamp: embed.timestamp
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user