From 5c070643a3647116246a18cbc5a25859d2a4e6bc Mon Sep 17 00:00:00 2001 From: Dark <7890309+DarkView@users.noreply.github.com> Date: Thu, 23 Jul 2020 21:26:22 +0200 Subject: [PATCH] Migrate Post to new Plugin structure --- backend/src/plugins/Post/PostPlugin.ts | 58 ++++++ backend/src/plugins/Post/commands/EditCmd.ts | 30 +++ .../src/plugins/Post/commands/EditEmbedCmd.ts | 63 ++++++ backend/src/plugins/Post/commands/PostCmd.ts | 23 +++ .../src/plugins/Post/commands/PostEmbedCmd.ts | 76 +++++++ .../Post/commands/SchedluedPostsDeleteCmd.ts | 25 +++ .../Post/commands/ScheduledPostsListCmd.ts | 57 ++++++ .../Post/commands/ScheduledPostsShowCmd.ts | 26 +++ backend/src/plugins/Post/types.ts | 23 +++ .../src/plugins/Post/util/actualPostCmd.ts | 185 ++++++++++++++++++ .../src/plugins/Post/util/formatContent.ts | 3 + .../plugins/Post/util/parseScheduleTime.ts | 32 +++ backend/src/plugins/Post/util/postMessage.ts | 67 +++++++ .../plugins/Post/util/scheduledPostLoop.ts | 82 ++++++++ backend/src/plugins/availablePlugins.ts | 2 + 15 files changed, 752 insertions(+) create mode 100644 backend/src/plugins/Post/PostPlugin.ts create mode 100644 backend/src/plugins/Post/commands/EditCmd.ts create mode 100644 backend/src/plugins/Post/commands/EditEmbedCmd.ts create mode 100644 backend/src/plugins/Post/commands/PostCmd.ts create mode 100644 backend/src/plugins/Post/commands/PostEmbedCmd.ts create mode 100644 backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts create mode 100644 backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts create mode 100644 backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts create mode 100644 backend/src/plugins/Post/types.ts create mode 100644 backend/src/plugins/Post/util/actualPostCmd.ts create mode 100644 backend/src/plugins/Post/util/formatContent.ts create mode 100644 backend/src/plugins/Post/util/parseScheduleTime.ts create mode 100644 backend/src/plugins/Post/util/postMessage.ts create mode 100644 backend/src/plugins/Post/util/scheduledPostLoop.ts diff --git a/backend/src/plugins/Post/PostPlugin.ts b/backend/src/plugins/Post/PostPlugin.ts new file mode 100644 index 00000000..5308d0ba --- /dev/null +++ b/backend/src/plugins/Post/PostPlugin.ts @@ -0,0 +1,58 @@ +import { zeppelinPlugin } from "../ZeppelinPluginBlueprint"; +import { PluginOptions } from "knub"; +import { ConfigSchema, PostPluginType } from "./types"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildScheduledPosts } from "src/data/GuildScheduledPosts"; +import { GuildLogs } from "src/data/GuildLogs"; +import { PostCmd } from "./commands/PostCmd"; +import { PostEmbedCmd } from "./commands/PostEmbedCmd"; +import { EditCmd } from "./commands/EditCmd"; +import { EditEmbedCmd } from "./commands/EditEmbedCmd"; +import { ScheduledPostsShowCmd } from "./commands/ScheduledPostsShowCmd"; +import { ScheduledPostsListCmd } from "./commands/ScheduledPostsListCmd"; +import { ScheduledPostsDeleteCmd } from "./commands/SchedluedPostsDeleteCmd"; +import { scheduledPostLoop } from "./util/scheduledPostLoop"; + +const defaultOptions: PluginOptions = { + config: { + can_post: false, + }, + overrides: [ + { + level: ">=100", + config: { + can_post: true, + }, + }, + ], +}; + +export const PostPlugin = zeppelinPlugin()("post", { + configSchema: ConfigSchema, + defaultOptions, + + // prettier-ignore + commands: [ + PostCmd, + PostEmbedCmd, + EditCmd, + EditEmbedCmd, + ScheduledPostsShowCmd, + ScheduledPostsListCmd, + ScheduledPostsDeleteCmd, + ], + + onLoad(pluginData) { + const { state, guild } = pluginData; + + state.savedMessages = GuildSavedMessages.getGuildInstance(guild.id); + state.scheduledPosts = GuildScheduledPosts.getGuildInstance(guild.id); + state.logs = new GuildLogs(guild.id); + + scheduledPostLoop(pluginData); + }, + + onUnload(pluginData) { + clearTimeout(pluginData.state.scheduledPostLoopTimeout); + }, +}); diff --git a/backend/src/plugins/Post/commands/EditCmd.ts b/backend/src/plugins/Post/commands/EditCmd.ts new file mode 100644 index 00000000..21594e13 --- /dev/null +++ b/backend/src/plugins/Post/commands/EditCmd.ts @@ -0,0 +1,30 @@ +import { postCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { formatContent } from "../util/formatContent"; + +export const EditCmd = postCmd({ + trigger: "edit", + permission: "can_post", + + signature: { + messageId: ct.string(), + content: ct.string({ catchAll: true }), + }, + + async run({ message: msg, args, pluginData }) { + const savedMessage = await pluginData.state.savedMessages.find(args.messageId); + if (!savedMessage) { + sendErrorMessage(pluginData, msg.channel, "Unknown message"); + return; + } + + if (savedMessage.user_id !== pluginData.client.user.id) { + sendErrorMessage(pluginData, msg.channel, "Message wasn't posted by me"); + return; + } + + await pluginData.client.editMessage(savedMessage.channel_id, savedMessage.id, formatContent(args.content)); + sendSuccessMessage(pluginData, msg.channel, "Message edited"); + }, +}); diff --git a/backend/src/plugins/Post/commands/EditEmbedCmd.ts b/backend/src/plugins/Post/commands/EditEmbedCmd.ts new file mode 100644 index 00000000..4ccb4d98 --- /dev/null +++ b/backend/src/plugins/Post/commands/EditEmbedCmd.ts @@ -0,0 +1,63 @@ +import { postCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { Embed } from "eris"; +import { trimLines } from "src/utils"; +import { formatContent } from "../util/formatContent"; + +const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/; + +export const EditEmbedCmd = postCmd({ + trigger: "edit_embed", + permission: "can_post", + + signature: { + messageId: ct.string(), + maincontent: ct.string({ catchAll: true }), + + title: ct.string({ option: true }), + content: ct.string({ option: true }), + color: ct.string({ option: true }), + }, + + async run({ message: msg, args, pluginData }) { + const savedMessage = await pluginData.state.savedMessages.find(args.messageId); + if (!savedMessage) { + sendErrorMessage(pluginData, msg.channel, "Unknown message"); + return; + } + + const content = args.content || args.maincontent; + + let color = null; + if (args.color) { + const colorMatch = args.color.match(COLOR_MATCH_REGEX); + if (!colorMatch) { + sendErrorMessage(pluginData, msg.channel, "Invalid color specified, use hex colors"); + return; + } + + color = parseInt(colorMatch[1], 16); + } + + const embed: Embed = savedMessage.data.embeds[0] as Embed; + embed.type = "rich"; + if (args.title) embed.title = args.title; + if (content) embed.description = formatContent(content); + if (color) embed.color = color; + + await pluginData.client.editMessage(savedMessage.channel_id, savedMessage.id, { embed }); + await sendSuccessMessage(pluginData, msg.channel, "Embed edited"); + + if (args.content) { + const prefix = pluginData.guildConfig.prefix || "!"; + msg.channel.createMessage( + trimLines(` + <@!${msg.author.id}> You can now specify an embed's content directly at the end of the command: + \`${prefix}edit_embed -title "Some title" content goes here\` + The \`-content\` option will soon be removed in favor of this. + `), + ); + } + }, +}); diff --git a/backend/src/plugins/Post/commands/PostCmd.ts b/backend/src/plugins/Post/commands/PostCmd.ts new file mode 100644 index 00000000..25ae5a6b --- /dev/null +++ b/backend/src/plugins/Post/commands/PostCmd.ts @@ -0,0 +1,23 @@ +import { postCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { actualPostCmd } from "../util/actualPostCmd"; + +export const PostCmd = postCmd({ + trigger: "post", + permission: "can_post", + + signature: { + channel: ct.textChannel(), + content: ct.string({ catchAll: true }), + + "enable-mentions": ct.bool({ option: true, isSwitch: true }), + schedule: ct.string({ option: true }), + repeat: ct.delay({ option: true }), + "repeat-until": ct.string({ option: true }), + "repeat-times": ct.number({ option: true }), + }, + + async run({ message: msg, args, pluginData }) { + actualPostCmd(pluginData, msg, args.channel, { content: args.content }, args); + }, +}); diff --git a/backend/src/plugins/Post/commands/PostEmbedCmd.ts b/backend/src/plugins/Post/commands/PostEmbedCmd.ts new file mode 100644 index 00000000..299aef7c --- /dev/null +++ b/backend/src/plugins/Post/commands/PostEmbedCmd.ts @@ -0,0 +1,76 @@ +import { postCmd } from "../types"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { actualPostCmd } from "../util/actualPostCmd"; +import { sendErrorMessage } from "src/pluginUtils"; +import { Embed } from "eris"; +import { isValidEmbed } from "src/utils"; +import { formatContent } from "../util/formatContent"; + +const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/; + +export const PostEmbedCmd = postCmd({ + trigger: "post_embed", + permission: "can_post", + + signature: { + channel: ct.textChannel(), + maincontent: ct.string({ catchAll: true }), + + title: ct.string({ option: true }), + content: ct.string({ option: true }), + color: ct.string({ option: true }), + raw: ct.bool({ option: true, isSwitch: true, shortcut: "r" }), + + schedule: ct.string({ option: true }), + repeat: ct.delay({ option: true }), + "repeat-until": ct.string({ option: true }), + "repeat-times": ct.number({ option: true }), + }, + + async run({ message: msg, args, pluginData }) { + const content = args.content || args.maincontent; + + if (!args.title && !content) { + sendErrorMessage(pluginData, msg.channel, "Title or content required"); + return; + } + + let color = null; + if (args.color) { + const colorMatch = args.color.toLowerCase().match(COLOR_MATCH_REGEX); + if (!colorMatch) { + sendErrorMessage(pluginData, msg.channel, "Invalid color specified, use hex colors"); + return; + } + + color = parseInt(colorMatch[1], 16); + } + + let embed: Embed = { type: "rich" }; + if (args.title) embed.title = args.title; + if (color) embed.color = color; + + if (content) { + if (args.raw) { + let parsed; + try { + parsed = JSON.parse(content); + } catch (e) { + sendErrorMessage(pluginData, msg.channel, "Syntax error in embed JSON"); + return; + } + + if (!isValidEmbed(parsed)) { + sendErrorMessage(pluginData, msg.channel, "Embed is not valid"); + return; + } + + embed = Object.assign({}, embed, parsed); + } else { + embed.description = formatContent(content); + } + } + + actualPostCmd(pluginData, msg, args.channel, { embed }, args); + }, +}); diff --git a/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts b/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts new file mode 100644 index 00000000..f78e7006 --- /dev/null +++ b/backend/src/plugins/Post/commands/SchedluedPostsDeleteCmd.ts @@ -0,0 +1,25 @@ +import { postCmd } from "../types"; +import { sorter } from "src/utils"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; + +export const ScheduledPostsDeleteCmd = postCmd({ + trigger: ["scheduled_posts delete", "scheduled_posts d"], + permission: "can_post", + + signature: { + num: ct.number(), + }, + + async run({ message: msg, args, pluginData }) { + const scheduledPosts = await pluginData.state.scheduledPosts.all(); + scheduledPosts.sort(sorter("post_at")); + const post = scheduledPosts[args.num - 1]; + if (!post) { + return sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); + } + + await pluginData.state.scheduledPosts.delete(post.id); + sendSuccessMessage(pluginData, msg.channel, "Scheduled post deleted!"); + }, +}); diff --git a/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts new file mode 100644 index 00000000..85d3f950 --- /dev/null +++ b/backend/src/plugins/Post/commands/ScheduledPostsListCmd.ts @@ -0,0 +1,57 @@ +import { postCmd } from "../types"; +import { trimLines, sorter, disableCodeBlocks, deactivateMentions, createChunkedMessage } from "src/utils"; +import humanizeDuration from "humanize-duration"; + +const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50; + +export const ScheduledPostsListCmd = postCmd({ + trigger: ["scheduled_posts", "scheduled_posts list"], + permission: "can_post", + + async run({ message: msg, pluginData }) { + const scheduledPosts = await pluginData.state.scheduledPosts.all(); + if (scheduledPosts.length === 0) { + msg.channel.createMessage("No scheduled posts"); + return; + } + + scheduledPosts.sort(sorter("post_at")); + + let i = 1; + const postLines = scheduledPosts.map(p => { + let previewText = + p.content.content || (p.content.embed && (p.content.embed.description || p.content.embed.title)) || ""; + + const isTruncated = previewText.length > SCHEDULED_POST_PREVIEW_TEXT_LENGTH; + + previewText = disableCodeBlocks(deactivateMentions(previewText)) + .replace(/\s+/g, " ") + .slice(0, SCHEDULED_POST_PREVIEW_TEXT_LENGTH); + + const parts = [`\`#${i++}\` \`[${p.post_at}]\` ${previewText}${isTruncated ? "..." : ""}`]; + if (p.attachments.length) parts.push("*(with attachment)*"); + if (p.content.embed) parts.push("*(embed)*"); + if (p.repeat_until) { + parts.push(`*(repeated every ${humanizeDuration(p.repeat_interval)} until ${p.repeat_until})*`); + } + if (p.repeat_times) { + parts.push( + `*(repeated every ${humanizeDuration(p.repeat_interval)}, ${p.repeat_times} more ${ + p.repeat_times === 1 ? "time" : "times" + })*`, + ); + } + parts.push(`*(${p.author_name})*`); + + return parts.join(" "); + }); + + const finalMessage = trimLines(` + ${postLines.join("\n")} + + Use \`scheduled_posts \` to view a scheduled post in full + Use \`scheduled_posts delete \` to delete a scheduled post + `); + createChunkedMessage(msg.channel, finalMessage); + }, +}); diff --git a/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts b/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts new file mode 100644 index 00000000..cc0e3a42 --- /dev/null +++ b/backend/src/plugins/Post/commands/ScheduledPostsShowCmd.ts @@ -0,0 +1,26 @@ +import { postCmd } from "../types"; +import { sorter } from "src/utils"; +import { sendErrorMessage } from "src/pluginUtils"; +import { commandTypeHelpers as ct } from "../../../commandTypes"; +import { postMessage } from "../util/postMessage"; +import { TextChannel } from "eris"; + +export const ScheduledPostsShowCmd = postCmd({ + trigger: ["scheduled_posts", "scheduled_posts show"], + permission: "can_post", + + signature: { + num: ct.number(), + }, + + async run({ message: msg, args, pluginData }) { + const scheduledPosts = await pluginData.state.scheduledPosts.all(); + scheduledPosts.sort(sorter("post_at")); + const post = scheduledPosts[args.num - 1]; + if (!post) { + return sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); + } + + postMessage(pluginData, msg.channel as TextChannel, post.content, post.attachments, post.enable_mentions); + }, +}); diff --git a/backend/src/plugins/Post/types.ts b/backend/src/plugins/Post/types.ts new file mode 100644 index 00000000..f1d697bf --- /dev/null +++ b/backend/src/plugins/Post/types.ts @@ -0,0 +1,23 @@ +import * as t from "io-ts"; +import { BasePluginType, command } from "knub"; +import { GuildSavedMessages } from "src/data/GuildSavedMessages"; +import { GuildScheduledPosts } from "src/data/GuildScheduledPosts"; +import { GuildLogs } from "src/data/GuildLogs"; + +export const ConfigSchema = t.type({ + can_post: t.boolean, +}); +export type TConfigSchema = t.TypeOf; + +export interface PostPluginType extends BasePluginType { + config: TConfigSchema; + state: { + savedMessages: GuildSavedMessages; + scheduledPosts: GuildScheduledPosts; + logs: GuildLogs; + + scheduledPostLoopTimeout: NodeJS.Timeout; + }; +} + +export const postCmd = command(); diff --git a/backend/src/plugins/Post/util/actualPostCmd.ts b/backend/src/plugins/Post/util/actualPostCmd.ts new file mode 100644 index 00000000..556d05a0 --- /dev/null +++ b/backend/src/plugins/Post/util/actualPostCmd.ts @@ -0,0 +1,185 @@ +import { Message, Channel, TextChannel } from "eris"; +import { StrictMessageContent, errorMessage, DBDateFormat, stripObjectToScalars, MINUTES } from "src/utils"; +import moment from "moment-timezone"; +import { LogType } from "src/data/LogType"; +import humanizeDuration from "humanize-duration"; +import { sendErrorMessage, sendSuccessMessage } from "src/pluginUtils"; +import { PluginData } from "knub"; +import { PostPluginType } from "../types"; +import { parseScheduleTime } from "./parseScheduleTime"; +import { postMessage } from "./postMessage"; + +const MIN_REPEAT_TIME = 5 * MINUTES; +const MAX_REPEAT_TIME = Math.pow(2, 32); +const MAX_REPEAT_UNTIL = moment().add(100, "years"); + +export async function actualPostCmd( + pluginData: PluginData, + msg: Message, + targetChannel: Channel, + content: StrictMessageContent, + opts?: { + "enable-mentions"?: boolean; + schedule?: string; + repeat?: number; + "repeat-until"?: string; + "repeat-times"?: number; + }, +) { + if (!(targetChannel instanceof TextChannel)) { + msg.channel.createMessage(errorMessage("Channel is not a text channel")); + return; + } + + if (content == null && msg.attachments.length === 0) { + msg.channel.createMessage(errorMessage("Message content or attachment required")); + return; + } + + if (opts.repeat) { + if (opts.repeat < MIN_REPEAT_TIME) { + return sendErrorMessage( + pluginData, + msg.channel, + `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`, + ); + } + if (opts.repeat > MAX_REPEAT_TIME) { + return sendErrorMessage(pluginData, msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`); + } + } + + // If this is a scheduled or repeated post, figure out the next post date + let postAt; + if (opts.schedule) { + // Schedule the post to be posted later + postAt = parseScheduleTime(opts.schedule); + if (!postAt) { + return sendErrorMessage(pluginData, msg.channel, "Invalid schedule time"); + } + } else if (opts.repeat) { + postAt = moment().add(opts.repeat, "ms"); + } + + // For repeated posts, make sure repeat-until or repeat-times is specified + let repeatUntil: moment.Moment = null; + let repeatTimes: number = null; + let repeatDetailsStr: string = null; + + if (opts["repeat-until"]) { + repeatUntil = parseScheduleTime(opts["repeat-until"]); + + // Invalid time + if (!repeatUntil) { + return sendErrorMessage(pluginData, msg.channel, "Invalid time specified for -repeat-until"); + } + if (repeatUntil.isBefore(moment())) { + return sendErrorMessage(pluginData, msg.channel, "You can't set -repeat-until in the past"); + } + if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) { + return sendErrorMessage( + pluginData, + msg.channel, + "Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?", + ); + } + } else if (opts["repeat-times"]) { + repeatTimes = opts["repeat-times"]; + if (repeatTimes <= 0) { + return sendErrorMessage(pluginData, msg.channel, "-repeat-times must be 1 or more"); + } + } + + if (repeatUntil && repeatTimes) { + return sendErrorMessage(pluginData, msg.channel, "You can only use one of -repeat-until or -repeat-times at once"); + } + + if (opts.repeat && !repeatUntil && !repeatTimes) { + return sendErrorMessage( + pluginData, + msg.channel, + "You must specify -repeat-until or -repeat-times for repeated messages", + ); + } + + if (opts.repeat) { + repeatDetailsStr = repeatUntil + ? `every ${humanizeDuration(opts.repeat)} until ${repeatUntil.format(DBDateFormat)}` + : `every ${humanizeDuration(opts.repeat)}, ${repeatTimes} times in total`; + } + + // Save schedule/repeat information in DB + if (postAt) { + if (postAt < moment()) { + return sendErrorMessage(pluginData, msg.channel, "Post can't be scheduled to be posted in the past"); + } + + await pluginData.state.scheduledPosts.create({ + author_id: msg.author.id, + author_name: `${msg.author.username}#${msg.author.discriminator}`, + channel_id: targetChannel.id, + content, + attachments: msg.attachments, + post_at: postAt.format(DBDateFormat), + enable_mentions: opts["enable-mentions"], + repeat_interval: opts.repeat, + repeat_until: repeatUntil ? repeatUntil.format(DBDateFormat) : null, + repeat_times: repeatTimes ?? null, + }); + + if (opts.repeat) { + pluginData.state.logs.log(LogType.SCHEDULED_REPEATED_MESSAGE, { + author: stripObjectToScalars(msg.author), + channel: stripObjectToScalars(targetChannel), + date: postAt.format("YYYY-MM-DD"), + time: postAt.format("HH:mm:ss"), + repeatInterval: humanizeDuration(opts.repeat), + repeatDetails: repeatDetailsStr, + }); + } else { + pluginData.state.logs.log(LogType.SCHEDULED_MESSAGE, { + author: stripObjectToScalars(msg.author), + channel: stripObjectToScalars(targetChannel), + date: postAt.format("YYYY-MM-DD"), + time: postAt.format("HH:mm:ss"), + }); + } + } + + // When the message isn't scheduled for later, post it immediately + if (!opts.schedule) { + await postMessage(pluginData, targetChannel, content, msg.attachments, opts["enable-mentions"]); + } + + if (opts.repeat) { + pluginData.state.logs.log(LogType.REPEATED_MESSAGE, { + author: stripObjectToScalars(msg.author), + channel: stripObjectToScalars(targetChannel), + date: postAt.format("YYYY-MM-DD"), + time: postAt.format("HH:mm:ss"), + repeatInterval: humanizeDuration(opts.repeat), + repeatDetails: repeatDetailsStr, + }); + } + + // Bot reply schenanigans + let successMessage = opts.schedule + ? `Message scheduled to be posted in <#${targetChannel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)` + : `Message posted in <#${targetChannel.id}>`; + + if (opts.repeat) { + successMessage += `. Message will be automatically reposted every ${humanizeDuration(opts.repeat)}`; + + if (repeatUntil) { + successMessage += ` until ${repeatUntil.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`; + } else if (repeatTimes) { + successMessage += `, ${repeatTimes} times in total`; + } + + successMessage += "."; + } + + if (targetChannel.id !== msg.channel.id || opts.schedule || opts.repeat) { + sendSuccessMessage(pluginData, msg.channel, successMessage); + } +} diff --git a/backend/src/plugins/Post/util/formatContent.ts b/backend/src/plugins/Post/util/formatContent.ts new file mode 100644 index 00000000..e8d6ba93 --- /dev/null +++ b/backend/src/plugins/Post/util/formatContent.ts @@ -0,0 +1,3 @@ +export function formatContent(str) { + return str.replace(/\\n/g, "\n"); +} diff --git a/backend/src/plugins/Post/util/parseScheduleTime.ts b/backend/src/plugins/Post/util/parseScheduleTime.ts new file mode 100644 index 00000000..c4d57231 --- /dev/null +++ b/backend/src/plugins/Post/util/parseScheduleTime.ts @@ -0,0 +1,32 @@ +import moment, { Moment } from "moment-timezone"; +import { convertDelayStringToMS } from "src/utils"; + +export function parseScheduleTime(str): Moment { + const dt1 = moment(str, "YYYY-MM-DD HH:mm:ss"); + if (dt1 && dt1.isValid()) return dt1; + + const dt2 = moment(str, "YYYY-MM-DD HH:mm"); + if (dt2 && dt2.isValid()) return dt2; + + const date = moment(str, "YYYY-MM-DD"); + if (date && date.isValid()) return date; + + const t1 = moment(str, "HH:mm:ss"); + if (t1 && t1.isValid()) { + if (t1.isBefore(moment())) t1.add(1, "day"); + return t1; + } + + const t2 = moment(str, "HH:mm"); + if (t2 && t2.isValid()) { + if (t2.isBefore(moment())) t2.add(1, "day"); + return t2; + } + + const delayStringMS = convertDelayStringToMS(str, "m"); + if (delayStringMS) { + return moment().add(delayStringMS, "ms"); + } + + return null; +} diff --git a/backend/src/plugins/Post/util/postMessage.ts b/backend/src/plugins/Post/util/postMessage.ts new file mode 100644 index 00000000..e45bfb24 --- /dev/null +++ b/backend/src/plugins/Post/util/postMessage.ts @@ -0,0 +1,67 @@ +import { PluginData } from "knub"; +import { PostPluginType } from "../types"; +import { TextChannel, MessageContent, Attachment, Message, Role } from "eris"; +import { downloadFile, getRoleMentions } from "src/utils"; +import fs from "fs"; +import { formatContent } from "./formatContent"; + +const fsp = fs.promises; + +export async function postMessage( + pluginData: PluginData, + channel: TextChannel, + content: MessageContent, + attachments: Attachment[] = [], + enableMentions: boolean = false, +): Promise { + if (typeof content === "string") { + content = { content }; + } + + if (content && content.content) { + content.content = formatContent(content.content); + } + + let downloadedAttachment; + let file; + if (attachments.length) { + downloadedAttachment = await downloadFile(attachments[0].url); + file = { + name: attachments[0].filename, + file: await fsp.readFile(downloadedAttachment.path), + }; + } + + const rolesMadeMentionable: Role[] = []; + if (enableMentions && content.content) { + const mentionedRoleIds = getRoleMentions(content.content); + if (mentionedRoleIds != null) { + for (const roleId of mentionedRoleIds) { + const role = pluginData.guild.roles.get(roleId); + if (role && !role.mentionable) { + await role.edit({ + mentionable: true, + }); + rolesMadeMentionable.push(role); + } + } + } + + content.allowedMentions.everyone = false; + } + + const createdMsg = await channel.createMessage(content, file); + pluginData.state.savedMessages.setPermanent(createdMsg.id); + + for (const role of rolesMadeMentionable) { + role.edit({ + mentionable: false, + }); + } + + if (downloadedAttachment) { + downloadedAttachment.deleteFn(); + } + + return createdMsg; +} diff --git a/backend/src/plugins/Post/util/scheduledPostLoop.ts b/backend/src/plugins/Post/util/scheduledPostLoop.ts new file mode 100644 index 00000000..124af05b --- /dev/null +++ b/backend/src/plugins/Post/util/scheduledPostLoop.ts @@ -0,0 +1,82 @@ +import { PluginData } from "knub"; +import { PostPluginType } from "../types"; +import { logger } from "src/logger"; +import { stripObjectToScalars, DBDateFormat, SECONDS } from "src/utils"; +import { LogType } from "src/data/LogType"; +import moment from "moment-timezone"; +import { TextChannel, User } from "eris"; +import { postMessage } from "./postMessage"; + +const SCHEDULED_POST_CHECK_INTERVAL = 5 * SECONDS; + +export async function scheduledPostLoop(pluginData: PluginData) { + const duePosts = await pluginData.state.scheduledPosts.getDueScheduledPosts(); + for (const post of duePosts) { + const channel = pluginData.guild.channels.get(post.channel_id); + if (channel instanceof TextChannel) { + const [username, discriminator] = post.author_name.split("#"); + const author: Partial = pluginData.client.users.get(post.author_id) || { + id: post.author_id, + username, + discriminator, + }; + + try { + const postedMessage = await postMessage( + pluginData, + channel, + post.content, + post.attachments, + post.enable_mentions, + ); + pluginData.state.logs.log(LogType.POSTED_SCHEDULED_MESSAGE, { + author: stripObjectToScalars(author), + channel: stripObjectToScalars(channel), + messageId: postedMessage.id, + }); + } catch (e) { + pluginData.state.logs.log(LogType.BOT_ALERT, { + body: `Failed to post scheduled message by {userMention(author)} to {channelMention(channel)}`, + channel: stripObjectToScalars(channel), + author: stripObjectToScalars(author), + }); + logger.warn( + `Failed to post scheduled message to #${channel.name} (${channel.id}) on ${pluginData.guild.name} (${pluginData.guild.id})`, + ); + } + } + + let shouldClear = true; + + if (post.repeat_interval) { + const nextPostAt = moment().add(post.repeat_interval, "ms"); + + if (post.repeat_until) { + const repeatUntil = moment(post.repeat_until, DBDateFormat); + if (nextPostAt.isSameOrBefore(repeatUntil)) { + await pluginData.state.scheduledPosts.update(post.id, { + post_at: nextPostAt.format(DBDateFormat), + }); + shouldClear = false; + } + } else if (post.repeat_times) { + if (post.repeat_times > 1) { + await pluginData.state.scheduledPosts.update(post.id, { + post_at: nextPostAt.format(DBDateFormat), + repeat_times: post.repeat_times - 1, + }); + shouldClear = false; + } + } + } + + if (shouldClear) { + await pluginData.state.scheduledPosts.delete(post.id); + } + } + + pluginData.state.scheduledPostLoopTimeout = setTimeout( + () => scheduledPostLoop(pluginData), + SCHEDULED_POST_CHECK_INTERVAL, + ); +} diff --git a/backend/src/plugins/availablePlugins.ts b/backend/src/plugins/availablePlugins.ts index 32825725..10f343f9 100644 --- a/backend/src/plugins/availablePlugins.ts +++ b/backend/src/plugins/availablePlugins.ts @@ -13,6 +13,7 @@ import { GuildConfigReloaderPlugin } from "./GuildConfigReloader/GuildConfigRelo import { CasesPlugin } from "./Cases/CasesPlugin"; import { MutesPlugin } from "./Mutes/MutesPlugin"; import { TagsPlugin } from "./Tags/TagsPlugin"; +import { PostPlugin } from "./Post/PostPlugin"; // prettier-ignore export const guildPlugins: Array> = [ @@ -20,6 +21,7 @@ export const guildPlugins: Array> = [ LocateUserPlugin, PersistPlugin, PingableRolesPlugin, + PostPlugin, MessageSaverPlugin, NameHistoryPlugin, RemindersPlugin,