diff --git a/src/data/GuildStarboards.ts b/src/data/GuildStarboards.ts new file mode 100644 index 00000000..5db94f14 --- /dev/null +++ b/src/data/GuildStarboards.ts @@ -0,0 +1,84 @@ +import { BaseRepository } from "./BaseRepository"; +import { getRepository, Repository } from "typeorm"; +import { Starboard } from "./entities/Starboard"; +import { StarboardMessage } from "./entities/StarboardMessage"; + +export class GuildStarboards extends BaseRepository { + private starboards: Repository; + private starboardMessages: Repository; + + constructor(guildId) { + super(guildId); + this.starboards = getRepository(Starboard); + this.starboardMessages = getRepository(StarboardMessage); + } + + getStarboardByChannelId(channelId): Promise { + return this.starboards.findOne({ + where: { + guild_id: this.guildId, + channel_id: channelId + } + }); + } + + getStarboardsByEmoji(emoji): Promise { + return this.starboards.find({ + where: { + guild_id: this.guildId, + emoji + } + }); + } + + getStarboardMessageByStarboardIdAndMessageId(starboardId, messageId): Promise { + return this.starboardMessages.findOne({ + relations: this.getRelations(), + where: { + starboard_id: starboardId, + message_id: messageId + } + }); + } + + getStarboardMessagesByMessageId(id): Promise { + return this.starboardMessages.find({ + relations: this.getRelations(), + where: { + message_id: id + } + }); + } + + async createStarboardMessage(starboardId, messageId, starboardMessageId): Promise { + await this.starboardMessages.insert({ + starboard_id: starboardId, + message_id: messageId, + starboard_message_id: starboardMessageId + }); + } + + async deleteStarboardMessage(starboardId, messageId): Promise { + await this.starboardMessages.delete({ + starboard_id: starboardId, + message_id: messageId + }); + } + + async create(channelId: string, channelWhitelist: string[], emoji: string, reactionsRequired: number): Promise { + await this.starboards.insert({ + guild_id: this.guildId, + channel_id: channelId, + channel_whitelist: channelWhitelist ? channelWhitelist.join(",") : null, + emoji, + reactions_required: reactionsRequired + }); + } + + async delete(channelId: string): Promise { + await this.starboards.delete({ + guild_id: this.guildId, + channel_id: channelId + }); + } +} diff --git a/src/data/entities/Starboard.ts b/src/data/entities/Starboard.ts new file mode 100644 index 00000000..af170f48 --- /dev/null +++ b/src/data/entities/Starboard.ts @@ -0,0 +1,23 @@ +import { Entity, Column, PrimaryColumn, OneToMany } from "typeorm"; +import { CaseNote } from "./CaseNote"; +import { StarboardMessage } from "./StarboardMessage"; + +@Entity("starboards") +export class Starboard { + @Column() + @PrimaryColumn() + id: number; + + @Column() guild_id: string; + + @Column() channel_id: string; + + @Column() channel_whitelist: string; + + @Column() emoji: string; + + @Column() reactions_required: number; + + @OneToMany(type => StarboardMessage, msg => msg.starboard) + starboardMessages: StarboardMessage[]; +} diff --git a/src/data/entities/StarboardMessage.ts b/src/data/entities/StarboardMessage.ts new file mode 100644 index 00000000..0b1b37a9 --- /dev/null +++ b/src/data/entities/StarboardMessage.ts @@ -0,0 +1,25 @@ +import { Entity, Column, PrimaryColumn, OneToMany, ManyToOne, JoinColumn, OneToOne } from "typeorm"; +import { Starboard } from "./Starboard"; +import { Case } from "./Case"; +import { SavedMessage } from "./SavedMessage"; + +@Entity("starboard_messages") +export class StarboardMessage { + @Column() + @PrimaryColumn() + starboard_id: number; + + @Column() + @PrimaryColumn() + message_id: string; + + @Column() starboard_message_id: string; + + @ManyToOne(type => Starboard, sb => sb.starboardMessages) + @JoinColumn({ name: "starboard_id" }) + starboard: Starboard; + + @OneToOne(type => SavedMessage) + @JoinColumn({ name: "message_id" }) + message: SavedMessage; +} diff --git a/src/index.ts b/src/index.ts index 0fc6071e..f3f77608 100644 --- a/src/index.ts +++ b/src/index.ts @@ -60,6 +60,7 @@ import { MessageSaverPlugin } from "./plugins/MessageSaver"; import { CasesPlugin } from "./plugins/Cases"; import { MutesPlugin } from "./plugins/Mutes"; import { SlowmodePlugin } from "./plugins/Slowmode"; +import { StarboardPlugin } from "./plugins/Starboard"; // Run latest database migrations logger.info("Running database migrations"); @@ -90,7 +91,8 @@ connect().then(async conn => { persist: PersistPlugin, spam: SpamPlugin, tags: TagsPlugin, - slowmode: SlowmodePlugin + slowmode: SlowmodePlugin, + starboard: StarboardPlugin }, globalPlugins: { bot_control: BotControlPlugin, diff --git a/src/migrations/1544887946307-CreateStarboardTable.ts b/src/migrations/1544887946307-CreateStarboardTable.ts new file mode 100644 index 00000000..8556f02b --- /dev/null +++ b/src/migrations/1544887946307-CreateStarboardTable.ts @@ -0,0 +1,85 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateStarboardTable1544887946307 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "starboards", + columns: [ + { + name: "id", + type: "int", + unsigned: true, + isGenerated: true, + generationStrategy: "increment", + isPrimary: true + }, + { + name: "guild_id", + type: "bigint", + unsigned: true + }, + { + name: "channel_id", + type: "bigint", + unsigned: true + }, + { + name: "channel_whitelist", + type: "text", + isNullable: true, + default: null + }, + { + name: "emoji", + type: "varchar", + length: "64" + }, + { + name: "reactions_required", + type: "smallint", + unsigned: true + } + ], + indices: [ + { + columnNames: ["guild_id", "emoji"] + }, + { + columnNames: ["guild_id", "channel_id"], + isUnique: true + } + ] + }) + ); + + await queryRunner.createTable( + new Table({ + name: "starboard_messages", + columns: [ + { + name: "starboard_id", + type: "int", + unsigned: true + }, + { + name: "message_id", + type: "bigint", + unsigned: true + }, + { + name: "starboard_message_id", + type: "bigint", + unsigned: true + } + ] + }) + ); + await queryRunner.createPrimaryKey("starboard_messages", ["starboard_id", "message_id"]); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("starboards", true); + await queryRunner.dropTable("starboard_messages", true); + } +} diff --git a/src/plugins/Starboard.ts b/src/plugins/Starboard.ts new file mode 100644 index 00000000..d53cca83 --- /dev/null +++ b/src/plugins/Starboard.ts @@ -0,0 +1,322 @@ +import { decorators as d, waitForReply, utils as knubUtils } from "knub"; +import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { GuildStarboards } from "../data/GuildStarboards"; +import { GuildChannel, Message, TextChannel } from "eris"; +import { customEmojiRegex, errorMessage, getEmojiInString, noop, snowflakeRegex, successMessage } from "../utils"; +import { Starboard } from "../data/entities/Starboard"; +import path from "path"; +import moment from "moment-timezone"; +import { GuildSavedMessages } from "../data/GuildSavedMessages"; +import { SavedMessage } from "../data/entities/SavedMessage"; + +export class StarboardPlugin extends ZeppelinPlugin { + protected starboards: GuildStarboards; + protected savedMessages: GuildSavedMessages; + + private onMessageDeleteFn; + + getDefaultOptions() { + return { + permissions: { + manage: false + }, + + overrides: [ + { + level: ">=100", + permissions: { + manage: true + } + } + ] + }; + } + + onLoad() { + this.starboards = GuildStarboards.getInstance(this.guildId); + this.savedMessages = GuildSavedMessages.getInstance(this.guildId); + + this.onMessageDeleteFn = this.onMessageDelete.bind(this); + this.savedMessages.events.on("delete", this.onMessageDeleteFn); + } + + onUnload() { + this.savedMessages.events.off("delete", this.onMessageDeleteFn); + } + + /** + * An interactive setup for creating a starboard + */ + @d.command("starboard create") + @d.permission("manage") + @d.nonBlocking() + async setupCmd(msg: Message) { + const cancelMsg = () => msg.channel.createMessage("Cancelled"); + + msg.channel.createMessage( + `⭐ Let's make a starboard! What channel should we use as the board? ("cancel" to cancel)` + ); + + let starboardChannel; + do { + const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id, 60000); + if (reply.content == null || reply.content === "cancel") return cancelMsg(); + + starboardChannel = knubUtils.resolveChannel(this.guild, reply.content || ""); + if (!starboardChannel) { + msg.channel.createMessage("Invalid channel. Try again?"); + continue; + } + + const existingStarboard = await this.starboards.getStarboardByChannelId(starboardChannel.id); + if (existingStarboard) { + msg.channel.createMessage("That channel already has a starboard. Try again?"); + continue; + } + } while (starboardChannel == null); + + msg.channel.createMessage(`Ok. Which emoji should we use as the trigger? ("cancel" to cancel)`); + + let emoji; + do { + const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id); + if (reply.content == null || reply.content === "cancel") return cancelMsg(); + + const allEmojis = getEmojiInString(reply.content || ""); + if (!allEmojis.length) { + msg.channel.createMessage("Invalid emoji. Try again?"); + continue; + } + + emoji = allEmojis[0]; + if (emoji.match(customEmojiRegex)) { + // <:name:id> to name:id, as Eris puts them in the message reactions object + emoji = emoji.substr(2, emoji.length - 1); + } + } while (emoji == null); + + msg.channel.createMessage( + `And how many reactions are required to immortalize a message in the starboard? ("cancel" to cancel)` + ); + + let requiredReactions; + do { + const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id); + if (reply.content == null || reply.content === "cancel") return cancelMsg(); + + requiredReactions = parseInt(reply.content || "", 10); + + if (Number.isNaN(requiredReactions)) { + msg.channel.createMessage("Invalid number. Try again?"); + continue; + } + + if (typeof requiredReactions === "number") { + if (requiredReactions <= 0) { + msg.channel.createMessage("The number must be higher than 0. Try again?"); + continue; + } else if (requiredReactions > 65536) { + msg.channel.createMessage("The number must be smaller than 65536. Try again?"); + continue; + } + } + } while (requiredReactions == null); + + msg.channel.createMessage( + `And finally, which channels can messages be starred in? "All" for any channel. ("cancel" to cancel)` + ); + + let channelWhitelist; + do { + const reply = await waitForReply(this.bot, msg.channel as TextChannel, msg.author.id); + if (reply.content == null || reply.content === "cancel") return cancelMsg(); + + if (reply.content.toLowerCase() === "all") { + channelWhitelist = null; + break; + } + + channelWhitelist = reply.content.match(new RegExp(snowflakeRegex, "g")); + + let hasInvalidChannels = false; + for (const id of channelWhitelist) { + const channel = this.guild.channels.get(id); + if (!channel || !(channel instanceof TextChannel)) { + msg.channel.createMessage(`Couldn't recognize channel <#${id}> (\`${id}\`). Try again?`); + hasInvalidChannels = true; + break; + } + } + if (hasInvalidChannels) continue; + } while (channelWhitelist == null); + + await this.starboards.create(starboardChannel.id, channelWhitelist, emoji, requiredReactions); + + msg.channel.createMessage(successMessage("Starboard created!")); + } + + /** + * Deletes the starboard from the specified channel. The already-posted starboard messages are retained. + */ + @d.command("starboard delete", "") + @d.permission("manage") + async deleteCmd(msg: Message, args: { channelId: string }) { + const starboard = await this.starboards.getStarboardByChannelId(args.channelId); + if (!starboard) { + msg.channel.createMessage(errorMessage(`Channel <#${args.channelId}> doesn't have a starboard!`)); + return; + } + + await this.starboards.delete(starboard.channel_id); + + msg.channel.createMessage(successMessage(`Starboard deleted from <#${args.channelId}>!`)); + } + + /** + * When a reaction is added to a message, check if there are any applicable starboards and if the reactions reach + * the required threshold. If they do, post the message in the starboard channel. + */ + @d.event("messageReactionAdd") + async onMessageReactionAdd(msg: Message, emoji: { id: string; name: string }) { + if (!msg.author) { + // Message is not cached, fetch it + msg = await msg.channel.getMessage(msg.id); + } + + const emojiStr = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name; + const applicableStarboards = await this.starboards.getStarboardsByEmoji(emojiStr); + + for (const starboard of applicableStarboards) { + // Can't star messages in the starboard channel itself + if (msg.channel.id === starboard.channel_id) continue; + + if (starboard.channel_whitelist) { + const allowedChannelIds = starboard.channel_whitelist.split(","); + if (!allowedChannelIds.includes(msg.channel.id)) continue; + } + + // If the message has already been posted to this starboard, we don't need to do anything else here + const existingSavedMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId( + starboard.id, + msg.id + ); + if (existingSavedMessage) return; + + const reactionsCount = await this.countReactions(msg, emojiStr); + + if (reactionsCount >= starboard.reactions_required) { + await this.saveMessageToStarboard(msg, starboard); + } + } + } + + /** + * When a reaction is removed from a message, check if there are any applicable starboards and if the message in + * question had already been posted there. If it has already been posted there, and the reaction count is now lower + * than the required threshold, remove the post from the starboard. + */ + @d.event("messageReactionRemove") + async onMessageReactionRemove(msg: Message, emoji: { id: string; name: string }) { + if (!msg.author) { + // Message is not cached, fetch it + msg = await msg.channel.getMessage(msg.id); + } + + const emojiStr = emoji.id ? `${emoji.name}:${emoji.id}` : emoji.name; + const applicableStarboards = await this.starboards.getStarboardsByEmoji(emojiStr); + + for (const starboard of applicableStarboards) { + const existingSavedMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId( + starboard.id, + msg.id + ); + if (!existingSavedMessage) return; + + const reactionsCount = await this.countReactions(msg, emojiStr); + + if (reactionsCount < starboard.reactions_required) { + await this.removeMessageFromStarboard(msg.id, starboard); + } + } + } + + /** + * Counts the specific reactions in the message, ignoring the message author + */ + async countReactions(msg: Message, reaction) { + let reactionsCount = (msg.reactions[reaction] && msg.reactions[reaction].count) || 0; + + // Ignore self-stars + const reactors = await msg.getReaction(reaction); + if (reactors.some(u => u.id === msg.author.id)) reactionsCount--; + + return reactionsCount; + } + + /** + * Saves/posts a message to the specified starboard. The message is posted as an embed and image attachments are + * included as the embed image. + */ + async saveMessageToStarboard(msg: Message, starboard: Starboard) { + const channel = this.guild.channels.get(starboard.channel_id); + if (!channel) return; + + const time = moment(msg.timestamp, "x").format("YYYY-MM-DD [at] HH:mm:ss [UTC]"); + + const embed: any = { + footer: { + text: `#${(msg.channel as GuildChannel).name} - ${time}` + }, + author: { + name: `${msg.author.username}#${msg.author.discriminator}` + } + }; + + if (msg.author.avatarURL) { + embed.author.icon_url = msg.author.avatarURL; + } + + if (msg.content) { + embed.description = msg.content; + } + + if (msg.attachments.length) { + const attachment = msg.attachments[0]; + const ext = path + .extname(attachment.filename) + .slice(1) + .toLowerCase(); + if (["jpeg", "jpg", "png", "gif", "webp"].includes(ext)) { + embed.image = { url: attachment.url }; + } + } + + const starboardMessage = await (channel as TextChannel).createMessage({ embed }); + await this.starboards.createStarboardMessage(starboard.id, msg.id, starboardMessage.id); + } + + /** + * Remove a message from the specified starboard + */ + async removeMessageFromStarboard(msgId: string, starboard: Starboard) { + const starboardMessage = await this.starboards.getStarboardMessageByStarboardIdAndMessageId(starboard.id, msgId); + if (!starboardMessage) return; + + await this.bot.deleteMessage(starboard.channel_id, starboardMessage.starboard_message_id).catch(noop); + await this.starboards.deleteStarboardMessage(starboard.id, msgId); + } + + /** + * When a message is deleted, also delete it from any starboards it's been posted in. + * This function is called in response to GuildSavedMessages events. + */ + async onMessageDelete(msg: SavedMessage) { + const starboardMessages = await this.starboards.with("starboard").getStarboardMessagesByMessageId(msg.id); + if (!starboardMessages.length) return; + + for (const starboardMessage of starboardMessages) { + if (!starboardMessage.starboard) continue; + this.removeMessageFromStarboard(starboardMessage.message_id, starboardMessage.starboard); + } + } +} diff --git a/src/utils.ts b/src/utils.ts index 877804b3..0696c3b2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -81,8 +81,11 @@ export function formatTemplateString(str: string, values) { }); } +export const snowflakeRegex = /[1-9][0-9]{5,19}/; + +const isSnowflakeRegex = new RegExp(`^${snowflakeRegex}$`); export function isSnowflake(v: string): boolean { - return /^\d{17,20}$/.test(v); + return isSnowflakeRegex.test(v); } export function sleep(ms: number): Promise { @@ -166,11 +169,12 @@ export function getInviteCodesInString(str: string): string[] { } export const unicodeEmojiRegex = emojiRegex(); -export const customEmojiRegex = /<:(?:.*?):(\d+)>/g; -export const anyEmojiRegex = new RegExp(`(?:(?:${unicodeEmojiRegex.source})|(?:${customEmojiRegex.source}))`, "g"); +export const customEmojiRegex = /<:(?:.*?):(\d+)>/; +export const anyEmojiRegex = new RegExp(`(?:(?:${unicodeEmojiRegex.source})|(?:${customEmojiRegex.source}))`); +const matchAllEmojiRegex = new RegExp(anyEmojiRegex.source, "g"); export function getEmojiInString(str: string): string[] { - return str.match(anyEmojiRegex) || []; + return str.match(matchAllEmojiRegex) || []; } export function trimLines(str: string) {