From bf4e6810a169f51efc35f31d0aebfa8b5f03c9c4 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Mon, 15 Jun 2026 17:14:08 +0200 Subject: [PATCH 01/13] feat: add starboard module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reposte les messages populaires dans un salon dédié quand ils reçoivent assez de réactions. Supporte plusieurs émois (dont customs), configuration par seuil, auto-star, et salons surveillés. --- .../message-reaction-add.listener.ts | 27 +++ .../message-reaction-remove.listener.ts | 27 +++ src/modules/starboard/models/starboard.prisma | 14 ++ .../starboard/services/starboard.service.ts | 198 ++++++++++++++++++ src/modules/starboard/starboard.config.ts | 56 +++++ src/modules/starboard/starboard.module.ts | 38 ++++ .../migration.sql | 20 ++ src/prisma/schema.prisma | 16 ++ 8 files changed, 396 insertions(+) create mode 100644 src/modules/starboard/listeners/message-reaction-add.listener.ts create mode 100644 src/modules/starboard/listeners/message-reaction-remove.listener.ts create mode 100644 src/modules/starboard/models/starboard.prisma create mode 100644 src/modules/starboard/services/starboard.service.ts create mode 100644 src/modules/starboard/starboard.config.ts create mode 100644 src/modules/starboard/starboard.module.ts create mode 100644 src/prisma/migrations/20260615150547_add_starboard_config/migration.sql diff --git a/src/modules/starboard/listeners/message-reaction-add.listener.ts b/src/modules/starboard/listeners/message-reaction-add.listener.ts new file mode 100644 index 0000000..18852fd --- /dev/null +++ b/src/modules/starboard/listeners/message-reaction-add.listener.ts @@ -0,0 +1,27 @@ +import type { + MessageReaction, + PartialMessageReaction, + PartialUser, + User, +} from "discord.js"; +import { declareEventListener } from "#lib/listener.js"; +import starboardService from "#modules/starboard/services/starboard.service.js"; +import type { StarboardConfigSchema } from "#modules/starboard/starboard.config.js"; + +export default declareEventListener< + "messageReactionAdd", + StarboardConfigSchema +>({ + eventType: "messageReactionAdd", + + async execute( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + _details: unknown, + config + ) { + if (!config) return; + + await starboardService.handleReactionChange(reaction, user, config); + }, +}); diff --git a/src/modules/starboard/listeners/message-reaction-remove.listener.ts b/src/modules/starboard/listeners/message-reaction-remove.listener.ts new file mode 100644 index 0000000..7d95df5 --- /dev/null +++ b/src/modules/starboard/listeners/message-reaction-remove.listener.ts @@ -0,0 +1,27 @@ +import type { + MessageReaction, + PartialMessageReaction, + PartialUser, + User, +} from "discord.js"; +import { declareEventListener } from "#lib/listener.js"; +import starboardService from "#modules/starboard/services/starboard.service.js"; +import type { StarboardConfigSchema } from "#modules/starboard/starboard.config.js"; + +export default declareEventListener< + "messageReactionRemove", + StarboardConfigSchema +>({ + eventType: "messageReactionRemove", + + async execute( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + _details: unknown, + config + ) { + if (!config) return; + + await starboardService.handleReactionChange(reaction, user, config); + }, +}); diff --git a/src/modules/starboard/models/starboard.prisma b/src/modules/starboard/models/starboard.prisma new file mode 100644 index 0000000..0ff135a --- /dev/null +++ b/src/modules/starboard/models/starboard.prisma @@ -0,0 +1,14 @@ +model StarboardEntry { + id String @id @default(cuid()) + guildId String + originalMessageId String + originalChannelId String + starboardMessageId String + starboardChannelId String + reactionCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([guildId, originalMessageId]) + @@index([guildId]) +} diff --git a/src/modules/starboard/services/starboard.service.ts b/src/modules/starboard/services/starboard.service.ts new file mode 100644 index 0000000..72af9ef --- /dev/null +++ b/src/modules/starboard/services/starboard.service.ts @@ -0,0 +1,198 @@ +import { + EmbedBuilder, + type Message, + type MessageReaction, + type PartialMessageReaction, + type PartialUser, + type TextChannel, + type User, +} from "discord.js"; +import type { ConfigProvider } from "#lib/config.js"; +import prisma from "#lib/database.js"; +import logger from "#lib/logger.js"; +import { declareService, type Service } from "#lib/service.js"; +import type { StarboardConfigSchema } from "#modules/starboard/starboard.config.js"; + +const EMBED_COLORS: Record = { + default: 0x2b2d31, + gold: 0xffd700, + blue: 0x3498db, + green: 0x2ecc71, + red: 0xe74c3c, +}; + +class StarboardService implements Service { + matchEmoji( + emoji: MessageReaction["emoji"], + configuredEmojis: string[] + ): boolean { + return configuredEmojis.some((configured) => { + const customMatch = configured.match(/^$/); + if (customMatch) { + return emoji.id === customMatch[1]; + } + if (/^\d+$/.test(configured)) { + return emoji.id === configured; + } + return emoji.name === configured; + }); + } + + computeStarCount(message: Message, configuredEmojis: string[]): number { + let total = 0; + for (const [, reaction] of message.reactions.cache) { + if (this.matchEmoji(reaction.emoji, configuredEmojis)) { + total += reaction.count; + } + } + return total; + } + + private buildStarboardEmbed( + message: Message, + reactionCount: number, + config: ConfigProvider + ) { + const embed = new EmbedBuilder() + .setAuthor({ + name: message.author.tag, + iconURL: message.author.displayAvatarURL(), + }) + .setDescription( + `${message.content || ""}\n\n[Aller au message](${message.url})` + ) + .setColor(EMBED_COLORS[config.get("embedColor")]!) + .setTimestamp(message.createdAt); + + const firstAttachment = message.attachments.first(); + if (firstAttachment && firstAttachment.contentType?.startsWith("image/")) { + embed.setThumbnail(firstAttachment.url); + } + + embed.setFooter({ + text: `${reactionCount} ⭐`, + }); + + return embed; + } + + async handleReactionChange( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + config: ConfigProvider + ): Promise { + try { + if (reaction.partial) return; + if (user.partial) return; + + const message = await reaction.message.fetch(); + const guild = message.guild; + if (!guild) return; + + const starboardChannel = config.get("starboardChannel"); + if (!starboardChannel) return; + if (message.channel.id === starboardChannel.id) return; + + const configuredEmojis = config.get("reactionEmojis"); + if (!this.matchEmoji(reaction.emoji, configuredEmojis)) return; + + if (!config.get("selfStar") && user.id === message.author.id) return; + + if (config.get("ignoreBotMessages") && message.author.bot) return; + + const allowedChannels = config.get("allowedChannels"); + if ( + allowedChannels && + allowedChannels.length > 0 && + !allowedChannels.some((c) => c.id === message.channel.id) + ) { + return; + } + + const totalStars = this.computeStarCount(message, configuredEmojis); + const threshold = config.get("reactionCount"); + + const existingEntry = await prisma.starboardEntry.findUnique({ + where: { + guildId_originalMessageId: { + guildId: guild.id, + originalMessageId: message.id, + }, + }, + }); + + if (existingEntry) { + if (totalStars >= threshold) { + await this.updateStarboardEntry(existingEntry, totalStars); + } else if (config.get("belowThresholdBehavior") === "remove") { + await this.removeStarboardEntry(existingEntry); + } else { + await this.updateStarboardEntry(existingEntry, totalStars); + } + } else if (totalStars >= threshold) { + await this.createStarboardEntry(message, totalStars, config); + } + } catch (error) { + logger.error( + `Erreur dans le StarboardService.handleReactionChange : ${error}` + ); + } + } + + private async createStarboardEntry( + message: Message, + reactionCount: number, + config: ConfigProvider + ): Promise { + const starboardChannel = config.get("starboardChannel"); + if (!starboardChannel) return; + + const guild = message.guild; + if (!guild) return; + + const channel = await guild.channels.fetch(starboardChannel.id); + if (!channel?.isTextBased()) return; + if (!("send" in channel)) return; + + const starboardEmbed = this.buildStarboardEmbed( + message, + reactionCount, + config + ); + + const starboardMessage = await (channel as TextChannel).send({ + embeds: [starboardEmbed, ...message.embeds], + }); + + await prisma.starboardEntry.create({ + data: { + guildId: guild.id, + originalMessageId: message.id, + originalChannelId: message.channel.id, + starboardMessageId: starboardMessage.id, + starboardChannelId: starboardChannel.id, + reactionCount, + }, + }); + } + + private async updateStarboardEntry( + entry: { + id: string; + }, + newCount: number + ): Promise { + await prisma.starboardEntry.update({ + where: { id: entry.id }, + data: { reactionCount: newCount }, + }); + } + + private async removeStarboardEntry(entry: { id: string }): Promise { + await prisma.starboardEntry.delete({ + where: { id: entry.id }, + }); + } +} + +export default declareService(new StarboardService()); diff --git a/src/modules/starboard/starboard.config.ts b/src/modules/starboard/starboard.config.ts new file mode 100644 index 0000000..862625b --- /dev/null +++ b/src/modules/starboard/starboard.config.ts @@ -0,0 +1,56 @@ +import { ConfigType, type ConfigSchema } from "#lib/config.js"; + +export const starboardConfigSchema = { + starboardChannel: { + name: "Salon starboard", + description: "Salon où les messages starboardés sont repostés.", + type: ConfigType.CHANNEL, + }, + reactionEmojis: { + name: "Émojis déclencheurs", + description: + "Émojis qui déclenchent le starboard (unicode ou custom <:name:id>).", + type: [ConfigType.STRING], + defaultValue: ["⭐"], + }, + reactionCount: { + name: "Seuil de réactions", + description: "Nombre minimum de réactions pour starboarder un message.", + type: ConfigType.NUMBER, + defaultValue: 3, + }, + selfStar: { + name: "Auto-star", + description: "Compter les réactions de l'auteur sur son propre message.", + type: ConfigType.BOOLEAN, + defaultValue: false, + }, + allowedChannels: { + name: "Salons surveillés", + description: "Salons où surveiller les réactions (vide = tous les salons).", + type: [ConfigType.CHANNEL], + }, + ignoreBotMessages: { + name: "Ignorer les bots", + description: "Ne pas starboarder les messages de bots.", + type: ConfigType.BOOLEAN, + defaultValue: true, + }, + belowThresholdBehavior: { + name: "Sous le seuil", + description: + "Que faire quand les réactions repassent sous le seuil : 'remove' supprime l'entrée, 'keep' la garde.", + type: ConfigType.ENUM, + options: ["remove", "keep"] as const, + defaultValue: "keep", + }, + embedColor: { + name: "Couleur de l'embed", + description: "Couleur de la bordure de l'embed starboard.", + type: ConfigType.ENUM, + options: ["default", "gold", "blue", "green", "red"] as const, + defaultValue: "gold", + }, +} satisfies ConfigSchema; + +export type StarboardConfigSchema = typeof starboardConfigSchema; diff --git a/src/modules/starboard/starboard.module.ts b/src/modules/starboard/starboard.module.ts new file mode 100644 index 0000000..474a655 --- /dev/null +++ b/src/modules/starboard/starboard.module.ts @@ -0,0 +1,38 @@ +import { GatewayIntentBits } from "discord.js"; +import logger from "#lib/logger.js"; +import { defineModule } from "#lib/module.js"; +import messageReactionAddListener from "./listeners/message-reaction-add.listener.js"; +import messageReactionRemoveListener from "./listeners/message-reaction-remove.listener.js"; +import { starboardConfigSchema } from "./starboard.config.js"; + +export default defineModule({ + id: "starboard", + name: "Starboard", + description: + "Reposte les messages populaires dans un salon dédié quand ils reçoivent assez de réactions. Supporte plusieurs émojis (dont customs), configuration par seuil, auto-star, et salons surveillés.", + version: "1.0.0", + author: "AsyncMod Team", + + config: starboardConfigSchema, + + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessageReactions], + + onLoad(_client, registry) { + registry.register(messageReactionAddListener); + registry.register(messageReactionRemoveListener); + + logger.info("Module Starboard chargé avec succès"); + }, + + onInstall(_client, guild) { + logger.info( + `Module Starboard installé sur le serveur "${guild.name}" (${guild.id})` + ); + }, + + onUninstall(_client, guild) { + logger.info( + `Module Starboard désinstallé du serveur "${guild.name}" (${guild.id})` + ); + }, +}); diff --git a/src/prisma/migrations/20260615150547_add_starboard_config/migration.sql b/src/prisma/migrations/20260615150547_add_starboard_config/migration.sql new file mode 100644 index 0000000..b840217 --- /dev/null +++ b/src/prisma/migrations/20260615150547_add_starboard_config/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "StarboardEntry" ( + "id" TEXT NOT NULL, + "guildId" TEXT NOT NULL, + "originalMessageId" TEXT NOT NULL, + "originalChannelId" TEXT NOT NULL, + "starboardMessageId" TEXT NOT NULL, + "starboardChannelId" TEXT NOT NULL, + "reactionCount" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "StarboardEntry_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "StarboardEntry_guildId_idx" ON "StarboardEntry"("guildId"); + +-- CreateIndex +CREATE UNIQUE INDEX "StarboardEntry_guildId_originalMessageId_key" ON "StarboardEntry"("guildId", "originalMessageId"); diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 8f262ef..7bb3caa 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -15,3 +15,19 @@ model ModuleActivation { @@id([moduleId, guildId]) } + +// === Modèles de modules/starboard/models/starboard.prisma === +model StarboardEntry { + id String @id @default(cuid()) + guildId String + originalMessageId String + originalChannelId String + starboardMessageId String + starboardChannelId String + reactionCount Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([guildId, originalMessageId]) + @@index([guildId]) +} From 941d35d79b30acb5b545197148ec75d5265f6020 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Mon, 15 Jun 2026 17:11:26 +0200 Subject: [PATCH 02/13] fix(listener-loader): crash when no arg has .guild property - Add optional chaining to prevent crash in guildId resolution - Add fallback for message.guild/message.guildId (handles messageReactionAdd/Remove events where the reaction object doesn't have a direct .guild property) - Remove incorrect arg.roles check (GuildMember.id is user ID, not guild ID) - Fix missing .guildId accessor on the guildId fallback --- src/core/loaders/listener-loader.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/core/loaders/listener-loader.ts b/src/core/loaders/listener-loader.ts index e799e90..8788356 100644 --- a/src/core/loaders/listener-loader.ts +++ b/src/core/loaders/listener-loader.ts @@ -45,9 +45,10 @@ export function loadModuleEvents(client: Client, module: Module) { client.on(listener.eventType, (...args) => { const guildId = - args.find((arg) => !!arg.roles)?.id || - args.find((arg) => !!arg?.guild).guild?.id || - args.find((arg) => !!arg?.guildId); + args.find((arg) => !!arg?.guild)?.guild?.id || + args.find((arg) => !!arg?.guildId)?.guildId || + args.find((arg) => !!arg?.message?.guild)?.message?.guildId || + args.find((arg) => !!arg?.message?.guildId)?.message?.guildId; if (guildId) { moduleService From 5478c373b2153edf61f17c756bb4d6808a78894e Mon Sep 17 00:00:00 2001 From: RedsTom Date: Mon, 15 Jun 2026 17:36:47 +0200 Subject: [PATCH 03/13] feat(starboard): use displayName, add button & reaction to starboard message --- .../starboard/services/starboard.service.ts | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/src/modules/starboard/services/starboard.service.ts b/src/modules/starboard/services/starboard.service.ts index 72af9ef..eab0e3b 100644 --- a/src/modules/starboard/services/starboard.service.ts +++ b/src/modules/starboard/services/starboard.service.ts @@ -1,4 +1,7 @@ import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, EmbedBuilder, type Message, type MessageReaction, @@ -55,12 +58,13 @@ class StarboardService implements Service { ) { const embed = new EmbedBuilder() .setAuthor({ - name: message.author.tag, + name: + message.member?.displayName ?? + message.author.globalName ?? + message.author.username, iconURL: message.author.displayAvatarURL(), }) - .setDescription( - `${message.content || ""}\n\n[Aller au message](${message.url})` - ) + .setDescription(message.content || null) .setColor(EMBED_COLORS[config.get("embedColor")]!) .setTimestamp(message.createdAt); @@ -76,14 +80,22 @@ class StarboardService implements Service { return embed; } + private buildGoToMessageButton(message: Message) { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setURL(message.url) + .setLabel("Aller au message") + ); + } + async handleReactionChange( reaction: MessageReaction | PartialMessageReaction, user: User | PartialUser, config: ConfigProvider ): Promise { try { - if (reaction.partial) return; - if (user.partial) return; + if (reaction.partial || user.partial) return; const message = await reaction.message.fetch(); const guild = message.guild; @@ -160,10 +172,18 @@ class StarboardService implements Service { config ); + const goToButton = this.buildGoToMessageButton(message); + const starboardMessage = await (channel as TextChannel).send({ embeds: [starboardEmbed, ...message.embeds], + components: [goToButton], }); + const firstEmoji = config.get("reactionEmojis")[0]; + if (firstEmoji) { + await starboardMessage.react(firstEmoji).catch(() => {}); + } + await prisma.starboardEntry.create({ data: { guildId: guild.id, @@ -177,9 +197,7 @@ class StarboardService implements Service { } private async updateStarboardEntry( - entry: { - id: string; - }, + entry: { id: string }, newCount: number ): Promise { await prisma.starboardEntry.update({ From 60d62993fc8df1e7bab2905a0f4ad07eed9ff9e5 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Mon, 15 Jun 2026 17:47:00 +0200 Subject: [PATCH 04/13] feat(starboard): per-emoji metrics, auto-react all emojis, embed update, message removal - Add reactions JSON column to StarboardEntry model - Replace computeStarCount with computeReactionMetrics (per-emoji + unique users) - buildStarboardEmbed now shows inline fields per emoji + total unique footer - createStarboardEntry reacts with all matching emojis - updateStarboardEntry updates embed and syncs reactions - removeStarboardEntry deletes the starboard message --- src/modules/starboard/models/starboard.prisma | 1 + .../starboard/services/starboard.service.ts | 169 +++++++++++++++--- .../migration.sql | 2 + src/prisma/schema.prisma | 1 + 4 files changed, 144 insertions(+), 29 deletions(-) create mode 100644 src/prisma/migrations/20260615154335_add_starboard_reactions/migration.sql diff --git a/src/modules/starboard/models/starboard.prisma b/src/modules/starboard/models/starboard.prisma index 0ff135a..d68e304 100644 --- a/src/modules/starboard/models/starboard.prisma +++ b/src/modules/starboard/models/starboard.prisma @@ -6,6 +6,7 @@ model StarboardEntry { starboardMessageId String starboardChannelId String reactionCount Int @default(0) + reactions Json @default("[]") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/modules/starboard/services/starboard.service.ts b/src/modules/starboard/services/starboard.service.ts index eab0e3b..aa08fb1 100644 --- a/src/modules/starboard/services/starboard.service.ts +++ b/src/modules/starboard/services/starboard.service.ts @@ -3,6 +3,7 @@ import { ButtonBuilder, ButtonStyle, EmbedBuilder, + type Guild, type Message, type MessageReaction, type PartialMessageReaction, @@ -16,6 +17,16 @@ import logger from "#lib/logger.js"; import { declareService, type Service } from "#lib/service.js"; import type { StarboardConfigSchema } from "#modules/starboard/starboard.config.js"; +interface ReactionMetric { + emoji: string; + count: number; +} + +interface ReactionMetrics { + emojis: ReactionMetric[]; + totalUniqueUsers: number; +} + const EMBED_COLORS: Record = { default: 0x2b2d31, gold: 0xffd700, @@ -41,19 +52,33 @@ class StarboardService implements Service { }); } - computeStarCount(message: Message, configuredEmojis: string[]): number { - let total = 0; + private async computeReactionMetrics( + message: Message, + configuredEmojis: string[] + ): Promise { + const emojis: ReactionMetric[] = []; + const uniqueUsers = new Set(); + for (const [, reaction] of message.reactions.cache) { - if (this.matchEmoji(reaction.emoji, configuredEmojis)) { - total += reaction.count; + if (!this.matchEmoji(reaction.emoji, configuredEmojis)) continue; + + emojis.push({ + emoji: reaction.emoji.toString(), + count: reaction.count, + }); + + const users = await reaction.users.fetch(); + for (const userId of users.keys()) { + uniqueUsers.add(userId); } } - return total; + + return { emojis, totalUniqueUsers: uniqueUsers.size }; } private buildStarboardEmbed( message: Message, - reactionCount: number, + metrics: ReactionMetrics, config: ConfigProvider ) { const embed = new EmbedBuilder() @@ -73,8 +98,16 @@ class StarboardService implements Service { embed.setThumbnail(firstAttachment.url); } + for (const metric of metrics.emojis) { + embed.addFields({ + name: metric.emoji, + value: `× **${metric.count}**`, + inline: true, + }); + } + embed.setFooter({ - text: `${reactionCount} ⭐`, + text: `${metrics.totalUniqueUsers} unique`, }); return embed; @@ -121,7 +154,10 @@ class StarboardService implements Service { return; } - const totalStars = this.computeStarCount(message, configuredEmojis); + const metrics = await this.computeReactionMetrics( + message, + configuredEmojis + ); const threshold = config.get("reactionCount"); const existingEntry = await prisma.starboardEntry.findUnique({ @@ -134,15 +170,25 @@ class StarboardService implements Service { }); if (existingEntry) { - if (totalStars >= threshold) { - await this.updateStarboardEntry(existingEntry, totalStars); + if (metrics.totalUniqueUsers >= threshold) { + await this.updateStarboardEntry( + existingEntry, + message, + metrics, + config + ); } else if (config.get("belowThresholdBehavior") === "remove") { - await this.removeStarboardEntry(existingEntry); + await this.removeStarboardEntry(existingEntry, guild); } else { - await this.updateStarboardEntry(existingEntry, totalStars); + await this.updateStarboardEntry( + existingEntry, + message, + metrics, + config + ); } - } else if (totalStars >= threshold) { - await this.createStarboardEntry(message, totalStars, config); + } else if (metrics.totalUniqueUsers >= threshold) { + await this.createStarboardEntry(message, metrics, config); } } catch (error) { logger.error( @@ -153,7 +199,7 @@ class StarboardService implements Service { private async createStarboardEntry( message: Message, - reactionCount: number, + metrics: ReactionMetrics, config: ConfigProvider ): Promise { const starboardChannel = config.get("starboardChannel"); @@ -166,12 +212,7 @@ class StarboardService implements Service { if (!channel?.isTextBased()) return; if (!("send" in channel)) return; - const starboardEmbed = this.buildStarboardEmbed( - message, - reactionCount, - config - ); - + const starboardEmbed = this.buildStarboardEmbed(message, metrics, config); const goToButton = this.buildGoToMessageButton(message); const starboardMessage = await (channel as TextChannel).send({ @@ -179,9 +220,8 @@ class StarboardService implements Service { components: [goToButton], }); - const firstEmoji = config.get("reactionEmojis")[0]; - if (firstEmoji) { - await starboardMessage.react(firstEmoji).catch(() => {}); + for (const metric of metrics.emojis) { + await starboardMessage.react(metric.emoji).catch(() => {}); } await prisma.starboardEntry.create({ @@ -191,22 +231,93 @@ class StarboardService implements Service { originalChannelId: message.channel.id, starboardMessageId: starboardMessage.id, starboardChannelId: starboardChannel.id, - reactionCount, + reactionCount: metrics.totalUniqueUsers, + reactions: metrics.emojis as any, }, }); } private async updateStarboardEntry( - entry: { id: string }, - newCount: number + entry: { + id: string; + starboardMessageId: string; + starboardChannelId: string; + }, + originalMessage: Message, + metrics: ReactionMetrics, + config: ConfigProvider ): Promise { + const guild = originalMessage.guild; + if (!guild) return; + + const channel = await guild.channels.fetch(entry.starboardChannelId); + if (!channel?.isTextBased()) return; + + let starboardMessage: Message; + try { + starboardMessage = await (channel as TextChannel).messages.fetch( + entry.starboardMessageId + ); + } catch { + return; + } + + const starboardEmbed = this.buildStarboardEmbed( + originalMessage, + metrics, + config + ); + await starboardMessage.edit({ + embeds: [starboardEmbed, ...originalMessage.embeds], + }); + + const currentReactions = starboardMessage.reactions.cache; + for (const metric of metrics.emojis) { + if (!currentReactions.has(metric.emoji)) { + await starboardMessage.react(metric.emoji).catch(() => {}); + } + } + + for (const [, reaction] of currentReactions) { + const emojiString = reaction.emoji.toString(); + if (!metrics.emojis.some((m) => m.emoji === emojiString)) { + await reaction.remove().catch(() => {}); + } + } + await prisma.starboardEntry.update({ where: { id: entry.id }, - data: { reactionCount: newCount }, + data: { + reactionCount: metrics.totalUniqueUsers, + reactions: metrics.emojis as any, + }, }); } - private async removeStarboardEntry(entry: { id: string }): Promise { + private async removeStarboardEntry( + entry: { + starboardMessageId: string; + starboardChannelId: string; + id: string; + }, + guild: Guild + ): Promise { + try { + const channel = await guild.channels.fetch(entry.starboardChannelId); + if (channel?.isTextBased()) { + try { + const msg = await (channel as TextChannel).messages.fetch( + entry.starboardMessageId + ); + await msg.delete(); + } catch { + // message already deleted or inaccessible + } + } + } catch { + // channel inaccessible + } + await prisma.starboardEntry.delete({ where: { id: entry.id }, }); diff --git a/src/prisma/migrations/20260615154335_add_starboard_reactions/migration.sql b/src/prisma/migrations/20260615154335_add_starboard_reactions/migration.sql new file mode 100644 index 0000000..1d6aa0d --- /dev/null +++ b/src/prisma/migrations/20260615154335_add_starboard_reactions/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "StarboardEntry" ADD COLUMN "reactions" JSONB NOT NULL DEFAULT '[]'; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 7bb3caa..160ce39 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -25,6 +25,7 @@ model StarboardEntry { starboardMessageId String starboardChannelId String reactionCount Int @default(0) + reactions Json @default("[]") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt From 8890cff6d88b039d15c54954c96081380f71f702 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Mon, 15 Jun 2026 18:33:15 +0200 Subject: [PATCH 05/13] fix(starboard): inline reaction line instead of separate fields --- .../starboard/services/starboard.service.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/modules/starboard/services/starboard.service.ts b/src/modules/starboard/services/starboard.service.ts index aa08fb1..045d7e8 100644 --- a/src/modules/starboard/services/starboard.service.ts +++ b/src/modules/starboard/services/starboard.service.ts @@ -81,6 +81,10 @@ class StarboardService implements Service { metrics: ReactionMetrics, config: ConfigProvider ) { + const description = message.content + ? `${message.content}\n\n${this.buildReactionLine(metrics)}` + : this.buildReactionLine(metrics); + const embed = new EmbedBuilder() .setAuthor({ name: @@ -89,7 +93,7 @@ class StarboardService implements Service { message.author.username, iconURL: message.author.displayAvatarURL(), }) - .setDescription(message.content || null) + .setDescription(description) .setColor(EMBED_COLORS[config.get("embedColor")]!) .setTimestamp(message.createdAt); @@ -98,14 +102,6 @@ class StarboardService implements Service { embed.setThumbnail(firstAttachment.url); } - for (const metric of metrics.emojis) { - embed.addFields({ - name: metric.emoji, - value: `× **${metric.count}**`, - inline: true, - }); - } - embed.setFooter({ text: `${metrics.totalUniqueUsers} unique`, }); @@ -113,6 +109,10 @@ class StarboardService implements Service { return embed; } + private buildReactionLine(metrics: ReactionMetrics): string { + return metrics.emojis.map((m) => `${m.emoji} **${m.count}**`).join(" | "); + } + private buildGoToMessageButton(message: Message) { return new ActionRowBuilder().addComponents( new ButtonBuilder() From b6574485a10f414f1df1474e6277f6cb1cb1db7f Mon Sep 17 00:00:00 2001 From: RedsTom Date: Mon, 15 Jun 2026 18:40:29 +0200 Subject: [PATCH 06/13] fix(starboard): format custom emojis with <:name:id> for embed display --- src/modules/starboard/services/starboard.service.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/modules/starboard/services/starboard.service.ts b/src/modules/starboard/services/starboard.service.ts index 045d7e8..21dde9f 100644 --- a/src/modules/starboard/services/starboard.service.ts +++ b/src/modules/starboard/services/starboard.service.ts @@ -52,6 +52,13 @@ class StarboardService implements Service { }); } + private formatEmoji(emoji: MessageReaction["emoji"]): string { + if (emoji.id) { + return `<${emoji.animated ? "a" : ""}:${emoji.name}:${emoji.id}>`; + } + return emoji.name!; + } + private async computeReactionMetrics( message: Message, configuredEmojis: string[] @@ -63,7 +70,7 @@ class StarboardService implements Service { if (!this.matchEmoji(reaction.emoji, configuredEmojis)) continue; emojis.push({ - emoji: reaction.emoji.toString(), + emoji: this.formatEmoji(reaction.emoji), count: reaction.count, }); From 656e60f4e303ed96350569f33034fb99e4dbe38c Mon Sep 17 00:00:00 2001 From: RedsTom Date: Mon, 15 Jun 2026 18:46:40 +0200 Subject: [PATCH 07/13] feat(starboard): use Components v2 instead of legacy EmbedBuilder --- .../starboard/services/starboard.service.ts | 101 ++++++++++-------- 1 file changed, 58 insertions(+), 43 deletions(-) diff --git a/src/modules/starboard/services/starboard.service.ts b/src/modules/starboard/services/starboard.service.ts index 21dde9f..2f0cda4 100644 --- a/src/modules/starboard/services/starboard.service.ts +++ b/src/modules/starboard/services/starboard.service.ts @@ -1,8 +1,7 @@ import { - ActionRowBuilder, - ButtonBuilder, ButtonStyle, - EmbedBuilder, + ContainerBuilder, + MessageFlags, type Guild, type Message, type MessageReaction, @@ -83,50 +82,64 @@ class StarboardService implements Service { return { emojis, totalUniqueUsers: uniqueUsers.size }; } - private buildStarboardEmbed( + private buildReactionLine(metrics: ReactionMetrics): string { + return metrics.emojis.map((m) => `${m.emoji} **${m.count}**`).join(" | "); + } + + private buildStarboardContainer( message: Message, metrics: ReactionMetrics, config: ConfigProvider - ) { - const description = message.content - ? `${message.content}\n\n${this.buildReactionLine(metrics)}` - : this.buildReactionLine(metrics); - - const embed = new EmbedBuilder() - .setAuthor({ - name: - message.member?.displayName ?? - message.author.globalName ?? - message.author.username, - iconURL: message.author.displayAvatarURL(), - }) - .setDescription(description) - .setColor(EMBED_COLORS[config.get("embedColor")]!) - .setTimestamp(message.createdAt); + ): ContainerBuilder { + const authorName = + message.member?.displayName ?? + message.author.globalName ?? + message.author.username; + const timestamp = Math.floor(message.createdAt.getTime() / 1000); + + const container = new ContainerBuilder().setAccentColor( + EMBED_COLORS[config.get("embedColor")]! + ); + + container.addTextDisplayComponents((text) => + text.setContent(`### ${authorName}\n`) + ); + + container.addSeparatorComponents((separator) => separator.setDivider(true)); + + if (message.content) { + container.addTextDisplayComponents((text) => + text.setContent(message.content) + ); + } const firstAttachment = message.attachments.first(); if (firstAttachment && firstAttachment.contentType?.startsWith("image/")) { - embed.setThumbnail(firstAttachment.url); + container.addTextDisplayComponents((text) => + text.setContent(`[🖼️ Image](${firstAttachment.url})`) + ); } - embed.setFooter({ - text: `${metrics.totalUniqueUsers} unique`, - }); - - return embed; - } - - private buildReactionLine(metrics: ReactionMetrics): string { - return metrics.emojis.map((m) => `${m.emoji} **${m.count}**`).join(" | "); - } + container.addTextDisplayComponents((text) => + text.setContent(this.buildReactionLine(metrics)) + ); - private buildGoToMessageButton(message: Message) { - return new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setStyle(ButtonStyle.Link) - .setURL(message.url) - .setLabel("Aller au message") + container.addSeparatorComponents((separator) => separator.setDivider(true)); + + container.addSectionComponents((section) => + section + .addTextDisplayComponents((text) => + text.setContent(`-# ${metrics.totalUniqueUsers} unique`) + ) + .setButtonAccessory((button) => + button + .setStyle(ButtonStyle.Link) + .setURL(message.url) + .setLabel("Aller au message") + ) ); + + return container; } async handleReactionChange( @@ -219,12 +232,12 @@ class StarboardService implements Service { if (!channel?.isTextBased()) return; if (!("send" in channel)) return; - const starboardEmbed = this.buildStarboardEmbed(message, metrics, config); - const goToButton = this.buildGoToMessageButton(message); + const container = this.buildStarboardContainer(message, metrics, config); const starboardMessage = await (channel as TextChannel).send({ - embeds: [starboardEmbed, ...message.embeds], - components: [goToButton], + components: [container], + embeds: [...message.embeds], + flags: MessageFlags.IsComponentsV2, }); for (const metric of metrics.emojis) { @@ -269,13 +282,15 @@ class StarboardService implements Service { return; } - const starboardEmbed = this.buildStarboardEmbed( + const container = this.buildStarboardContainer( originalMessage, metrics, config ); await starboardMessage.edit({ - embeds: [starboardEmbed, ...originalMessage.embeds], + components: [container], + embeds: [...originalMessage.embeds], + flags: MessageFlags.IsComponentsV2, }); const currentReactions = starboardMessage.reactions.cache; From 2a557b7f448612fab0d204f4f76b5c1e6eff6ec7 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Mon, 15 Jun 2026 19:35:09 +0200 Subject: [PATCH 08/13] fix(starboard): add MessageContent intent --- src/modules/starboard/starboard.module.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/starboard/starboard.module.ts b/src/modules/starboard/starboard.module.ts index 474a655..1f3432f 100644 --- a/src/modules/starboard/starboard.module.ts +++ b/src/modules/starboard/starboard.module.ts @@ -15,7 +15,11 @@ export default defineModule({ config: starboardConfigSchema, - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessageReactions], + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessageReactions, + GatewayIntentBits.MessageContent, + ], onLoad(_client, registry) { registry.register(messageReactionAddListener); From 1110f15cefb8388663acbc38439cd39ddf7471ff Mon Sep 17 00:00:00 2001 From: RedsTom Date: Mon, 15 Jun 2026 19:41:50 +0200 Subject: [PATCH 09/13] feat(starboard): avatar thumbnail, media gallery for images, total, timestamp inline --- .../starboard/services/starboard.service.ts | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/modules/starboard/services/starboard.service.ts b/src/modules/starboard/services/starboard.service.ts index 2f0cda4..2567cd2 100644 --- a/src/modules/starboard/services/starboard.service.ts +++ b/src/modules/starboard/services/starboard.service.ts @@ -101,8 +101,14 @@ class StarboardService implements Service { EMBED_COLORS[config.get("embedColor")]! ); - container.addTextDisplayComponents((text) => - text.setContent(`### ${authorName}\n`) + container.addSectionComponents((section) => + section + .addTextDisplayComponents((text) => + text.setContent(`### ${authorName}\n-# `) + ) + .setThumbnailAccessory((thumbnail) => + thumbnail.setURL(message.author.displayAvatarURL()) + ) ); container.addSeparatorComponents((separator) => separator.setDivider(true)); @@ -113,10 +119,14 @@ class StarboardService implements Service { ); } - const firstAttachment = message.attachments.first(); - if (firstAttachment && firstAttachment.contentType?.startsWith("image/")) { - container.addTextDisplayComponents((text) => - text.setContent(`[🖼️ Image](${firstAttachment.url})`) + const imageAttachments = message.attachments.filter((a) => + a.contentType?.startsWith("image/") + ); + if (imageAttachments.size > 0) { + container.addMediaGalleryComponents((gallery) => + gallery.addItems( + ...imageAttachments.map((a) => ({ media: { url: a.url } })) + ) ); } @@ -129,7 +139,7 @@ class StarboardService implements Service { container.addSectionComponents((section) => section .addTextDisplayComponents((text) => - text.setContent(`-# ${metrics.totalUniqueUsers} unique`) + text.setContent(`-# ${metrics.totalUniqueUsers} total`) ) .setButtonAccessory((button) => button @@ -236,7 +246,6 @@ class StarboardService implements Service { const starboardMessage = await (channel as TextChannel).send({ components: [container], - embeds: [...message.embeds], flags: MessageFlags.IsComponentsV2, }); @@ -289,7 +298,6 @@ class StarboardService implements Service { ); await starboardMessage.edit({ components: [container], - embeds: [...originalMessage.embeds], flags: MessageFlags.IsComponentsV2, }); From 3fe4653772459bf687d352fdeb3f815f688ee922 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Mon, 15 Jun 2026 19:50:13 +0200 Subject: [PATCH 10/13] fix(starboard): spread Collection values instead of entries for media gallery --- src/modules/starboard/services/starboard.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/starboard/services/starboard.service.ts b/src/modules/starboard/services/starboard.service.ts index 2567cd2..5f242fb 100644 --- a/src/modules/starboard/services/starboard.service.ts +++ b/src/modules/starboard/services/starboard.service.ts @@ -125,7 +125,7 @@ class StarboardService implements Service { if (imageAttachments.size > 0) { container.addMediaGalleryComponents((gallery) => gallery.addItems( - ...imageAttachments.map((a) => ({ media: { url: a.url } })) + ...imageAttachments.map((a) => ({ media: { url: a.url } })).values() ) ); } From 0d9fc00c924d3954c8c28179444b3bd12c5b4a4c Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 17:11:04 +0200 Subject: [PATCH 11/13] fix(starboard): enable partials for uncached reactions Add Partials to Client constructor and ConfigProvider import to starboard listeners for self-star feature. --- src/index.ts | 8 +++++++- .../starboard/listeners/message-reaction-add.listener.ts | 3 ++- .../listeners/message-reaction-remove.listener.ts | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5d9df07..7ee297d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { Client, Events } from "discord.js"; +import { Client, Events, Partials } from "discord.js"; import coreModule from "./core/core.module.js"; import { syncCommands } from "./core/loaders/command-loader.js"; import { @@ -26,6 +26,12 @@ const intents = modules.flatMap((module) => module.intents).filter((a) => !!a); export const client = new Client({ intents: intents, + partials: [ + Partials.Message, + Partials.Reaction, + Partials.User, + Partials.Channel, + ], }); client.once(Events.ClientReady, async (readyClient) => { diff --git a/src/modules/starboard/listeners/message-reaction-add.listener.ts b/src/modules/starboard/listeners/message-reaction-add.listener.ts index 18852fd..1331249 100644 --- a/src/modules/starboard/listeners/message-reaction-add.listener.ts +++ b/src/modules/starboard/listeners/message-reaction-add.listener.ts @@ -4,6 +4,7 @@ import type { PartialUser, User, } from "discord.js"; +import type { ConfigProvider } from "#lib/config.js"; import { declareEventListener } from "#lib/listener.js"; import starboardService from "#modules/starboard/services/starboard.service.js"; import type { StarboardConfigSchema } from "#modules/starboard/starboard.config.js"; @@ -18,7 +19,7 @@ export default declareEventListener< reaction: MessageReaction | PartialMessageReaction, user: User | PartialUser, _details: unknown, - config + config: ConfigProvider | undefined ) { if (!config) return; diff --git a/src/modules/starboard/listeners/message-reaction-remove.listener.ts b/src/modules/starboard/listeners/message-reaction-remove.listener.ts index 7d95df5..6ad30df 100644 --- a/src/modules/starboard/listeners/message-reaction-remove.listener.ts +++ b/src/modules/starboard/listeners/message-reaction-remove.listener.ts @@ -4,6 +4,7 @@ import type { PartialUser, User, } from "discord.js"; +import type { ConfigProvider } from "#lib/config.js"; import { declareEventListener } from "#lib/listener.js"; import starboardService from "#modules/starboard/services/starboard.service.js"; import type { StarboardConfigSchema } from "#modules/starboard/starboard.config.js"; @@ -18,7 +19,7 @@ export default declareEventListener< reaction: MessageReaction | PartialMessageReaction, user: User | PartialUser, _details: unknown, - config + config: ConfigProvider | undefined ) { if (!config) return; From aedb5cb77aa16745031c4f5d0ea12e2a0619454a Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 17:11:18 +0200 Subject: [PATCH 12/13] refactor(starboard): redesign with containers v2, fix race & self-star - Replace legacy embed with Discord components v2 containers - Add reply container with attachment indicator and inline button - Fix concurrent reaction race condition (P2002 handler) - Filter author reactions when selfStar is disabled - Replace plain text name with author ping in headers - Use h2 for reaction line and header --- .../starboard/services/starboard.service.ts | 239 ++++++++++++++---- 1 file changed, 185 insertions(+), 54 deletions(-) diff --git a/src/modules/starboard/services/starboard.service.ts b/src/modules/starboard/services/starboard.service.ts index 5f242fb..2007553 100644 --- a/src/modules/starboard/services/starboard.service.ts +++ b/src/modules/starboard/services/starboard.service.ts @@ -1,4 +1,5 @@ import { + ButtonBuilder, ButtonStyle, ContainerBuilder, MessageFlags, @@ -11,7 +12,7 @@ import { type User, } from "discord.js"; import type { ConfigProvider } from "#lib/config.js"; -import prisma from "#lib/database.js"; +import prisma, { Prisma } from "#lib/database.js"; import logger from "#lib/logger.js"; import { declareService, type Service } from "#lib/service.js"; import type { StarboardConfigSchema } from "#modules/starboard/starboard.config.js"; @@ -60,105 +61,194 @@ class StarboardService implements Service { private async computeReactionMetrics( message: Message, - configuredEmojis: string[] + configuredEmojis: string[], + config: ConfigProvider ): Promise { const emojis: ReactionMetric[] = []; const uniqueUsers = new Set(); + const selfStar = config.get("selfStar"); for (const [, reaction] of message.reactions.cache) { if (!this.matchEmoji(reaction.emoji, configuredEmojis)) continue; + const users = await reaction.users.fetch(); + const filteredUsers = [...users.keys()].filter( + (id) => selfStar || id !== message.author.id + ); + + filteredUsers.forEach((id) => uniqueUsers.add(id)); emojis.push({ emoji: this.formatEmoji(reaction.emoji), - count: reaction.count, + count: filteredUsers.length, }); - - const users = await reaction.users.fetch(); - for (const userId of users.keys()) { - uniqueUsers.add(userId); - } } return { emojis, totalUniqueUsers: uniqueUsers.size }; } private buildReactionLine(metrics: ReactionMetrics): string { - return metrics.emojis.map((m) => `${m.emoji} **${m.count}**`).join(" | "); + if (metrics.emojis.length === 0) return ""; + return metrics.emojis.map((m) => `${m.emoji} **${m.count}**`).join(" · "); } - private buildStarboardContainer( - message: Message, - metrics: ReactionMetrics, - config: ConfigProvider - ): ContainerBuilder { - const authorName = - message.member?.displayName ?? - message.author.globalName ?? - message.author.username; + private truncate(text: string, max = 1000): string { + if (text.length <= max) return text; + return text.slice(0, max - 3) + "..."; + } + + private buildNameAndTimestamp(message: Message) { const timestamp = Math.floor(message.createdAt.getTime() / 1000); + return { timestamp }; + } - const container = new ContainerBuilder().setAccentColor( - EMBED_COLORS[config.get("embedColor")]! - ); + private buildReplyContainer(referenced: Message): ContainerBuilder { + const { timestamp } = this.buildNameAndTimestamp(referenced); + const container = new ContainerBuilder(); container.addSectionComponents((section) => section .addTextDisplayComponents((text) => - text.setContent(`### ${authorName}\n-# `) + text.setContent(`## ↪ ${referenced.author} · `) ) .setThumbnailAccessory((thumbnail) => - thumbnail.setURL(message.author.displayAvatarURL()) + thumbnail.setURL(referenced.author.displayAvatarURL()) ) ); + if (referenced.content) { + container.addTextDisplayComponents((text) => + text.setContent(this.truncate(referenced.content)) + ); + } + + const images = referenced.attachments.filter((a) => + a.contentType?.startsWith("image/") + ); + const attachmentText = + images.size > 0 + ? `🖼️ ${images.size} pièce${images.size > 1 ? "s" : ""} jointe${images.size > 1 ? "s" : ""}` + : null; + container.addSeparatorComponents((separator) => separator.setDivider(true)); + if (attachmentText) { + container.addSectionComponents((section) => + section + .addTextDisplayComponents((text) => text.setContent(attachmentText)) + .setButtonAccessory((button) => + button + .setStyle(ButtonStyle.Link) + .setURL(referenced.url) + .setLabel("Voir le message") + ) + ); + } else { + container.addActionRowComponents((row) => + row.addComponents( + new ButtonBuilder() + .setStyle(ButtonStyle.Link) + .setURL(referenced.url) + .setLabel("Voir le message") + ) + ); + } + + return container; + } + + private async buildMainContainer( + message: Message, + metrics: ReactionMetrics, + config: ConfigProvider + ): Promise { + const { timestamp } = this.buildNameAndTimestamp(message); + const container = new ContainerBuilder(); + + container.addSectionComponents((section) => + section + .addTextDisplayComponents((text) => + text.setContent(`## ${message.author} · `) + ) + .setThumbnailAccessory((thumbnail) => + thumbnail.setURL(message.author.displayAvatarURL()) + ) + ); + if (message.content) { container.addTextDisplayComponents((text) => - text.setContent(message.content) + text.setContent(this.truncate(message.content)) ); } - const imageAttachments = message.attachments.filter((a) => + const images = message.attachments.filter((a) => a.contentType?.startsWith("image/") ); - if (imageAttachments.size > 0) { + if (images.size > 0) { container.addMediaGalleryComponents((gallery) => gallery.addItems( - ...imageAttachments.map((a) => ({ media: { url: a.url } })).values() + ...images.map((a) => ({ media: { url: a.url } })).values() ) ); } - container.addTextDisplayComponents((text) => - text.setContent(this.buildReactionLine(metrics)) - ); + container.setAccentColor(EMBED_COLORS[config.get("embedColor")]!); container.addSeparatorComponents((separator) => separator.setDivider(true)); + if (metrics.emojis.length > 0) { + container.addTextDisplayComponents((text) => + text.setContent(`## ${this.buildReactionLine(metrics)}`) + ); + } + container.addSectionComponents((section) => section .addTextDisplayComponents((text) => - text.setContent(`-# ${metrics.totalUniqueUsers} total`) + text.setContent( + `-# **${metrics.totalUniqueUsers}** unique${ + metrics.totalUniqueUsers > 1 ? "s" : "" + }` + ) ) .setButtonAccessory((button) => button .setStyle(ButtonStyle.Link) .setURL(message.url) - .setLabel("Aller au message") + .setLabel("Voir le message") ) ); return container; } + private async buildComponents( + message: Message, + metrics: ReactionMetrics, + config: ConfigProvider + ): Promise { + const components: ContainerBuilder[] = []; + + if (message.reference?.messageId) { + try { + const referenced = await message.fetchReference(); + components.push(this.buildReplyContainer(referenced)); + } catch { + // referenced message deleted or inaccessible + } + } + + components.push(await this.buildMainContainer(message, metrics, config)); + return components; + } + async handleReactionChange( reaction: MessageReaction | PartialMessageReaction, user: User | PartialUser, config: ConfigProvider ): Promise { try { - if (reaction.partial || user.partial) return; + if (reaction.partial) reaction = await reaction.fetch(); + if (user.partial) user = await user.fetch(); const message = await reaction.message.fetch(); const guild = message.guild; @@ -186,7 +276,8 @@ class StarboardService implements Service { const metrics = await this.computeReactionMetrics( message, - configuredEmojis + configuredEmojis, + config ); const threshold = config.get("reactionCount"); @@ -218,12 +309,47 @@ class StarboardService implements Service { ); } } else if (metrics.totalUniqueUsers >= threshold) { - await this.createStarboardEntry(message, metrics, config); + try { + await this.createStarboardEntry(message, metrics, config); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + const existingEntry = await prisma.starboardEntry.findUnique({ + where: { + guildId_originalMessageId: { + guildId: guild.id, + originalMessageId: message.id, + }, + }, + }); + if (existingEntry) { + if (metrics.totalUniqueUsers >= threshold) { + await this.updateStarboardEntry( + existingEntry, + message, + metrics, + config + ); + } else if (config.get("belowThresholdBehavior") === "remove") { + await this.removeStarboardEntry(existingEntry, guild); + } else { + await this.updateStarboardEntry( + existingEntry, + message, + metrics, + config + ); + } + } + } else { + throw error; + } + } } } catch (error) { - logger.error( - `Erreur dans le StarboardService.handleReactionChange : ${error}` - ); + logger.error({ err: error }, "Starboard reaction handler failed"); } } @@ -242,10 +368,10 @@ class StarboardService implements Service { if (!channel?.isTextBased()) return; if (!("send" in channel)) return; - const container = this.buildStarboardContainer(message, metrics, config); + const components = await this.buildComponents(message, metrics, config); const starboardMessage = await (channel as TextChannel).send({ - components: [container], + components, flags: MessageFlags.IsComponentsV2, }); @@ -253,17 +379,22 @@ class StarboardService implements Service { await starboardMessage.react(metric.emoji).catch(() => {}); } - await prisma.starboardEntry.create({ - data: { - guildId: guild.id, - originalMessageId: message.id, - originalChannelId: message.channel.id, - starboardMessageId: starboardMessage.id, - starboardChannelId: starboardChannel.id, - reactionCount: metrics.totalUniqueUsers, - reactions: metrics.emojis as any, - }, - }); + try { + await prisma.starboardEntry.create({ + data: { + guildId: guild.id, + originalMessageId: message.id, + originalChannelId: message.channel.id, + starboardMessageId: starboardMessage.id, + starboardChannelId: starboardChannel.id, + reactionCount: metrics.totalUniqueUsers, + reactions: metrics.emojis as unknown as Prisma.InputJsonValue[], + }, + }); + } catch (error) { + await starboardMessage.delete().catch(() => {}); + throw error; + } } private async updateStarboardEntry( @@ -291,13 +422,13 @@ class StarboardService implements Service { return; } - const container = this.buildStarboardContainer( + const components = await this.buildComponents( originalMessage, metrics, config ); await starboardMessage.edit({ - components: [container], + components, flags: MessageFlags.IsComponentsV2, }); @@ -319,7 +450,7 @@ class StarboardService implements Service { where: { id: entry.id }, data: { reactionCount: metrics.totalUniqueUsers, - reactions: metrics.emojis as any, + reactions: metrics.emojis as unknown as Prisma.InputJsonValue[], }, }); } From 61105a7e5a560f5bff470022278f26347f8d45f3 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 17:24:54 +0200 Subject: [PATCH 13/13] fix(starboard): extract threshold logic, rename reactionCount, add color fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract duplicate threshold branching into applyMetrics method - Rename reactionCount → uniqueUserCount in Prisma schema - Add fallback for EMBED_COLORS lookup - Reorder DB check before metrics computation --- src/modules/starboard/models/starboard.prisma | 2 +- .../starboard/services/starboard.service.ts | 213 ++++++++---------- 2 files changed, 101 insertions(+), 114 deletions(-) diff --git a/src/modules/starboard/models/starboard.prisma b/src/modules/starboard/models/starboard.prisma index d68e304..def0ad9 100644 --- a/src/modules/starboard/models/starboard.prisma +++ b/src/modules/starboard/models/starboard.prisma @@ -5,7 +5,7 @@ model StarboardEntry { originalChannelId String starboardMessageId String starboardChannelId String - reactionCount Int @default(0) + uniqueUserCount Int @default(0) reactions Json @default("[]") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/modules/starboard/services/starboard.service.ts b/src/modules/starboard/services/starboard.service.ts index 2007553..f32b4a2 100644 --- a/src/modules/starboard/services/starboard.service.ts +++ b/src/modules/starboard/services/starboard.service.ts @@ -1,5 +1,4 @@ import { - ButtonBuilder, ButtonStyle, ContainerBuilder, MessageFlags, @@ -105,21 +104,23 @@ class StarboardService implements Service { const { timestamp } = this.buildNameAndTimestamp(referenced); const container = new ContainerBuilder(); - container.addSectionComponents((section) => - section - .addTextDisplayComponents((text) => - text.setContent(`## ↪ ${referenced.author} · `) - ) - .setThumbnailAccessory((thumbnail) => - thumbnail.setURL(referenced.author.displayAvatarURL()) - ) - ); + container.addSectionComponents((section) => { + section.addTextDisplayComponents((text) => + text.setContent(`## ↪ ${referenced.author} · \n`) + ); - if (referenced.content) { - container.addTextDisplayComponents((text) => - text.setContent(this.truncate(referenced.content)) + if (referenced.content) { + section.addTextDisplayComponents((text) => + text.setContent(this.truncate(referenced.content)) + ); + } + + section.setThumbnailAccessory((thumbnail) => + thumbnail.setURL(referenced.author.displayAvatarURL()) ); - } + + return section; + }); const images = referenced.attachments.filter((a) => a.contentType?.startsWith("image/") @@ -131,27 +132,18 @@ class StarboardService implements Service { container.addSeparatorComponents((separator) => separator.setDivider(true)); - if (attachmentText) { - container.addSectionComponents((section) => - section - .addTextDisplayComponents((text) => text.setContent(attachmentText)) - .setButtonAccessory((button) => - button - .setStyle(ButtonStyle.Link) - .setURL(referenced.url) - .setLabel("Voir le message") - ) - ); - } else { - container.addActionRowComponents((row) => - row.addComponents( - new ButtonBuilder() + container.addSectionComponents((section) => + section + .addTextDisplayComponents((text) => + text.setContent(attachmentText ?? "\u200b") + ) + .setButtonAccessory((button) => + button .setStyle(ButtonStyle.Link) .setURL(referenced.url) .setLabel("Voir le message") ) - ); - } + ); return container; } @@ -164,21 +156,23 @@ class StarboardService implements Service { const { timestamp } = this.buildNameAndTimestamp(message); const container = new ContainerBuilder(); - container.addSectionComponents((section) => - section - .addTextDisplayComponents((text) => - text.setContent(`## ${message.author} · `) - ) - .setThumbnailAccessory((thumbnail) => - thumbnail.setURL(message.author.displayAvatarURL()) - ) - ); + container.addSectionComponents((section) => { + section.addTextDisplayComponents((text) => + text.setContent(`## ${message.author} · \n`) + ); - if (message.content) { - container.addTextDisplayComponents((text) => - text.setContent(this.truncate(message.content)) + if (message.content) { + section.addTextDisplayComponents((text) => + text.setContent(this.truncate(message.content)) + ); + } + + section.setThumbnailAccessory((thumbnail) => + thumbnail.setURL(message.author.displayAvatarURL()) ); - } + + return section; + }); const images = message.attachments.filter((a) => a.contentType?.startsWith("image/") @@ -191,13 +185,15 @@ class StarboardService implements Service { ); } - container.setAccentColor(EMBED_COLORS[config.get("embedColor")]!); + container.setAccentColor( + EMBED_COLORS[config.get("embedColor")] ?? EMBED_COLORS["default"] + ); container.addSeparatorComponents((separator) => separator.setDivider(true)); if (metrics.emojis.length > 0) { container.addTextDisplayComponents((text) => - text.setContent(`## ${this.buildReactionLine(metrics)}`) + text.setContent(`### ${this.buildReactionLine(metrics)}`) ); } @@ -274,13 +270,6 @@ class StarboardService implements Service { return; } - const metrics = await this.computeReactionMetrics( - message, - configuredEmojis, - config - ); - const threshold = config.get("reactionCount"); - const existingEntry = await prisma.starboardEntry.findUnique({ where: { guildId_originalMessageId: { @@ -290,69 +279,67 @@ class StarboardService implements Service { }, }); - if (existingEntry) { - if (metrics.totalUniqueUsers >= threshold) { - await this.updateStarboardEntry( - existingEntry, - message, - metrics, - config - ); - } else if (config.get("belowThresholdBehavior") === "remove") { - await this.removeStarboardEntry(existingEntry, guild); - } else { - await this.updateStarboardEntry( - existingEntry, - message, - metrics, - config - ); - } - } else if (metrics.totalUniqueUsers >= threshold) { - try { - await this.createStarboardEntry(message, metrics, config); - } catch (error) { - if ( - error instanceof Prisma.PrismaClientKnownRequestError && - error.code === "P2002" - ) { - const existingEntry = await prisma.starboardEntry.findUnique({ - where: { - guildId_originalMessageId: { - guildId: guild.id, - originalMessageId: message.id, - }, - }, - }); - if (existingEntry) { - if (metrics.totalUniqueUsers >= threshold) { - await this.updateStarboardEntry( - existingEntry, - message, - metrics, - config - ); - } else if (config.get("belowThresholdBehavior") === "remove") { - await this.removeStarboardEntry(existingEntry, guild); - } else { - await this.updateStarboardEntry( - existingEntry, - message, - metrics, - config - ); - } - } - } else { - throw error; - } - } - } + const metrics = await this.computeReactionMetrics( + message, + configuredEmojis, + config + ); + + await this.applyMetrics(existingEntry, message, metrics, config); } catch (error) { logger.error({ err: error }, "Starboard reaction handler failed"); } } + private async applyMetrics( + entry: { + id: string; + starboardMessageId: string; + starboardChannelId: string; + } | null, + message: Message, + metrics: ReactionMetrics, + config: ConfigProvider + ): Promise { + const threshold = config.get("reactionCount"); + const belowThreshold = config.get("belowThresholdBehavior"); + const guild = message.guild!; + + if (entry) { + if (metrics.totalUniqueUsers >= threshold || belowThreshold === "keep") { + await this.updateStarboardEntry(entry, message, metrics, config); + } else { + await this.removeStarboardEntry(entry, guild); + } + return; + } + + if (metrics.totalUniqueUsers < threshold) return; + + try { + await this.createStarboardEntry(message, metrics, config); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + const existingEntry = await prisma.starboardEntry.findUnique({ + where: { + guildId_originalMessageId: { + guildId: guild.id, + originalMessageId: message.id, + }, + }, + }); + if (existingEntry) { + await this.applyMetrics(existingEntry, message, metrics, config); + } + } else { + throw error; + } + } + } + private async createStarboardEntry( message: Message, metrics: ReactionMetrics, @@ -387,7 +374,7 @@ class StarboardService implements Service { originalChannelId: message.channel.id, starboardMessageId: starboardMessage.id, starboardChannelId: starboardChannel.id, - reactionCount: metrics.totalUniqueUsers, + uniqueUserCount: metrics.totalUniqueUsers, reactions: metrics.emojis as unknown as Prisma.InputJsonValue[], }, }); @@ -449,7 +436,7 @@ class StarboardService implements Service { await prisma.starboardEntry.update({ where: { id: entry.id }, data: { - reactionCount: metrics.totalUniqueUsers, + uniqueUserCount: metrics.totalUniqueUsers, reactions: metrics.emojis as unknown as Prisma.InputJsonValue[], }, });