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 {
|
||||
|
||||
@@ -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 (
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">{t(locale, 'modules.welcome.description')}</p>
|
||||
</div>
|
||||
<WelcomeForm guildId={guildId} initialValue={config} />
|
||||
<WelcomeForm
|
||||
guildId={guildId}
|
||||
initialValue={config}
|
||||
previewVars={buildWelcomePreviewVars(session.user, guild)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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<
|
||||
WelcomeConfigDashboard,
|
||||
@@ -85,9 +119,10 @@ async function saveWelcome(guildId: string, value: LocalWelcomeValue): Promise<S
|
||||
interface WelcomeFormProps {
|
||||
guildId: string;
|
||||
initialValue: WelcomeConfigDashboard;
|
||||
previewVars: Record<string, string>;
|
||||
}
|
||||
|
||||
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) {
|
||||
<DiscordEmbedBuilder
|
||||
value={value.welcomeEmbed}
|
||||
onChange={(welcomeEmbed) => setValue((prev) => ({ ...prev, welcomeEmbed }))}
|
||||
previewVars={WELCOME_PREVIEW_VARS}
|
||||
previewVars={previewVars}
|
||||
placeholderPreset="welcome"
|
||||
/>
|
||||
</div>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
@@ -312,7 +347,7 @@ export function WelcomeForm({ guildId, initialValue }: WelcomeFormProps) {
|
||||
<DiscordEmbedBuilder
|
||||
value={value.leaveEmbed}
|
||||
onChange={(leaveEmbed) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user