import { ChannelType, EmbedBuilder, PermissionFlagsBits, type Emoji, type GuildBasedChannel, type Message, type PartialMessage, type PartialMessageReaction, type MessageReaction, type TextChannel } from 'discord.js'; import type { PrismaClient, StarboardConfig } from '@prisma/client'; import { t, type Locale } from '@nexumi/shared'; import { ensureGuild } from '../../guild.js'; import { logger } from '../../logger.js'; import type { BotContext } from '../../types.js'; import { getGuildLocale } from '../../i18n.js'; export async function getStarboardConfig(prisma: PrismaClient, guildId: string) { await ensureGuild(prisma, guildId); let config = await prisma.starboardConfig.findUnique({ where: { guildId } }); if (!config) { config = await prisma.starboardConfig.create({ data: { guildId } }); } return config; } export async function updateStarboardConfig( prisma: PrismaClient, guildId: string, data: { enabled: boolean; channelId: string; emoji: string; threshold: number; allowSelfStar: boolean; } ) { await ensureGuild(prisma, guildId); return prisma.starboardConfig.upsert({ where: { guildId }, update: data, create: { guildId, ...data } }); } export function emojiMatches(configEmoji: string, reactionEmoji: Emoji): boolean { if (reactionEmoji.id) { return ( configEmoji === reactionEmoji.identifier || configEmoji === reactionEmoji.toString() || configEmoji === `<:${reactionEmoji.name}:${reactionEmoji.id}>` || configEmoji === `` ); } return configEmoji === reactionEmoji.name || configEmoji === reactionEmoji.toString(); } export function shouldIgnoreMessage(message: Message, config: StarboardConfig): boolean { if (message.author.bot) { return true; } if (!message.guild || !message.channel.isTextBased() || message.channel.isDMBased()) { return true; } if ('nsfw' in message.channel && message.channel.nsfw) { return true; } if (config.ignoredChannelIds.includes(message.channel.id)) { return true; } if (config.channelId && message.channel.id === config.channelId) { return true; } return false; } export function botCanPostStarboard(channel: GuildBasedChannel, meId: string): channel is TextChannel { if (!channel.isTextBased() || channel.isDMBased()) { return false; } if ( channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement ) { return false; } const permissions = channel.permissionsFor(meId); if (!permissions) { return false; } return ( permissions.has(PermissionFlagsBits.ViewChannel) && permissions.has(PermissionFlagsBits.SendMessages) && permissions.has(PermissionFlagsBits.EmbedLinks) ); } export async function countMatchingStars( message: Message, config: StarboardConfig ): Promise { const reaction = message.reactions.cache.find((r) => emojiMatches(config.emoji, r.emoji)); if (!reaction || reaction.count === 0) { return 0; } const fetchedReaction = reaction.partial ? await reaction.fetch() : reaction; const users = await fetchedReaction.users.fetch(); let count = 0; for (const [, user] of users) { if (user.bot) { continue; } if (!config.allowSelfStar && user.id === message.author.id) { continue; } count++; } return count; } function resolveImageUrl(message: Message): string | undefined { const attachment = message.attachments.find((a) => { const type = a.contentType ?? ''; return type.startsWith('image/') || /\.(png|jpe?g|gif|webp)$/i.test(a.name); }); if (attachment) { return attachment.url; } const embedImage = message.embeds.find((e) => e.image?.url)?.image?.url; return embedImage ?? undefined; } function authorDisplayName(message: Message): string { return message.member?.displayName || message.author.displayName || message.author.username; } export function buildStarboardEmbed( message: Message, starCount: number, config: StarboardConfig, locale: Locale ): EmbedBuilder { const content = message.content?.trim() || t(locale, 'starboard.noContent'); const embed = new EmbedBuilder() .setColor(0x6366f1) .setAuthor({ name: authorDisplayName(message), iconURL: message.author.displayAvatarURL() }) .setDescription(content.slice(0, 4096)) .addFields({ name: t(locale, 'starboard.field.stars'), value: `${config.emoji} **${starCount}**`, inline: true }) .addFields({ name: t(locale, 'starboard.field.source'), value: `[${t(locale, 'starboard.jumpLink')}](${message.url})`, inline: true }) .setTimestamp(message.createdAt); const imageUrl = resolveImageUrl(message); if (imageUrl) { embed.setImage(imageUrl); } return embed; } async function fetchStarboardTextChannel( context: BotContext, channelId: string ): Promise { const channel = await context.client.channels.fetch(channelId).catch(() => null); if (!channel || !channel.isTextBased() || channel.isDMBased()) { return null; } return channel as TextChannel; } async function removeStarboardEntry(context: BotContext, entryId: string): Promise { const entry = await context.prisma.starboardEntry.findUnique({ where: { id: entryId } }); if (!entry) { return; } try { const config = await getStarboardConfig(context.prisma, entry.guildId); if (config.channelId) { const channel = await fetchStarboardTextChannel(context, config.channelId); if (channel) { const starboardMessage = await channel.messages .fetch(entry.starboardMessageId) .catch(() => null); await starboardMessage?.delete().catch(() => undefined); } } } catch (error) { logger.warn({ error, entryId }, 'Failed to delete starboard message'); } await context.prisma.starboardEntry.delete({ where: { id: entryId } }).catch(() => undefined); } export async function removeStarboardEntryBySource( context: BotContext, guildId: string, sourceMessageId: string ): Promise { const entry = await context.prisma.starboardEntry.findUnique({ where: { guildId_sourceMessageId: { guildId, sourceMessageId } } }); if (entry) { await removeStarboardEntry(context, entry.id); } } async function createOrRecreateStarboardPost( context: BotContext, message: Message, config: StarboardConfig, locale: Locale, starCount: number, starboardChannel: TextChannel, existingId?: string ): Promise { const embed = buildStarboardEmbed(message, starCount, config, locale); const starboardMessage = await starboardChannel.send({ embeds: [embed] }); if (existingId) { await context.prisma.starboardEntry.update({ where: { id: existingId }, data: { starboardMessageId: starboardMessage.id, starCount, sourceChannelId: message.channel.id } }); return; } try { await context.prisma.starboardEntry.create({ data: { guildId: message.guild!.id, sourceChannelId: message.channel.id, sourceMessageId: message.id, starboardMessageId: starboardMessage.id, starCount } }); } catch (error) { // Race: another reaction created the row first — keep the newer post, drop ours. logger.warn({ error, messageId: message.id }, 'Starboard entry create raced; cleaning up'); await starboardMessage.delete().catch(() => undefined); const winner = await context.prisma.starboardEntry.findUnique({ where: { guildId_sourceMessageId: { guildId: message.guild!.id, sourceMessageId: message.id } } }); if (winner) { await context.prisma.starboardEntry.update({ where: { id: winner.id }, data: { starCount } }); const existingMsg = await starboardChannel.messages .fetch(winner.starboardMessageId) .catch(() => null); if (existingMsg) { await existingMsg.edit({ embeds: [embed] }).catch(() => undefined); } } } } export async function processStarboardMessage(context: BotContext, message: Message): Promise { if (!message.guild) { return; } const config = await getStarboardConfig(context.prisma, message.guild.id); if (!config.enabled || !config.channelId) { return; } if (shouldIgnoreMessage(message, config)) { return; } const starCount = await countMatchingStars(message, config); const locale = await getGuildLocale(context.prisma, message.guild.id); const existing = await context.prisma.starboardEntry.findUnique({ where: { guildId_sourceMessageId: { guildId: message.guild.id, sourceMessageId: message.id } } }); if (starCount < config.threshold) { if (existing) { await removeStarboardEntry(context, existing.id); } 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); if (existing) { try { const starboardMessage = await starboardChannel.messages.fetch(existing.starboardMessageId); await starboardMessage.edit({ embeds: [embed] }); await context.prisma.starboardEntry.update({ where: { id: existing.id }, data: { starCount } }); } catch (error) { logger.warn({ error, messageId: message.id }, 'Starboard message missing; recreating'); await createOrRecreateStarboardPost( context, message, config, locale, starCount, starboardChannel, existing.id ); } return; } await createOrRecreateStarboardPost( context, message, config, locale, starCount, starboardChannel ); } export async function processStarboardReaction( context: BotContext, reaction: MessageReaction | PartialMessageReaction ): Promise { if (reaction.partial) { await reaction.fetch(); } const message = reaction.message; if (message.partial) { await message.fetch(); } if (!message.guild || !message.author) { return; } const config = await getStarboardConfig(context.prisma, message.guild.id); if (!config.enabled || !config.channelId) { return; } if (!emojiMatches(config.emoji, reaction.emoji)) { return; } await processStarboardMessage(context, message as Message); } export async function processStarboardSourceDelete( context: BotContext, message: Message | PartialMessage ): Promise { if (!message.guildId || !message.id) { return; } await removeStarboardEntryBySource(context, message.guildId, message.id); } export async function processStarboardSourceUpdate( context: BotContext, message: Message | PartialMessage ): Promise { if (!message.guildId || !message.id) { return; } const entry = await context.prisma.starboardEntry.findUnique({ where: { guildId_sourceMessageId: { guildId: message.guildId, sourceMessageId: message.id } } }); if (!entry) { return; } const full = message.partial ? await message.fetch().catch(() => null) : message; if (!full || !full.guild) { return; } await processStarboardMessage(context, full as Message); }