diff --git a/backend/src/plugins/LocateUser/commands/FollowCmd.ts b/backend/src/plugins/LocateUser/commands/FollowCmd.ts index d0e41a79..5d44a3e6 100644 --- a/backend/src/plugins/LocateUser/commands/FollowCmd.ts +++ b/backend/src/plugins/LocateUser/commands/FollowCmd.ts @@ -13,7 +13,7 @@ export const FollowCmd = locateUserCommand({ signature: { member: ct.resolvedMember(), - reminder: ct.string({ required: false, rest: true }), + reminder: ct.string({ required: false, catchAll: true }), duration: ct.delay({ option: true, shortcut: "d" }), active: ct.bool({ option: true, shortcut: "a" }), diff --git a/backend/src/plugins/Reminders/RemindersPlugin.ts b/backend/src/plugins/Reminders/RemindersPlugin.ts new file mode 100644 index 00000000..c183eb7d --- /dev/null +++ b/backend/src/plugins/Reminders/RemindersPlugin.ts @@ -0,0 +1,50 @@ +import { PluginOptions } from "knub"; +import { ConfigSchema, RemindersPluginType } from "./types"; +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { GuildReminders } from "src/data/GuildReminders"; +import { postDueRemindersLoop } from "./utils/postDueRemindersLoop"; +import { RemindCmd } from "./commands/RemindCmd"; +import { RemindersCmd } from "./commands/RemindersCmd"; +import { RemindersDeleteCmd } from "./commands/RemindersDeleteCmd"; + +const defaultOptions: PluginOptions = { + config: { + can_use: false, + }, + overrides: [ + { + level: ">=50", + config: { + can_use: true, + }, + }, + ], +}; + +export const RemindersPlugin = zeppelinPlugin()("reminders", { + configSchema: ConfigSchema, + defaultOptions, + + // prettier-ignore + commands: [ + RemindCmd, + RemindersCmd, + RemindersDeleteCmd, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.reminders = GuildReminders.getGuildInstance(guild.id); + state.tries = new Map(); + state.unloaded = false; + + state.postRemindersTimeout = null; + postDueRemindersLoop(pluginData); + }, + + onUnload(pluginData) { + clearTimeout(pluginData.state.postRemindersTimeout); + pluginData.state.unloaded = true; + }, +}); diff --git a/backend/src/plugins/Reminders/commands/RemindCmd.ts b/backend/src/plugins/Reminders/commands/RemindCmd.ts new file mode 100644 index 00000000..faeacb70 --- /dev/null +++ b/backend/src/plugins/Reminders/commands/RemindCmd.ts @@ -0,0 +1,65 @@ +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import moment from "moment-timezone"; +import { convertDelayStringToMS } from "src/utils"; +import humanizeDuration from "humanize-duration"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { remindersCommand } from "../types"; + +export const RemindCmd = remindersCommand({ + trigger: ["remind", "remindme"], + usage: "!remind 3h Remind me of this in 3 hours please", + permission: "can_use", + + signature: { + time: ct.string(), + reminder: ct.string({ required: false, catchAll: true }), + }, + + async run({ message: msg, args, pluginData }) { + 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) { + sendErrorMessage(pluginData, msg.channel, "Invalid reminder time"); + return; + } + + reminderTime = moment().add(ms, "millisecond"); + } + + if (!reminderTime.isValid() || reminderTime.isBefore(now)) { + sendErrorMessage(pluginData, msg.channel, "Invalid reminder time"); + return; + } + + const reminderBody = args.reminder || `https://discord.com/channels/${this.guildId}/${msg.channel.id}/${msg.id}`; + await pluginData.state.reminders.add( + msg.author.id, + msg.channel.id, + reminderTime.format("YYYY-MM-DD HH:mm:ss"), + reminderBody, + moment().format("YYYY-MM-DD HH:mm:ss"), + ); + + const msUntilReminder = reminderTime.diff(now); + const timeUntilReminder = humanizeDuration(msUntilReminder, { largest: 2, round: true }); + sendSuccessMessage( + pluginData, + msg.channel, + `I will remind you in **${timeUntilReminder}** at **${reminderTime.format("YYYY-MM-DD, HH:mm")}**`, + ); + }, +}); diff --git a/backend/src/plugins/Reminders/commands/RemindersCmd.ts b/backend/src/plugins/Reminders/commands/RemindersCmd.ts new file mode 100644 index 00000000..0273880d --- /dev/null +++ b/backend/src/plugins/Reminders/commands/RemindersCmd.ts @@ -0,0 +1,31 @@ +import { remindersCommand } from "../types"; +import { sendErrorMessage } from "src/pluginUtils"; +import { sorter, createChunkedMessage } from "src/utils"; +import moment from "moment-timezone"; +import humanizeDuration from "humanize-duration"; + +export const RemindersCmd = remindersCommand({ + trigger: "reminders", + permission: "can_use", + + async run({ message: msg, args, pluginData }) { + const reminders = await pluginData.state.reminders.getRemindersByUserId(msg.author.id); + if (reminders.length === 0) { + sendErrorMessage(pluginData, msg.channel, "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, " "); + const target = moment(reminder.remind_at, "YYYY-MM-DD HH:mm:ss"); + const diff = target.diff(moment()); + const result = humanizeDuration(diff, { largest: 2, round: true }); + return `\`${paddedNum}.\` \`${reminder.remind_at} (${result})\` ${reminder.body}`; + }); + + createChunkedMessage(msg.channel, lines.join("\n")); + }, +}); diff --git a/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts b/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts new file mode 100644 index 00000000..e31677b4 --- /dev/null +++ b/backend/src/plugins/Reminders/commands/RemindersDeleteCmd.ts @@ -0,0 +1,29 @@ +import { remindersCommand } from "../types"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { sorter } from "src/utils"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; + +export const RemindersDeleteCmd = remindersCommand({ + trigger: ["reminders delete", "reminders d"], + permission: "can_use", + + signature: { + num: ct.number(), + }, + + async run({ message: msg, args, pluginData }) { + const reminders = await pluginData.state.reminders.getRemindersByUserId(msg.author.id); + reminders.sort(sorter("remind_at")); + const lastNum = reminders.length + 1; + + if (args.num > lastNum || args.num < 0) { + sendErrorMessage(pluginData, msg.channel, "Unknown reminder"); + return; + } + + const toDelete = reminders[args.num - 1]; + await pluginData.state.reminders.delete(toDelete.id); + + sendSuccessMessage(pluginData, msg.channel, "Reminder deleted"); + }, +}); diff --git a/backend/src/plugins/Reminders/types.ts b/backend/src/plugins/Reminders/types.ts new file mode 100644 index 00000000..8154e6a2 --- /dev/null +++ b/backend/src/plugins/Reminders/types.ts @@ -0,0 +1,22 @@ +import * as t from "io-ts"; +import { BasePluginType, command } from "knub"; +import { GuildReminders } from "src/data/GuildReminders"; + +export const ConfigSchema = t.type({ + can_use: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export interface RemindersPluginType extends BasePluginType { + config: TConfigSchema; + + state: { + reminders: GuildReminders; + tries: Map; + + postRemindersTimeout; + unloaded: boolean; + }; +} + +export const remindersCommand = command(); diff --git a/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts b/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts new file mode 100644 index 00000000..18d94a2b --- /dev/null +++ b/backend/src/plugins/Reminders/utils/postDueRemindersLoop.ts @@ -0,0 +1,48 @@ +import { TextChannel } from "eris"; +import { PluginData } from "knub"; +import { RemindersPluginType } from "../types"; +import moment from "moment-timezone"; +import humanizeDuration from "humanize-duration"; +import { disableLinkPreviews } from "knub/dist/helpers"; +import { SECONDS } from "src/utils"; + +const REMINDER_LOOP_TIME = 10 * SECONDS; +const MAX_TRIES = 3; + +export async function postDueRemindersLoop(pluginData: PluginData) { + const pendingReminders = await pluginData.state.reminders.getDueReminders(); + for (const reminder of pendingReminders) { + const channel = pluginData.guild.channels.get(reminder.channel_id); + if (channel && channel instanceof TextChannel) { + try { + // Only show created at date if one exists + if (moment(reminder.created_at).isValid()) { + const target = moment(); + const diff = target.diff(moment(reminder.created_at, "YYYY-MM-DD HH:mm:ss")); + const result = humanizeDuration(diff, { largest: 2, round: true }); + await channel.createMessage( + disableLinkPreviews( + `Reminder for <@!${reminder.user_id}>: ${reminder.body} \n\`Set at ${reminder.created_at} (${result} ago)\``, + ), + ); + } else { + await channel.createMessage(disableLinkPreviews(`Reminder for <@!${reminder.user_id}>: ${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 = pluginData.state.tries.get(reminder.id) || 0; + if (tries < MAX_TRIES) { + pluginData.state.tries.set(reminder.id, tries + 1); + continue; + } + } + } + + await pluginData.state.reminders.delete(reminder.id); + } + + if (!pluginData.state.unloaded) { + pluginData.state.postRemindersTimeout = setTimeout(() => postDueRemindersLoop(pluginData), REMINDER_LOOP_TIME); + } +} diff --git a/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts b/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts new file mode 100644 index 00000000..592258ea --- /dev/null +++ b/backend/src/plugins/UsernameSaver/UsernameSaverPlugin.ts @@ -0,0 +1,21 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { UsernameHistory } from "src/data/UsernameHistory"; +import { Queue } from "src/Queue"; +import { UsernameSaverPluginType } from "./types"; +import { MessageCreateEvt } from "./events/MessageCreateEvt"; +import { VoiceChannelJoinEvt } from "./events/VoiceChannelJoinEvt"; + +export const UsernameSaverPlugin = zeppelinPlugin()("username_saver", { + // prettier-ignore + events: [ + MessageCreateEvt, + VoiceChannelJoinEvt, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.usernameHistory = new UsernameHistory(); + state.updateQueue = new Queue(); + }, +}); diff --git a/backend/src/plugins/UsernameSaver/events/MessageCreateEvt.ts b/backend/src/plugins/UsernameSaver/events/MessageCreateEvt.ts new file mode 100644 index 00000000..14c6aded --- /dev/null +++ b/backend/src/plugins/UsernameSaver/events/MessageCreateEvt.ts @@ -0,0 +1,11 @@ +import { usernameEvent } from "../types"; +import { updateUsername } from "../updateUsername"; + +export const MessageCreateEvt = usernameEvent({ + event: "messageCreate", + + async listener(meta) { + if (meta.args.message.author.bot) return; + meta.pluginData.state.updateQueue.add(() => updateUsername(meta.pluginData, meta.args.message.author)); + }, +}); diff --git a/backend/src/plugins/UsernameSaver/events/VoiceChannelJoinEvt.ts b/backend/src/plugins/UsernameSaver/events/VoiceChannelJoinEvt.ts new file mode 100644 index 00000000..e26ea435 --- /dev/null +++ b/backend/src/plugins/UsernameSaver/events/VoiceChannelJoinEvt.ts @@ -0,0 +1,11 @@ +import { usernameEvent } from "../types"; +import { updateUsername } from "../updateUsername"; + +export const VoiceChannelJoinEvt = usernameEvent({ + event: "voiceChannelJoin", + + async listener(meta) { + if (meta.args.member.bot) return; + meta.pluginData.state.updateQueue.add(() => updateUsername(meta.pluginData, meta.args.member.user)); + }, +}); diff --git a/backend/src/plugins/UsernameSaver/types.ts b/backend/src/plugins/UsernameSaver/types.ts new file mode 100644 index 00000000..eeb078e7 --- /dev/null +++ b/backend/src/plugins/UsernameSaver/types.ts @@ -0,0 +1,12 @@ +import { BasePluginType, eventListener } from "knub"; +import { UsernameHistory } from "src/data/UsernameHistory"; +import { Queue } from "src/Queue"; + +export interface UsernameSaverPluginType extends BasePluginType { + state: { + usernameHistory: UsernameHistory; + updateQueue: Queue; + }; +} + +export const usernameEvent = eventListener(); diff --git a/backend/src/plugins/UsernameSaver/updateUsername.ts b/backend/src/plugins/UsernameSaver/updateUsername.ts new file mode 100644 index 00000000..6b5129a9 --- /dev/null +++ b/backend/src/plugins/UsernameSaver/updateUsername.ts @@ -0,0 +1,12 @@ +import { User } from "eris"; +import { PluginData } from "knub"; +import { UsernameSaverPluginType } from "./types"; + +export async function updateUsername(pluginData: PluginData, user: User) { + if (!user) return; + const newUsername = `${user.username}#${user.discriminator}`; + const latestEntry = await pluginData.state.usernameHistory.getLastEntry(user.id); + if (!latestEntry || newUsername !== latestEntry.username) { + await pluginData.state.usernameHistory.addEntry(user.id, newUsername); + } +} diff --git a/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts new file mode 100644 index 00000000..b34e5659 --- /dev/null +++ b/backend/src/plugins/WelcomeMessage/WelcomeMessagePlugin.ts @@ -0,0 +1,29 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { PluginOptions } from "knub"; +import { WelcomeMessagePluginType, ConfigSchema } from "./types"; +import { GuildLogs } from "src/data/GuildLogs"; +import { GuildMemberAddEvt } from "./events/GuildMemberAddEvt"; + +const defaultOptions: PluginOptions = { + config: { + send_dm: false, + send_to_channel: null, + message: "", + }, +}; + +export const WelcomeMessagePlugin = zeppelinPlugin()("welcome_message", { + configSchema: ConfigSchema, + defaultOptions, + + // prettier-ignore + events: [ + GuildMemberAddEvt, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.logs = new GuildLogs(guild.id); + }, +}); diff --git a/backend/src/plugins/WelcomeMessage/events/GuildMemberAddEvt.ts b/backend/src/plugins/WelcomeMessage/events/GuildMemberAddEvt.ts new file mode 100644 index 00000000..8e33080e --- /dev/null +++ b/backend/src/plugins/WelcomeMessage/events/GuildMemberAddEvt.ts @@ -0,0 +1,51 @@ +import { welcomeEvent } from "../types"; +import { renderTemplate } from "src/templateFormatter"; +import { stripObjectToScalars, createChunkedMessage } from "src/utils"; +import { LogType } from "src/data/LogType"; +import { TextChannel } from "eris"; + +export const GuildMemberAddEvt = welcomeEvent({ + event: "guildMemberAdd", + + async listener(meta) { + const pluginData = meta.pluginData; + const member = meta.args.member; + + const config = pluginData.config.get(); + if (!config.message) return; + if (!config.send_dm && !config.send_to_channel) return; + + const formatted = await renderTemplate(config.message, { + member: stripObjectToScalars(member, ["user"]), + }); + + if (config.send_dm) { + const dmChannel = await member.user.getDMChannel(); + if (!dmChannel) return; + + try { + await createChunkedMessage(dmChannel, formatted); + } catch (e) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Failed send a welcome DM to {userMention(member)}`, + member: stripObjectToScalars(member), + }); + } + } + + if (config.send_to_channel) { + const channel = meta.args.guild.channels.get(config.send_to_channel); + if (!channel || !(channel instanceof TextChannel)) return; + + try { + await createChunkedMessage(channel, formatted); + } catch (e) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Failed send a welcome message for {userMention(member)} to {channelMention(channel)}`, + member: stripObjectToScalars(member), + channel: stripObjectToScalars(channel), + }); + } + } + }, +}); diff --git a/backend/src/plugins/WelcomeMessage/types.ts b/backend/src/plugins/WelcomeMessage/types.ts new file mode 100644 index 00000000..eef61be9 --- /dev/null +++ b/backend/src/plugins/WelcomeMessage/types.ts @@ -0,0 +1,20 @@ +import * as t from "io-ts"; +import { BasePluginType, eventListener } from "knub"; +import { tNullable } from "src/utils"; +import { GuildLogs } from "src/data/GuildLogs"; + +export const ConfigSchema = t.type({ + send_dm: t.boolean, + send_to_channel: tNullable(t.string), + message: t.string, +}); +export type TConfigSchema = t.TypeOf; + +export interface WelcomeMessagePluginType extends BasePluginType { + config: TConfigSchema; + state: { + logs: GuildLogs; + }; +} + +export const welcomeEvent = eventListener(); diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 430061c6..14794d80 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -2,12 +2,18 @@ import { UtilityPlugin } from "./Utility/UtilityPlugin"; import { LocateUserPlugin } from "./LocateUser/LocateUserPlugin"; import { ZeppelinPluginBlueprint } from "./ZeppelinPluginBlueprint"; import { AutoReactionsPlugin } from "./AutoReactions/AutoReactionsPlugin"; +import { RemindersPlugin } from "./Reminders/RemindersPlugin"; +import { UsernameSaverPlugin } from "./UsernameSaver/UsernameSaverPlugin"; +import { WelcomeMessagePlugin } from "./WelcomeMessage/WelcomeMessagePlugin"; // prettier-ignore export const guildPlugins: Array> = [ AutoReactionsPlugin, LocateUserPlugin, + RemindersPlugin, + UsernameSaverPlugin, UtilityPlugin, + WelcomeMessagePlugin, ]; export const globalPlugins = [];