diff --git a/apps/bot/src/lib/embed-payload.ts b/apps/bot/src/lib/embed-payload.ts index 4160743..f5ac5c7 100644 --- a/apps/bot/src/lib/embed-payload.ts +++ b/apps/bot/src/lib/embed-payload.ts @@ -2,6 +2,7 @@ import { EmbedBuilder } from 'discord.js'; import { embedHasContent, mapEmbedTextFields, + type EmbedTextField, type WelcomeEmbed } from '@nexumi/shared'; @@ -18,8 +19,12 @@ function isHttpUrl(value: string | undefined): value is string { } 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). * Pass `null` to force no thumbnail. diff --git a/apps/bot/src/modules/welcome/events.ts b/apps/bot/src/modules/welcome/events.ts index f344ffe..11a9d50 100644 --- a/apps/bot/src/modules/welcome/events.ts +++ b/apps/bot/src/modules/welcome/events.ts @@ -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 { getWelcomeConfig, resolveWelcomePayload, resolveLeavePayload, renderWelcomeText } from './service.js'; import { sendLeaveMessage, sendWelcomeMessage } from './renderer.js'; import { logger } from '../../logger.js'; +async function resolveRole(member: GuildMember, roleId: string): Promise { + 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 { - const me = member.guild.members.me; - if (!me?.permissions.has(PermissionFlagsBits.ManageRoles)) { + const uniqueIds = [...new Set(roleIds.filter(Boolean))]; + if (uniqueIds.length === 0) { return; } - const assignable = roleIds.filter((roleId) => { - const role = member.guild.roles.cache.get(roleId); - return role && role.position < me.roles.highest.position; - }); + + const me = + 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) { 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'); }); } diff --git a/apps/bot/src/modules/welcome/renderer.ts b/apps/bot/src/modules/welcome/renderer.ts index 339d880..2b2eb8c 100644 --- a/apps/bot/src/modules/welcome/renderer.ts +++ b/apps/bot/src/modules/welcome/renderer.ts @@ -10,7 +10,7 @@ import { createCanvas, loadImage } from '@napi-rs/canvas'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; import type { LeavePayload, WelcomePayload } from './service.js'; -import { renderWelcomeText } from './service.js'; +import { renderWelcomeEmbedText, renderWelcomeText } from './service.js'; export async function buildWelcomeMessage( payload: WelcomePayload, @@ -31,7 +31,7 @@ export async function buildWelcomeMessage( if (payload.type === '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 }) }); return embed ? { embeds: [embed] } : { content: renderWelcomeText('{user}', member, guild) }; @@ -77,14 +77,15 @@ async function renderWelcomeCard( const title = payload.imageTitle ?? 'Welcome!'; const subtitle = payload.imageSubtitle ?? `${member.displayName} joined ${guild.name}`; + const plain = { mentionUser: false as const }; ctx.fillStyle = '#f9fafb'; 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.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'); } @@ -119,7 +120,7 @@ export async function buildLeaveMessage( if (payload.type === '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 }) }); if (embed) { diff --git a/apps/bot/src/modules/welcome/service.ts b/apps/bot/src/modules/welcome/service.ts index 4d5c7c0..472a4b0 100644 --- a/apps/bot/src/modules/welcome/service.ts +++ b/apps/bot/src/modules/welcome/service.ts @@ -13,9 +13,23 @@ export async function getWelcomeConfig(prisma: PrismaClient, guildId: string) { 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 { - user: `<@${member.id}>`, + user: mentionUser ? `<@${member.id}>` : member.displayName, 'user.name': member.displayName, 'user.tag': member.user.tag, '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 { - return applyWelcomePlaceholders(template, buildPlaceholderVars(member, guild)); +export function renderWelcomeText( + 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 { diff --git a/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx b/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx index fe0533f..fe20cf1 100644 --- a/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx @@ -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 { getWelcomeDashboard } from '@/lib/module-configs/welcome'; @@ -8,7 +11,16 @@ interface WelcomePageProps { export default async function WelcomePage({ params }: WelcomePageProps) { 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 (
@@ -16,7 +28,11 @@ export default async function WelcomePage({ params }: WelcomePageProps) {

{t(locale, 'modules.welcome.label')}

{t(locale, 'modules.welcome.description')}

- + ); } diff --git a/apps/webui/src/components/modules/welcome-form.tsx b/apps/webui/src/components/modules/welcome-form.tsx index 1a492e2..4367552 100644 --- a/apps/webui/src/components/modules/welcome-form.tsx +++ b/apps/webui/src/components/modules/welcome-form.tsx @@ -1,6 +1,12 @@ '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 { useTranslations } from '@/components/locale-provider'; 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 { SettingsForm } from '@/components/settings/settings-form'; -const WELCOME_PREVIEW_VARS = { - user: '@Alex', - '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' -}; +function discordDefaultAvatarUrl(userId: string): string { + try { + const index = Number((BigInt(userId) >> 22n) % 6n); + return `https://cdn.discordapp.com/embed/avatars/${index}.png`; + } catch { + return 'https://cdn.discordapp.com/embed/avatars/0.png'; + } +} + +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 { + 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< WelcomeConfigDashboard, @@ -85,9 +119,10 @@ async function saveWelcome(guildId: string, value: LocalWelcomeValue): Promise; } -export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) { +export function WelcomeForm({ guildId, initialValue, previewVars }: WelcomeFormProps) { const t = useTranslations(); return ( @@ -162,7 +197,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) { setValue((prev) => ({ ...prev, welcomeEmbed }))} - previewVars={WELCOME_PREVIEW_VARS} + previewVars={previewVars} placeholderPreset="welcome" /> @@ -176,7 +211,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) { guildId={guildId} value={value.welcomeComponents} onChange={(welcomeComponents) => setValue((prev) => ({ ...prev, welcomeComponents }))} - previewVars={WELCOME_PREVIEW_VARS} + previewVars={previewVars} placeholderPreset="welcome" /> @@ -312,7 +347,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) { setValue((prev) => ({ ...prev, leaveEmbed }))} - previewVars={WELCOME_PREVIEW_VARS} + previewVars={previewVars} placeholderPreset="welcome" titlePlaceholder={t('modulePages.welcome.leaveEmbedTitlePlaceholder')} descriptionPlaceholder={t('modulePages.welcome.leaveEmbedDescriptionPlaceholder')} @@ -328,7 +363,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) { guildId={guildId} value={value.leaveComponents} onChange={(leaveComponents) => setValue((prev) => ({ ...prev, leaveComponents }))} - previewVars={WELCOME_PREVIEW_VARS} + previewVars={previewVars} placeholderPreset="welcome" /> diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index f665493..4d420ce 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -290,6 +290,22 @@ - [ ] Original löschen/editieren → Starboard aktualisiert/entfernt - [ ] 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) ### Abgeschlossen (Code) diff --git a/packages/shared/src/phase2.test.ts b/packages/shared/src/phase2.test.ts index 77aa2da..d138f50 100644 --- a/packages/shared/src/phase2.test.ts +++ b/packages/shared/src/phase2.test.ts @@ -27,6 +27,17 @@ describe('phase2 helpers', () => { 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 () => { const { WelcomeEmbedSchema, embedHasContent } = await import('./phase2.js'); const parsed = WelcomeEmbedSchema.parse({ diff --git a/packages/shared/src/phase2.ts b/packages/shared/src/phase2.ts index c86d6b8..d6acd27 100644 --- a/packages/shared/src/phase2.ts +++ b/packages/shared/src/phase2.ts @@ -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. * 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( embed: WelcomeEmbed, - render: (value: string) => string + render: (value: string, field: EmbedTextField) => string ): WelcomeEmbed { - const map = (value: string | undefined): string | undefined => { + const map = (value: string | undefined, field: EmbedTextField): string | undefined => { if (!value?.trim()) { return undefined; } - return render(value); + return render(value, field); }; return { - title: map(embed.title), - description: map(embed.description), - url: map(embed.url), + title: map(embed.title, 'title'), + description: map(embed.description, 'description'), + url: map(embed.url, '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), + authorName: map(embed.authorName, 'authorName'), + authorIconUrl: map(embed.authorIconUrl, 'authorIconUrl'), + authorUrl: map(embed.authorUrl, 'authorUrl'), + thumbnailUrl: map(embed.thumbnailUrl, 'thumbnailUrl'), + imageUrl: map(embed.imageUrl, 'imageUrl'), + footerText: map(embed.footerText, 'footerText'), + footerIconUrl: map(embed.footerIconUrl, 'footerIconUrl'), timestamp: embed.timestamp }; }