3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-03-18 23:09:59 +00:00
zeppelin/src/plugins/ReactionRoles.ts

355 lines
12 KiB
TypeScript

import { decorators as d, logger } from "knub";
import { CustomEmoji, errorMessage, isSnowflake, noop, sleep, successMessage } from "../utils";
import { GuildReactionRoles } from "../data/GuildReactionRoles";
import { Message, TextChannel } from "eris";
import { ZeppelinPlugin } from "./ZeppelinPlugin";
import { GuildSavedMessages } from "../data/GuildSavedMessages";
import { Queue } from "../Queue";
import { ReactionRole } from "../data/entities/ReactionRole";
import Timeout = NodeJS.Timeout;
type ReactionRolePair = [string, string, string?];
const MIN_AUTO_REFRESH = 1000 * 60 * 15; // 15min minimum, let's not abuse the API
const CLEAR_ROLES_EMOJI = "❌";
const ROLE_CHANGE_BATCH_DEBOUNCE_TIME = 1500;
type RoleChangeMode = "+" | "-";
type PendingMemberRoleChanges = {
timeout: Timeout;
applyFn: () => void;
changes: Array<{
mode: RoleChangeMode;
roleId: string;
}>;
};
export class ReactionRolesPlugin extends ZeppelinPlugin {
public static pluginName = "reaction_roles";
protected reactionRoles: GuildReactionRoles;
protected savedMessages: GuildSavedMessages;
protected reactionRemoveQueue: Queue;
protected pendingRoleChanges: Map<string, PendingMemberRoleChanges>;
protected pendingRefreshes: Set<string>;
private autoRefreshTimeout;
getDefaultOptions() {
return {
config: {
auto_refresh_interval: null,
},
permissions: {
manage: false,
fallback_command: false,
},
overrides: [
{
level: ">=100",
permissions: {
manage: true,
},
},
],
};
}
async onLoad() {
this.reactionRoles = GuildReactionRoles.getInstance(this.guildId);
this.savedMessages = GuildSavedMessages.getInstance(this.guildId);
this.reactionRemoveQueue = new Queue();
this.pendingRoleChanges = new Map();
this.pendingRefreshes = new Set();
let autoRefreshInterval = this.configValue("auto_refresh_interval");
if (autoRefreshInterval != null) {
autoRefreshInterval = Math.max(MIN_AUTO_REFRESH, autoRefreshInterval);
this.autoRefreshLoop(autoRefreshInterval);
}
}
async onUnload() {
if (this.autoRefreshTimeout) {
clearTimeout(this.autoRefreshTimeout);
}
}
async autoRefreshLoop(interval: number) {
this.autoRefreshTimeout = setTimeout(async () => {
await this.runAutoRefresh();
this.autoRefreshLoop(interval);
}, interval);
}
async runAutoRefresh() {
// Refresh reaction roles on all reaction role messages
const reactionRoles = await this.reactionRoles.all();
const idPairs = new Set(reactionRoles.map(r => `${r.channel_id}-${r.message_id}`));
for (const pair of idPairs) {
const [channelId, messageId] = pair.split("-");
await this.refreshReactionRoles(channelId, messageId);
}
}
/**
* Refreshes the reaction roles in a message. Basically just calls applyReactionRoleReactionsToMessage().
*/
async refreshReactionRoles(channelId: string, messageId: string) {
const pendingKey = `${channelId}-${messageId}`;
if (this.pendingRefreshes.has(pendingKey)) return;
this.pendingRefreshes.add(pendingKey);
try {
const reactionRoles = await this.reactionRoles.getForMessage(messageId);
await this.applyReactionRoleReactionsToMessage(channelId, messageId, reactionRoles);
} finally {
this.pendingRefreshes.delete(pendingKey);
}
}
/**
* Applies the reactions from the specified reaction roles to a message
*/
async applyReactionRoleReactionsToMessage(channelId: string, messageId: string, reactionRoles: ReactionRole[]) {
const channel = this.guild.channels.get(channelId) as TextChannel;
const targetMessage = await channel.getMessage(messageId);
// Remove old reactions, if any
const removeSleep = sleep(1250);
await targetMessage.removeReactions();
await removeSleep;
// Add reaction role reactions
for (const rr of reactionRoles) {
const emoji = isSnowflake(rr.emoji) ? `foo:${rr.emoji}` : rr.emoji;
const sleepTime = sleep(1250); // Make sure we only add 1 reaction per ~second so as not to hit rate limits
await targetMessage.addReaction(emoji);
await sleepTime;
}
// Add the "clear reactions" button
await targetMessage.addReaction(CLEAR_ROLES_EMOJI);
}
/**
* Adds a pending role change for a member. After a delay, all pending role changes for a member are applied at once.
* This delay is refreshed any time new pending changes are added (i.e. "debounced").
*/
async addMemberPendingRoleChange(memberId: string, mode: RoleChangeMode, roleId: string) {
if (!this.pendingRoleChanges.has(memberId)) {
const newPendingRoleChangeObj: PendingMemberRoleChanges = {
timeout: null,
changes: [],
applyFn: async () => {
const member = await this.guild.members.get(memberId);
if (member) {
const newRoleIds = new Set(member.roles);
for (const change of newPendingRoleChangeObj.changes) {
if (change.mode === "+") newRoleIds.add(change.roleId);
else newRoleIds.delete(change.roleId);
}
try {
await member.edit({
roles: Array.from(newRoleIds.values()),
});
} catch (e) {
logger.warn(
`Failed to apply role changes to ${member.username}#${member.discriminator} (${member.id}): ${
e.message
}`,
);
}
this.pendingRoleChanges.delete(memberId);
}
},
};
this.pendingRoleChanges.set(memberId, newPendingRoleChangeObj);
}
const pendingRoleChangeObj = this.pendingRoleChanges.get(memberId);
pendingRoleChangeObj.changes.push({ mode, roleId });
if (pendingRoleChangeObj.timeout) clearTimeout(pendingRoleChangeObj.timeout);
setTimeout(() => pendingRoleChangeObj.applyFn(), ROLE_CHANGE_BATCH_DEBOUNCE_TIME);
}
/**
* COMMAND: Clear reaction roles from the specified message
*/
@d.command("reaction_roles clear", "<messageId:string>")
@d.permission("manage")
async clearReactionRolesCmd(msg: Message, args: { messageId: string }) {
const savedMessage = await this.savedMessages.find(args.messageId);
if (!savedMessage) {
msg.channel.createMessage(errorMessage("Unknown message"));
return;
}
const existingReactionRoles = this.reactionRoles.getForMessage(args.messageId);
if (!existingReactionRoles) {
msg.channel.createMessage(errorMessage("Message doesn't have reaction roles on it"));
return;
}
this.reactionRoles.removeFromMessage(args.messageId);
const channel = this.guild.channels.get(savedMessage.channel_id) as TextChannel;
const targetMessage = await channel.getMessage(savedMessage.id);
await targetMessage.removeReactions();
msg.channel.createMessage(successMessage("Reaction roles cleared"));
}
/**
* COMMAND: Refresh reaction roles in the specified message by removing all reactions and re-adding them
*/
@d.command("reaction_roles refresh", "<messageId:string>")
@d.permission("manage")
async refreshReactionRolesCmd(msg: Message, args: { messageId: string }) {
const savedMessage = await this.savedMessages.find(args.messageId);
if (!savedMessage) {
msg.channel.createMessage(errorMessage("Unknown message"));
return;
}
if (this.pendingRefreshes.has(`${savedMessage.channel_id}-${savedMessage.id}`)) {
msg.channel.createMessage(errorMessage("Another refresh in progress"));
return;
}
await this.refreshReactionRoles(savedMessage.channel_id, savedMessage.id);
msg.channel.createMessage(successMessage("Reaction roles refreshed"));
}
/**
* COMMAND: Initialize reaction roles on a message.
* The second parameter, reactionRolePairs, is a list of emoji/role pairs separated by a newline. For example:
* :zep_twitch: = 473086848831455234
* :zep_ps4: = 543184300250759188
*/
@d.command("reaction_roles", "<messageId:string> <reactionRolePairs:string$>")
@d.permission("manage")
async reactionRolesCmd(msg: Message, args: { messageId: string; reactionRolePairs: string }) {
const savedMessage = await this.savedMessages.find(args.messageId);
if (!savedMessage) {
msg.channel.createMessage(errorMessage("Unknown message"));
return;
}
const channel = await this.guild.channels.get(savedMessage.channel_id);
if (!channel || !(channel instanceof TextChannel)) {
msg.channel.createMessage(errorMessage("Channel no longer exists"));
return;
}
const targetMessage = await channel.getMessage(args.messageId);
if (!targetMessage) {
msg.channel.createMessage(errorMessage("Unknown message (2)"));
return;
}
// Clear old reaction roles for the message from the DB
await this.reactionRoles.removeFromMessage(targetMessage.id);
// Turn "emoji = role" pairs into an array of tuples of the form [emoji, roleId]
// Emoji is either a unicode emoji or the snowflake of a custom emoji
const emojiRolePairs: ReactionRolePair[] = args.reactionRolePairs
.trim()
.split("\n")
.map(v => v.split("=").map(v => v.trim())) // tslint:disable-line
.map(
(pair): ReactionRolePair => {
const customEmojiMatch = pair[0].match(/^<a?:(.*?):(\d+)>$/);
if (customEmojiMatch) {
return [customEmojiMatch[2], pair[1], customEmojiMatch[1]];
} else {
return pair as ReactionRolePair;
}
},
);
// Verify the specified emojis and roles are valid and usable
for (const pair of emojiRolePairs) {
if (pair[0] === CLEAR_ROLES_EMOJI) {
msg.channel.createMessage(
errorMessage(`The emoji for clearing roles (${CLEAR_ROLES_EMOJI}) is reserved and cannot be used`),
);
return;
}
if (!this.canUseEmoji(pair[0])) {
msg.channel.createMessage(errorMessage("I can only use regular emojis and custom emojis from servers I'm on"));
return;
}
if (!this.guild.roles.has(pair[1])) {
msg.channel.createMessage(errorMessage(`Unknown role ${pair[1]}`));
return;
}
}
// Save the new reaction roles to the database
for (const pair of emojiRolePairs) {
await this.reactionRoles.add(channel.id, targetMessage.id, pair[0], pair[1]);
}
// Apply the reactions themselves
const reactionRoles = await this.reactionRoles.getForMessage(targetMessage.id);
await this.applyReactionRoleReactionsToMessage(targetMessage.channel.id, targetMessage.id, reactionRoles);
msg.channel.createMessage(successMessage("Reaction roles added"));
}
/**
* When a reaction is added to a message with reaction roles, see which role that reaction matches (if any) and queue
* those role changes for the member. Multiple role changes in rapid succession are batched and applied at once.
* Reacting with CLEAR_ROLES_EMOJI will queue a removal of all roles granted by this message's reaction roles.
*/
@d.event("messageReactionAdd")
async onAddReaction(msg: Message, emoji: CustomEmoji, userId: string) {
// Make sure this message has reaction roles on it
const reactionRoles = await this.reactionRoles.getForMessage(msg.id);
if (reactionRoles.length === 0) return;
const member = this.guild.members.get(userId);
if (!member) return;
if (emoji.name === CLEAR_ROLES_EMOJI) {
// User reacted with "clear roles" emoji -> clear their roles
const reactionRoleRoleIds = reactionRoles.map(rr => rr.role_id);
for (const roleId of reactionRoleRoleIds) {
this.addMemberPendingRoleChange(userId, "-", roleId);
}
this.reactionRemoveQueue.add(async () => {
await msg.channel.removeMessageReaction(msg.id, CLEAR_ROLES_EMOJI, userId);
});
} else {
// User reacted with a reaction role emoji -> add the role
const matchingReactionRole = await this.reactionRoles.getByMessageAndEmoji(msg.id, emoji.id || emoji.name);
if (!matchingReactionRole) return;
this.addMemberPendingRoleChange(userId, "+", matchingReactionRole.role_id);
}
// Remove the reaction after a small delay
setTimeout(() => {
this.reactionRemoveQueue.add(async () => {
const reaction = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name;
const wait = sleep(1500);
await msg.channel.removeMessageReaction(msg.id, reaction, userId).catch(noop);
await wait;
});
}, 1500);
}
}