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:
smueller
2026-07-24 10:54:33 +02:00
parent ccdf9aafe8
commit 0ebe540c3f
9 changed files with 234 additions and 52 deletions

View File

@@ -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.

View File

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

View File

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

View File

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