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,6 +214,11 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
moduleId moduleId
); );
if (!enabled) { if (!enabled) {
// 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({ await interaction.reply({
content: t(locale, 'generic.moduleDisabled'), content: t(locale, 'generic.moduleDisabled'),
ephemeral: true ephemeral: true
@@ -221,6 +226,7 @@ export async function routeCommand(interaction: ChatInputCommandInteraction, con
return; return;
} }
} }
}
const gate = await evaluateCommandGate(interaction, context); const gate = await evaluateCommandGate(interaction, context);
if (!gate.ok) { if (!gate.ok) {

View File

@@ -1,15 +1,19 @@
import { ChannelType } from 'discord.js'; import { ChannelType, PermissionFlagsBits } from 'discord.js';
import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared'; import { applyCommandDescription, applyOptionDescription } from '@nexumi/shared';
import { SlashCommandBuilder } from 'discord.js'; import { SlashCommandBuilder } from 'discord.js';
export const starboardCommandData = applyCommandDescription( export const starboardCommandData = applyCommandDescription(
new SlashCommandBuilder() new SlashCommandBuilder()
.setName('starboard') .setName('starboard')
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.addSubcommand((s) => .addSubcommand((s) =>
applyCommandDescription(s.setName('setup'), 'starboard.setup.description') applyCommandDescription(s.setName('setup'), 'starboard.setup.description')
.addChannelOption((o) => .addChannelOption((o) =>
applyOptionDescription( applyOptionDescription(
o.setName('channel').addChannelTypes(ChannelType.GuildText).setRequired(true), o
.setName('channel')
.addChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement)
.setRequired(true),
'starboard.setup.options.channel' 'starboard.setup.options.channel'
) )
) )
@@ -28,6 +32,9 @@ export const starboardCommandData = applyCommandDescription(
'starboard.setup.options.allow_self_star' 'starboard.setup.options.allow_self_star'
) )
) )
)
.addSubcommand((s) =>
applyCommandDescription(s.setName('status'), 'starboard.status.description')
), ),
'starboard.description' 'starboard.description'
); );

View File

@@ -1,17 +1,18 @@
import { PermissionFlagsBits } from 'discord.js'; import { PermissionFlagsBits } from 'discord.js';
import { t } from '@nexumi/shared'; import { t, tf } from '@nexumi/shared';
import type { SlashCommand } from '../../types.js'; import type { SlashCommand } from '../../types.js';
import { getGuildLocale } from '../../i18n.js'; import { getGuildLocale } from '../../i18n.js';
import { requirePermission } from '../../permissions.js'; import { requirePermission } from '../../permissions.js';
import { ephemeral } from '../../interaction-reply.js';
import { starboardCommandData } from './command-definitions.js'; import { starboardCommandData } from './command-definitions.js';
import { getStarboardConfig, updateStarboardConfig } from './service.js'; import { botCanPostStarboard, getStarboardConfig, updateStarboardConfig } from './service.js';
const starboardCommand: SlashCommand = { const starboardCommand: SlashCommand = {
data: starboardCommandData, data: starboardCommandData,
async execute(interaction, context) { async execute(interaction, context) {
const locale = await getGuildLocale(context.prisma, interaction.guildId); const locale = await getGuildLocale(context.prisma, interaction.guildId);
if (!interaction.inGuild()) { if (!interaction.inGuild()) {
await interaction.reply({ content: t(locale, 'generic.guildOnly'), ephemeral: true }); await interaction.reply({ content: t(locale, 'generic.guildOnly'), ...ephemeral() });
return; return;
} }
if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) { if (!(await requirePermission(interaction, PermissionFlagsBits.ManageGuild, locale))) {
@@ -22,11 +23,21 @@ const starboardCommand: SlashCommand = {
const sub = interaction.options.getSubcommand(); const sub = interaction.options.getSubcommand();
if (sub === 'setup') { 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 threshold = interaction.options.getInteger('threshold', true);
const emoji = interaction.options.getString('emoji') ?? '⭐'; const emoji = interaction.options.getString('emoji') ?? '⭐';
const allowSelfStar = interaction.options.getBoolean('allow_self_star') ?? false; 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, { await updateStarboardConfig(context.prisma, guildId, {
enabled: true, enabled: true,
channelId: channel.id, channelId: channel.id,
@@ -35,13 +46,30 @@ const starboardCommand: SlashCommand = {
allowSelfStar allowSelfStar
}); });
await interaction.reply({ content: t(locale, 'starboard.setupDone'), ephemeral: true }); await interaction.reply({ content: t(locale, 'starboard.setupDone'), ...ephemeral() });
return; return;
} }
if (sub === 'status') {
const config = await getStarboardConfig(context.prisma, guildId); const config = await getStarboardConfig(context.prisma, guildId);
if (!config.enabled || !config.channelId) { if (!config.enabled || !config.channelId) {
await interaction.reply({ content: t(locale, 'starboard.notConfigured'), ephemeral: true }); 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 type { BotContext } from '../../types.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import { processStarboardReaction } from './service.js'; import {
processStarboardReaction,
processStarboardSourceDelete,
processStarboardSourceUpdate
} from './service.js';
export function registerStarboardEvents(context: BotContext): void { export function registerStarboardEvents(context: BotContext): void {
const handleReaction = async ( const handleReaction = async (
@@ -26,4 +31,48 @@ export function registerStarboardEvents(context: BotContext): void {
} }
await handleReaction(_reaction); 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 { import {
ChannelType,
EmbedBuilder, EmbedBuilder,
PermissionFlagsBits,
type Emoji, type Emoji,
type GuildBasedChannel,
type Message, type Message,
type PartialMessage,
type PartialMessageReaction, type PartialMessageReaction,
type MessageReaction, type MessageReaction,
type TextChannel type TextChannel
} from 'discord.js'; } from 'discord.js';
import type { PrismaClient, StarboardConfig } from '@prisma/client'; import type { PrismaClient, StarboardConfig } from '@prisma/client';
import { t } from '@nexumi/shared'; import { t, type Locale } from '@nexumi/shared';
import type { Locale } from '@nexumi/shared';
import { ensureGuild } from '../../guild.js'; import { ensureGuild } from '../../guild.js';
import { logger } from '../../logger.js'; import { logger } from '../../logger.js';
import type { BotContext } from '../../types.js'; import type { BotContext } from '../../types.js';
@@ -73,6 +76,27 @@ export function shouldIgnoreMessage(message: Message, config: StarboardConfig):
return false; 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( export async function countMatchingStars(
message: Message, message: Message,
config: StarboardConfig config: StarboardConfig
@@ -109,6 +133,10 @@ function resolveImageUrl(message: Message): string | undefined {
return embedImage ?? undefined; return embedImage ?? undefined;
} }
function authorDisplayName(message: Message): string {
return message.member?.displayName || message.author.displayName || message.author.username;
}
export function buildStarboardEmbed( export function buildStarboardEmbed(
message: Message, message: Message,
starCount: number, starCount: number,
@@ -119,7 +147,7 @@ export function buildStarboardEmbed(
const embed = new EmbedBuilder() const embed = new EmbedBuilder()
.setColor(0x6366f1) .setColor(0x6366f1)
.setAuthor({ .setAuthor({
name: message.author.tag, name: authorDisplayName(message),
iconURL: message.author.displayAvatarURL() iconURL: message.author.displayAvatarURL()
}) })
.setDescription(content.slice(0, 4096)) .setDescription(content.slice(0, 4096))
@@ -143,6 +171,17 @@ export function buildStarboardEmbed(
return embed; 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> { async function removeStarboardEntry(context: BotContext, entryId: string): Promise<void> {
const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } }); const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } });
if (!entry) { if (!entry) {
@@ -152,10 +191,14 @@ async function removeStarboardEntry(context: BotContext, entryId: string): Promi
try { try {
const config = await getStarboardConfig(context.prisma, entry.guildId); const config = await getStarboardConfig(context.prisma, entry.guildId);
if (config.channelId) { if (config.channelId) {
const channel = (await context.client.channels.fetch(config.channelId)) as TextChannel; const channel = await fetchStarboardTextChannel(context, config.channelId);
const starboardMessage = await channel.messages.fetch(entry.starboardMessageId).catch(() => null); if (channel) {
const starboardMessage = await channel.messages
.fetch(entry.starboardMessageId)
.catch(() => null);
await starboardMessage?.delete().catch(() => undefined); await starboardMessage?.delete().catch(() => undefined);
} }
}
} catch (error) { } catch (error) {
logger.warn({ error, entryId }, 'Failed to delete starboard message'); 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); 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> { export async function processStarboardMessage(context: BotContext, message: Message): Promise<void> {
if (!message.guild) { if (!message.guild) {
return; return;
@@ -194,8 +313,13 @@ export async function processStarboardMessage(context: BotContext, message: Mess
return; 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 embed = buildStarboardEmbed(message, starCount, config, locale);
const starboardChannel = (await context.client.channels.fetch(config.channelId)) as TextChannel;
if (existing) { if (existing) {
try { try {
@@ -206,21 +330,28 @@ export async function processStarboardMessage(context: BotContext, message: Mess
data: { starCount } data: { starCount }
}); });
} catch (error) { } 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; return;
} }
const starboardMessage = await starboardChannel.send({ embeds: [embed] }); await createOrRecreateStarboardPost(
await context.prisma.starboardEntry.create({ context,
data: { message,
guildId: message.guild.id, config,
sourceChannelId: message.channel.id, locale,
sourceMessageId: message.id, starCount,
starboardMessageId: starboardMessage.id, starboardChannel
starCount );
}
});
} }
export async function processStarboardReaction( export async function processStarboardReaction(
@@ -249,3 +380,41 @@ export async function processStarboardReaction(
await processStarboardMessage(context, message as Message); 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 type { SettingsSaveResult } from '@/components/settings/settings-form';
import { SettingsForm } 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 { try {
const response = await fetch(`/api/guilds/${guildId}/starboard`, { const response = await fetch(`/api/guilds/${guildId}/starboard`, {
method: 'PATCH', method: 'PATCH',
@@ -42,7 +53,7 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
return ( return (
<SettingsForm<StarboardConfigDashboard> <SettingsForm<StarboardConfigDashboard>
initialValue={initialValue} initialValue={initialValue}
onSave={(value) => saveStarboard(guildId, value)} onSave={(value) => saveStarboard(guildId, value, t)}
> >
{({ value, setValue }) => ( {({ value, setValue }) => (
<Card> <Card>
@@ -53,8 +64,14 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<FieldAnchor id="field-starboard-enabled"> <FieldAnchor id="field-starboard-enabled">
<div className="flex items-center justify-between rounded-md border border-border p-4"> <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> <Label>{t('modulePages.starboard.enabledLabel')}</Label>
<Switch checked={value.enabled} onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))} /> <p className="text-xs text-muted-foreground">{t('modulePages.starboard.enabledHint')}</p>
</div>
<Switch
checked={value.enabled}
onCheckedChange={(enabled) => setValue((prev) => ({ ...prev, enabled }))}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
@@ -71,20 +88,46 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
<FieldAnchor id="field-starboard-emoji"> <FieldAnchor id="field-starboard-emoji">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={emojiId}>{t('modulePages.starboard.emoji')}</Label> <Label htmlFor={emojiId}>{t('modulePages.starboard.emoji')}</Label>
<Input id={emojiId} value={value.emoji} onChange={(event) => setValue((prev) => ({ ...prev, emoji: event.target.value }))} /> <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> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-starboard-threshold"> <FieldAnchor id="field-starboard-threshold">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={thresholdId}>{t('modulePages.starboard.threshold')}</Label> <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 }))} /> <Input
id={thresholdId}
type="number"
min={1}
max={100}
value={value.threshold}
onChange={(event) =>
setValue((prev) => ({
...prev,
threshold: Number(event.target.value) || 1
}))
}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
</div> </div>
<FieldAnchor id="field-starboard-self"> <FieldAnchor id="field-starboard-self">
<div className="flex items-center justify-between rounded-md border border-border p-4"> <div className="flex items-center justify-between rounded-md border border-border p-4">
<Label>{t('modulePages.starboard.allowSelfStar')}</Label> <Label>{t('modulePages.starboard.allowSelfStar')}</Label>
<Switch checked={value.allowSelfStar} onCheckedChange={(allowSelfStar) => setValue((prev) => ({ ...prev, allowSelfStar }))} /> <Switch
checked={value.allowSelfStar}
onCheckedChange={(allowSelfStar) =>
setValue((prev) => ({ ...prev, allowSelfStar }))
}
/>
</div> </div>
</FieldAnchor> </FieldAnchor>
<FieldAnchor id="field-starboard-ignored"> <FieldAnchor id="field-starboard-ignored">
@@ -93,8 +136,11 @@ export function StarboardForm({ guildId, initialValue }: StarboardFormProps) {
<DiscordChannelMultiSelect <DiscordChannelMultiSelect
guildId={guildId} guildId={guildId}
value={value.ignoredChannelIds} value={value.ignoredChannelIds}
onChange={(ignoredChannelIds) => setValue((prev) => ({ ...prev, ignoredChannelIds }))} onChange={(ignoredChannelIds) =>
setValue((prev) => ({ ...prev, ignoredChannelIds }))
}
/> />
<p className="text-xs text-muted-foreground">{t('modulePages.starboard.nsfwHint')}</p>
</div> </div>
</FieldAnchor> </FieldAnchor>
</CardContent> </CardContent>

View File

@@ -768,12 +768,18 @@
"title": "Starboard", "title": "Starboard",
"description": "Beliebte Nachrichten in einem eigenen Kanal hervorheben.", "description": "Beliebte Nachrichten in einem eigenen Kanal hervorheben.",
"enabledLabel": "Starboard aktiviert", "enabledLabel": "Starboard aktiviert",
"channelId": "Starboard-Kanal-ID", "enabledHint": "Ohne Zielkanal kann Starboard nicht aktiviert werden.",
"channelId": "Starboard-Kanal",
"emoji": "Auslöse-Emoji", "emoji": "Auslöse-Emoji",
"emojiHint": "Unicode (⭐) oder Custom-Emoji als <:name:id> / <a:name:id>.",
"threshold": "Stern-Schwellenwert", "threshold": "Stern-Schwellenwert",
"allowSelfStar": "Eigene Nachrichten dürfen selbst markiert werden", "allowSelfStar": "Eigene Nachrichten dürfen selbst markiert werden",
"ignoredChannelIds": "Ignorierte Kanal-IDs", "ignoredChannelIds": "Ignorierte Kanäle",
"idsHint": "Kommagetrennte Liste von Kanal-IDs, die vom Starboard ausgeschlossen werden." "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": { "suggestions": {
"configTitle": "Vorschläge-Einstellungen", "configTitle": "Vorschläge-Einstellungen",

View File

@@ -768,12 +768,18 @@
"title": "Starboard", "title": "Starboard",
"description": "Highlight popular messages in a dedicated channel.", "description": "Highlight popular messages in a dedicated channel.",
"enabledLabel": "Starboard enabled", "enabledLabel": "Starboard enabled",
"channelId": "Starboard channel ID", "enabledHint": "A target channel is required before enabling starboard.",
"channelId": "Starboard channel",
"emoji": "Trigger emoji", "emoji": "Trigger emoji",
"emojiHint": "Unicode (⭐) or custom emoji as <:name:id> / <a:name:id>.",
"threshold": "Star threshold", "threshold": "Star threshold",
"allowSelfStar": "Allow starring your own messages", "allowSelfStar": "Allow starring your own messages",
"ignoredChannelIds": "Ignored channel IDs", "ignoredChannelIds": "Ignored channels",
"idsHint": "Comma-separated list of channel IDs to exclude from the starboard." "nsfwHint": "NSFW channels are always excluded.",
"errors": {
"channelRequired": "Pick a starboard channel before enabling.",
"thresholdRange": "Threshold must be between 1 and 100."
}
}, },
"suggestions": { "suggestions": {
"configTitle": "Suggestions settings", "configTitle": "Suggestions settings",

View File

@@ -272,6 +272,25 @@
- [ ] `/giveaway end` mit falscher ID → Fehlermeldung; mit korrekter ID → Reroll möglich - [ ] `/giveaway end` mit falscher ID → Fehlermeldung; mit korrekter ID → Reroll möglich
- [ ] Dashboard „Jetzt beenden“ → Status beendet, Discord-Nachricht aktualisiert - [ ] 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) ### Abgeschlossen (Code)

View File

@@ -747,6 +747,10 @@ export const commandLocales = {
de: 'Self-Star erlauben', de: 'Self-Star erlauben',
en: 'Allow self-star' en: 'Allow self-star'
}, },
'starboard.status.description': {
de: 'Aktuelle Starboard-Einstellungen anzeigen',
en: 'Show current starboard settings'
},
'suggest.description': { 'suggest.description': {
de: 'Einen Vorschlag einreichen', de: 'Einen Vorschlag einreichen',
en: 'Submit a suggestion' en: 'Submit a suggestion'

View File

@@ -514,17 +514,44 @@ export type TagDashboardUpdate = z.infer<typeof TagDashboardUpdateSchema>;
// Starboard // Starboard
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const StarboardConfigDashboardSchema = z.object({ export const StarboardConfigDashboardSchema = z
.object({
enabled: z.boolean(), enabled: z.boolean(),
channelId: OptionalSnowflakeSchema, channelId: OptionalSnowflakeSchema,
emoji: z.string().min(1).max(32), emoji: z.string().min(1).max(80),
threshold: z.number().int().positive(), threshold: z.number().int().min(1).max(100),
allowSelfStar: z.boolean(), allowSelfStar: z.boolean(),
ignoredChannelIds: z.array(z.string()) 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 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>; export type StarboardConfigDashboardPatch = z.infer<typeof StarboardConfigDashboardPatchSchema>;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -339,12 +339,17 @@ const de: Dictionary = {
'fun.cat.title': 'Random Cat', 'fun.cat.title': 'Random Cat',
'fun.dog.title': 'Random Dog', 'fun.dog.title': 'Random Dog',
'starboard.notConfigured': 'Starboard ist noch nicht konfiguriert. Nutze `/starboard setup`.', 'starboard.notConfigured': 'Starboard ist noch nicht konfiguriert. Nutze `/starboard setup`.',
'starboard.invalidChannel': 'Der Starboard-Kanal ist ungültig.', 'starboard.invalidChannel':
'starboard.setupDone': 'Starboard konfiguriert.', '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.noContent': '*Kein Textinhalt*',
'starboard.field.stars': 'Sterne', 'starboard.field.stars': 'Sterne',
'starboard.field.source': 'Quelle', 'starboard.field.source': 'Quelle',
'starboard.jumpLink': 'Zur Nachricht', '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.created': 'Vorschlag eingereicht (ID: `{id}`).',
'suggestion.setupDone': 'Vorschlagssystem konfiguriert.', 'suggestion.setupDone': 'Vorschlagssystem konfiguriert.',
'suggestion.staffUpdated': 'Vorschlag aktualisiert ({status}).', 'suggestion.staffUpdated': 'Vorschlag aktualisiert ({status}).',
@@ -925,12 +930,17 @@ const en: Dictionary = {
'fun.cat.title': 'Random Cat', 'fun.cat.title': 'Random Cat',
'fun.dog.title': 'Random Dog', 'fun.dog.title': 'Random Dog',
'starboard.notConfigured': 'Starboard is not configured yet. Use `/starboard setup`.', 'starboard.notConfigured': 'Starboard is not configured yet. Use `/starboard setup`.',
'starboard.invalidChannel': 'The starboard channel is invalid.', 'starboard.invalidChannel':
'starboard.setupDone': 'Starboard configured.', '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.noContent': '*No text content*',
'starboard.field.stars': 'Stars', 'starboard.field.stars': 'Stars',
'starboard.field.source': 'Source', 'starboard.field.source': 'Source',
'starboard.jumpLink': 'Jump to message', '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.created': 'Suggestion submitted (ID: `{id}`).',
'suggestion.setupDone': 'Suggestion system configured.', 'suggestion.setupDone': 'Suggestion system configured.',
'suggestion.staffUpdated': 'Suggestion updated ({status}).', 'suggestion.staffUpdated': 'Suggestion updated ({status}).',