From ccdf9aafe83af27b19c6a3e3131303ee987eeacf Mon Sep 17 00:00:00 2001 From: smueller Date: Fri, 24 Jul 2026 10:46:12 +0200 Subject: [PATCH 01/24] fix: Starboard --- apps/bot/src/commands.ts | 16 +- .../modules/starboard/command-definitions.ts | 11 +- apps/bot/src/modules/starboard/commands.ts | 44 +++- apps/bot/src/modules/starboard/events.ts | 51 ++++- .../bot/src/modules/starboard/service.test.ts | 85 ++++++++ apps/bot/src/modules/starboard/service.ts | 205 ++++++++++++++++-- .../src/components/modules/starboard-form.tsx | 114 +++++++--- apps/webui/src/messages/de.json | 12 +- apps/webui/src/messages/en.json | 12 +- docs/PHASE-TRACKING.md | 19 ++ packages/shared/src/command-locales.ts | 4 + packages/shared/src/dashboard.ts | 45 +++- packages/shared/src/index.ts | 18 +- 13 files changed, 549 insertions(+), 87 deletions(-) create mode 100644 apps/bot/src/modules/starboard/service.test.ts diff --git a/apps/bot/src/commands.ts b/apps/bot/src/commands.ts index e74e256..ee828a7 100644 --- a/apps/bot/src/commands.ts +++ b/apps/bot/src/commands.ts @@ -214,11 +214,17 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con moduleId ); if (!enabled) { - await interaction.reply({ - content: t(locale, 'generic.moduleDisabled'), - ephemeral: true - }); - return; + // Allow first-time setup while the module is still disabled (chicken-egg). + const sub = interaction.options.getSubcommand(false); + const isBootstrapSetup = + interaction.commandName === 'starboard' && sub === 'setup'; + if (!isBootstrapSetup) { + await interaction.reply({ + content: t(locale, 'generic.moduleDisabled'), + ephemeral: true + }); + return; + } } } diff --git a/apps/bot/src/modules/starboard/command-definitions.ts b/apps/bot/src/modules/starboard/command-definitions.ts index 65a3fa1..a70800e 100644 --- a/apps/bot/src/modules/starboard/command-definitions.ts +++ b/apps/bot/src/modules/starboard/command-definitions.ts @@ -1,15 +1,19 @@ -import { ChannelType } from 'discord.js'; +import { ChannelType, PermissionFlagsBits } from 'discord.js'; import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared'; import { SlashCommandBuilder } from 'discord.js'; export const starboardCommandData = applyCommandDescription( new SlashCommandBuilder() .setName('starboard') + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) .addSubcommand((s) => applyCommandDescription(s.setName('setup'), 'starboard.setup.description') .addChannelOption((o) => applyOptionDescription( - o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true), + o + .setName('channel') + .addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement) + .setRequired(true), 'starboard.setup.options.channel' ) ) @@ -28,6 +32,9 @@ export const starboardCommandData = applyCommandDescription( 'starboard.setup.options.allow_self_star' ) ) + ) + .addSubcommand((s) => + applyCommandDescription(s.setName('status'), 'starboard.status.description') ), 'starboard.description' ); diff --git a/apps/bot/src/modules/starboard/commands.ts b/apps/bot/src/modules/starboard/commands.ts index 5818c74..e68bf60 100644 --- a/apps/bot/src/modules/starboard/commands.ts +++ b/apps/bot/src/modules/starboard/commands.ts @@ -1,17 +1,18 @@ import { PermissionFlagsBits } from 'discord.js'; -import { t } from '@nexumi/shared'; +import { t, tf } from '@nexumi/shared'; import type { SlashCommand } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; import { requirePermission } from '../../permissions.js'; +import { ephemeral } from '../../interaction-reply.js'; import { starboardCommandData } from './command-definitions.js'; -import { getStarboardConfig, updateStarboardConfig } from './service.js'; +import { botCanPostStarboard, getStarboardConfig, updateStarboardConfig } from './service.js'; const starboardCommand: SlashCommand = { data: starboardCommandData, async execute(interaction, context) { const locale = await getGuildLocale(context.prisma, interaction.guildId); if (!interaction.inGuild()) { - await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() }); return; } if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) { @@ -22,11 +23,21 @@ const starboardCommand: SlashCommand = { const sub = interaction.options.getSubcommand(); if (sub === 'setup') { - const channel = interaction.options.getChannel('channel', true); + const channelOption = interaction.options.getChannel('channel', true); const threshold = interaction.options.getInteger('threshold', true); const emoji = interaction.options.getString('emoji') ?? '⭐'; const allowSelfStar = interaction.options.getBoolean('allow_self_star') ?? false; + const channel = await interaction.guild!.channels.fetch(channelOption.id).catch(() => null); + const me = interaction.guild!.members.me; + if (!channel || !me || !botCanPostStarboard(channel, me.id)) { + await interaction.reply({ + content: t(locale, 'starboard.invalidChannel'), + ...ephemeral() + }); + return; + } + await updateStarboardConfig(context.prisma, guildId, { enabled: true, channelId: channel.id, @@ -35,13 +46,30 @@ const starboardCommand: SlashCommand = { allowSelfStar }); - await interaction.reply({ content: t(locale, 'starboard.setupDone'), ephemeral: true }); + await interaction.reply({ content: t(locale, 'starboard.setupDone'), ...ephemeral() }); return; } - const config = await getStarboardConfig(context.prisma, guildId); - if (!config.enabled || !config.channelId) { - await interaction.reply({ content: t(locale, 'starboard.notConfigured'), ephemeral: true }); + if (sub === 'status') { + const config = await getStarboardConfig(context.prisma, guildId); + if (!config.enabled || !config.channelId) { + await interaction.reply({ + content: t(locale, 'starboard.notConfigured'), + ...ephemeral() + }); + return; + } + await interaction.reply({ + content: tf(locale, 'starboard.statusLine', { + channel: `<#${config.channelId}>`, + emoji: config.emoji, + threshold: String(config.threshold), + selfStar: config.allowSelfStar + ? t(locale, 'starboard.selfStarOn') + : t(locale, 'starboard.selfStarOff') + }), + ...ephemeral() + }); } } }; diff --git a/apps/bot/src/modules/starboard/events.ts b/apps/bot/src/modules/starboard/events.ts index e3ad7e1..2b98ad5 100644 --- a/apps/bot/src/modules/starboard/events.ts +++ b/apps/bot/src/modules/starboard/events.ts @@ -1,6 +1,11 @@ +import type { Message, PartialMessage } from 'discord.js'; import type { BotContext } from '../../types.js'; import { logger } from '../../logger.js'; -import { processStarboardReaction } from './service.js'; +import { + processStarboardReaction, + processStarboardSourceDelete, + processStarboardSourceUpdate +} from './service.js'; export function registerStarboardEvents(context: BotContext): void { const handleReaction = async ( @@ -26,4 +31,48 @@ export function registerStarboardEvents(context: BotContext): void { } await handleReaction(_reaction); }); + + context.client.on('messageReactionRemoveAll', async (message) => { + try { + if (message.partial) { + await message.fetch().catch(() => null); + } + if (!message.guild || !('author' in message) || !message.author) { + return; + } + await processStarboardSourceUpdate(context, message as Message | PartialMessage); + } catch (error) { + logger.warn({ error }, 'Starboard remove-all handler failed'); + } + }); + + context.client.on('messageReactionRemoveEmoji', async (reaction) => { + await handleReaction(reaction); + }); + + context.client.on('messageDelete', async (message) => { + try { + await processStarboardSourceDelete(context, message); + } catch (error) { + logger.warn({ error }, 'Starboard source-delete handler failed'); + } + }); + + context.client.on('messageDeleteBulk', async (messages) => { + for (const message of messages.values()) { + try { + await processStarboardSourceDelete(context, message); + } catch (error) { + logger.warn({ error }, 'Starboard bulk-delete handler failed'); + } + } + }); + + context.client.on('messageUpdate', async (_oldMessage, newMessage) => { + try { + await processStarboardSourceUpdate(context, newMessage); + } catch (error) { + logger.warn({ error }, 'Starboard source-update handler failed'); + } + }); } diff --git a/apps/bot/src/modules/starboard/service.test.ts b/apps/bot/src/modules/starboard/service.test.ts new file mode 100644 index 0000000..28ef539 --- /dev/null +++ b/apps/bot/src/modules/starboard/service.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { emojiMatches, shouldIgnoreMessage } from './service.js'; + +function reactionEmoji(partial: { id?: string | null; name?: string | null }) { + return { + id: partial.id ?? null, + name: partial.name ?? null, + identifier: partial.id ? `${partial.name}:${partial.id}` : (partial.name ?? ''), + toString() { + if (partial.id) { + return `<:${partial.name}:${partial.id}>`; + } + return partial.name ?? ''; + } + } as unknown as import('discord.js').Emoji; +} + +describe('starboard emojiMatches', () => { + it('matches unicode emoji', () => { + expect(emojiMatches('⭐', reactionEmoji({ name: '⭐' }))).toBe(true); + }); + + it('matches custom emoji forms', () => { + const emoji = reactionEmoji({ id: '123', name: 'star' }); + expect(emojiMatches('<:star:123>', emoji)).toBe(true); + expect(emojiMatches('star:123', emoji)).toBe(true); + }); +}); + +describe('starboard shouldIgnoreMessage', () => { + const baseConfig = { + id: 'cfg', + guildId: 'g1', + enabled: true, + channelId: 'star-channel', + emoji: '⭐', + threshold: 3, + allowSelfStar: false, + ignoredChannelIds: ['ignored'], + createdAt: new Date(), + updatedAt: new Date() + }; + + it('ignores bot authors', () => { + const message = { + author: { bot: true }, + guild: { id: 'g1' }, + channel: { + id: 'c1', + isTextBased: () => true, + isDMBased: () => false, + nsfw: false + } + } as unknown as import('discord.js').Message; + expect(shouldIgnoreMessage(message, baseConfig)).toBe(true); + }); + + it('ignores the starboard channel itself', () => { + const message = { + author: { bot: false, id: 'u1' }, + guild: { id: 'g1' }, + channel: { + id: 'star-channel', + isTextBased: () => true, + isDMBased: () => false, + nsfw: false + } + } as unknown as import('discord.js').Message; + expect(shouldIgnoreMessage(message, baseConfig)).toBe(true); + }); + + it('ignores listed channels', () => { + const message = { + author: { bot: false, id: 'u1' }, + guild: { id: 'g1' }, + channel: { + id: 'ignored', + isTextBased: () => true, + isDMBased: () => false, + nsfw: false + } + } as unknown as import('discord.js').Message; + expect(shouldIgnoreMessage(message, baseConfig)).toBe(true); + }); +}); diff --git a/apps/bot/src/modules/starboard/service.ts b/apps/bot/src/modules/starboard/service.ts index 051f9f3..4c56254 100644 --- a/apps/bot/src/modules/starboard/service.ts +++ b/apps/bot/src/modules/starboard/service.ts @@ -1,14 +1,17 @@ import { + ChannelType, EmbedBuilder, + PermissionFlagsBits, type Emoji, + type GuildBasedChannel, type Message, + type PartialMessage, type PartialMessageReaction, type MessageReaction, type TextChannel } from 'discord.js'; import type { PrismaClient, StarboardConfig } from '@prisma/client'; -import { t } from '@nexumi/shared'; -import type { Locale } from '@nexumi/shared'; +import { t, type Locale } from '@nexumi/shared'; import { ensureGuild } from '../../guild.js'; import { logger } from '../../logger.js'; import type { BotContext } from '../../types.js'; @@ -73,6 +76,27 @@ export function shouldIgnoreMessage(message: Message, config: StarboardConfig): return false; } +export function botCanPostStarboard(channel: GuildBasedChannel, meId: string): channel is TextChannel { + if (!channel.isTextBased() || channel.isDMBased()) { + return false; + } + if ( + channel.type !== ChannelType.GuildText && + channel.type !== ChannelType.GuildAnnouncement + ) { + return false; + } + const permissions = channel.permissionsFor(meId); + if (!permissions) { + return false; + } + return ( + permissions.has(PermissionFlagsBits.ViewChannel) && + permissions.has(PermissionFlagsBits.SendMessages) && + permissions.has(PermissionFlagsBits.EmbedLinks) + ); +} + export async function countMatchingStars( message: Message, config: StarboardConfig @@ -109,6 +133,10 @@ function resolveImageUrl(message: Message): string | undefined { return embedImage ?? undefined; } +function authorDisplayName(message: Message): string { + return message.member?.displayName || message.author.displayName || message.author.username; +} + export function buildStarboardEmbed( message: Message, starCount: number, @@ -119,7 +147,7 @@ export function buildStarboardEmbed( const embed = new EmbedBuilder() .setColor(0x6366f1) .setAuthor({ - name: message.author.tag, + name: authorDisplayName(message), iconURL: message.author.displayAvatarURL() }) .setDescription(content.slice(0, 4096)) @@ -143,6 +171,17 @@ export function buildStarboardEmbed( return embed; } +async function fetchStarboardTextChannel( + context: BotContext, + channelId: string +): Promise { + const channel = await context.client.channels.fetch(channelId).catch(() => null); + if (!channel || !channel.isTextBased() || channel.isDMBased()) { + return null; + } + return channel as TextChannel; +} + async function removeStarboardEntry(context: BotContext, entryId: string): Promise { const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } }); if (!entry) { @@ -152,9 +191,13 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi try { const config = await getStarboardConfig(context.prisma, entry.guildId); if (config.channelId) { - const channel = (await context.client.channels.fetch(config.channelId)) as TextChannel; - const starboardMessage = await channel.messages.fetch(entry.starboardMessageId).catch(() => null); - await starboardMessage?.delete().catch(() => undefined); + const channel = await fetchStarboardTextChannel(context, config.channelId); + if (channel) { + const starboardMessage = await channel.messages + .fetch(entry.starboardMessageId) + .catch(() => null); + await starboardMessage?.delete().catch(() => undefined); + } } } catch (error) { logger.warn({ error, entryId }, 'Failed to delete starboard message'); @@ -163,6 +206,82 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined); } +export async function removeStarboardEntryBySource( + context: BotContext, + guildId: string, + sourceMessageId: string +): Promise { + const entry = await context.prisma.starboardEntry.findUnique({ + where: { + guildId_sourceMessageId: { guildId, sourceMessageId } + } + }); + if (entry) { + await removeStarboardEntry(context, entry.id); + } +} + +async function createOrRecreateStarboardPost( + context: BotContext, + message: Message, + config: StarboardConfig, + locale: Locale, + starCount: number, + starboardChannel: TextChannel, + existingId?: string +): Promise { + const embed = buildStarboardEmbed(message, starCount, config, locale); + const starboardMessage = await starboardChannel.send({ embeds: [embed] }); + + if (existingId) { + await context.prisma.starboardEntry.update({ + where: { id: existingId }, + data: { + starboardMessageId: starboardMessage.id, + starCount, + sourceChannelId: message.channel.id + } + }); + return; + } + + try { + await context.prisma.starboardEntry.create({ + data: { + guildId: message.guild!.id, + sourceChannelId: message.channel.id, + sourceMessageId: message.id, + starboardMessageId: starboardMessage.id, + starCount + } + }); + } catch (error) { + // Race: another reaction created the row first — keep the newer post, drop ours. + logger.warn({ error, messageId: message.id }, 'Starboard entry create raced; cleaning up'); + await starboardMessage.delete().catch(() => undefined); + const winner = await context.prisma.starboardEntry.findUnique({ + where: { + guildId_sourceMessageId: { + guildId: message.guild!.id, + sourceMessageId: message.id + } + } + }); + if (winner) { + await context.prisma.starboardEntry.update({ + where: { id: winner.id }, + data: { starCount } + }); + const existingMsg = await starboardChannel.messages + .fetch(winner.starboardMessageId) + .catch(() => null); + if (existingMsg) { + await existingMsg.edit({ embeds: [embed] }).catch(() => undefined); + } + } + } +} + export async function processStarboardMessage(context: BotContext, message: Message): Promise { if (!message.guild) { return; @@ -194,8 +313,13 @@ export async function processStarboardMessage(context: BotContext, message: Mess return; } + const starboardChannel = await fetchStarboardTextChannel(context, config.channelId); + if (!starboardChannel) { + logger.warn({ guildId: message.guild.id, channelId: config.channelId }, 'Starboard channel missing'); + return; + } + const embed = buildStarboardEmbed(message, starCount, config, locale); - const starboardChannel = (await context.client.channels.fetch(config.channelId)) as TextChannel; if (existing) { try { @@ -206,21 +330,28 @@ export async function processStarboardMessage(context: BotContext, message: Mess data: { starCount } }); } catch (error) { - logger.warn({ error, messageId: message.id }, 'Failed to update starboard message'); + logger.warn({ error, messageId: message.id }, 'Starboard message missing; recreating'); + await createOrRecreateStarboardPost( + context, + message, + config, + locale, + starCount, + starboardChannel, + existing.id + ); } return; } - const starboardMessage = await starboardChannel.send({ embeds: [embed] }); - await context.prisma.starboardEntry.create({ - data: { - guildId: message.guild.id, - sourceChannelId: message.channel.id, - sourceMessageId: message.id, - starboardMessageId: starboardMessage.id, - starCount - } - }); + await createOrRecreateStarboardPost( + context, + message, + config, + locale, + starCount, + starboardChannel + ); } export async function processStarboardReaction( @@ -249,3 +380,41 @@ export async function processStarboardReaction( await processStarboardMessage(context, message as Message); } + +export async function processStarboardSourceDelete( + context: BotContext, + message: Message | PartialMessage +): Promise { + if (!message.guildId || !message.id) { + return; + } + await removeStarboardEntryBySource(context, message.guildId, message.id); +} + +export async function processStarboardSourceUpdate( + context: BotContext, + message: Message | PartialMessage +): Promise { + if (!message.guildId || !message.id) { + return; + } + + const entry = await context.prisma.starboardEntry.findUnique({ + where: { + guildId_sourceMessageId: { + guildId: message.guildId, + sourceMessageId: message.id + } + } + }); + if (!entry) { + return; + } + + const full = message.partial ? await message.fetch().catch(() => null) : message; + if (!full || !full.guild) { + return; + } + + await processStarboardMessage(context, full as Message); +} diff --git a/apps/webui/src/components/modules/starboard-form.tsx b/apps/webui/src/components/modules/starboard-form.tsx index f764f66..b1f1470 100644 --- a/apps/webui/src/components/modules/starboard-form.tsx +++ b/apps/webui/src/components/modules/starboard-form.tsx @@ -12,7 +12,18 @@ import { Switch } from '@/components/ui/switch'; import type { SettingsSaveResult } from '@/components/settings/settings-form'; import { SettingsForm } from '@/components/settings/settings-form'; -async function saveStarboard(guildId: string, value: StarboardConfigDashboard): Promise { +async function saveStarboard( + guildId: string, + value: StarboardConfigDashboard, + t: (key: string) => string +): Promise { + if (value.enabled && !value.channelId) { + return { ok: false, error: t('modulePages.starboard.errors.channelRequired') }; + } + if (value.threshold < 1 || value.threshold > 100) { + return { ok: false, error: t('modulePages.starboard.errors.thresholdRange') }; + } + try { const response = await fetch(`/api/guilds/${guildId}/starboard`, { method: 'PATCH', @@ -42,7 +53,7 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) { return ( initialValue={initialValue} - onSave={(value) => saveStarboard(guildId, value)} + onSave={(value) => saveStarboard(guildId, value, t)} > {({ value, setValue }) => ( @@ -52,50 +63,85 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) { -
- - setValue((prev) => ({ ...prev, enabled }))} /> -
+
+
+ +

{t('modulePages.starboard.enabledHint')}

+
+ setValue((prev) => ({ ...prev, enabled }))} + /> +
-
- - setValue((prev) => ({ ...prev, channelId }))} - /> -
+
+ + setValue((prev) => ({ ...prev, channelId }))} + /> +
-
- - setValue((prev) => ({ ...prev, emoji: event.target.value }))} /> -
+
+ + + setValue((prev) => ({ ...prev, emoji: event.target.value })) + } + placeholder="⭐ or <:name:id>" + /> +

{t('modulePages.starboard.emojiHint')}

+
-
- - setValue((prev) => ({ ...prev, threshold: Number(event.target.value) || 1 }))} /> -
+
+ + + setValue((prev) => ({ + ...prev, + threshold: Number(event.target.value) || 1 + })) + } + /> +
-
- - setValue((prev) => ({ ...prev, allowSelfStar }))} /> -
+
+ + + setValue((prev) => ({ ...prev, allowSelfStar })) + } + /> +
-
- - setValue((prev) => ({ ...prev, ignoredChannelIds }))} - /> -
+
+ + + setValue((prev) => ({ ...prev, ignoredChannelIds })) + } + /> +

{t('modulePages.starboard.nsfwHint')}

+
diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index a53ec0a..6a76f60 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -768,12 +768,18 @@ "title": "Starboard", "description": "Beliebte Nachrichten in einem eigenen Kanal hervorheben.", "enabledLabel": "Starboard aktiviert", - "channelId": "Starboard-Kanal-ID", + "enabledHint": "Ohne Zielkanal kann Starboard nicht aktiviert werden.", + "channelId": "Starboard-Kanal", "emoji": "Auslöse-Emoji", + "emojiHint": "Unicode (⭐) oder Custom-Emoji als <:name:id> / .", "threshold": "Stern-Schwellenwert", "allowSelfStar": "Eigene Nachrichten dürfen selbst markiert werden", - "ignoredChannelIds": "Ignorierte Kanal-IDs", - "idsHint": "Kommagetrennte Liste von Kanal-IDs, die vom Starboard ausgeschlossen werden." + "ignoredChannelIds": "Ignorierte Kanäle", + "nsfwHint": "NSFW-Kanäle werden immer ausgeschlossen.", + "errors": { + "channelRequired": "Bitte einen Starboard-Kanal wählen, bevor du aktivierst.", + "thresholdRange": "Der Schwellenwert muss zwischen 1 und 100 liegen." + } }, "suggestions": { "configTitle": "Vorschläge-Einstellungen", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index c1c8c67..f8b2d74 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -768,12 +768,18 @@ "title": "Starboard", "description": "Highlight popular messages in a dedicated channel.", "enabledLabel": "Starboard enabled", - "channelId": "Starboard channel ID", + "enabledHint": "A target channel is required before enabling starboard.", + "channelId": "Starboard channel", "emoji": "Trigger emoji", + "emojiHint": "Unicode (⭐) or custom emoji as <:name:id> / .", "threshold": "Star threshold", "allowSelfStar": "Allow starring your own messages", - "ignoredChannelIds": "Ignored channel IDs", - "idsHint": "Comma-separated list of channel IDs to exclude from the starboard." + "ignoredChannelIds": "Ignored channels", + "nsfwHint": "NSFW channels are always excluded.", + "errors": { + "channelRequired": "Pick a starboard channel before enabling.", + "thresholdRange": "Threshold must be between 1 and 100." + } }, "suggestions": { "configTitle": "Suggestions settings", diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 853c690..f665493 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -272,6 +272,25 @@ - [ ] `/giveaway end` mit falscher ID → Fehlermeldung; mit korrekter ID → Reroll möglich - [ ] Dashboard „Jetzt beenden“ → Status beendet, Discord-Nachricht aktualisiert +## Post-Phase – Starboard Verbesserungen (Status: implementiert) + +### Abgeschlossen (Code) + +- `/starboard setup` funktioniert trotz `enabled: false` (Gate-Ausnahme) +- Kanal-Permission-Check vor Setup; `/starboard status` +- Sync: Source-Delete/Bulk-Delete, Message-Edit, RemoveAll/RemoveEmoji +- Race-sicheres Create + Recreate wenn Starboard-Msg fehlt +- Zod/UI: Emoji bis 80 Zeichen, Threshold 1–100, Channel Pflicht bei enabled, NSFW-Hinweis +- Unit-Tests `emojiMatches` / `shouldIgnoreMessage` + +### Manuell testen + +- [ ] `/starboard setup` auf frischem Server ohne Dashboard-Toggle +- [ ] Stern ≥ Schwelle → Post; unter Schwelle → Post entfernt +- [ ] Original löschen/editieren → Starboard aktualisiert/entfernt +- [ ] Dashboard: aktivieren ohne Kanal → Fehler + +## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert) ### Abgeschlossen (Code) diff --git a/packages/shared/src/command-locales.ts b/packages/shared/src/command-locales.ts index ae71498..53b84aa 100644 --- a/packages/shared/src/command-locales.ts +++ b/packages/shared/src/command-locales.ts @@ -747,6 +747,10 @@ export const commandLocales = { de: 'Self-Star erlauben', en: 'Allow self-star' }, + 'starboard.status.description': { + de: 'Aktuelle Starboard-Einstellungen anzeigen', + en: 'Show current starboard settings' + }, 'suggest.description': { de: 'Einen Vorschlag einreichen', en: 'Submit a suggestion' diff --git a/packages/shared/src/dashboard.ts b/packages/shared/src/dashboard.ts index cdc0f7d..889e3b2 100644 --- a/packages/shared/src/dashboard.ts +++ b/packages/shared/src/dashboard.ts @@ -514,17 +514,44 @@ export type TagDashboardUpdate = z.infer; // Starboard // --------------------------------------------------------------------------- -export const StarboardConfigDashboardSchema = z.object({ - enabled: z.boolean(), - channelId: OptionalSnowflakeSchema, - emoji: z.string().min(1).max(32), - threshold: z.number().int().positive(), - allowSelfStar: z.boolean(), - ignoredChannelIds: z.array(z.string()) -}); +export const StarboardConfigDashboardSchema = z + .object({ + enabled: z.boolean(), + channelId: OptionalSnowflakeSchema, + emoji: z.string().min(1).max(80), + threshold: z.number().int().min(1).max(100), + allowSelfStar: z.boolean(), + ignoredChannelIds: z.array(SnowflakeSchema) + }) + .superRefine((value, ctx) => { + if (value.enabled && !value.channelId) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'channelId is required when starboard is enabled', + path: ['channelId'] + }); + } + }); export type StarboardConfigDashboard = z.infer; -export const StarboardConfigDashboardPatchSchema = nonEmptyPatch(StarboardConfigDashboardSchema); +export const StarboardConfigDashboardPatchSchema = nonEmptyPatch( + z.object({ + enabled: z.boolean(), + channelId: OptionalSnowflakeSchema, + emoji: z.string().min(1).max(80), + threshold: z.number().int().min(1).max(100), + allowSelfStar: z.boolean(), + ignoredChannelIds: z.array(SnowflakeSchema) + }) +).superRefine((value, ctx) => { + if (value.enabled === true && value.channelId === '') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'channelId is required when starboard is enabled', + path: ['channelId'] + }); + } +}); export type StarboardConfigDashboardPatch = z.infer; // --------------------------------------------------------------------------- diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cd2fa0e..60dbf4d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -339,12 +339,17 @@ const de: Dictionary = { 'fun.cat.title': 'Random Cat', 'fun.dog.title': 'Random Dog', 'starboard.notConfigured': 'Starboard ist noch nicht konfiguriert. Nutze `/starboard setup`.', - 'starboard.invalidChannel': 'Der Starboard-Kanal ist ungültig.', - 'starboard.setupDone': 'Starboard konfiguriert.', + 'starboard.invalidChannel': + 'Der Starboard-Kanal ist ungültig oder dem Bot fehlen Rechte (Ansehen, Senden, Embeds).', + 'starboard.setupDone': 'Starboard konfiguriert und aktiviert.', 'starboard.noContent': '*Kein Textinhalt*', 'starboard.field.stars': 'Sterne', 'starboard.field.source': 'Quelle', 'starboard.jumpLink': 'Zur Nachricht', + 'starboard.statusLine': + 'Starboard aktiv → Kanal {channel}, Emoji {emoji}, Schwelle {threshold}, Self-Star: {selfStar}', + 'starboard.selfStarOn': 'erlaubt', + 'starboard.selfStarOff': 'verboten', 'suggestion.created': 'Vorschlag eingereicht (ID: `{id}`).', 'suggestion.setupDone': 'Vorschlagssystem konfiguriert.', 'suggestion.staffUpdated': 'Vorschlag aktualisiert ({status}).', @@ -925,12 +930,17 @@ const en: Dictionary = { 'fun.cat.title': 'Random Cat', 'fun.dog.title': 'Random Dog', 'starboard.notConfigured': 'Starboard is not configured yet. Use `/starboard setup`.', - 'starboard.invalidChannel': 'The starboard channel is invalid.', - 'starboard.setupDone': 'Starboard configured.', + 'starboard.invalidChannel': + 'The starboard channel is invalid or the bot lacks permissions (View, Send, Embed Links).', + 'starboard.setupDone': 'Starboard configured and enabled.', 'starboard.noContent': '*No text content*', 'starboard.field.stars': 'Stars', 'starboard.field.source': 'Source', 'starboard.jumpLink': 'Jump to message', + 'starboard.statusLine': + 'Starboard active → channel {channel}, emoji {emoji}, threshold {threshold}, self-star: {selfStar}', + 'starboard.selfStarOn': 'allowed', + 'starboard.selfStarOff': 'denied', 'suggestion.created': 'Suggestion submitted (ID: `{id}`).', 'suggestion.setupDone': 'Suggestion system configured.', 'suggestion.staffUpdated': 'Suggestion updated ({status}).', -- 2.47.3 From 0ebe540c3fb147194fbae694db58fad5f72d0ffb Mon Sep 17 00:00:00 2001 From: smueller Date: Fri, 24 Jul 2026 10:54:33 +0200 Subject: [PATCH 02/24] 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. --- apps/bot/src/lib/embed-payload.ts | 9 ++- apps/bot/src/modules/welcome/events.ts | 68 ++++++++++++++++--- apps/bot/src/modules/welcome/renderer.ts | 11 +-- apps/bot/src/modules/welcome/service.ts | 41 +++++++++-- .../app/dashboard/[guildId]/welcome/page.tsx | 22 +++++- .../src/components/modules/welcome-form.tsx | 67 +++++++++++++----- docs/PHASE-TRACKING.md | 16 +++++ packages/shared/src/phase2.test.ts | 11 +++ packages/shared/src/phase2.ts | 41 +++++++---- 9 files changed, 234 insertions(+), 52 deletions(-) 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 }; } -- 2.47.3 From 211403d54d721ac62e0ca10e0410b587850050cd Mon Sep 17 00:00:00 2001 From: smueller Date: Fri, 24 Jul 2026 10:59:07 +0200 Subject: [PATCH 03/24] Enhance guild management features in the owner panel - Updated the guilds API to support searching by both guild name and ID, improving user experience. - Increased the number of guilds retrieved from the database from 100 to 200 for better visibility. - Implemented invite creation functionality, allowing owners to generate invites directly from the guild management interface. - Enhanced the UI to display guild names, icons, and member counts, providing a more informative overview. - Updated localization files to reflect new search capabilities and invite-related messages in both English and German. --- apps/webui/src/app/api/owner/guilds/route.ts | 45 ++++- apps/webui/src/app/owner/guilds/page.tsx | 18 +- .../src/components/owner/owner-forms.tsx | 168 ++++++++++++------ apps/webui/src/lib/discord-guild-resources.ts | 16 +- apps/webui/src/lib/owner-guilds.ts | 145 +++++++++++++++ apps/webui/src/messages/de.json | 8 +- apps/webui/src/messages/en.json | 8 +- docs/PHASE-TRACKING.md | 14 ++ packages/shared/src/owner.ts | 13 ++ 9 files changed, 359 insertions(+), 76 deletions(-) create mode 100644 apps/webui/src/lib/owner-guilds.ts diff --git a/apps/webui/src/app/api/owner/guilds/route.ts b/apps/webui/src/app/api/owner/guilds/route.ts index d582ff4..6d7b909 100644 --- a/apps/webui/src/app/api/owner/guilds/route.ts +++ b/apps/webui/src/app/api/owner/guilds/route.ts @@ -4,27 +4,39 @@ import { toApiErrorResponse } from '@/lib/auth'; import { env } from '@/lib/env'; import { writeOwnerAudit } from '@/lib/owner-audit'; import { requireOwner } from '@/lib/owner-auth'; +import { createOwnerGuildInvite, enrichOwnerGuildListItems } from '@/lib/owner-guilds'; import { prisma } from '@/lib/prisma'; export async function GET(request: Request) { try { await requireOwner('VIEWER'); - const q = new URL(request.url).searchParams.get('q')?.trim() ?? ''; + const q = new URL(request.url).searchParams.get('q')?.trim().toLowerCase() ?? ''; const guilds = await prisma.guild.findMany({ - where: q ? { id: { contains: q } } : undefined, include: { settings: true }, orderBy: { createdAt: 'desc' }, - take: 100 + take: 200 }); const blacklist = await prisma.guildBlacklist.findMany(); const blacklisted = new Set(blacklist.map((entry) => entry.guildId)); - return NextResponse.json({ - guilds: guilds.map((guild) => ({ + const enriched = await enrichOwnerGuildListItems( + guilds.map((guild) => ({ id: guild.id, locale: guild.settings?.locale ?? 'de', createdAt: guild.createdAt.toISOString(), blacklisted: blacklisted.has(guild.id) - })), + })) + ); + + const filtered = + q.length === 0 + ? enriched + : enriched.filter( + (guild) => + guild.id.includes(q) || (guild.name?.toLowerCase().includes(q) ?? false) + ); + + return NextResponse.json({ + guilds: filtered, blacklist }); } catch (error) { @@ -34,12 +46,31 @@ export async function GET(request: Request) { export async function POST(request: Request) { try { - const session = await requireOwner('ADMIN'); const body = (await request.json()) as { action?: string; guildId?: string; reason?: string }; if (!body.guildId || !/^\d{17,20}$/.test(body.guildId)) { return NextResponse.json({ error: 'Invalid guildId' }, { status: 400 }); } + if (body.action === 'createInvite') { + const session = await requireOwner('SUPPORT'); + try { + const invite = await createOwnerGuildInvite(body.guildId); + await writeOwnerAudit(prisma, { + actorUserId: session.user.id, + action: 'guilds.createInvite', + targetType: 'Guild', + targetId: body.guildId, + after: invite + }); + return NextResponse.json({ ok: true, ...invite }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Invite creation failed'; + return NextResponse.json({ error: message }, { status: 502 }); + } + } + + const session = await requireOwner('ADMIN'); + if (body.action === 'leave') { const response = await fetch(`https://discord.com/api/v10/users/@me/guilds/${body.guildId}`, { method: 'DELETE', diff --git a/apps/webui/src/app/owner/guilds/page.tsx b/apps/webui/src/app/owner/guilds/page.tsx index 7055a53..37d8bf2 100644 --- a/apps/webui/src/app/owner/guilds/page.tsx +++ b/apps/webui/src/app/owner/guilds/page.tsx @@ -1,5 +1,6 @@ import { GuildsManager } from '@/components/owner/owner-forms'; import { getLocale, t } from '@/lib/i18n'; +import { enrichOwnerGuildListItems } from '@/lib/owner-guilds'; import { prisma } from '@/lib/prisma'; export default async function OwnerGuildsPage() { @@ -13,6 +14,14 @@ export default async function OwnerGuildsPage() { prisma.guildBlacklist.findMany() ]); const blacklisted = new Set(blacklist.map((entry) => entry.guildId)); + const initialGuilds = await enrichOwnerGuildListItems( + guilds.map((guild) => ({ + id: guild.id, + locale: guild.settings?.locale ?? 'de', + createdAt: guild.createdAt.toISOString(), + blacklisted: blacklisted.has(guild.id) + })) + ); return (
@@ -20,14 +29,7 @@ export default async function OwnerGuildsPage() {

{t(locale, 'owner.guilds.title')}

{t(locale, 'owner.guilds.subtitle')}

- ({ - id: guild.id, - locale: guild.settings?.locale ?? 'de', - createdAt: guild.createdAt.toISOString(), - blacklisted: blacklisted.has(guild.id) - }))} - /> + ); } diff --git a/apps/webui/src/components/owner/owner-forms.tsx b/apps/webui/src/components/owner/owner-forms.tsx index 2d5907f..89517be 100644 --- a/apps/webui/src/components/owner/owner-forms.tsx +++ b/apps/webui/src/components/owner/owner-forms.tsx @@ -3,9 +3,10 @@ import { useRouter } from 'next/navigation'; import { useMemo, useState, useTransition } from 'react'; import { toast } from 'sonner'; -import type { BotPresenceConfig, OwnerRole } from '@nexumi/shared'; +import type { BotPresenceConfig, OwnerGuildListItem, OwnerRole } from '@nexumi/shared'; import { useTranslations } from '@/components/locale-provider'; import { SettingsForm } from '@/components/settings/settings-form'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; @@ -195,16 +196,23 @@ export function UsersManager({ export function GuildsManager({ initialGuilds }: { - initialGuilds: Array<{ id: string; locale: string; createdAt: string; blacklisted: boolean }>; + initialGuilds: OwnerGuildListItem[]; }) { const t = useTranslations(); const router = useRouter(); const [query, setQuery] = useState(''); const [pending, startTransition] = useTransition(); - const filtered = useMemo( - () => initialGuilds.filter((guild) => guild.id.includes(query.trim())), - [initialGuilds, query] - ); + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) { + return initialGuilds; + } + return initialGuilds.filter( + (guild) => + guild.id.includes(needle) || + (guild.name?.toLowerCase().includes(needle) ?? false) + ); + }, [initialGuilds, query]); async function postAction(action: string, guildId: string, reason?: string) { const response = await fetch('/api/owner/guilds', { @@ -215,6 +223,7 @@ export function GuildsManager({ if (!response.ok) { throw new Error(await readError(response)); } + return (await response.json()) as { ok?: boolean; url?: string }; } return ( @@ -225,58 +234,103 @@ export function GuildsManager({ {filtered.length === 0 ? (

{t('owner.common.empty')}

) : ( - filtered.map((guild) => ( -
-
-

{guild.id}

-

- {guild.locale} · {new Date(guild.createdAt).toLocaleDateString()} - {guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''} -

+ filtered.map((guild) => { + const displayName = guild.name ?? t('owner.guilds.unknownName'); + const initials = displayName.slice(0, 2).toUpperCase(); + return ( +
+
+ + {guild.iconUrl ? : null} + {initials} + +
+

{displayName}

+

+ {guild.id} + {' · '} + {guild.memberCount != null + ? t('owner.guilds.members', { count: String(guild.memberCount) }) + : t('owner.guilds.membersUnknown')} + {' · '} + {guild.locale} · {new Date(guild.createdAt).toLocaleDateString()} + {guild.blacklisted ? ` · ${t('owner.guilds.blacklisted')}` : ''} +

+
+
+
+ + + +
-
- - -
-
- )) + ); + }) )} diff --git a/apps/webui/src/lib/discord-guild-resources.ts b/apps/webui/src/lib/discord-guild-resources.ts index fbd96a3..e67b836 100644 --- a/apps/webui/src/lib/discord-guild-resources.ts +++ b/apps/webui/src/lib/discord-guild-resources.ts @@ -6,14 +6,26 @@ import { redis } from './redis'; const DISCORD_API_BASE = 'https://discord.com/api/v10'; const CACHE_TTL_SECONDS = 60; -async function discordBotFetch(path: string): Promise { +export async function discordBotFetch( + path: string, + init?: { method?: string; body?: unknown } +): Promise { + const method = init?.method ?? 'GET'; const response = await fetch(`${DISCORD_API_BASE}${path}`, { - headers: { Authorization: `Bot ${env.BOT_TOKEN}` }, + method, + headers: { + Authorization: `Bot ${env.BOT_TOKEN}`, + ...(init?.body !== undefined ? { 'Content-Type': 'application/json' } : {}) + }, + body: init?.body !== undefined ? JSON.stringify(init.body) : undefined, cache: 'no-store' }); if (!response.ok) { throw new Error(`Discord API request to ${path} failed with status ${response.status}`); } + if (response.status === 204) { + return undefined as T; + } return (await response.json()) as T; } diff --git a/apps/webui/src/lib/owner-guilds.ts b/apps/webui/src/lib/owner-guilds.ts new file mode 100644 index 0000000..10d969d --- /dev/null +++ b/apps/webui/src/lib/owner-guilds.ts @@ -0,0 +1,145 @@ +import type { OwnerGuildListItem } from '@nexumi/shared'; +import { discordBotFetch } from './discord-guild-resources'; +import { redis } from './redis'; + +const META_CACHE_TTL_SECONDS = 300; +const INVITE_CHANNEL_TYPES = new Set([0, 2, 5, 13]); // text, voice, announcement, stage + +interface DiscordGuildRest { + id: string; + name: string; + icon: string | null; + system_channel_id?: string | null; + approximate_member_count?: number; +} + +interface DiscordChannelRest { + id: string; + name: string; + type: number; + position?: number; +} + +interface DiscordInviteRest { + code: string; + channel?: { id: string }; +} + +export type OwnerGuildDiscordMeta = { + id: string; + name: string; + icon: string | null; + iconUrl: string | null; + memberCount: number | null; +}; + +function guildIconUrl(guildId: string, icon: string | null): string | null { + if (!icon) { + return null; + } + const ext = icon.startsWith('a_') ? 'gif' : 'png'; + return `https://cdn.discordapp.com/icons/${guildId}/${icon}.${ext}?size=64`; +} + +export async function getOwnerGuildDiscordMeta(guildId: string): Promise { + const cacheKey = `owner:guild:${guildId}:meta:v1`; + const cached = await redis.get(cacheKey); + if (cached) { + return JSON.parse(cached) as OwnerGuildDiscordMeta; + } + + try { + const guild = await discordBotFetch(`/guilds/${guildId}?with_counts=true`); + const meta: OwnerGuildDiscordMeta = { + id: guild.id, + name: guild.name, + icon: guild.icon, + iconUrl: guildIconUrl(guild.id, guild.icon), + memberCount: guild.approximate_member_count ?? null + }; + await redis.set(cacheKey, JSON.stringify(meta), 'EX', META_CACHE_TTL_SECONDS); + return meta; + } catch { + return null; + } +} + +async function mapPool(items: T[], concurrency: number, fn: (item: T) => Promise): Promise { + const results = new Array(items.length); + let nextIndex = 0; + + async function worker(): Promise { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await fn(items[index]!); + } + } + + const workers = Array.from({ length: Math.min(concurrency, Math.max(items.length, 1)) }, () => worker()); + await Promise.all(workers); + return results; +} + +export async function enrichOwnerGuildListItems( + base: Array<{ + id: string; + locale: string; + createdAt: string; + blacklisted: boolean; + }> +): Promise { + const metas = await mapPool(base, 8, (guild) => getOwnerGuildDiscordMeta(guild.id)); + return base.map((guild, index) => { + const meta = metas[index]; + return { + id: guild.id, + name: meta?.name ?? null, + iconUrl: meta?.iconUrl ?? null, + memberCount: meta?.memberCount ?? null, + locale: guild.locale, + createdAt: guild.createdAt, + blacklisted: guild.blacklisted + }; + }); +} + +export async function createOwnerGuildInvite(guildId: string): Promise<{ + url: string; + code: string; + channelId: string; +}> { + const guild = await discordBotFetch(`/guilds/${guildId}?with_counts=true`); + const channels = await discordBotFetch(`/guilds/${guildId}/channels`); + const candidates = channels + .filter((channel) => INVITE_CHANNEL_TYPES.has(channel.type)) + .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + + const ordered = [ + ...candidates.filter((channel) => channel.id === guild.system_channel_id), + ...candidates.filter((channel) => channel.id !== guild.system_channel_id) + ]; + + let lastError: Error | null = null; + for (const channel of ordered) { + try { + const invite = await discordBotFetch(`/channels/${channel.id}/invites`, { + method: 'POST', + body: { + max_age: 86_400, + max_uses: 5, + unique: true + } + }); + return { + code: invite.code, + url: `https://discord.gg/${invite.code}`, + channelId: channel.id + }; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + + throw lastError ?? new Error('Could not create an invite for this guild'); +} diff --git a/apps/webui/src/messages/de.json b/apps/webui/src/messages/de.json index 6a76f60..142caf8 100644 --- a/apps/webui/src/messages/de.json +++ b/apps/webui/src/messages/de.json @@ -990,7 +990,13 @@ "guilds": { "title": "Server-Verwaltung", "subtitle": "Server suchen, blacklisten oder den Bot entfernen.", - "search": "Server-ID suchen…", + "search": "Servername oder ID suchen…", + "unknownName": "Unbekannter Server", + "members": "{count} Mitglieder", + "membersUnknown": "Mitglieder unbekannt", + "createInvite": "Invite erstellen", + "inviteCopied": "Invite kopiert: {url}", + "inviteCreated": "Invite erstellt: {url}", "blacklisted": "Blacklist", "blacklist": "Blacklisten", "unblacklist": "Blacklist entfernen", diff --git a/apps/webui/src/messages/en.json b/apps/webui/src/messages/en.json index f8b2d74..1fb186a 100644 --- a/apps/webui/src/messages/en.json +++ b/apps/webui/src/messages/en.json @@ -990,7 +990,13 @@ "guilds": { "title": "Guild management", "subtitle": "Search guilds, blacklist them, or leave with the bot.", - "search": "Search guild ID…", + "search": "Search guild name or ID…", + "unknownName": "Unknown guild", + "members": "{count} members", + "membersUnknown": "Member count unknown", + "createInvite": "Create invite", + "inviteCopied": "Invite copied: {url}", + "inviteCreated": "Invite created: {url}", "blacklisted": "Blacklisted", "blacklist": "Blacklist", "unblacklist": "Remove blacklist", diff --git a/docs/PHASE-TRACKING.md b/docs/PHASE-TRACKING.md index 4d420ce..0fbcf18 100644 --- a/docs/PHASE-TRACKING.md +++ b/docs/PHASE-TRACKING.md @@ -306,6 +306,20 @@ - [ ] 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 – Owner Guild-Liste Meta + Invite (Status: implementiert) + +### Abgeschlossen (Code) + +- Owner `/owner/guilds`: Discord-Name, Icon, Member-Count, ID (Live-Meta via Bot-Token, Redis-Cache) +- Suche nach Name und ID +- Aktion „Invite erstellen“ (Systemkanal zuerst, max. 24h / 5 Uses, Clipboard + Owner-Audit) + +### Manuell testen + +- [ ] Serverliste zeigt Name/Logo/Mitglieder statt nur ID +- [ ] Suche nach Servername filtert +- [ ] Invite erstellen → Link im Toast/Clipboard; Bot braucht Create Instant Invite + ## Post-Phase – Automod/Moderation Dashboard Ausbau (Status: implementiert) ### Abgeschlossen (Code) diff --git a/packages/shared/src/owner.ts b/packages/shared/src/owner.ts index 33158e5..ea36d96 100644 --- a/packages/shared/src/owner.ts +++ b/packages/shared/src/owner.ts @@ -79,6 +79,19 @@ export const GuildBlacklistSchema = z.object({ export type GuildBlacklist = z.infer; +/** Enriched guild row for the Owner panel guild manager. */ +export const OwnerGuildListItemSchema = z.object({ + id: snowflake, + name: z.string().nullable(), + iconUrl: z.string().url().nullable(), + memberCount: z.number().int().nonnegative().nullable(), + locale: z.string().min(1).max(16), + createdAt: z.string(), + blacklisted: z.boolean() +}); + +export type OwnerGuildListItem = z.infer; + export const ChangelogEntrySchema = z.object({ id: z.string().optional(), version: z.string().min(1).max(32), -- 2.47.3 From e45642b6f96d4256e62c06a88fc0b59ff7f91887 Mon Sep 17 00:00:00 2001 From: smueller Date: Fri, 24 Jul 2026 11:11:58 +0200 Subject: [PATCH 04/24] Implement maintenance mode and presence updates in bot and WebUI - Added maintenance mode functionality in the bot, allowing for a custom message and DND status when enabled. - Updated the OwnerPresencePage to include read-only access for VIEWER roles, with save permissions restricted to ADMIN roles. - Enhanced the PresenceForm component to support immediate application of changes and added a confirmation for maintenance mode activation. - Introduced localization updates for presence-related messages in both English and German, improving user guidance. - Implemented a presence refresh queue to handle updates efficiently after configuration changes. --- apps/bot/src/presence.ts | 17 ++ .../app/dashboard/[guildId]/welcome/page.tsx | 3 +- apps/webui/src/app/owner/presence/page.tsx | 9 +- .../src/components/modules/welcome-form.tsx | 47 +--- .../src/components/owner/owner-forms.tsx | 99 +------ .../src/components/owner/presence-form.tsx | 249 ++++++++++++++++++ .../src/components/settings/settings-form.tsx | 19 +- apps/webui/src/lib/owner-presence.ts | 9 + apps/webui/src/lib/queues.ts | 9 + apps/webui/src/lib/welcome-preview-vars.ts | 40 +++ apps/webui/src/messages/de.json | 25 +- apps/webui/src/messages/en.json | 25 +- docs/PHASE-TRACKING.md | 16 ++ 13 files changed, 414 insertions(+), 153 deletions(-) create mode 100644 apps/webui/src/components/owner/presence-form.tsx create mode 100644 apps/webui/src/lib/welcome-preview-vars.ts diff --git a/apps/bot/src/presence.ts b/apps/bot/src/presence.ts index c94bdff..2be5608 100644 --- a/apps/bot/src/presence.ts +++ b/apps/bot/src/presence.ts @@ -120,6 +120,23 @@ export async function readPresenceConfig(context: BotContext): Promise { const config = await readPresenceConfig(context); + + if (config.maintenanceMode) { + const text = + config.maintenanceMessage?.trim() || + 'Maintenance mode — please try again later.'; + await context.client.user?.setPresence({ + status: 'dnd', + activities: [ + { + name: text.slice(0, 128), + type: ActivityType.Watching + } + ] + }); + return; + } + const messages = config.rotatingMessages.length > 0 ? config.rotatingMessages : [config.activityText]; const index = Math.floor(Date.now() / 60_000) % messages.length; diff --git a/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx b/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx index fe20cf1..4c7adc7 100644 --- a/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx +++ b/apps/webui/src/app/dashboard/[guildId]/welcome/page.tsx @@ -1,9 +1,10 @@ import { notFound } from 'next/navigation'; -import { buildWelcomePreviewVars, WelcomeForm } from '@/components/modules/welcome-form'; +import { 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'; +import { buildWelcomePreviewVars } from '@/lib/welcome-preview-vars'; interface WelcomePageProps { params: Promise<{ guildId: string }>; diff --git a/apps/webui/src/app/owner/presence/page.tsx b/apps/webui/src/app/owner/presence/page.tsx index b2a8246..71ea8b3 100644 --- a/apps/webui/src/app/owner/presence/page.tsx +++ b/apps/webui/src/app/owner/presence/page.tsx @@ -1,16 +1,21 @@ -import { PresenceForm } from '@/components/owner/owner-forms'; +import { ownerRoleAtLeast } from '@nexumi/shared'; +import { PresenceForm } from '@/components/owner/presence-form'; import { getLocale, t } from '@/lib/i18n'; +import { requireOwnerOrRedirect } from '@/lib/owner-auth'; import { getPresenceConfig } from '@/lib/owner-presence'; export default async function OwnerPresencePage() { + const session = await requireOwnerOrRedirect('VIEWER'); const [locale, config] = await Promise.all([getLocale(), getPresenceConfig()]); + const canEdit = ownerRoleAtLeast(session.ownerRole, 'ADMIN'); + return (

{t(locale, 'owner.presence.title')}

{t(locale, 'owner.presence.subtitle')}

- +
); } diff --git a/apps/webui/src/components/modules/welcome-form.tsx b/apps/webui/src/components/modules/welcome-form.tsx index 4367552..4218d5c 100644 --- a/apps/webui/src/components/modules/welcome-form.tsx +++ b/apps/webui/src/components/modules/welcome-form.tsx @@ -1,12 +1,6 @@ 'use client'; -import type { - DashboardGuild, - MessageComponentsV2, - SessionUser, - WelcomeConfigDashboard, - WelcomeEmbed -} from '@nexumi/shared'; +import type { MessageComponentsV2, 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'; @@ -31,45 +25,6 @@ import { Textarea } from '@/components/ui/textarea'; import type { SettingsSaveResult } from '@/components/settings/settings-form'; import { SettingsForm } from '@/components/settings/settings-form'; -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, 'welcomeEmbed' | 'leaveEmbed' | 'welcomeComponents' | 'leaveComponents' diff --git a/apps/webui/src/components/owner/owner-forms.tsx b/apps/webui/src/components/owner/owner-forms.tsx index 89517be..97bb51d 100644 --- a/apps/webui/src/components/owner/owner-forms.tsx +++ b/apps/webui/src/components/owner/owner-forms.tsx @@ -3,115 +3,18 @@ import { useRouter } from 'next/navigation'; import { useMemo, useState, useTransition } from 'react'; import { toast } from 'sonner'; -import type { BotPresenceConfig, OwnerGuildListItem, OwnerRole } from '@nexumi/shared'; +import type { OwnerGuildListItem, OwnerRole } from '@nexumi/shared'; import { useTranslations } from '@/components/locale-provider'; -import { SettingsForm } from '@/components/settings/settings-form'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; async function readError(response: Response): Promise { const data = (await response.json().catch(() => null)) as { error?: string } | null; return data?.error ?? 'Request failed'; } -export function PresenceForm({ initialValue }: { initialValue: BotPresenceConfig }) { - const t = useTranslations(); - return ( - { - const response = await fetch('/api/owner/presence', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(value) - }); - if (!response.ok) { - return { ok: false, error: await readError(response) }; - } - return { ok: true }; - }} - > - {({ value, setValue, error }) => ( - - - {error &&

{error}

} -
-
- - -
-
- - -
-
-
- - setValue({ ...value, activityText: e.target.value })} /> -
-
- -