From ccdf9aafe83af27b19c6a3e3131303ee987eeacf Mon Sep 17 00:00:00 2001 From: smueller Date: Fri, 24 Jul 2026 10:46:12 +0200 Subject: [PATCH] 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}).',