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 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 new file mode 100644 index 0000000..1331249 --- /dev/null +++ b/src/modules/starboard/listeners/message-reaction-add.listener.ts @@ -0,0 +1,28 @@ +import type { + MessageReaction, + PartialMessageReaction, + 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"; + +export default declareEventListener< + "messageReactionAdd", + StarboardConfigSchema +>({ + eventType: "messageReactionAdd", + + async execute( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + _details: unknown, + config: ConfigProvider | undefined + ) { + 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..6ad30df --- /dev/null +++ b/src/modules/starboard/listeners/message-reaction-remove.listener.ts @@ -0,0 +1,28 @@ +import type { + MessageReaction, + PartialMessageReaction, + 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"; + +export default declareEventListener< + "messageReactionRemove", + StarboardConfigSchema +>({ + eventType: "messageReactionRemove", + + async execute( + reaction: MessageReaction | PartialMessageReaction, + user: User | PartialUser, + _details: unknown, + config: ConfigProvider | undefined + ) { + 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..def0ad9 --- /dev/null +++ b/src/modules/starboard/models/starboard.prisma @@ -0,0 +1,15 @@ +model StarboardEntry { + id String @id @default(cuid()) + guildId String + originalMessageId String + originalChannelId String + starboardMessageId String + starboardChannelId String + uniqueUserCount Int @default(0) + reactions Json @default("[]") + 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..f32b4a2 --- /dev/null +++ b/src/modules/starboard/services/starboard.service.ts @@ -0,0 +1,475 @@ +import { + ButtonStyle, + ContainerBuilder, + MessageFlags, + type Guild, + type Message, + type MessageReaction, + type PartialMessageReaction, + type PartialUser, + type TextChannel, + type User, +} from "discord.js"; +import type { ConfigProvider } from "#lib/config.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"; + +interface ReactionMetric { + emoji: string; + count: number; +} + +interface ReactionMetrics { + emojis: ReactionMetric[]; + totalUniqueUsers: number; +} + +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; + }); + } + + 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[], + 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: filteredUsers.length, + }); + } + + return { emojis, totalUniqueUsers: uniqueUsers.size }; + } + + private buildReactionLine(metrics: ReactionMetrics): string { + if (metrics.emojis.length === 0) return ""; + return metrics.emojis.map((m) => `${m.emoji} **${m.count}**`).join(" · "); + } + + 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 }; + } + + private buildReplyContainer(referenced: Message): ContainerBuilder { + const { timestamp } = this.buildNameAndTimestamp(referenced); + const container = new ContainerBuilder(); + + container.addSectionComponents((section) => { + section.addTextDisplayComponents((text) => + text.setContent(`## ↪ ${referenced.author} · \n`) + ); + + 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/") + ); + 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)); + + 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; + } + + 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} · \n`) + ); + + 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/") + ); + if (images.size > 0) { + container.addMediaGalleryComponents((gallery) => + gallery.addItems( + ...images.map((a) => ({ media: { url: a.url } })).values() + ) + ); + } + + 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)}`) + ); + } + + container.addSectionComponents((section) => + section + .addTextDisplayComponents((text) => + text.setContent( + `-# **${metrics.totalUniqueUsers}** unique${ + metrics.totalUniqueUsers > 1 ? "s" : "" + }` + ) + ) + .setButtonAccessory((button) => + button + .setStyle(ButtonStyle.Link) + .setURL(message.url) + .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) reaction = await reaction.fetch(); + if (user.partial) user = await user.fetch(); + + 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 existingEntry = await prisma.starboardEntry.findUnique({ + where: { + guildId_originalMessageId: { + guildId: guild.id, + originalMessageId: message.id, + }, + }, + }); + + 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, + 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 components = await this.buildComponents(message, metrics, config); + + const starboardMessage = await (channel as TextChannel).send({ + components, + flags: MessageFlags.IsComponentsV2, + }); + + for (const metric of metrics.emojis) { + await starboardMessage.react(metric.emoji).catch(() => {}); + } + + try { + await prisma.starboardEntry.create({ + data: { + guildId: guild.id, + originalMessageId: message.id, + originalChannelId: message.channel.id, + starboardMessageId: starboardMessage.id, + starboardChannelId: starboardChannel.id, + uniqueUserCount: metrics.totalUniqueUsers, + reactions: metrics.emojis as unknown as Prisma.InputJsonValue[], + }, + }); + } catch (error) { + await starboardMessage.delete().catch(() => {}); + throw error; + } + } + + private async updateStarboardEntry( + 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 components = await this.buildComponents( + originalMessage, + metrics, + config + ); + await starboardMessage.edit({ + components, + flags: MessageFlags.IsComponentsV2, + }); + + 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: { + uniqueUserCount: metrics.totalUniqueUsers, + reactions: metrics.emojis as unknown as Prisma.InputJsonValue[], + }, + }); + } + + 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 }, + }); + } +} + +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..1f3432f --- /dev/null +++ b/src/modules/starboard/starboard.module.ts @@ -0,0 +1,42 @@ +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, + GatewayIntentBits.MessageContent, + ], + + 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/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 8f262ef..160ce39 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -15,3 +15,20 @@ 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) + reactions Json @default("[]") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([guildId, originalMessageId]) + @@index([guildId]) +}