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 {
|
||||
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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user