From 6491c482895ee1fa10bf721be0b76e09395a766b Mon Sep 17 00:00:00 2001 From: Dragory Date: Wed, 20 Feb 2019 00:01:14 +0200 Subject: [PATCH] Add reminders --- src/data/GuildReminders.ts | 46 ++++++ src/data/entities/Reminder.ts | 18 +++ src/index.ts | 14 +- .../1550609900261-CreateRemindersTable.ts | 53 +++++++ ...utoReactions.ts => AutoReactionsPlugin.ts} | 2 +- src/plugins/CustomNameColors.ts | 107 +++++++++++++ ...ingableRoles.ts => PingableRolesPlugin.ts} | 3 +- src/plugins/Reminders.ts | 149 ++++++++++++++++++ ...leRoles.ts => SelfGrantableRolesPlugin.ts} | 2 +- 9 files changed, 384 insertions(+), 10 deletions(-) create mode 100644 src/data/GuildReminders.ts create mode 100644 src/data/entities/Reminder.ts create mode 100644 src/migrations/1550609900261-CreateRemindersTable.ts rename src/plugins/{AutoReactions.ts => AutoReactionsPlugin.ts} (98%) create mode 100644 src/plugins/CustomNameColors.ts rename src/plugins/{PingableRoles.ts => PingableRolesPlugin.ts} (97%) create mode 100644 src/plugins/Reminders.ts rename src/plugins/{SelfGrantableRoles.ts => SelfGrantableRolesPlugin.ts} (99%) diff --git a/src/data/GuildReminders.ts b/src/data/GuildReminders.ts new file mode 100644 index 00000000..079af4f1 --- /dev/null +++ b/src/data/GuildReminders.ts @@ -0,0 +1,46 @@ +import { BaseRepository } from "./BaseRepository"; +import { getRepository, Repository } from "typeorm"; +import { Reminder } from "./entities/Reminder"; + +export class GuildReminders extends BaseRepository { + private reminders: Repository; + + constructor(guildId) { + super(guildId); + this.reminders = getRepository(Reminder); + } + + async getDueReminders(): Promise { + return this.reminders + .createQueryBuilder() + .where("guild_id = :guildId", { guildId: this.guildId }) + .andWhere("remind_at <= NOW()") + .getMany(); + } + + async getRemindersByUserId(userId: string): Promise { + return this.reminders.find({ + where: { + guild_id: this.guildId, + user_id: userId, + }, + }); + } + + async delete(id) { + await this.reminders.delete({ + guild_id: this.guildId, + id, + }); + } + + async add(userId: string, channelId: string, remindAt: string, body: string) { + await this.reminders.insert({ + guild_id: this.guildId, + user_id: userId, + channel_id: channelId, + remind_at: remindAt, + body, + }); + } +} diff --git a/src/data/entities/Reminder.ts b/src/data/entities/Reminder.ts new file mode 100644 index 00000000..a069ddcf --- /dev/null +++ b/src/data/entities/Reminder.ts @@ -0,0 +1,18 @@ +import { Entity, Column, PrimaryColumn } from "typeorm"; + +@Entity("reminders") +export class Reminder { + @Column() + @PrimaryColumn() + id: number; + + @Column() guild_id: string; + + @Column() user_id: string; + + @Column() channel_id: string; + + @Column() remind_at: string; + + @Column() body: string; +} diff --git a/src/index.ts b/src/index.ts index b82c5e2a..6a32644d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,9 +70,10 @@ import { MutesPlugin } from "./plugins/Mutes"; import { SlowmodePlugin } from "./plugins/Slowmode"; import { StarboardPlugin } from "./plugins/Starboard"; import { NameHistoryPlugin } from "./plugins/NameHistory"; -import { AutoReactions } from "./plugins/AutoReactions"; -import { PingableRoles } from "./plugins/PingableRoles"; -import { SelfGrantableRoles } from "./plugins/SelfGrantableRoles"; +import { AutoReactionsPlugin } from "./plugins/AutoReactionsPlugin"; +import { PingableRolesPlugin } from "./plugins/PingableRolesPlugin"; +import { SelfGrantableRolesPlugin } from "./plugins/SelfGrantableRolesPlugin"; +import { RemindersPlugin } from "./plugins/Reminders"; // Run latest database migrations logger.info("Running database migrations"); @@ -112,9 +113,10 @@ connect().then(async conn => { TagsPlugin, SlowmodePlugin, StarboardPlugin, - AutoReactions, - PingableRoles, - SelfGrantableRoles, + AutoReactionsPlugin, + PingableRolesPlugin, + SelfGrantableRolesPlugin, + RemindersPlugin, ], globalPlugins: [BotControlPlugin, LogServerPlugin], diff --git a/src/migrations/1550609900261-CreateRemindersTable.ts b/src/migrations/1550609900261-CreateRemindersTable.ts new file mode 100644 index 00000000..e1e10fa2 --- /dev/null +++ b/src/migrations/1550609900261-CreateRemindersTable.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateRemindersTable1550609900261 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "reminders", + columns: [ + { + name: "id", + type: "int", + unsigned: true, + isGenerated: true, + generationStrategy: "increment", + isPrimary: true, + }, + { + name: "guild_id", + type: "bigint", + unsigned: true, + }, + { + name: "user_id", + type: "bigint", + unsigned: true, + }, + { + name: "channel_id", + type: "bigint", + unsigned: true, + }, + { + name: "remind_at", + type: "datetime", + }, + { + name: "body", + type: "text", + }, + ], + indices: [ + { + columnNames: ["guild_id", "user_id"], + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("reminders", true); + } +} diff --git a/src/plugins/AutoReactions.ts b/src/plugins/AutoReactionsPlugin.ts similarity index 98% rename from src/plugins/AutoReactions.ts rename to src/plugins/AutoReactionsPlugin.ts index 81315bf1..fcf6e1ac 100644 --- a/src/plugins/AutoReactions.ts +++ b/src/plugins/AutoReactionsPlugin.ts @@ -6,7 +6,7 @@ import { Message } from "eris"; import { customEmojiRegex, errorMessage, isEmoji, successMessage } from "../utils"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; -export class AutoReactions extends ZeppelinPlugin { +export class AutoReactionsPlugin extends ZeppelinPlugin { public static pluginName = "auto_reactions"; protected savedMessages: GuildSavedMessages; diff --git a/src/plugins/CustomNameColors.ts b/src/plugins/CustomNameColors.ts new file mode 100644 index 00000000..64604239 --- /dev/null +++ b/src/plugins/CustomNameColors.ts @@ -0,0 +1,107 @@ +import { Plugin, decorators as d } from "knub"; +import { GuildSavedMessages } from "../data/GuildSavedMessages"; +import { SavedMessage } from "../data/entities/SavedMessage"; +import { GuildAutoReactions } from "../data/GuildAutoReactions"; +import { Message } from "eris"; +import { CustomEmoji, customEmojiRegex, errorMessage, isEmoji, successMessage } from "../utils"; + +export class AutoReactions extends Plugin { + public static pluginName = "auto_reactions"; + + protected savedMessages: GuildSavedMessages; + protected autoReactions: GuildAutoReactions; + + private onMessageCreateFn; + + getDefaultOptions() { + return { + permissions: { + use: false, + }, + + overrides: [ + { + level: ">=100", + permissions: { + use: true, + }, + }, + ], + }; + } + + onLoad() { + this.savedMessages = GuildSavedMessages.getInstance(this.guildId); + this.autoReactions = GuildAutoReactions.getInstance(this.guildId); + + this.onMessageCreateFn = this.savedMessages.events.on("create", this.onMessageCreate.bind(this)); + } + + onUnload() { + this.savedMessages.events.off("create", this.onMessageCreateFn); + } + + @d.command("auto_reactions", " ") + @d.permission("use") + async setAutoReactionsCmd(msg: Message, args: { channelId: string; reactions: string[] }) { + const guildEmojis = this.guild.emojis as CustomEmoji[]; + const guildEmojiIds = guildEmojis.map(e => e.id); + + const finalReactions = []; + + for (const reaction of args.reactions) { + if (!isEmoji(reaction)) { + console.log("invalid:", reaction); + msg.channel.createMessage(errorMessage("One or more of the specified reactions were invalid!")); + return; + } + + let savedValue; + + const customEmojiMatch = reaction.match(customEmojiRegex); + if (customEmojiMatch) { + // Custom emoji + if (!guildEmojiIds.includes(customEmojiMatch[2])) { + msg.channel.createMessage(errorMessage("I can only use regular emojis and custom emojis from this server")); + + return; + } + + savedValue = `${customEmojiMatch[1]}:${customEmojiMatch[2]}`; + } else { + // Unicode emoji + savedValue = reaction; + } + + finalReactions.push(savedValue); + } + + await this.autoReactions.set(args.channelId, finalReactions); + msg.channel.createMessage(successMessage(`Auto-reactions set for <#${args.channelId}>`)); + } + + @d.command("auto_reactions disable", "") + @d.permission("use") + async disableAutoReactionsCmd(msg: Message, args: { channelId: string }) { + const autoReaction = await this.autoReactions.getForChannel(args.channelId); + if (!autoReaction) { + msg.channel.createMessage(errorMessage(`Auto-reactions aren't enabled in <#${args.channelId}>`)); + return; + } + + await this.autoReactions.removeFromChannel(args.channelId); + msg.channel.createMessage(successMessage(`Auto-reactions disabled in <#${args.channelId}>`)); + } + + async onMessageCreate(msg: SavedMessage) { + const autoReaction = await this.autoReactions.getForChannel(msg.channel_id); + if (!autoReaction) return; + + const realMsg = await this.bot.getMessage(msg.channel_id, msg.id); + if (!realMsg) return; + + for (const reaction of autoReaction.reactions) { + realMsg.addReaction(reaction); + } + } +} diff --git a/src/plugins/PingableRoles.ts b/src/plugins/PingableRolesPlugin.ts similarity index 97% rename from src/plugins/PingableRoles.ts rename to src/plugins/PingableRolesPlugin.ts index e1b8bc19..ba823469 100644 --- a/src/plugins/PingableRoles.ts +++ b/src/plugins/PingableRolesPlugin.ts @@ -1,5 +1,4 @@ import { Plugin, decorators as d } from "knub"; -import { SavedMessage } from "../data/entities/SavedMessage"; import { Message, Role, TextableChannel, User } from "eris"; import { GuildPingableRoles } from "../data/GuildPingableRoles"; import { PingableRole } from "../data/entities/PingableRole"; @@ -7,7 +6,7 @@ import { errorMessage, successMessage } from "../utils"; const TIMEOUT = 10 * 1000; -export class PingableRoles extends Plugin { +export class PingableRolesPlugin extends Plugin { public static pluginName = "pingable_roles"; protected pingableRoles: GuildPingableRoles; diff --git a/src/plugins/Reminders.ts b/src/plugins/Reminders.ts new file mode 100644 index 00000000..005cbc83 --- /dev/null +++ b/src/plugins/Reminders.ts @@ -0,0 +1,149 @@ +import { decorators as d } from "knub"; +import { ZeppelinPlugin } from "./ZeppelinPlugin"; +import { GuildReminders } from "../data/GuildReminders"; +import { Message, TextChannel } from "eris"; +import moment from "moment-timezone"; +import humanizeDuration from "humanize-duration"; +import { convertDelayStringToMS, createChunkedMessage, errorMessage, sorter, successMessage } from "../utils"; + +const REMINDER_LOOP_TIME = 10 * 1000; +const MAX_TRIES = 3; + +export class RemindersPlugin extends ZeppelinPlugin { + public static pluginName = "reminders"; + + protected reminders: GuildReminders; + protected tries: Map; + + private postRemindersTimeout; + + getDefaultOptions() { + return { + permissions: { + use: false, + }, + + overrides: [ + { + level: ">=50", + permissions: { + use: true, + }, + }, + ], + }; + } + + onLoad() { + this.reminders = GuildReminders.getInstance(this.guildId); + this.tries = new Map(); + this.postDueRemindersLoop(); + } + + async postDueRemindersLoop() { + const pendingReminders = await this.reminders.getDueReminders(); + for (const reminder of pendingReminders) { + const channel = this.guild.channels.get(reminder.channel_id); + if (channel && channel instanceof TextChannel) { + try { + await channel.createMessage(`<@!${reminder.user_id}> You asked me to remind you: ${reminder.body}`); + } catch (e) { + // Probably random Discord internal server error or missing permissions or somesuch + // Try again next round unless we've already tried to post this a bunch of times + const tries = this.tries.get(reminder.id) || 0; + if (tries < MAX_TRIES) { + this.tries.set(reminder.id, tries + 1); + continue; + } + } + } + + await this.reminders.delete(reminder.id); + } + + this.postRemindersTimeout = setTimeout(() => this.postDueRemindersLoop(), REMINDER_LOOP_TIME); + } + + @d.command("remind", " ") + @d.command("remindme", " ") + @d.permission("use") + async addReminderCmd(msg: Message, args: { time: string; reminder: string }) { + const now = moment(); + + let reminderTime; + if (args.time.match(/^\d{4}-\d{1,2}-\d{1,2}$/)) { + // Date in YYYY-MM-DD format, remind at current time on that date + reminderTime = moment(args.time, "YYYY-M-D").set({ + hour: now.hour(), + minute: now.minute(), + second: now.second(), + }); + } else if (args.time.match(/^\d{4}-\d{1,2}-\d{1,2}T\d{2}:\d{2}$/)) { + // Date and time in YYYY-MM-DD[T]HH:mm format + reminderTime = moment(args.time, "YYYY-M-D[T]HH:mm").second(0); + } else { + // "Delay string" i.e. e.g. "2h30m" + const ms = convertDelayStringToMS(args.time); + if (ms === null) { + msg.channel.createMessage(errorMessage("Invalid reminder time")); + return; + } + + reminderTime = moment().add(ms, "millisecond"); + } + + if (!reminderTime.isValid() || reminderTime.isBefore(now)) { + msg.channel.createMessage(errorMessage("Invalid reminder time")); + return; + } + + await this.reminders.add(msg.author.id, msg.channel.id, reminderTime.format("YYYY-MM-DD HH:mm:ss"), args.reminder); + + const msUntilReminder = reminderTime.diff(now); + const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true }); + msg.channel.createMessage( + successMessage( + `I will remind you in **${timeUntilReminder}** at **${reminderTime.format("YYYY-MM-DD, HH:mm")}**`, + ), + ); + } + + @d.command("reminders") + @d.permission("use") + async reminderListCmd(msg: Message) { + const reminders = await this.reminders.getRemindersByUserId(msg.author.id); + if (reminders.length === 0) { + msg.channel.createMessage(errorMessage("No reminders")); + return; + } + + reminders.sort(sorter("remind_at")); + const longestNum = (reminders.length + 1).toString().length; + const lines = Array.from(reminders.entries()).map(([i, reminder]) => { + const num = i + 1; + const paddedNum = num.toString().padStart(longestNum, " "); + return `\`${paddedNum}.\` \`${reminder.remind_at}\` ${reminder.body}`; + }); + + createChunkedMessage(msg.channel, lines.join("\n")); + } + + @d.command("reminders delete", "") + @d.command("reminders d", "") + @d.permission("use") + async deleteReminderCmd(msg: Message, args: { num: number }) { + const reminders = await this.reminders.getRemindersByUserId(msg.author.id); + reminders.sort(sorter("remind_at")); + const lastNum = reminders.length + 1; + + if (args.num > lastNum || args.num < 0) { + msg.channel.createMessage(errorMessage("Unknown reminder")); + return; + } + + const toDelete = reminders[args.num - 1]; + await this.reminders.delete(toDelete.id); + + msg.channel.createMessage(successMessage("Reminder deleted")); + } +} diff --git a/src/plugins/SelfGrantableRoles.ts b/src/plugins/SelfGrantableRolesPlugin.ts similarity index 99% rename from src/plugins/SelfGrantableRoles.ts rename to src/plugins/SelfGrantableRolesPlugin.ts index ecfea331..5dfc3436 100644 --- a/src/plugins/SelfGrantableRoles.ts +++ b/src/plugins/SelfGrantableRolesPlugin.ts @@ -3,7 +3,7 @@ import { GuildSelfGrantableRoles } from "../data/GuildSelfGrantableRoles"; import { GuildChannel, Message, Role, TextChannel } from "eris"; import { chunkArray, errorMessage, sorter, successMessage } from "../utils"; -export class SelfGrantableRoles extends Plugin { +export class SelfGrantableRolesPlugin extends Plugin { public static pluginName = "self_grantable_roles"; protected selfGrantableRoles: GuildSelfGrantableRoles;