diff --git a/src/data/GuildSlowmodes.ts b/src/data/GuildSlowmodes.ts new file mode 100644 index 00000000..f6f27d76 --- /dev/null +++ b/src/data/GuildSlowmodes.ts @@ -0,0 +1,121 @@ +import { BaseRepository } from "./BaseRepository"; +import { getRepository, Repository } from "typeorm"; +import { SlowmodeChannel } from "./entities/SlowmodeChannel"; +import { SlowmodeUser } from "./entities/SlowmodeUser"; +import moment from "moment-timezone"; + +export class GuildSlowmodes extends BaseRepository { + private slowmodeChannels: Repository; + private slowmodeUsers: Repository; + + constructor(guildId) { + super(guildId); + this.slowmodeChannels = getRepository(SlowmodeChannel); + this.slowmodeUsers = getRepository(SlowmodeUser); + } + + async getChannelSlowmode(channelId): Promise { + return this.slowmodeChannels.findOne({ + where: { + guild_id: this.guildId, + channel_id: channelId + } + }); + } + + async setChannelSlowmode(channelId, seconds): Promise { + const existingSlowmode = await this.getChannelSlowmode(channelId); + if (existingSlowmode) { + await this.slowmodeChannels.update( + { + guild_id: this.guildId, + channel_id: channelId + }, + { + slowmode_seconds: seconds + } + ); + } else { + await this.slowmodeChannels.insert({ + guild_id: this.guildId, + channel_id: channelId, + slowmode_seconds: seconds + }); + } + } + + async clearChannelSlowmode(channelId): Promise { + await this.slowmodeChannels.delete({ + guild_id: this.guildId, + channel_id: channelId + }); + } + + async getChannelSlowmodeUser(channelId, userId): Promise { + return this.slowmodeUsers.findOne({ + guild_id: this.guildId, + channel_id: channelId, + user_id: userId + }); + } + + async userHasSlowmode(channelId, userId): Promise { + return (await this.getChannelSlowmodeUser(channelId, userId)) != null; + } + + async addSlowmodeUser(channelId, userId): Promise { + const slowmode = await this.getChannelSlowmode(channelId); + if (!slowmode) return; + + const expiresAt = moment() + .add(slowmode.slowmode_seconds, "seconds") + .format("YYYY-MM-DD HH:mm:ss"); + + if (await this.userHasSlowmode(channelId, userId)) { + // Update existing + await this.slowmodeUsers.update( + { + guild_id: this.guildId, + channel_id: channelId, + user_id: userId + }, + { + expires_at: expiresAt + } + ); + } else { + // Add new + await this.slowmodeUsers.insert({ + guild_id: this.guildId, + channel_id: channelId, + user_id: userId, + expires_at: expiresAt + }); + } + } + + async clearSlowmodeUser(channelId, userId): Promise { + await this.slowmodeUsers.delete({ + guild_id: this.guildId, + channel_id: channelId, + user_id: userId + }); + } + + async getChannelSlowmodeUsers(channelId): Promise { + return this.slowmodeUsers.find({ + where: { + guild_id: this.guildId, + channel_id: channelId + } + }); + } + + async getExpiredSlowmodeUsers(): Promise { + return this.slowmodeUsers + .createQueryBuilder() + .where("guild_id = :guildId", { guildId: this.guildId }) + .andWhere("expires_at <= NOW()") + .getMany(); + } +} diff --git a/src/data/entities/SlowmodeChannel.ts b/src/data/entities/SlowmodeChannel.ts new file mode 100644 index 00000000..4ae31a84 --- /dev/null +++ b/src/data/entities/SlowmodeChannel.ts @@ -0,0 +1,14 @@ +import { Entity, Column, PrimaryColumn } from "typeorm"; + +@Entity("slowmode_channels") +export class SlowmodeChannel { + @Column() + @PrimaryColumn() + guild_id: string; + + @Column() + @PrimaryColumn() + channel_id: string; + + @Column() slowmode_seconds: number; +} diff --git a/src/data/entities/SlowmodeUser.ts b/src/data/entities/SlowmodeUser.ts new file mode 100644 index 00000000..9b70193d --- /dev/null +++ b/src/data/entities/SlowmodeUser.ts @@ -0,0 +1,18 @@ +import { Entity, Column, PrimaryColumn } from "typeorm"; + +@Entity("slowmode_users") +export class SlowmodeUser { + @Column() + @PrimaryColumn() + guild_id: string; + + @Column() + @PrimaryColumn() + channel_id: string; + + @Column() + @PrimaryColumn() + user_id: string; + + @Column() expires_at: string; +} diff --git a/src/index.ts b/src/index.ts index ec353698..0fc6071e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,7 @@ import { TagsPlugin } from "./plugins/Tags"; import { MessageSaverPlugin } from "./plugins/MessageSaver"; import { CasesPlugin } from "./plugins/Cases"; import { MutesPlugin } from "./plugins/Mutes"; +import { SlowmodePlugin } from "./plugins/Slowmode"; // Run latest database migrations logger.info("Running database migrations"); @@ -88,7 +89,8 @@ connect().then(async conn => { censor: CensorPlugin, persist: PersistPlugin, spam: SpamPlugin, - tags: TagsPlugin + tags: TagsPlugin, + slowmode: SlowmodePlugin }, globalPlugins: { bot_control: BotControlPlugin, diff --git a/src/migrations/1544877081073-CreateSlowmodeTables.ts b/src/migrations/1544877081073-CreateSlowmodeTables.ts new file mode 100644 index 00000000..59ac9596 --- /dev/null +++ b/src/migrations/1544877081073-CreateSlowmodeTables.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateSlowmodeTables1544877081073 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "slowmode_channels", + columns: [ + { + name: "guild_id", + type: "bigint", + unsigned: true + }, + { + name: "channel_id", + type: "bigint", + unsigned: true + }, + { + name: "slowmode_seconds", + type: "int", + unsigned: true + } + ], + indices: [] + }) + ); + await queryRunner.createPrimaryKey("slowmode_channels", ["guild_id", "channel_id"]); + + await queryRunner.createTable( + new Table({ + name: "slowmode_users", + columns: [ + { + name: "guild_id", + type: "bigint", + unsigned: true + }, + { + name: "channel_id", + type: "bigint", + unsigned: true + }, + { + name: "user_id", + type: "bigint", + unsigned: true + }, + { + name: "expires_at", + type: "datetime" + } + ], + indices: [ + { + columnNames: ["expires_at"] + } + ] + }) + ); + await queryRunner.createPrimaryKey("slowmode_users", ["guild_id", "channel_id", "user_id"]); + } + + public async down(queryRunner: QueryRunner): Promise { + await Promise.all([queryRunner.dropTable("slowmode_channels"), queryRunner.dropTable("slowmode_users")]); + } +} diff --git a/src/plugins/Slowmode.ts b/src/plugins/Slowmode.ts new file mode 100644 index 00000000..996731e4 --- /dev/null +++ b/src/plugins/Slowmode.ts @@ -0,0 +1,179 @@ +import { Plugin, decorators as d } from "knub"; +import { GuildChannel, Message, TextChannel, Constants as ErisConstants } from "eris"; +import { convertDelayStringToMS, errorMessage, noop, successMessage } from "../utils"; +import { GuildSlowmodes } from "../data/GuildSlowmodes"; +import humanizeDuration from "humanize-duration"; + +export class SlowmodePlugin extends Plugin { + protected slowmodes: GuildSlowmodes; + protected clearInterval; + + getDefaultOptions() { + return { + permissions: { + manage: false, + affected: true + }, + + overrides: [ + { + level: ">=50", + permissions: { + manage: true, + affected: false + } + } + ] + }; + } + + onLoad() { + this.slowmodes = GuildSlowmodes.getInstance(this.guildId); + this.clearInterval = setInterval(() => this.clearExpiredSlowmodes(), 2000); + } + + onUnload() { + clearInterval(this.clearInterval); + } + + /** + * Applies slowmode to the specified user id on the specified channel. + * This sets the channel permissions so the user is unable to send messages there, and saves the slowmode in the db. + */ + async applySlowmodeToUserId(channel: GuildChannel & TextChannel, userId: string) { + // Deny sendMessage permission from the user. If there are existing permission overwrites, take those into account. + const existingOverride = channel.permissionOverwrites.get(userId); + const newDeniedPermissions = + (existingOverride ? existingOverride.deny : 0) | ErisConstants.Permissions.sendMessages; + const newAllowedPermissions = + (existingOverride ? existingOverride.allow : 0) & ~ErisConstants.Permissions.sendMessages; + await channel.editPermission(userId, newAllowedPermissions, newDeniedPermissions, "member"); + + await this.slowmodes.addSlowmodeUser(channel.id, userId); + } + + /** + * Removes slowmode from the specified user id on the specified channel. + * This reverts the channel permissions changed above and clears the database entry. + */ + async removeSlowmodeFromUserId(channel: GuildChannel & TextChannel, userId: string) { + // We only need to tweak permissions if there is an existing permission override + // In most cases there should be, since one is created in applySlowmodeToUserId() + const existingOverride = channel.permissionOverwrites.get(userId); + if (existingOverride) { + if (existingOverride.allow === 0 && existingOverride.deny === ErisConstants.Permissions.sendMessages) { + // If the only override for this user is what we applied earlier, remove the entire permission overwrite + await channel.deletePermission(userId); + } else { + // Otherwise simply negate the sendMessages permission from the denied permissions + const newDeniedPermissions = existingOverride.deny & ~ErisConstants.Permissions.sendMessages; + await channel.editPermission(userId, existingOverride.allow, newDeniedPermissions, "member"); + } + } + + await this.slowmodes.clearSlowmodeUser(channel.id, userId); + } + + /** + * COMMAND: Disable slowmode on the specified channel. This also removes any currently applied slowmodes on the channel. + */ + @d.command("slowmode disable", "") + @d.permission("manage") + async disableSlowmodeCmd(msg: Message, args: { channel: GuildChannel & TextChannel }) { + const slowmode = await this.slowmodes.getChannelSlowmode(args.channel.id); + if (!slowmode) { + msg.channel.createMessage(errorMessage("Channel is not on slowmode!")); + return; + } + + // Disable channel slowmode + const initMsg = await msg.channel.createMessage("Disabling slowmode..."); + await this.slowmodes.clearChannelSlowmode(args.channel.id); + + // Remove currently applied slowmodes + const users = await this.slowmodes.getChannelSlowmodeUsers(args.channel.id); + const failedUsers = []; + + for (const slowmodeUser of users) { + try { + await this.removeSlowmodeFromUserId(args.channel, slowmodeUser.user_id); + } catch (e) { + // Removing the slowmode failed. Record this so the permissions can be changed manually, and remove the database entry. + failedUsers.push(slowmodeUser.user_id); + await this.slowmodes.clearSlowmodeUser(args.channel.id, slowmodeUser.user_id); + } + } + + if (failedUsers.length) { + msg.channel.createMessage( + successMessage( + `Slowmode disabled! Failed to remove slowmode from the following users:\n\n<@!${failedUsers.join(">\n<@!")}>` + ) + ); + } else { + msg.channel.createMessage(successMessage("Slowmode disabled!")); + initMsg.delete().catch(noop); + } + } + + /** + * COMMAND: Enable slowmode on the specified channel + */ + @d.command("slowmode", " ") + @d.command("slowmode", "") + @d.permission("manage") + async slowmodeCmd(msg: Message, args: { channel?: GuildChannel & TextChannel; time: string }) { + const channel = args.channel || msg.channel; + + if (channel == null || !(channel instanceof TextChannel)) { + msg.channel.createMessage(errorMessage("Channel must be a text channel")); + return; + } + + const seconds = Math.ceil(convertDelayStringToMS(args.time) / 1000); + await this.slowmodes.setChannelSlowmode(channel.id, seconds); + + const humanizedSlowmodeTime = humanizeDuration(seconds * 1000); + msg.channel.createMessage( + successMessage(`Slowmode enabled for <#${channel.id}> (1 message in ${humanizedSlowmodeTime})`) + ); + } + + /** + * EVENT: On every new message, check if the channel has slowmode. If it does, apply slowmode to the user. + * If the user already had slowmode but was still able to send a message (e.g. sending a lot of messages at once), + * remove the messages sent after slowmode was applied. + */ + @d.event("messageCreate") + @d.permission("affected") + async onMessageCreate(msg: Message) { + if (msg.author.bot) return; + + const channelSlowmode = await this.slowmodes.getChannelSlowmode(msg.channel.id); + if (!channelSlowmode) return; + + const userHasSlowmode = await this.slowmodes.userHasSlowmode(msg.channel.id, msg.author.id); + if (userHasSlowmode) { + msg.delete(); + return; + } + + await this.applySlowmodeToUserId(msg.channel as GuildChannel & TextChannel, msg.author.id); + } + + /** + * Clears all expired slowmodes in this guild + */ + async clearExpiredSlowmodes() { + const expiredSlowmodeUsers = await this.slowmodes.getExpiredSlowmodeUsers(); + for (const user of expiredSlowmodeUsers) { + const channel = this.guild.channels.get(user.channel_id); + if (!channel) { + await this.slowmodes.clearSlowmodeUser(user.channel_id, user.user_id); + continue; + } + + await this.removeSlowmodeFromUserId(channel as GuildChannel & TextChannel, user.user_id); + } + } +} diff --git a/src/utils.ts b/src/utils.ts index b874aaf7..ee5ea5e9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -269,4 +269,8 @@ export function chunkMessageLines(str: string): string[] { }); } +export function noop() { + // IT'S LITERALLY NOTHING +} + export const DBDateFormat = "YYYY-MM-DD HH:mm:ss";