deploy #2

Merged
smueller merged 24 commits from deploy into main 2026-07-29 07:44:03 +00:00
9 changed files with 234 additions and 52 deletions
Showing only changes of commit 0ebe540c3f - Show all commits

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 {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
};
}