deploy #2

Merged
smueller merged 24 commits from deploy into main 2026-07-29 07:44:03 +00:00
13 changed files with 549 additions and 87 deletions
Showing only changes of commit ccdf9aafe8 - Show all commits

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<TextChannel | null> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
if (!message.guildId || !message.id) {
return;
}
await removeStarboardEntryBySource(context, message.guildId, message.id);
}
export async function processStarboardSourceUpdate(
context: BotContext,
message: Message | PartialMessage
): Promise<void> {
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);
}

View File

@@ -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<SettingsSaveResult> {
async function saveStarboard(
guildId: string,
value: StarboardConfigDashboard,
t: (key: string) => string
): Promise<SettingsSaveResult> {
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 (
<SettingsForm<StarboardConfigDashboard>
initialValue={initialValue}
onSave={(value) => saveStarboard(guildId, value)}
onSave={(value) => saveStarboard(guildId, value, t)}
>
{({ value, setValue }) => (
<Card>
@@ -52,50 +63,85 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
</CardHeader>
<CardContent className="space-y-4">
<FieldAnchor id="field-starboard-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.starboard.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} />
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<div className="space-y-0.5 pr-4">
<Label>{t('modulePages.starboard.enabledLabel')}</Label>
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.enabledHint')}</p>
</div>
<Switch
checked={value.enabled}
onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))}
/>
</div>
</FieldAnchor>
<div className="grid gap-4 sm:grid-cols-3">
<FieldAnchor id="field-starboard-channel">
<div className="space-y-2">
<Label>{t('modulePages.starboard.channelId')}</Label>
<DiscordChannelSelect
guildId={guildId}
value={value.channelId}
onChange={(channelId) => setValue((prev) => ({ ...prev, channelId }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.starboard.channelId')}</Label>
<DiscordChannelSelect
guildId={guildId}
value={value.channelId}
onChange={(channelId) => setValue((prev) => ({ ...prev, channelId }))}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-starboard-emoji">
<div className="space-y-2">
<Label htmlFor={emojiId}>{t('modulePages.starboard.emoji')}</Label>
<Input id={emojiId} value={value.emoji} onChange={(event) => setValue((prev) => ({ ...prev, emoji: event.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor={emojiId}>{t('modulePages.starboard.emoji')}</Label>
<Input
id={emojiId}
value={value.emoji}
maxLength={80}
onChange={(event) =>
setValue((prev) => ({ ...prev, emoji: event.target.value }))
}
placeholder="⭐ or <:name:id>"
/>
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.emojiHint')}</p>
</div>
</FieldAnchor>
<FieldAnchor id="field-starboard-threshold">
<div className="space-y-2">
<Label htmlFor={thresholdId}>{t('modulePages.starboard.threshold')}</Label>
<Input id={thresholdId} type="number" min={1} value={value.threshold} onChange={(event) => setValue((prev) => ({ ...prev, threshold: Number(event.target.value) || 1 }))} />
</div>
<div className="space-y-2">
<Label htmlFor={thresholdId}>{t('modulePages.starboard.threshold')}</Label>
<Input
id={thresholdId}
type="number"
min={1}
max={100}
value={value.threshold}
onChange={(event) =>
setValue((prev) => ({
...prev,
threshold: Number(event.target.value) || 1
}))
}
/>
</div>
</FieldAnchor>
</div>
<FieldAnchor id="field-starboard-self">
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.starboard.allowSelfStar')}</Label>
<Switch checked={value.allowSelfStar} onCheckedChange={(allowSelfStar) => setValue((prev) => ({ ...prev, allowSelfStar }))} />
</div>
<div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.starboard.allowSelfStar')}</Label>
<Switch
checked={value.allowSelfStar}
onCheckedChange={(allowSelfStar) =>
setValue((prev) => ({ ...prev, allowSelfStar }))
}
/>
</div>
</FieldAnchor>
<FieldAnchor id="field-starboard-ignored">
<div className="space-y-2">
<Label>{t('modulePages.starboard.ignoredChannelIds')}</Label>
<DiscordChannelMultiSelect
guildId={guildId}
value={value.ignoredChannelIds}
onChange={(ignoredChannelIds) => setValue((prev) => ({ ...prev, ignoredChannelIds }))}
/>
</div>
<div className="space-y-2">
<Label>{t('modulePages.starboard.ignoredChannelIds')}</Label>
<DiscordChannelMultiSelect
guildId={guildId}
value={value.ignoredChannelIds}
onChange={(ignoredChannelIds) =>
setValue((prev) => ({ ...prev, ignoredChannelIds }))
}
/>
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.nsfwHint')}</p>
</div>
</FieldAnchor>
</CardContent>
</Card>

View File

@@ -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> / <a: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",

View File

@@ -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> / <a: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",

View File

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

View File

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

View File

@@ -514,17 +514,44 @@ export type TagDashboardUpdate = z.infer<typeof TagDashboardUpdateSchema>;
// 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<typeof StarboardConfigDashboardSchema>;
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<typeof StarboardConfigDashboardPatchSchema>;
// ---------------------------------------------------------------------------

View File

@@ -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}).',