diff --git a/deno.json b/deno.json index c810f15..148ffc1 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "tasks": { "check": "deno fmt --check && deno lint && deno check src/index.ts && deno test", - "start": "deno run --allow-env=TOKEN,CLIENT_ID,CONFIG_PATH,NODE_V8_COVERAGE,UNDICI_NO_FG,JEST_WORKER_ID,SHARDS,SHARD_COUNT,SHARDING_MANAGER,SHARDING_MANAGER_MODE,DISCORD_TOKEN,WS_NO_BUFFER_UTIL,WS_NO_UTF_8_VALIDATE --allow-net=discord.com,gateway.discord.gg --allow-read=./config.json,/data/config.json --allow-write=.,/data src/index.ts" + "start": "deno run --allow-env=TOKEN,CLIENT_ID,CONFIG_PATH,NODE_V8_COVERAGE,UNDICI_NO_FG,JEST_WORKER_ID,SHARDS,SHARD_COUNT,SHARDING_MANAGER,SHARDING_MANAGER_MODE,DISCORD_TOKEN,WS_NO_BUFFER_UTIL,WS_NO_UTF_8_VALIDATE --allow-net=discord.com,gateway.discord.gg --allow-read=./config.json,./config.json.tmp,/data/config.json,/data/config.json.tmp --allow-write=.,/data src/index.ts" }, "compilerOptions": { "lib": ["deno.window"] diff --git a/src/award_message.ts b/src/award_message.ts index 79d0f41..1a96ea3 100644 --- a/src/award_message.ts +++ b/src/award_message.ts @@ -1,10 +1,9 @@ -import { EmbedBuilder, MessageReaction, PartialMessageReaction } from "discord.js"; +import { EmbedBuilder, Message } from "discord.js"; export default function awardEmbed( - reaction: MessageReaction | PartialMessageReaction, + message: Message, score: number, ): EmbedBuilder { - const message = reaction.message; const reactionSummary = message.reactions.cache .map((item) => `${item.emoji} ร—${item.count}`) .join(" ยท "); diff --git a/src/index.ts b/src/index.ts index e821005..eaff404 100644 --- a/src/index.ts +++ b/src/index.ts @@ -51,6 +51,7 @@ client.once(Events.ClientReady, (readyClient) => { `Logged in as ${readyClient.user.tag}; serving ${readyClient.guilds.cache.size} guild(s).`, ); readyClient.user.setActivity("๐Ÿ‘‘ awarding funny messages"); + void reconcileAwards().catch((error) => console.error("Failed to reconcile awards:", error)); }); client.on(Events.InteractionCreate, async (interaction) => { @@ -185,9 +186,13 @@ async function updateAward( reaction: MessageReaction | PartialMessageReaction, ): Promise { if (reaction.partial) await reaction.fetch(); - if (reaction.message.partial) await reaction.message.fetch(); + // Reaction gateway events only populate the cache entry that changed. Always fetch the + // message so reactions that existed before this process started are included as well. + const message = await reaction.message.fetch(true); + await updateAwardForMessage(message); +} - const message = reaction.message; +async function updateAwardForMessage(message: Message): Promise { const entry = watchedEntry(message); if (!message.guild || !entry) return; @@ -225,7 +230,7 @@ async function updateAward( return; } - const payload = { embeds: [awardEmbed(reaction, score)] }; + const payload = { embeds: [awardEmbed(message, score)] }; if (oldAward) { await oldAward.edit(payload); if (entry.awards[message.id] !== oldAward.id) { @@ -239,6 +244,65 @@ async function updateAward( } } +async function reconcileAwards(): Promise { + let reconciled = 0; + for (const entry of entries) { + if (!entry.output_channel) continue; + + const guild = await client.guilds.fetch(entry.server).catch(() => undefined); + if (!guild) continue; + const outputChannel = await guild.channels.fetch(entry.output_channel).catch(() => undefined); + if (!(outputChannel instanceof BaseGuildTextChannel)) continue; + + const sourceChannels = new Map(); + const recentAwards = await outputChannel.messages.fetch({ limit: 100 }); + for (const award of recentAwards.values()) { + if (award.author.id !== client.user?.id) continue; + const source = getSourceMessageLocation(award); + if (source) sourceChannels.set(source.messageId, source.channelId); + } + + for (const [sourceMessageId, awardMessageId] of Object.entries(entry.awards)) { + if (sourceChannels.has(sourceMessageId)) continue; + const award = await outputChannel.messages.fetch(awardMessageId).catch(() => undefined); + if (!award) continue; + const source = getSourceMessageLocation(award); + if (source?.messageId === sourceMessageId) { + sourceChannels.set(sourceMessageId, source.channelId); + } + } + + for (const [sourceMessageId, sourceChannelId] of sourceChannels) { + const sourceChannel = await guild.channels.fetch(sourceChannelId).catch(() => undefined); + if (!(sourceChannel instanceof BaseGuildTextChannel)) continue; + + const sourceMessage = await sourceChannel.messages.fetch({ + message: sourceMessageId, + force: true, + }).catch(() => undefined); + if (!sourceMessage) continue; + + await updateAwardForMessage(sourceMessage); + reconciled++; + } + } + if (reconciled > 0) console.log(`Reconciled ${reconciled} existing award(s).`); +} + +function getSourceMessageLocation( + award: Message, +): { channelId: string; messageId: string } | undefined { + for (const embed of award.embeds) { + for (const field of embed.fields) { + const match = field.value.match( + /https:\/\/discord\.com\/channels\/\d+\/(\d+)\/(\d+)/, + ); + if (match) return { channelId: match[1], messageId: match[2] }; + } + } + return undefined; +} + function watchedEntry(message: Message | PartialMessage): Entry | undefined { const entry = entries.find((item) => item.server === message.guildId); if (!entry?.output_channel) return undefined; @@ -253,7 +317,7 @@ function listAllows(type: Type, values: string[], value: string): boolean { async function getMessageScore( entry: Entry, - message: Message | PartialMessage, + message: Message, ): Promise { const reactionCounts = new Map(); for (const item of message.reactions.cache.values()) {