From 0bb8f7f213174c5b6c2a0cc335e406e8c06b631e Mon Sep 17 00:00:00 2001 From: Declan Chidlow Date: Sat, 11 Jul 2026 21:23:34 +0800 Subject: [PATCH] Much, much better reaction role handling --- .../bot/src/bot/commands/moderation/role.ts | 13 ++-- packages/bot/src/bot/db.ts | 1 + .../bot/src/bot/modules/reaction_roles.ts | 52 ++++++++++++-- packages/bot/src/stoat/Client.ts | 72 ++++++++++--------- .../collections/ServerMemberCollection.ts | 13 ++++ packages/bot/src/stoat/structures/Message.ts | 12 ++++ packages/bot/src/stoat/structures/Server.ts | 9 +++ 7 files changed, 126 insertions(+), 46 deletions(-) diff --git a/packages/bot/src/bot/commands/moderation/role.ts b/packages/bot/src/bot/commands/moderation/role.ts index aceb923..ef97e7d 100644 --- a/packages/bot/src/bot/commands/moderation/role.ts +++ b/packages/bot/src/bot/commands/moderation/role.ts @@ -18,7 +18,7 @@ export default { const action = args.shift()?.toLowerCase(); const normalizeEmoji = (emoji: string) => { - return emoji.replace(/^:([A-Z0-9]+):$/i, "$1").replace(/[\uFE0F\uE0E2]/g, ""); + return emoji.replace(/^:([A-Z0-9]+):$/i, "$1").replace(/[\uFE0F\uFE0E\uE0E2]/g, ""); }; if (action === "reaction") { @@ -43,12 +43,8 @@ export default { const emoji = normalizeEmoji(emojiRaw); - const isCustomEmoji = /^[A-Z0-9]{26}$/i.test(emoji); - const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); - const graphemeCount = [...segmenter.segment(emoji)].length; - - if (!isCustomEmoji && graphemeCount > 1) { - return message.reply("Please provide exactly **one** valid emoji."); + if (!emoji || emoji.includes(" ")) { + return message.reply("Please provide a valid emoji."); } const channel = message.channel; @@ -67,12 +63,13 @@ export default { roleId: roleId, }); + const isCustomEmoji = /^[A-Z0-9]{26}$/i.test(emoji); const displayEmoji = isCustomEmoji ? `:${emoji}:` : emoji; return message.reply(`Reaction role added! Reacting to message \`${messageId}\` with ${displayEmoji} will now grant the role.`); } catch (e) { console.error("Could not add initial reaction:", e); return message.reply( - `Failed to add reaction role. Check that the message ID is correct, the emoji is valid, you're sending the command in the same channel as the message, and the bot has permission to read messages and add reactions in this channel.`, + `Failed to add reaction role. Check that the message ID is correct, the emoji is valid, you're sending the command in the same channel as the message, and the bot has permission to add reactions in this channel.`, ); } } diff --git a/packages/bot/src/bot/db.ts b/packages/bot/src/bot/db.ts index e15f390..655dd82 100644 --- a/packages/bot/src/bot/db.ts +++ b/packages/bot/src/bot/db.ts @@ -78,6 +78,7 @@ async function databaseMigrations() { await setIndexes(dbs.TEMPBANS, ["id", "until"]); await setIndexes(dbs.USERS, ["id"]); await setIndexes(dbs.REACTION_ROLES, ["server", "messageId"]); + await dbs.REACTION_ROLES.createIndex({ messageId: 1, emoji: 1 }).catch((e) => console.warn(`Failed to create compound index on reaction_roles: ${e}`)); } export { databaseMigrations }; diff --git a/packages/bot/src/bot/modules/reaction_roles.ts b/packages/bot/src/bot/modules/reaction_roles.ts index 76d5829..be64b6b 100644 --- a/packages/bot/src/bot/modules/reaction_roles.ts +++ b/packages/bot/src/bot/modules/reaction_roles.ts @@ -1,9 +1,24 @@ import { client, dbs } from "../../index"; const normalizeEmoji = (emoji: string) => { - return emoji.replace(/^:([A-Z0-9]+):$/i, "$1").replace(/[\uFE0F\uE0E2]/g, ""); + return emoji.replace(/^:([A-Z0-9]+):$/i, "$1").replace(/[️︎]/g, ""); }; +/** + * Try to notify a user via DM that their reaction role could not be applied. + * Swallows errors as we don't want notification failures to become unhandled rejections. + */ +async function notifyUser(userId: string, message: string) { + try { + const user = client.users.get(userId); + if (!user) return; + const dm = await user.openDM(); + if (dm) await dm.sendMessage({ content: message }); + } catch (_) { + // DM could not be delivered (user has DMs closed, etc). Nothing AutoMod can do. :( + } +} + client.on("messageReactionAdd", async (message, user, emoji) => { if (user === client.user?.id) return; @@ -16,7 +31,15 @@ client.on("messageReactionAdd", async (message, user, emoji) => { const server = client.servers.get(reactionRole.server); if (!server) return; - const member = await server.fetchMember(user); + // Verify the role still exists on the server. + if (!server.roles?.get(reactionRole.roleId)) { + console.warn(`[ReactionRoles] Role ${reactionRole.roleId} no longer exists in server ${server.id}; removing orphaned entry`); + await dbs.REACTION_ROLES.deleteOne({ _id: reactionRole._id }).catch(() => {}); + return; + } + + // Always fetch fresh member data from the API to avoid stale-cache races. + const member = await server.fetchMemberFresh(user); if (!member) return; const currentRoles = member.roles || []; @@ -26,6 +49,7 @@ client.on("messageReactionAdd", async (message, user, emoji) => { } } catch (e) { console.error("Failed to process reaction role add:", e); + notifyUser(user, "Unable to assign your reaction role. Please contact a server admin.").catch(() => {}); } }); @@ -41,15 +65,35 @@ client.on("messageReactionRemove", async (message, user, emoji) => { const server = client.servers.get(reactionRole.server); if (!server) return; - const member = await server.fetchMember(user); + // Verify the role still exists on the server. + if (!server.roles?.get(reactionRole.roleId)) { + console.warn(`[ReactionRoles] Role ${reactionRole.roleId} no longer exists in server ${server.id}; removing orphaned entry`); + await dbs.REACTION_ROLES.deleteOne({ _id: reactionRole._id }).catch(() => {}); + return; + } + + // Fetch fresh data, then send the full roles array minus the removed role. + const member = await server.fetchMemberFresh(user); if (!member) return; const currentRoles = member.roles || []; - if (currentRoles.includes(reactionRole.roleId)) { await member.edit({ roles: currentRoles.filter((role) => role !== reactionRole.roleId) }); } } catch (e) { console.error("Failed to process reaction role remove:", e); + notifyUser(user, "Unable to remove your reaction role. Please contact a server admin.").catch(() => {}); + } +}); + +// Clean up orphaned reaction role entries when a role is deleted from a server. +client.on("serverRoleDelete", async (server, roleId) => { + try { + const result = await dbs.REACTION_ROLES.deleteMany({ server: server.id, roleId }); + if (result.deletedCount > 0) { + console.info(`[ReactionRoles] Cleaned up ${result.deletedCount} orphaned reaction role(s) for deleted role ${roleId} in server ${server.id}`); + } + } catch (e) { + console.error("Failed to clean up orphaned reaction roles:", e); } }); diff --git a/packages/bot/src/stoat/Client.ts b/packages/bot/src/stoat/Client.ts index 85f2ad6..5198b33 100644 --- a/packages/bot/src/stoat/Client.ts +++ b/packages/bot/src/stoat/Client.ts @@ -123,12 +123,7 @@ export class Client extends EventEmitter { * Process an array in chunks, yielding to the event loop between chunks * so heartbeats and other I/O are not starved during large data loads. */ - private async _processInChunks( - items: T[], - chunkSize: number, - processor: (item: T) => void, - label: string = "items", - ): Promise { + private async _processInChunks(items: T[], chunkSize: number, processor: (item: T) => void, label: string = "items"): Promise { for (let i = 0; i < items.length; i += chunkSize) { const end = Math.min(i + chunkSize, items.length); for (let j = i; j < end; j++) { @@ -160,23 +155,29 @@ export class Client extends EventEmitter { // Channels are the bulk of the data — process in chunks to avoid // blocking the event loop and starving heartbeats. if (event.channels) { - await this._processInChunks(event.channels, 5000, (channel: any) => { - this.channels.getOrCreate(channel._id, channel); - }, "channels"); + await this._processInChunks( + event.channels, + 5000, + (channel: any) => { + this.channels.getOrCreate(channel._id, channel); + }, + "channels", + ); } // If users were not included in Ready (because we used ?ready= to slim the payload), // fetch the bot's own user via REST so this.user is set before we emit 'ready'. if (!this.user) { - this.api.get("/users/@me").then((me: any) => { - this.user = this.users.getOrCreate(me._id, me); - }).catch((e: any) => { - console.error("Failed to fetch bot user via REST:", e?.message || e); - }); + this.api + .get("/users/@me") + .then((me: any) => { + this.user = this.users.getOrCreate(me._id, me); + }) + .catch((e: any) => { + console.error("Failed to fetch bot user via REST:", e?.message || e); + }); } const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); - console.info( - `[WS] Ready processed: ${this.servers.size()} servers, ${this.channels.size()} channels in ${elapsed}s`, - ); + console.info(`[WS] Ready processed: ${this.servers.size()} servers, ${this.channels.size()} channels in ${elapsed}s`); this.ready = true; this.emit("ready"); break; @@ -223,27 +224,28 @@ export class Client extends EventEmitter { break; } case "MessageReact": { - const msg = this.messages.get(event.id); - if (msg) { - const reactions = this.messages.getUnderlyingObject(event.id).reactions || {}; - if (!reactions[event.emoji_id]) reactions[event.emoji_id] = []; - if (!reactions[event.emoji_id].includes(event.user_id)) reactions[event.emoji_id].push(event.user_id); - this.messages.updateUnderlyingObject(event.id, "reactions", reactions); - this.emit("messageReactionAdd", msg, event.user_id, event.emoji_id); - } + // Always create a message wrapper so the event fires even when the message isn't in the local cache + const channelId = event.channel_id || event.channel; + const msg = this.messages.getOrCreate(event.id, { _id: event.id, channel: channelId }); + const underlying = this.messages.getUnderlyingObject(event.id); + const reactions = underlying.reactions || {}; + if (!reactions[event.emoji_id]) reactions[event.emoji_id] = []; + if (!reactions[event.emoji_id].includes(event.user_id)) reactions[event.emoji_id].push(event.user_id); + this.messages.updateUnderlyingObject(event.id, "reactions", reactions); + this.emit("messageReactionAdd", msg, event.user_id, event.emoji_id); break; } case "MessageUnreact": { - const msg = this.messages.get(event.id); - if (msg) { - const reactions = this.messages.getUnderlyingObject(event.id).reactions || {}; - if (reactions[event.emoji_id]) { - reactions[event.emoji_id] = reactions[event.emoji_id].filter((uid: string) => uid !== event.user_id); - if (reactions[event.emoji_id].length === 0) delete reactions[event.emoji_id]; - } - this.messages.updateUnderlyingObject(event.id, "reactions", reactions); - this.emit("messageReactionRemove", msg, event.user_id, event.emoji_id); + const channelId = event.channel_id || event.channel; + const msg = this.messages.getOrCreate(event.id, { _id: event.id, channel: channelId }); + const underlying = this.messages.getUnderlyingObject(event.id); + const reactions = underlying.reactions || {}; + if (reactions[event.emoji_id]) { + reactions[event.emoji_id] = reactions[event.emoji_id].filter((uid: string) => uid !== event.user_id); + if (reactions[event.emoji_id].length === 0) delete reactions[event.emoji_id]; } + this.messages.updateUnderlyingObject(event.id, "reactions", reactions); + this.emit("messageReactionRemove", msg, event.user_id, event.emoji_id); break; } case "ChannelCreate": { @@ -327,6 +329,7 @@ export class Client extends EventEmitter { const roles = { ...(this.servers.getUnderlyingObject(event.id).roles || {}) }; roles[event.role_id] = { ...(roles[event.role_id] || {}), ...event.data }; this.servers.updateUnderlyingObject(event.id, "roles", roles); + this.emit("serverRoleUpdate", server, event.role_id, event.data); } break; } @@ -336,6 +339,7 @@ export class Client extends EventEmitter { const roles = { ...(this.servers.getUnderlyingObject(event.id).roles || {}) }; delete roles[event.role_id]; this.servers.updateUnderlyingObject(event.id, "roles", roles); + this.emit("serverRoleDelete", server, event.role_id); } break; } diff --git a/packages/bot/src/stoat/collections/ServerMemberCollection.ts b/packages/bot/src/stoat/collections/ServerMemberCollection.ts index fbe8e01..1a58c26 100644 --- a/packages/bot/src/stoat/collections/ServerMemberCollection.ts +++ b/packages/bot/src/stoat/collections/ServerMemberCollection.ts @@ -19,6 +19,19 @@ export class ServerMemberCollection extends BaseCollection { return this.getOrCreate(data._id, data); } + /** Always fetch from the REST API, bypassing the local cache. */ + async fetchFresh(serverId: string, userId: string): Promise { + const data = await this.client.api.get(`/servers/${serverId}/members/${userId}`); + // Update the cache with fresh data + const key = serverId + userId; + const existing = this.get(key); + if (existing) { + this.updateUnderlyingObject(key, data); + return existing; + } + return this.getOrCreate(data._id, data); + } + getOrCreate(id: { server: string; user: string }, data: any): ServerMember { const key = id.server + id.user; const existing = this.get(key); diff --git a/packages/bot/src/stoat/structures/Message.ts b/packages/bot/src/stoat/structures/Message.ts index 07ace26..a9abc9f 100644 --- a/packages/bot/src/stoat/structures/Message.ts +++ b/packages/bot/src/stoat/structures/Message.ts @@ -108,6 +108,18 @@ export class Message { return await this.client.api.delete(`/channels/${this.channelId}/messages/${this.id}`); } + async react(emoji: string) { + return await this.client.api.put( + `/channels/${this.channelId}/messages/${this.id}/reactions/${encodeURIComponent(emoji)}`, + ); + } + + async unreact(emoji: string) { + return await this.client.api.delete( + `/channels/${this.channelId}/messages/${this.id}/reactions/${encodeURIComponent(emoji)}`, + ); + } + async reply(data: any, mention: boolean = true) { const obj: any = typeof data === "string" ? { content: data } : { ...data }; obj.replies = [{ id: this.id, mention }]; diff --git a/packages/bot/src/stoat/structures/Server.ts b/packages/bot/src/stoat/structures/Server.ts index 8496ae6..e2c9dde 100644 --- a/packages/bot/src/stoat/structures/Server.ts +++ b/packages/bot/src/stoat/structures/Server.ts @@ -129,6 +129,15 @@ export class Server { return this.client.serverMembers.fetch(this.id, userId); } + /** + * Always fetch member data directly from the REST API, bypassing the local cache. + * Use when you need the most up-to-date role list (eg reaction role handlers). + */ + async fetchMemberFresh(user: any) { + const userId = typeof user === "string" ? user : user.id; + return this.client.serverMembers.fetchFresh(this.id, userId); + } + async fetchMembers() { const data: any = await this.client.api.get(`/servers/${this.id}/members`); for (const user of data.users) this.client.users.getOrCreate(user._id, user); -- 2.51.2