From e18193c1a2c83fd5c2860126cec87090c7dd5251 Mon Sep 17 00:00:00 2001 From: Dragory <2606411+Dragory@users.noreply.github.com> Date: Sat, 4 May 2019 18:41:50 +0300 Subject: [PATCH] Add post scheduling. Add cleaner post_embed syntax. --- src/data/DefaultLogMessages.json | 5 +- src/data/GuildScheduledPosts.ts | 41 ++ src/data/LogType.ts | 3 + src/data/entities/ScheduledPost.ts | 26 ++ ...1556973844545-CreateScheduledPostsTable.ts | 68 ++++ src/plugins/Post.ts | 377 +++++++++++++++--- src/utils.ts | 21 +- 7 files changed, 489 insertions(+), 52 deletions(-) create mode 100644 src/data/GuildScheduledPosts.ts create mode 100644 src/data/entities/ScheduledPost.ts create mode 100644 src/migrations/1556973844545-CreateScheduledPostsTable.ts diff --git a/src/data/DefaultLogMessages.json b/src/data/DefaultLogMessages.json index 1b409d4e..da307d81 100644 --- a/src/data/DefaultLogMessages.json +++ b/src/data/DefaultLogMessages.json @@ -52,5 +52,8 @@ "CASE_UPDATE": "✏ {userMention(mod)} updated case #{caseNumber} ({caseType}) with note:\n```{note}```", - "MEMBER_MUTE_REJOIN": "⚠ Reapplied active mute for {userMention(member)} on rejoin" + "MEMBER_MUTE_REJOIN": "⚠ Reapplied active mute for {userMention(member)} on rejoin", + + "SCHEDULED_MESSAGE": "⏰ {userMention(author)} scheduled a message to be posted to {channelMention(channel)} on {date} at {time} (UTC)", + "POSTED_SCHEDULED_MESSAGE": "\uD83D\uDCE8 Posted scheduled message (`{messageId}`) to {channelMention(channel)} as scheduled by {userMention(author)}" } diff --git a/src/data/GuildScheduledPosts.ts b/src/data/GuildScheduledPosts.ts new file mode 100644 index 00000000..d462bc0e --- /dev/null +++ b/src/data/GuildScheduledPosts.ts @@ -0,0 +1,41 @@ +import { BaseRepository } from "./BaseRepository"; +import { getRepository, Repository } from "typeorm"; +import { ScheduledPost } from "./entities/ScheduledPost"; + +export class GuildScheduledPosts extends BaseRepository { + private scheduledPosts: Repository; + + constructor(guildId) { + super(guildId); + this.scheduledPosts = getRepository(ScheduledPost); + } + + all(): Promise { + return this.scheduledPosts + .createQueryBuilder() + .where("guild_id = :guildId", { guildId: this.guildId }) + .getMany(); + } + + getDueScheduledPosts(): Promise { + return this.scheduledPosts + .createQueryBuilder() + .where("guild_id = :guildId", { guildId: this.guildId }) + .andWhere("post_at <= NOW()") + .getMany(); + } + + async delete(id) { + await this.scheduledPosts.delete({ + guild_id: this.guildId, + id, + }); + } + + async create(data: Partial) { + await this.scheduledPosts.insert({ + ...data, + guild_id: this.guildId, + }); + } +} diff --git a/src/data/LogType.ts b/src/data/LogType.ts index 2ff4db75..fb198e85 100644 --- a/src/data/LogType.ts +++ b/src/data/LogType.ts @@ -53,4 +53,7 @@ export enum LogType { CASE_UPDATE, MEMBER_MUTE_REJOIN, + + SCHEDULED_MESSAGE, + POSTED_SCHEDULED_MESSAGE, } diff --git a/src/data/entities/ScheduledPost.ts b/src/data/entities/ScheduledPost.ts new file mode 100644 index 00000000..e5592f51 --- /dev/null +++ b/src/data/entities/ScheduledPost.ts @@ -0,0 +1,26 @@ +import { Entity, Column, PrimaryColumn } from "typeorm"; +import { Attachment } from "eris"; +import { StrictMessageContent } from "../../utils"; + +@Entity("scheduled_posts") +export class ScheduledPost { + @Column() + @PrimaryColumn() + id: number; + + @Column() guild_id: string; + + @Column() author_id: string; + + @Column() author_name: string; + + @Column() channel_id: string; + + @Column("simple-json") content: StrictMessageContent; + + @Column("simple-json") attachments: Attachment[]; + + @Column() post_at: string; + + @Column() enable_mentions: boolean; +} diff --git a/src/migrations/1556973844545-CreateScheduledPostsTable.ts b/src/migrations/1556973844545-CreateScheduledPostsTable.ts new file mode 100644 index 00000000..e462fe3f --- /dev/null +++ b/src/migrations/1556973844545-CreateScheduledPostsTable.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner, Table } from "typeorm"; + +export class CreateScheduledPostsTable1556973844545 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: "scheduled_posts", + columns: [ + { + name: "id", + type: "int", + unsigned: true, + isGenerated: true, + generationStrategy: "increment", + isPrimary: true, + }, + { + name: "guild_id", + type: "bigint", + unsigned: true, + }, + { + name: "author_id", + type: "bigint", + unsigned: true, + }, + { + name: "author_name", + type: "varchar", + length: "160", + }, + { + name: "channel_id", + type: "bigint", + unsigned: true, + }, + { + name: "content", + type: "text", + }, + { + name: "attachments", + type: "text", + }, + { + name: "post_at", + type: "datetime", + }, + { + name: "enable_mentions", + type: "tinyint", + unsigned: true, + default: 0, + }, + ], + indices: [ + { + columnNames: ["guild_id", "post_at"], + }, + ], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable("scheduled_posts", true); + } +} diff --git a/src/plugins/Post.ts b/src/plugins/Post.ts index 35ee7d96..a7dcfc7c 100644 --- a/src/plugins/Post.ts +++ b/src/plugins/Post.ts @@ -1,14 +1,34 @@ -import { decorators as d, IPluginOptions } from "knub"; -import { Channel, EmbedBase, Message, Role, TextChannel } from "eris"; -import { errorMessage, downloadFile, getRoleMentions } from "../utils"; +import { decorators as d, IPluginOptions, logger } from "knub"; +import { Attachment, Channel, EmbedBase, Message, MessageContent, Role, TextChannel, User } from "eris"; +import { + errorMessage, + downloadFile, + getRoleMentions, + trimLines, + DBDateFormat, + convertDelayStringToMS, + SECONDS, + sorter, + disableCodeBlocks, + deactivateMentions, + createChunkedMessage, + stripObjectToScalars, +} from "../utils"; import { GuildSavedMessages } from "../data/GuildSavedMessages"; import { ZeppelinPlugin } from "./ZeppelinPlugin"; import fs from "fs"; +import { GuildScheduledPosts } from "../data/GuildScheduledPosts"; +import moment, { Moment } from "moment-timezone"; +import { GuildLogs } from "../data/GuildLogs"; +import { LogType } from "../data/LogType"; const fsp = fs.promises; const COLOR_MATCH_REGEX = /^#?([0-9a-f]{6})$/; +const SCHEDULED_POST_CHECK_INTERVAL = 15 * SECONDS; +const SCHEDULED_POST_PREVIEW_TEXT_LENGTH = 50; + interface IPostPluginConfig { can_post: boolean; } @@ -17,9 +37,15 @@ export class PostPlugin extends ZeppelinPlugin { public static pluginName = "post"; protected savedMessages: GuildSavedMessages; + protected scheduledPosts: GuildScheduledPosts; + protected logs: GuildLogs; onLoad() { this.savedMessages = GuildSavedMessages.getInstance(this.guildId); + this.scheduledPosts = GuildScheduledPosts.getInstance(this.guildId); + this.logs = new GuildLogs(this.guildId); + + this.scheduledPostLoop(); } getDefaultOptions(): IPluginOptions { @@ -43,44 +69,33 @@ export class PostPlugin extends ZeppelinPlugin { return str.replace(/\\n/g, "\n"); } - /** - * COMMAND: Post a message as the bot to the specified channel - */ - @d.command("post", " [content:string$]", { - options: [ - { - name: "enable-mentions", - type: "bool", - }, - ], - }) - @d.permission("can_post") - async postCmd(msg: Message, args: { channel: Channel; content?: string; "enable-mentions": boolean }) { - if (!(args.channel instanceof TextChannel)) { - msg.channel.createMessage(errorMessage("Channel is not a text channel")); - return; + protected async postMessage( + channel: TextChannel, + content: MessageContent, + attachments: Attachment[] = [], + enableMentions: boolean = false, + ): Promise { + if (typeof content === "string") { + content = { content }; + } + + if (content && content.content) { + content.content = this.formatContent(content.content); } - const content: string = (args.content && this.formatContent(args.content)) || undefined; let downloadedAttachment; let file; - - if (msg.attachments.length) { - downloadedAttachment = await downloadFile(msg.attachments[0].url); + if (attachments.length) { + downloadedAttachment = await downloadFile(attachments[0].url); file = { - name: msg.attachments[0].filename, + name: attachments[0].filename, file: await fsp.readFile(downloadedAttachment.path), }; } - if (content == null && file == null) { - msg.channel.createMessage(errorMessage("Text content or attachment required")); - return; - } - const rolesMadeMentionable: Role[] = []; - if (args["enable-mentions"] && content) { - const mentionedRoleIds = getRoleMentions(content); + if (enableMentions && content.content) { + const mentionedRoleIds = getRoleMentions(content.content); if (mentionedRoleIds != null) { for (const roleId of mentionedRoleIds) { const role = this.guild.roles.get(roleId); @@ -94,8 +109,8 @@ export class PostPlugin extends ZeppelinPlugin { } } - const createdMsg = await args.channel.createMessage(content, file); - await this.savedMessages.setPermanent(createdMsg.id); + const createdMsg = await channel.createMessage(content, file); + this.savedMessages.setPermanent(createdMsg.id); for (const role of rolesMadeMentionable) { role.edit({ @@ -106,26 +121,159 @@ export class PostPlugin extends ZeppelinPlugin { if (downloadedAttachment) { downloadedAttachment.deleteFn(); } + + return createdMsg; + } + + protected parseScheduleTime(str): Moment { + const dtMatch = str.match(/^\d{4}-\d{2}-\d{2} \d{1,2}:\d{1,2}(:\d{1,2})?$/); + if (dtMatch) { + const dt = moment(str, dtMatch[1] ? "YYYY-MM-DD H:m:s" : "YYYY-MM-DD H:m"); + return dt; + } + + const tMatch = str.match(/^\d{1,2}:\d{1,2}(:\d{1,2})?$/); + if (tMatch) { + const dt = moment(str, tMatch[1] ? "H:m:s" : "H:m"); + if (dt.isBefore(moment())) dt.add(1, "day"); + return dt; + } + + const delayStringMS = convertDelayStringToMS(str, "m"); + if (delayStringMS) { + return moment().add(delayStringMS, "ms"); + } + + return null; + } + + protected async scheduledPostLoop() { + const duePosts = await this.scheduledPosts.getDueScheduledPosts(); + for (const post of duePosts) { + const channel = this.guild.channels.get(post.channel_id); + if (channel instanceof TextChannel) { + try { + const postedMessage = await this.postMessage(channel, post.content, post.attachments, post.enable_mentions); + + const [username, discriminator] = post.author_name.split("#"); + this.logs.log(LogType.POSTED_SCHEDULED_MESSAGE, { + author: ({ id: post.author_id, username, discriminator } as any) as Partial, + channel: stripObjectToScalars(channel), + messageId: postedMessage.id, + }); + } catch (e) { + logger.warn( + `Failed to post scheduled message to #${channel.name} (${channel.id}) on ${this.guild.name} (${ + this.guildId + })`, + ); + } + } + + await this.scheduledPosts.delete(post.id); + } + + setTimeout(() => this.scheduledPostLoop(), SCHEDULED_POST_CHECK_INTERVAL); } /** - * COMMAND: Post a message with an embed as the bot to the specified channel + * COMMAND: Post a regular text message as the bot to the specified channel */ - @d.command("post_embed", "", { + @d.command("post", " [content:string$]", { options: [ - { name: "title", type: "string" }, - { name: "content", type: "string" }, - { name: "color", type: "string" }, + { + name: "enable-mentions", + type: "bool", + }, + { + name: "schedule", + type: "string", + }, ], }) @d.permission("can_post") - async postEmbedCmd(msg: Message, args: { channel: Channel; title?: string; content?: string; color?: string }) { + async postCmd( + msg: Message, + args: { channel: Channel; content?: string; "enable-mentions": boolean; schedule?: string }, + ) { if (!(args.channel instanceof TextChannel)) { msg.channel.createMessage(errorMessage("Channel is not a text channel")); return; } - if (!args.title && !args.content) { + if (args.content == null && msg.attachments.length === 0) { + msg.channel.createMessage(errorMessage("Text content or attachment required")); + return; + } + + if (args.schedule) { + // Schedule the post to be posted later + const postAt = this.parseScheduleTime(args.schedule); + if (!postAt) { + return this.sendErrorMessage(msg.channel, "Invalid schedule time"); + } + + if (postAt < moment()) { + return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past"); + } + + await this.scheduledPosts.create({ + author_id: msg.author.id, + author_name: `${msg.author.username}#${msg.author.discriminator}`, + channel_id: args.channel.id, + content: { content: args.content }, + attachments: msg.attachments, + post_at: postAt.format(DBDateFormat), + enable_mentions: args["enable-mentions"], + }); + this.sendSuccessMessage( + msg.channel, + `Message scheduled to be posted in <#${args.channel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`, + ); + this.logs.log(LogType.SCHEDULED_MESSAGE, { + author: stripObjectToScalars(msg.author), + channel: stripObjectToScalars(args.channel), + date: postAt.format("YYYY-MM-DD"), + time: postAt.format("HH:mm:ss"), + }); + } else { + // Post the message immediately + await this.postMessage(args.channel, args.content, msg.attachments, args["enable-mentions"]); + this.sendSuccessMessage(msg.channel, `Message posted in <#${args.channel.id}>`); + } + } + + /** + * COMMAND: Post a message with an embed as the bot to the specified channel + */ + @d.command("post_embed", " [maincontent:string$]", { + options: [ + { name: "title", type: "string" }, + { name: "content", type: "string" }, + { name: "color", type: "string" }, + { name: "schedule", type: "string" }, + ], + }) + @d.permission("can_post") + async postEmbedCmd( + msg: Message, + args: { + channel: Channel; + title?: string; + maincontent?: string; + content?: string; + color?: string; + schedule?: string; + }, + ) { + if (!(args.channel instanceof TextChannel)) { + msg.channel.createMessage(errorMessage("Channel is not a text channel")); + return; + } + + const content = args.content || args.maincontent; + + if (!args.title && !content) { msg.channel.createMessage(errorMessage("Title or content required")); return; } @@ -143,11 +291,55 @@ export class PostPlugin extends ZeppelinPlugin { const embed: EmbedBase = {}; if (args.title) embed.title = args.title; - if (args.content) embed.description = this.formatContent(args.content); + if (content) embed.description = this.formatContent(content); if (color) embed.color = color; - const createdMsg = await args.channel.createMessage({ embed }); - await this.savedMessages.setPermanent(createdMsg.id); + if (args.schedule) { + // Schedule the post to be posted later + const postAt = this.parseScheduleTime(args.schedule); + if (!postAt) { + return this.sendErrorMessage(msg.channel, "Invalid schedule time"); + } + + if (postAt < moment()) { + return this.sendErrorMessage(msg.channel, "Post can't be scheduled to be posted in the past"); + } + + await this.scheduledPosts.create({ + author_id: msg.author.id, + author_name: `${msg.author.username}#${msg.author.discriminator}`, + channel_id: args.channel.id, + content: { embed }, + attachments: msg.attachments, + post_at: postAt.format(DBDateFormat), + }); + await this.sendSuccessMessage( + msg.channel, + `Embed scheduled to be posted in <#${args.channel.id}> on ${postAt.format("YYYY-MM-DD [at] HH:mm:ss")} (UTC)`, + ); + this.logs.log(LogType.SCHEDULED_MESSAGE, { + author: stripObjectToScalars(msg.author), + channel: stripObjectToScalars(args.channel), + date: postAt.format("YYYY-MM-DD"), + time: postAt.format("HH:mm:ss"), + }); + } else { + const createdMsg = await args.channel.createMessage({ embed }); + this.savedMessages.setPermanent(createdMsg.id); + + await this.sendSuccessMessage(msg.channel, `Embed posted in <#${args.channel.id}>`); + } + + if (args.content) { + const prefix = this.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}post_embed --title="Some title" content goes here\` + The \`--content\` option will soon be removed in favor of this. + `), + ); + } } /** @@ -168,12 +360,13 @@ export class PostPlugin extends ZeppelinPlugin { } await this.bot.editMessage(savedMessage.channel_id, savedMessage.id, this.formatContent(args.content)); + this.sendSuccessMessage(msg.channel, "Message edited"); } /** * COMMAND: Edit the specified message with an embed posted by the bot */ - @d.command("edit_embed", "", { + @d.command("edit_embed", " [maincontent:string$]", { options: [ { name: "title", type: "string" }, { name: "content", type: "string" }, @@ -181,17 +374,17 @@ export class PostPlugin extends ZeppelinPlugin { ], }) @d.permission("can_post") - async editEmbedCmd(msg: Message, args: { messageId: string; title?: string; content?: string; color?: string }) { + async editEmbedCmd( + msg: Message, + args: { messageId: string; title?: string; maincontent?: string; content?: string; color?: string }, + ) { const savedMessage = await this.savedMessages.find(args.messageId); if (!savedMessage) { msg.channel.createMessage(errorMessage("Unknown message")); return; } - if (!args.title && !args.content) { - msg.channel.createMessage(errorMessage("Title or content required")); - return; - } + const content = args.content || args.maincontent; let color = null; if (args.color) { @@ -206,9 +399,93 @@ export class PostPlugin extends ZeppelinPlugin { const embed: EmbedBase = savedMessage.data.embeds[0]; if (args.title) embed.title = args.title; - if (args.content) embed.description = this.formatContent(args.content); + if (content) embed.description = this.formatContent(content); if (color) embed.color = color; await this.bot.editMessage(savedMessage.channel_id, savedMessage.id, { embed }); + await this.sendSuccessMessage(msg.channel, "Embed edited"); + + if (args.content) { + const prefix = this.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. + `), + ); + } + } + + @d.command("scheduled_posts", [], { + aliases: ["scheduled_posts list"], + }) + @d.permission("can_post") + async scheduledPostListCmd(msg: Message) { + const scheduledPosts = await this.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)*"); + parts.push(`*(${p.author_name})*`); + + return parts.join(" "); + }); + + const finalMessage = trimLines(` + ${postLines.join("\n")} + + Use \`scheduled_posts show \` to view a scheduled post in full + Use \`scheduled_posts delete \` to delete a scheduled post + `); + createChunkedMessage(msg.channel, finalMessage); + } + + @d.command("scheduled_posts delete", "", { + aliases: ["scheduled_posts d"], + }) + @d.permission("can_post") + async scheduledPostDeleteCmd(msg: Message, args: { num: number }) { + const scheduledPosts = await this.scheduledPosts.all(); + scheduledPosts.sort(sorter("post_at")); + const post = scheduledPosts[args.num - 1]; + if (!post) { + return this.sendErrorMessage(msg.channel, "Scheduled post not found"); + } + + await this.scheduledPosts.delete(post.id); + this.sendSuccessMessage(msg.channel, "Scheduled post deleted!"); + } + + @d.command("scheduled_posts", "", { + aliases: ["scheduled_posts show"], + }) + @d.permission("can_post") + async scheduledPostShowCmd(msg: Message, args: { num: number }) { + const scheduledPosts = await this.scheduledPosts.all(); + scheduledPosts.sort(sorter("post_at")); + const post = scheduledPosts[args.num - 1]; + if (!post) { + return this.sendErrorMessage(msg.channel, "Scheduled post not found"); + } + + this.postMessage(msg.channel as TextChannel, post.content, post.attachments, post.enable_mentions); } } diff --git a/src/utils.ts b/src/utils.ts index 008e13f2..edf328f9 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,4 +1,15 @@ -import { Client, Emoji, Guild, GuildAuditLogEntry, Member, TextableChannel, TextChannel, User } from "eris"; +import { + Client, + EmbedOptions, + Emoji, + Guild, + GuildAuditLogEntry, + Member, + MessageContent, + TextableChannel, + TextChannel, + User, +} from "eris"; import url from "url"; import tlds from "tlds"; import emojiRegex from "emoji-regex"; @@ -613,3 +624,11 @@ export async function resolveMember(bot: Client, guild: Guild, value: string): P return member; } + +export const MS = 1; +export const SECONDS = 1000 * MS; +export const MINUTES = 60 * SECONDS; +export const HOURS = 60 * MINUTES; +export const DAYS = 24 * HOURS; + +export type StrictMessageContent = { content?: string; tts?: boolean; disableEveryone?: boolean; embed?: EmbedOptions };