diff --git a/config.json b/config.json index 03efd92a..e6ec59a5 100644 --- a/config.json +++ b/config.json @@ -95,6 +95,14 @@ "flushIntervalMs": 5000 } }, + "starboard": { + "enabled": false, + "channelId": null, + "threshold": 3, + "emoji": "*", + "selfStarAllowed": false, + "ignoredChannels": [] + }, "permissions": { "enabled": true, "adminRoleId": null, diff --git a/migrations/002_starboard-posts.cjs b/migrations/002_starboard-posts.cjs new file mode 100644 index 00000000..369cbfeb --- /dev/null +++ b/migrations/002_starboard-posts.cjs @@ -0,0 +1,27 @@ +/** + * Migration: starboard_posts table + * + * Tracks which messages have been reposted to the starboard channel, + * enabling dedup (update instead of repost) and star-count syncing. + */ + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE IF NOT EXISTS starboard_posts ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + source_message_id TEXT NOT NULL UNIQUE, + source_channel_id TEXT NOT NULL, + starboard_message_id TEXT NOT NULL, + star_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW() + ) + `); + // No explicit index needed — UNIQUE constraint on source_message_id creates one implicitly +}; + +/** @param {import('node-pg-migrate').MigrationBuilder} pgm */ +exports.down = (pgm) => { + pgm.sql('DROP TABLE IF EXISTS starboard_posts CASCADE'); +}; diff --git a/src/index.js b/src/index.js index 976529cb..811675ca 100644 --- a/src/index.js +++ b/src/index.js @@ -17,7 +17,7 @@ import './sentry.js'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { Client, Collection, Events, GatewayIntentBits } from 'discord.js'; +import { Client, Collection, Events, GatewayIntentBits, Partials } from 'discord.js'; import { config as dotenvConfig } from 'dotenv'; import { startServer, stopServer } from './api/server.js'; import { closeRedis } from './api/utils/redisClient.js'; @@ -104,7 +104,9 @@ const client = new Client({ GatewayIntentBits.MessageContent, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildVoiceStates, + GatewayIntentBits.GuildMessageReactions, ], + partials: [Partials.Message, Partials.Reaction], allowedMentions: { parse: ['users'] }, }); diff --git a/src/modules/events.js b/src/modules/events.js index c5f69bb6..c182c487 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -14,6 +14,7 @@ import { getConfig } from './config.js'; import { checkLinks } from './linkFilter.js'; import { checkRateLimit } from './rateLimit.js'; import { isSpam, sendSpamAlert } from './spam.js'; +import { handleReactionAdd, handleReactionRemove } from './starboard.js'; import { accumulateMessage, evaluateNow } from './triage.js'; import { recordCommunityActivity, sendWelcomeMessage } from './welcome.js'; @@ -56,6 +57,12 @@ export function registerReadyHandler(client, config, healthMonitor) { if (config.moderation?.enabled) { info('Moderation enabled'); } + if (config.starboard?.enabled) { + info('Starboard enabled', { + channelId: config.starboard.channelId, + threshold: config.starboard.threshold, + }); + } }); } @@ -214,6 +221,70 @@ export function registerMessageCreateHandler(client, _config, healthMonitor) { }); } +/** + * Register reaction event handlers for the starboard feature. + * Listens to both MessageReactionAdd and MessageReactionRemove to + * post, update, or remove starboard embeds based on star count. + * + * @param {Client} client - Discord client instance + * @param {Object} _config - Unused (kept for API compatibility); handler resolves per-guild config via getConfig(). + */ +export function registerReactionHandlers(client, _config) { + client.on(Events.MessageReactionAdd, async (reaction, user) => { + // Ignore bot reactions + if (user.bot) return; + + // Fetch partial messages so we have full guild/channel data + if (reaction.message.partial) { + try { + await reaction.message.fetch(); + } catch { + return; + } + } + const guildId = reaction.message.guild?.id; + if (!guildId) return; + + const guildConfig = getConfig(guildId); + if (!guildConfig.starboard?.enabled) return; + + try { + await handleReactionAdd(reaction, user, client, guildConfig); + } catch (err) { + logError('Starboard reaction add handler failed', { + messageId: reaction.message.id, + error: err.message, + }); + } + }); + + client.on(Events.MessageReactionRemove, async (reaction, user) => { + if (user.bot) return; + + if (reaction.message.partial) { + try { + await reaction.message.fetch(); + } catch { + return; + } + } + const guildId = reaction.message.guild?.id; + if (!guildId) return; + + const guildConfig = getConfig(guildId); + if (!guildConfig.starboard?.enabled) return; + + try { + await handleReactionRemove(reaction, user, client, guildConfig); + } catch (err) { + logError('Starboard reaction remove handler failed', { + messageId: reaction.message.id, + error: err.message, + }); + } + }); +} + /** * Register error event handlers * @param {Client} client - Discord client @@ -241,5 +312,6 @@ export function registerEventHandlers(client, config, healthMonitor) { registerReadyHandler(client, config, healthMonitor); registerGuildMemberAddHandler(client, config); registerMessageCreateHandler(client, config, healthMonitor); + registerReactionHandlers(client, config); registerErrorHandlers(client); } diff --git a/src/modules/starboard.js b/src/modules/starboard.js new file mode 100644 index 00000000..66eb35ec --- /dev/null +++ b/src/modules/starboard.js @@ -0,0 +1,415 @@ +/** + * Starboard Module + * + * When a message accumulates enough star reactions (configurable threshold), + * it gets reposted to a dedicated starboard channel with a gold embed. + * Handles dedup (update vs repost), star removal, and self-star prevention. + */ + +import { EmbedBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { debug, info, error as logError, warn } from '../logger.js'; +import { safeSend } from '../utils/safeSend.js'; + +/** Default starboard configuration values */ +export const STARBOARD_DEFAULTS = { + enabled: false, + channelId: null, + threshold: 3, + emoji: '*', + selfStarAllowed: false, + ignoredChannels: [], +}; + +/** Gold color for starboard embeds */ +const STARBOARD_COLOR = 0xffd700; + +/** + * Build the starboard embed for a message. + * + * @param {import('discord.js').Message} message - The original message + * @param {number} starCount - Current star count + * @param {string} [displayEmoji='⭐'] - Emoji to display in the Stars field + * @returns {EmbedBuilder} The starboard embed + */ +export function buildStarboardEmbed(message, starCount, displayEmoji = '⭐') { + const embed = new EmbedBuilder() + .setColor(STARBOARD_COLOR) + .setAuthor({ + name: message.author?.displayName ?? message.author?.username ?? 'Unknown', + iconURL: message.author?.displayAvatarURL?.() ?? undefined, + }) + .setTimestamp(message.createdAt) + .addFields( + { name: 'Source', value: `<#${message.channel.id}>`, inline: true }, + { name: 'Stars', value: `${displayEmoji} ${starCount}`, inline: true }, + { + name: 'Jump', + value: `[Go to message](https://discord.com/channels/${message.guild.id}/${message.channel.id}/${message.id})`, + inline: true, + }, + ); + + if (message.content) { + embed.setDescription(message.content); + } + + // Attach the first image from the message (attachment or embed). + // Discord.js Collections have .find(); fall back to iteration for plain Maps. + let imageAttachment = null; + if (message.attachments) { + if (typeof message.attachments.find === 'function') { + imageAttachment = message.attachments.find((a) => a.contentType?.startsWith('image/')); + } else { + for (const a of message.attachments.values()) { + if (a.contentType?.startsWith('image/')) { + imageAttachment = a; + break; + } + } + } + } + + if (imageAttachment) { + embed.setImage(imageAttachment.url); + } else if (message.embeds?.length > 0) { + const imageEmbed = message.embeds.find((e) => e.image?.url); + if (imageEmbed) { + embed.setImage(imageEmbed.image.url); + } + } + + return embed; +} + +/** + * Look up an existing starboard post by source message ID. + * + * @param {string} sourceMessageId - The original message ID + * @returns {Promise} The starboard_posts row or null + */ +export async function findStarboardPost(sourceMessageId) { + try { + const pool = getPool(); + const { rows } = await pool.query( + 'SELECT * FROM starboard_posts WHERE source_message_id = $1', + [sourceMessageId], + ); + return rows[0] || null; + } catch (err) { + logError('Failed to query starboard_posts', { error: err.message, sourceMessageId }); + return null; + } +} + +/** + * Insert a new starboard post record. + * + * @param {Object} params + * @param {string} params.guildId - Guild ID + * @param {string} params.sourceMessageId - Original message ID + * @param {string} params.sourceChannelId - Original channel ID + * @param {string} params.starboardMessageId - Starboard embed message ID + * @param {number} params.starCount - Current star count + * @returns {Promise} + */ +export async function insertStarboardPost({ + guildId, + sourceMessageId, + sourceChannelId, + starboardMessageId, + starCount, +}) { + const pool = getPool(); + await pool.query( + `INSERT INTO starboard_posts (guild_id, source_message_id, source_channel_id, starboard_message_id, star_count) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (source_message_id) DO UPDATE SET starboard_message_id = $4, star_count = $5`, + [guildId, sourceMessageId, sourceChannelId, starboardMessageId, starCount], + ); +} + +/** + * Update the star count for an existing starboard post. + * + * @param {string} sourceMessageId - Original message ID + * @param {number} starCount - New star count + * @returns {Promise} + */ +export async function updateStarboardPostCount(sourceMessageId, starCount) { + const pool = getPool(); + await pool.query('UPDATE starboard_posts SET star_count = $1 WHERE source_message_id = $2', [ + starCount, + sourceMessageId, + ]); +} + +/** + * Delete a starboard post record. + * + * @param {string} sourceMessageId - Original message ID + * @returns {Promise} + */ +export async function deleteStarboardPost(sourceMessageId) { + const pool = getPool(); + await pool.query('DELETE FROM starboard_posts WHERE source_message_id = $1', [sourceMessageId]); +} + +/** + * Resolve the effective starboard config with defaults applied. + * + * @param {Object} config - Guild config + * @returns {Object} Merged starboard config with defaults + */ +export function resolveStarboardConfig(config) { + return { ...STARBOARD_DEFAULTS, ...config?.starboard }; +} + +/** + * Get the star count for a specific emoji on a message. + * Handles both unicode and custom emoji matching. + * When emoji is '*', finds the reaction with the highest count. + * + * @param {import('discord.js').Message} message - The message to check + * @param {string} emoji - The emoji to count (e.g. '⭐'), or '*' for any emoji + * @param {boolean} selfStarAllowed - Whether to count the author's own reaction + * @returns {Promise<{count: number, emoji: string}>} The star count and matched emoji + */ +export async function getStarCount(message, emoji, selfStarAllowed) { + let reaction = null; + + if (emoji === '*') { + // Wildcard: find the reaction with the highest count + let maxCount = 0; + for (const r of message.reactions.cache.values()) { + if (r.count > maxCount) { + maxCount = r.count; + reaction = r; + } + } + } else { + for (const r of message.reactions.cache.values()) { + if (r.emoji.name === emoji) { + reaction = r; + break; + } + } + } + + if (!reaction) return { count: 0, emoji: emoji === '*' ? '⭐' : emoji }; + + const matchedEmoji = reaction.emoji.name ?? '⭐'; + let count = reaction.count; + + if (!selfStarAllowed) { + try { + const users = await reaction.users.fetch({ limit: 100 }); + if (users.has(message.author.id)) { + count -= 1; + } + } catch (err) { + debug('Could not fetch reaction users for self-star check', { + messageId: message.id, + error: err.message, + }); + } + } + + return { count: Math.max(0, count), emoji: matchedEmoji }; +} + +/** + * Handle a reaction being added to a message. + * If the star count meets/exceeds the threshold, post or update the starboard embed. + * + * @param {import('discord.js').MessageReaction} reaction - The reaction + * @param {import('discord.js').User} user - The user who reacted + * @param {import('discord.js').Client} client - Discord client + * @param {Object} config - Guild config + */ +export async function handleReactionAdd(reaction, user, client, config) { + const sbConfig = resolveStarboardConfig(config); + if (!sbConfig.enabled || !sbConfig.channelId) return; + + // Ensure reaction and message are fully fetched + if (reaction.partial) { + try { + reaction = await reaction.fetch(); + } catch (err) { + warn('Failed to fetch partial reaction', { error: err.message }); + return; + } + } + + const message = reaction.message; + if (message.partial) { + try { + await message.fetch(); + } catch (err) { + warn('Failed to fetch partial message for starboard', { error: err.message }); + return; + } + } + + // Prevent feedback loop — don't star messages posted in the starboard channel itself + if (message.channel.id === sbConfig.channelId) return; + + // Only process the configured emoji (skip check for wildcard '*') + if (sbConfig.emoji !== '*' && reaction.emoji.name !== sbConfig.emoji) return; + + // Ignore messages in ignored channels + if (sbConfig.ignoredChannels.includes(message.channel.id)) return; + + // Prevent self-star if not allowed + if (!sbConfig.selfStarAllowed && user.id === message.author.id) { + debug('Self-star ignored', { userId: user.id, messageId: message.id }); + return; + } + + const { count: starCount, emoji: displayEmoji } = await getStarCount( + message, + sbConfig.emoji, + sbConfig.selfStarAllowed, + ); + + if (starCount < sbConfig.threshold) return; + + const existing = await findStarboardPost(message.id); + + try { + const starboardChannel = await client.channels.fetch(sbConfig.channelId); + if (!starboardChannel) { + warn('Starboard channel not found', { channelId: sbConfig.channelId }); + return; + } + + const embed = buildStarboardEmbed(message, starCount, displayEmoji); + const content = `${displayEmoji} **${starCount}** | <#${message.channel.id}>`; + + if (existing) { + // Update existing starboard message + try { + const starboardMessage = await starboardChannel.messages.fetch( + existing.starboard_message_id, + ); + await starboardMessage.edit({ content, embeds: [embed] }); + await updateStarboardPostCount(message.id, starCount); + debug('Starboard post updated', { messageId: message.id, starCount }); + } catch (err) { + warn('Failed to update starboard message, reposting', { error: err.message }); + // If the starboard message was deleted, repost + const newMsg = await safeSend(starboardChannel, { content, embeds: [embed] }); + await insertStarboardPost({ + guildId: message.guild.id, + sourceMessageId: message.id, + sourceChannelId: message.channel.id, + starboardMessageId: newMsg.id, + starCount, + }); + } + } else { + // New starboard post + const newMsg = await safeSend(starboardChannel, { content, embeds: [embed] }); + await insertStarboardPost({ + guildId: message.guild.id, + sourceMessageId: message.id, + sourceChannelId: message.channel.id, + starboardMessageId: newMsg.id, + starCount, + }); + info('New starboard post', { messageId: message.id, starCount }); + } + } catch (err) { + logError('Starboard handleReactionAdd failed', { + messageId: message.id, + error: err.message, + }); + } +} + +/** + * Handle a reaction being removed from a message. + * Updates the starboard embed count, or removes it if below threshold. + * + * @param {import('discord.js').MessageReaction} reaction - The reaction + * @param {import('discord.js').User} _user - The user who removed the reaction (unused, kept for API symmetry) + * @param {import('discord.js').Client} client - Discord client + * @param {Object} config - Guild config + */ +export async function handleReactionRemove(reaction, _user, client, config) { + const sbConfig = resolveStarboardConfig(config); + if (!sbConfig.enabled || !sbConfig.channelId) return; + + // Ensure reaction and message are fully fetched + if (reaction.partial) { + try { + reaction = await reaction.fetch(); + } catch (err) { + warn('Failed to fetch partial reaction on remove', { error: err.message }); + return; + } + } + + const message = reaction.message; + if (message.partial) { + try { + await message.fetch(); + } catch (err) { + warn('Failed to fetch partial message for starboard remove', { error: err.message }); + return; + } + } + + // Only process the configured emoji (skip check for wildcard '*') + if (sbConfig.emoji !== '*' && reaction.emoji.name !== sbConfig.emoji) return; + + const existing = await findStarboardPost(message.id); + if (!existing) return; // Nothing to update + + const { count: starCount, emoji: displayEmoji } = await getStarCount( + message, + sbConfig.emoji, + sbConfig.selfStarAllowed, + ); + + try { + const starboardChannel = await client.channels.fetch(sbConfig.channelId); + if (!starboardChannel) { + warn('Starboard channel not found on reaction remove', { channelId: sbConfig.channelId }); + return; + } + + if (starCount < sbConfig.threshold) { + // Below threshold — remove from starboard + try { + const starboardMessage = await starboardChannel.messages.fetch( + existing.starboard_message_id, + ); + await starboardMessage.delete(); + } catch (err) { + debug('Starboard message already deleted', { error: err.message }); + } + await deleteStarboardPost(message.id); + info('Starboard post removed (below threshold)', { messageId: message.id, starCount }); + } else { + // Update count + try { + const starboardMessage = await starboardChannel.messages.fetch( + existing.starboard_message_id, + ); + const embed = buildStarboardEmbed(message, starCount, displayEmoji); + const content = `${displayEmoji} **${starCount}** | <#${message.channel.id}>`; + await starboardMessage.edit({ content, embeds: [embed] }); + await updateStarboardPostCount(message.id, starCount); + debug('Starboard post updated on reaction remove', { messageId: message.id, starCount }); + } catch (err) { + warn('Failed to update starboard message on reaction remove', { error: err.message }); + } + } + } catch (err) { + logError('Starboard handleReactionRemove failed', { + messageId: message.id, + error: err.message, + }); + } +} diff --git a/tests/index.test.js b/tests/index.test.js index a0052f32..aee3f385 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -127,6 +127,11 @@ vi.mock('discord.js', () => { MessageContent: 3, GuildMembers: 4, GuildVoiceStates: 5, + GuildMessageReactions: 6, + }, + Partials: { + Message: 0, + Reaction: 2, }, }; }); diff --git a/tests/modules/events.test.js b/tests/modules/events.test.js index 82b9ae82..df09f6ac 100644 --- a/tests/modules/events.test.js +++ b/tests/modules/events.test.js @@ -28,6 +28,10 @@ vi.mock('../../src/modules/welcome.js', () => ({ vi.mock('../../src/utils/errors.js', () => ({ getUserFriendlyMessage: vi.fn().mockReturnValue('Something went wrong. Try again!'), })); +vi.mock('../../src/modules/starboard.js', () => ({ + handleReactionAdd: vi.fn().mockResolvedValue(undefined), + handleReactionRemove: vi.fn().mockResolvedValue(undefined), +})); // Mock config module — getConfig returns per-guild config vi.mock('../../src/modules/config.js', () => ({ @@ -40,9 +44,11 @@ import { registerEventHandlers, registerGuildMemberAddHandler, registerMessageCreateHandler, + registerReactionHandlers, registerReadyHandler, } from '../../src/modules/events.js'; import { isSpam, sendSpamAlert } from '../../src/modules/spam.js'; +import { handleReactionAdd, handleReactionRemove } from '../../src/modules/starboard.js'; import { accumulateMessage, evaluateNow } from '../../src/modules/triage.js'; import { recordCommunityActivity, sendWelcomeMessage } from '../../src/modules/welcome.js'; import { getUserFriendlyMessage } from '../../src/utils/errors.js'; @@ -404,6 +410,88 @@ describe('events module', () => { }); }); + // ── registerReactionHandlers ─────────────────────────────────────────── + + describe('registerReactionHandlers', () => { + let onCallbacks; + let client; + + function setup(configOverrides = {}) { + onCallbacks = {}; + client = { + on: vi.fn((event, cb) => { + // Support multiple handlers per event + if (!onCallbacks[event]) onCallbacks[event] = []; + onCallbacks[event].push(cb); + }), + }; + getConfig.mockReturnValue({ + starboard: { enabled: true, channelId: 'sb-ch', threshold: 3, emoji: '⭐' }, + ...configOverrides, + }); + registerReactionHandlers(client, {}); + } + + it('should register messageReactionAdd and messageReactionRemove', () => { + setup(); + const events = client.on.mock.calls.map((c) => c[0]); + expect(events).toContain('messageReactionAdd'); + expect(events).toContain('messageReactionRemove'); + }); + + it('should ignore bot reactions', async () => { + setup(); + const addCb = onCallbacks.messageReactionAdd[0]; + const reaction = { message: { guild: { id: 'g1' }, partial: false } }; + await addCb(reaction, { bot: true, id: 'bot-1' }); + expect(handleReactionAdd).not.toHaveBeenCalled(); + }); + + it('should skip when starboard is not enabled', async () => { + setup(); + getConfig.mockReturnValue({ starboard: { enabled: false } }); + const addCb = onCallbacks.messageReactionAdd[0]; + const reaction = { message: { guild: { id: 'g1' }, partial: false } }; + await addCb(reaction, { bot: false, id: 'user-1' }); + expect(handleReactionAdd).not.toHaveBeenCalled(); + }); + + it('should call handleReactionAdd when starboard is enabled', async () => { + setup(); + const addCb = onCallbacks.messageReactionAdd[0]; + const reaction = { message: { guild: { id: 'g1' }, partial: false } }; + await addCb(reaction, { bot: false, id: 'user-1' }); + expect(handleReactionAdd).toHaveBeenCalledWith( + reaction, + { bot: false, id: 'user-1' }, + client, + expect.objectContaining({ starboard: expect.any(Object) }), + ); + }); + + it('should call handleReactionRemove on reaction remove', async () => { + setup(); + const removeCb = onCallbacks.messageReactionRemove[0]; + const reaction = { message: { guild: { id: 'g1' }, partial: false } }; + await removeCb(reaction, { bot: false, id: 'user-1' }); + expect(handleReactionRemove).toHaveBeenCalledWith( + reaction, + { bot: false, id: 'user-1' }, + client, + expect.objectContaining({ starboard: expect.any(Object) }), + ); + }); + + it('should handle errors in handleReactionAdd gracefully', async () => { + setup(); + handleReactionAdd.mockRejectedValueOnce(new Error('starboard boom')); + const addCb = onCallbacks.messageReactionAdd[0]; + const reaction = { message: { guild: { id: 'g1' }, id: 'msg-1', partial: false } }; + // Should not throw + await addCb(reaction, { bot: false, id: 'user-1' }); + }); + }); + // ── registerErrorHandlers ───────────────────────────────────────────── describe('registerErrorHandlers', () => { @@ -452,6 +540,8 @@ describe('events module', () => { expect(once).toHaveBeenCalledWith('clientReady', expect.any(Function)); expect(on).toHaveBeenCalledWith('guildMemberAdd', expect.any(Function)); expect(on).toHaveBeenCalledWith('messageCreate', expect.any(Function)); + expect(on).toHaveBeenCalledWith('messageReactionAdd', expect.any(Function)); + expect(on).toHaveBeenCalledWith('messageReactionRemove', expect.any(Function)); expect(on).toHaveBeenCalledWith('error', expect.any(Function)); processOnSpy.mockRestore(); diff --git a/tests/modules/starboard.test.js b/tests/modules/starboard.test.js new file mode 100644 index 00000000..cce58d1d --- /dev/null +++ b/tests/modules/starboard.test.js @@ -0,0 +1,707 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Mocks ─────────────────────────────────────────────────────────────────── + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), +})); + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +import { getPool } from '../../src/db.js'; +import { + buildStarboardEmbed, + deleteStarboardPost, + findStarboardPost, + getStarCount, + handleReactionAdd, + handleReactionRemove, + insertStarboardPost, + resolveStarboardConfig, + STARBOARD_DEFAULTS, + updateStarboardPostCount, +} from '../../src/modules/starboard.js'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function mockPool(queryResult = { rows: [] }) { + const pool = { query: vi.fn().mockResolvedValue(queryResult) }; + getPool.mockReturnValue(pool); + return pool; +} + +function makeMockMessage(overrides = {}) { + return { + id: 'msg-1', + content: 'Hello world!', + author: { + id: 'author-1', + username: 'testuser', + displayName: 'Test User', + displayAvatarURL: () => 'https://cdn.example.com/avatar.png', + }, + channel: { id: 'ch-1' }, + guild: { id: 'guild-1' }, + createdAt: new Date('2025-01-01'), + attachments: new Map(), + embeds: [], + reactions: { + cache: new Map(), + }, + partial: false, + fetch: vi.fn(), + ...overrides, + }; +} + +function makeStarboardConfig(overrides = {}) { + return { + starboard: { + enabled: true, + channelId: 'starboard-ch', + threshold: 3, + emoji: '⭐', + selfStarAllowed: false, + ignoredChannels: [], + ...overrides, + }, + }; +} + +function makeMockReaction(message, emojiName = '⭐', count = 3) { + const users = new Map(); + return { + emoji: { name: emojiName }, + count, + message, + partial: false, + fetch: vi.fn(), + users: { + fetch: vi.fn().mockResolvedValue(users), + }, + }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +describe('starboard module', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ── resolveStarboardConfig ────────────────────────────────────────── + + describe('resolveStarboardConfig', () => { + it('should return defaults when no starboard config exists', () => { + const result = resolveStarboardConfig({}); + expect(result).toEqual(STARBOARD_DEFAULTS); + }); + + it('should merge provided config with defaults', () => { + const result = resolveStarboardConfig({ + starboard: { enabled: true, threshold: 5 }, + }); + expect(result.enabled).toBe(true); + expect(result.threshold).toBe(5); + expect(result.emoji).toBe('*'); + }); + }); + + // ── buildStarboardEmbed ───────────────────────────────────────────── + + describe('buildStarboardEmbed', () => { + it('should build a gold embed with message content', () => { + const message = makeMockMessage(); + const embed = buildStarboardEmbed(message, 5); + const json = embed.toJSON(); + + expect(json.color).toBe(0xffd700); + expect(json.author.name).toBe('Test User'); + expect(json.description).toBe('Hello world!'); + expect(json.fields).toHaveLength(3); + expect(json.fields[0].value).toBe('<#ch-1>'); + expect(json.fields[1].value).toBe('⭐ 5'); + expect(json.fields[2].value).toContain('discord.com/channels/guild-1/ch-1/msg-1'); + }); + + it('should handle message with no content', () => { + const message = makeMockMessage({ content: '' }); + const embed = buildStarboardEmbed(message, 3); + const json = embed.toJSON(); + + expect(json.description).toBeUndefined(); + }); + + it('should include image from attachment', () => { + const attachments = new Map(); + attachments.set('att-1', { + contentType: 'image/png', + url: 'https://cdn.example.com/img.png', + }); + // Make attachments iterable with .find() + attachments.find = (fn) => { + for (const v of attachments.values()) { + if (fn(v)) return v; + } + return undefined; + }; + const message = makeMockMessage({ attachments }); + const embed = buildStarboardEmbed(message, 3); + const json = embed.toJSON(); + + expect(json.image.url).toBe('https://cdn.example.com/img.png'); + }); + + it('should include image from embed when no attachment', () => { + const embeds = [{ image: { url: 'https://cdn.example.com/embed-img.png' } }]; + embeds.find = Array.prototype.find.bind(embeds); + const attachments = new Map(); + attachments.find = () => undefined; + const message = makeMockMessage({ attachments, embeds }); + const embed = buildStarboardEmbed(message, 3); + const json = embed.toJSON(); + + expect(json.image.url).toBe('https://cdn.example.com/embed-img.png'); + }); + + it('should handle missing author info gracefully', () => { + const message = makeMockMessage({ + author: { + id: 'a1', + username: undefined, + displayName: undefined, + displayAvatarURL: undefined, + }, + }); + const embed = buildStarboardEmbed(message, 2); + const json = embed.toJSON(); + expect(json.author.name).toBe('Unknown'); + }); + }); + + // ── Database functions ────────────────────────────────────────────── + + describe('findStarboardPost', () => { + it('should return the row when found', async () => { + const row = { source_message_id: 'msg-1', starboard_message_id: 'sb-1', star_count: 5 }; + mockPool({ rows: [row] }); + const result = await findStarboardPost('msg-1'); + expect(result).toEqual(row); + }); + + it('should return null when not found', async () => { + mockPool({ rows: [] }); + const result = await findStarboardPost('nonexistent'); + expect(result).toBeNull(); + }); + + it('should return null on DB error', async () => { + getPool.mockReturnValue({ + query: vi.fn().mockRejectedValue(new Error('db error')), + }); + const result = await findStarboardPost('msg-1'); + expect(result).toBeNull(); + }); + }); + + describe('insertStarboardPost', () => { + it('should call INSERT with correct params', async () => { + const pool = mockPool(); + await insertStarboardPost({ + guildId: 'g1', + sourceMessageId: 'msg-1', + sourceChannelId: 'ch-1', + starboardMessageId: 'sb-1', + starCount: 3, + }); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO starboard_posts'), + ['g1', 'msg-1', 'ch-1', 'sb-1', 3], + ); + }); + }); + + describe('updateStarboardPostCount', () => { + it('should call UPDATE with correct params', async () => { + const pool = mockPool(); + await updateStarboardPostCount('msg-1', 7); + expect(pool.query).toHaveBeenCalledWith(expect.stringContaining('UPDATE starboard_posts'), [ + 7, + 'msg-1', + ]); + }); + }); + + describe('deleteStarboardPost', () => { + it('should call DELETE with correct params', async () => { + const pool = mockPool(); + await deleteStarboardPost('msg-1'); + expect(pool.query).toHaveBeenCalledWith( + expect.stringContaining('DELETE FROM starboard_posts'), + ['msg-1'], + ); + }); + }); + + // ── getStarCount ──────────────────────────────────────────────────── + + describe('getStarCount', () => { + it('should return 0 when no matching reaction', async () => { + const message = makeMockMessage(); + const result = await getStarCount(message, '⭐', false); + expect(result.count).toBe(0); + expect(result.emoji).toBe('⭐'); + }); + + it('should return reaction count when selfStarAllowed', async () => { + const reactions = new Map(); + reactions.set('⭐', { + emoji: { name: '⭐' }, + count: 5, + users: { fetch: vi.fn().mockResolvedValue(new Map()) }, + }); + reactions.find = (fn) => { + for (const v of reactions.values()) { + if (fn(v)) return v; + } + return undefined; + }; + const message = makeMockMessage({ + reactions: { cache: reactions }, + }); + + const result = await getStarCount(message, '⭐', true); + expect(result.count).toBe(5); + expect(result.emoji).toBe('⭐'); + }); + + it('should subtract self-star when not allowed', async () => { + const users = new Map(); + users.set('author-1', { id: 'author-1' }); + + const reactions = new Map(); + reactions.set('⭐', { + emoji: { name: '⭐' }, + count: 4, + users: { fetch: vi.fn().mockResolvedValue(users) }, + }); + reactions.find = (fn) => { + for (const v of reactions.values()) { + if (fn(v)) return v; + } + return undefined; + }; + const message = makeMockMessage({ + reactions: { cache: reactions }, + }); + + const result = await getStarCount(message, '⭐', false); + expect(result.count).toBe(3); + }); + + it('should not go below 0', async () => { + const users = new Map(); + users.set('author-1', { id: 'author-1' }); + + const reactions = new Map(); + reactions.set('⭐', { + emoji: { name: '⭐' }, + count: 1, + users: { fetch: vi.fn().mockResolvedValue(users) }, + }); + reactions.find = (fn) => { + for (const v of reactions.values()) { + if (fn(v)) return v; + } + return undefined; + }; + const message = makeMockMessage({ + reactions: { cache: reactions }, + }); + + const result = await getStarCount(message, '⭐', false); + expect(result.count).toBe(0); + }); + + it('should match any emoji when wildcard "*" is used', async () => { + const reactions = new Map(); + reactions.set('🔥', { + emoji: { name: '🔥' }, + count: 3, + users: { fetch: vi.fn().mockResolvedValue(new Map()) }, + }); + reactions.set('👍', { + emoji: { name: '👍' }, + count: 7, + users: { fetch: vi.fn().mockResolvedValue(new Map()) }, + }); + const message = makeMockMessage({ + reactions: { cache: reactions }, + }); + + const result = await getStarCount(message, '*', true); + expect(result.count).toBe(7); + expect(result.emoji).toBe('👍'); + }); + + it('should return default emoji when wildcard has no reactions', async () => { + const message = makeMockMessage(); + const result = await getStarCount(message, '*', false); + expect(result.count).toBe(0); + expect(result.emoji).toBe('⭐'); + }); + }); + + // ── handleReactionAdd ─────────────────────────────────────────────── + + describe('handleReactionAdd', () => { + it('should do nothing when starboard is disabled', async () => { + const pool = mockPool(); + const message = makeMockMessage(); + const reaction = makeMockReaction(message); + const client = { channels: { fetch: vi.fn() } }; + + await handleReactionAdd(reaction, { id: 'user-1', bot: false }, client, {}); + expect(client.channels.fetch).not.toHaveBeenCalled(); + expect(pool.query).not.toHaveBeenCalled(); + }); + + it('should ignore non-star emoji', async () => { + const message = makeMockMessage(); + const reaction = makeMockReaction(message, '🎉', 5); + const client = { channels: { fetch: vi.fn() } }; + + await handleReactionAdd( + reaction, + { id: 'user-1', bot: false }, + client, + makeStarboardConfig(), + ); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + it('should ignore messages in ignored channels', async () => { + const message = makeMockMessage({ channel: { id: 'ignored-ch' } }); + const reaction = makeMockReaction(message); + const client = { channels: { fetch: vi.fn() } }; + + await handleReactionAdd( + reaction, + { id: 'user-1', bot: false }, + client, + makeStarboardConfig({ ignoredChannels: ['ignored-ch'] }), + ); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + it('should ignore self-star when not allowed', async () => { + const message = makeMockMessage(); + const reaction = makeMockReaction(message); + const client = { channels: { fetch: vi.fn() } }; + + await handleReactionAdd( + reaction, + { id: 'author-1', bot: false }, + client, + makeStarboardConfig({ selfStarAllowed: false }), + ); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + it('should not post when below threshold', async () => { + // Set up reaction with count below threshold + const reactions = new Map(); + reactions.set('⭐', { + emoji: { name: '⭐' }, + count: 2, + users: { fetch: vi.fn().mockResolvedValue(new Map()) }, + }); + reactions.find = (fn) => { + for (const v of reactions.values()) { + if (fn(v)) return v; + } + return undefined; + }; + const message = makeMockMessage({ reactions: { cache: reactions } }); + const reaction = { emoji: { name: '⭐' }, count: 2, message, partial: false }; + const client = { channels: { fetch: vi.fn() } }; + + await handleReactionAdd( + reaction, + { id: 'user-1', bot: false }, + client, + makeStarboardConfig({ threshold: 3 }), + ); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + it('should create new starboard post when threshold reached', async () => { + const pool = mockPool({ rows: [] }); + const reactions = new Map(); + reactions.set('⭐', { + emoji: { name: '⭐' }, + count: 3, + users: { fetch: vi.fn().mockResolvedValue(new Map()) }, + }); + reactions.find = (fn) => { + for (const v of reactions.values()) { + if (fn(v)) return v; + } + return undefined; + }; + const message = makeMockMessage({ reactions: { cache: reactions } }); + const reaction = { emoji: { name: '⭐' }, count: 3, message, partial: false }; + + const mockSend = vi.fn().mockResolvedValue({ id: 'sb-msg-1' }); + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ send: mockSend, messages: { fetch: vi.fn() } }), + }, + }; + + await handleReactionAdd( + reaction, + { id: 'user-1', bot: false }, + client, + makeStarboardConfig(), + ); + + expect(client.channels.fetch).toHaveBeenCalledWith('starboard-ch'); + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('3'), + embeds: expect.any(Array), + }), + ); + // Should have called SELECT (findStarboardPost) + INSERT (insertStarboardPost) + expect(pool.query).toHaveBeenCalledTimes(2); + }); + + it('should update existing starboard post when already posted', async () => { + const existingRow = { + source_message_id: 'msg-1', + starboard_message_id: 'sb-msg-1', + star_count: 3, + }; + const pool = mockPool({ rows: [existingRow] }); + + const reactions = new Map(); + reactions.set('⭐', { + emoji: { name: '⭐' }, + count: 5, + users: { fetch: vi.fn().mockResolvedValue(new Map()) }, + }); + reactions.find = (fn) => { + for (const v of reactions.values()) { + if (fn(v)) return v; + } + return undefined; + }; + const message = makeMockMessage({ reactions: { cache: reactions } }); + const reaction = { emoji: { name: '⭐' }, count: 5, message, partial: false }; + + const mockEdit = vi.fn().mockResolvedValue({}); + const mockFetchMessage = vi.fn().mockResolvedValue({ edit: mockEdit }); + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ + messages: { fetch: mockFetchMessage }, + send: vi.fn(), + }), + }, + }; + + await handleReactionAdd( + reaction, + { id: 'user-1', bot: false }, + client, + makeStarboardConfig(), + ); + + expect(mockFetchMessage).toHaveBeenCalledWith('sb-msg-1'); + expect(mockEdit).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('5'), + }), + ); + // SELECT + UPDATE + expect(pool.query).toHaveBeenCalledTimes(2); + }); + + it('should handle partial reactions', async () => { + mockPool({ rows: [] }); + const reactions = new Map(); + reactions.set('⭐', { + emoji: { name: '⭐' }, + count: 3, + users: { fetch: vi.fn().mockResolvedValue(new Map()) }, + }); + reactions.find = (fn) => { + for (const v of reactions.values()) { + if (fn(v)) return v; + } + return undefined; + }; + const message = makeMockMessage({ reactions: { cache: reactions } }); + + const fetchedReaction = { + emoji: { name: '⭐' }, + count: 3, + message, + partial: false, + }; + const reaction = { + emoji: { name: '⭐' }, + count: 3, + message, + partial: true, + fetch: vi.fn().mockResolvedValue(fetchedReaction), + }; + + const mockSend = vi.fn().mockResolvedValue({ id: 'sb-msg-1' }); + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ send: mockSend, messages: { fetch: vi.fn() } }), + }, + }; + + await handleReactionAdd( + reaction, + { id: 'user-1', bot: false }, + client, + makeStarboardConfig(), + ); + + expect(reaction.fetch).toHaveBeenCalled(); + expect(mockSend).toHaveBeenCalled(); + }); + }); + + // ── handleReactionRemove ──────────────────────────────────────────── + + describe('handleReactionRemove', () => { + it('should do nothing when starboard is disabled', async () => { + const message = makeMockMessage(); + const reaction = makeMockReaction(message); + const client = { channels: { fetch: vi.fn() } }; + + await handleReactionRemove(reaction, { id: 'user-1', bot: false }, client, {}); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + it('should do nothing when no existing starboard post', async () => { + mockPool({ rows: [] }); + const message = makeMockMessage(); + const reaction = { emoji: { name: '⭐' }, message, partial: false }; + const client = { channels: { fetch: vi.fn() } }; + + await handleReactionRemove( + reaction, + { id: 'user-1', bot: false }, + client, + makeStarboardConfig(), + ); + expect(client.channels.fetch).not.toHaveBeenCalled(); + }); + + it('should delete starboard post when below threshold', async () => { + const existingRow = { + source_message_id: 'msg-1', + starboard_message_id: 'sb-msg-1', + star_count: 3, + }; + const pool = mockPool({ rows: [existingRow] }); + + // After removal, count is 2 (below threshold of 3) + const reactions = new Map(); + reactions.set('⭐', { + emoji: { name: '⭐' }, + count: 2, + users: { fetch: vi.fn().mockResolvedValue(new Map()) }, + }); + reactions.find = (fn) => { + for (const v of reactions.values()) { + if (fn(v)) return v; + } + return undefined; + }; + const message = makeMockMessage({ reactions: { cache: reactions } }); + const reaction = { emoji: { name: '⭐' }, message, partial: false }; + + const mockDelete = vi.fn().mockResolvedValue({}); + const mockFetchMessage = vi.fn().mockResolvedValue({ delete: mockDelete }); + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ messages: { fetch: mockFetchMessage } }), + }, + }; + + await handleReactionRemove( + reaction, + { id: 'user-1', bot: false }, + client, + makeStarboardConfig(), + ); + + expect(mockDelete).toHaveBeenCalled(); + // SELECT + DELETE + expect(pool.query).toHaveBeenCalledTimes(2); + expect(pool.query).toHaveBeenLastCalledWith( + expect.stringContaining('DELETE FROM starboard_posts'), + ['msg-1'], + ); + }); + + it('should update count when still above threshold', async () => { + const existingRow = { + source_message_id: 'msg-1', + starboard_message_id: 'sb-msg-1', + star_count: 5, + }; + const pool = mockPool({ rows: [existingRow] }); + + const reactions = new Map(); + reactions.set('⭐', { + emoji: { name: '⭐' }, + count: 4, + users: { fetch: vi.fn().mockResolvedValue(new Map()) }, + }); + reactions.find = (fn) => { + for (const v of reactions.values()) { + if (fn(v)) return v; + } + return undefined; + }; + const message = makeMockMessage({ reactions: { cache: reactions } }); + const reaction = { emoji: { name: '⭐' }, message, partial: false }; + + const mockEdit = vi.fn().mockResolvedValue({}); + const mockFetchMessage = vi.fn().mockResolvedValue({ edit: mockEdit }); + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ messages: { fetch: mockFetchMessage } }), + }, + }; + + await handleReactionRemove( + reaction, + { id: 'user-1', bot: false }, + client, + makeStarboardConfig(), + ); + + expect(mockEdit).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('4'), + }), + ); + // SELECT + UPDATE + expect(pool.query).toHaveBeenCalledTimes(2); + }); + }); +});