import TurndownService from "turndown"; import interval from "interval-promise"; import { client, FORUMS_DOMAIN } from "../client"; import sha1 from "sha1"; import { TextChannel, Message, ReactionCollector, MessageReaction, User, Channel } from "discord.js"; import { Dict } from "../util"; import { getRepository, Not, IsNull } from "typeorm"; import { PostedForumNewsItem } from "@shared/db/entity/PostedForumsNewsItem"; import { KnownChannel } from "@shared/db/entity/KnownChannel"; import { PostVerifyMessage } from "@shared/db/entity/PostVerifyMessage"; import { render } from "../bbcode-parser/bbcode-js"; import { logger } from "src/logging"; import { Command, ICommandData, Plugin } from "src/model/plugin"; const PREVIEW_CHAR_LIMIT = 300; const NEWS_POST_VERIFY_CHANNEL = "newsPostVerify"; const NEWS_FEED_CHANNEL = "newsFeed"; const RSS_UPDATE_INTERVAL_MIN = process.env.NODE_ENV == "dev" ? 60 : 5; const NEWS_FORUM_ID = 49; @Plugin export class ForumsNewsChecker { verifyMessageIdToPost: Dict = {} reactionCollectors: Dict = {}; verifyChannelId?: string; botUserId = 0; turndown = new TurndownService(); constructor() { this.turndown.addRule("image", { filter: "img", replacement: () => "" }); this.turndown.addRule("link", { filter: (node: HTMLElement) => node.nodeName === "A" && node.getAttribute("href") != null, replacement: (content: string, node: Node) => (node as HTMLElement).getAttribute("htef") ?? "" }); } bbCodeToMarkdown(bbCode: string): string { const html = render(bbCode).replace(/\n/gm, "
"); return this.turndown.turndown(html).trim();//.replace(/( {2}\n|\n\n){2,}/gm, "\n"); } checkFeeds = async (): Promise => { try { logger.info(`Checking feeds on ${new Date().toISOString()}`); const forumsNewsRepo = getRepository(PostedForumNewsItem); const postVerifyMessageRepo = getRepository(PostVerifyMessage); const forumThreads = await client.forum.getForumThreads(NEWS_FORUM_ID); for (const thread of [...forumThreads.threads, ...forumThreads.sticky]) { const firstPost = await client.forum.getPost(thread.first_post_id); const contents = this.bbCodeToMarkdown(firstPost.message); let itemObj = forumsNewsRepo.create({ id: thread.thread_id.toString(), hash: sha1(firstPost.message), verifyMessage: postVerifyMessageRepo.create({ author: thread.username, link: `${FORUMS_DOMAIN}/index.php?threads/${thread.thread_id}/`, title: thread.title, text: `${contents.substr(0, Math.min(contents.length, PREVIEW_CHAR_LIMIT))}...`, isNew: true }) }); const postItem = await forumsNewsRepo.findOne({ where: { id: itemObj.id }, relations: ["verifyMessage"] }); if (postItem) { if (process.env.IGNORE_CHANGED_NEWS === "TRUE") { await forumsNewsRepo.update({ id: postItem.id }, { hash: itemObj.hash }); continue; } // Add message ID to mark for edit if (postItem.hash != itemObj.hash) { const newHash = itemObj.hash; if (!postItem.verifyMessage) postItem.verifyMessage = itemObj.verifyMessage; itemObj = postItem; if (itemObj.verifyMessage) itemObj.verifyMessage.isNew = false; itemObj.hash = newHash; } else continue; } if (!this.verifyChannelId || firstPost.user_id == this.botUserId) await this.sendNews(itemObj); else await this.addVerifyMessage(itemObj); } } catch (err) { logger.error("Failed to check forums: %s", err); } } async initPendingReactors(): Promise { if (!this.verifyChannelId) return; const verifyChannel = client.bot.channels.resolve(this.verifyChannelId); if (!verifyChannel) return; const repo = getRepository(PostedForumNewsItem); const verifyMessageRepo = getRepository(PostVerifyMessage); const pendingVerifyMessages = await repo.find({ where: { verifyMessage: Not(IsNull()) }, select: ["id"], relations: ["verifyMessage"] }); for (const msg of pendingVerifyMessages) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const m = await this.tryFetchMessage(verifyChannel, msg.verifyMessage!.messageId); if (!m) { await repo.update({ id: msg.id }, { verifyMessage: undefined }); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion await verifyMessageRepo.delete(msg.verifyMessage!); continue; } const collector = m.createReactionCollector({ filter: this.isVerifyReaction, maxEmojis: 1, }); collector.on("collect", this.collectorFor(collector)); this.reactionCollectors[m.id] = collector; this.verifyMessageIdToPost[m.id] = msg.id; } } async addVerifyMessage(item: PostedForumNewsItem): Promise { if (!this.verifyChannelId) return; if (!item.verifyMessage) throw new Error("No verify message! This shouldn't happen!"); const verifyChannel = client.bot.channels.resolve(this.verifyChannelId) as TextChannel; if (!verifyChannel) { logger.warn(`Skipping adding item ${item.id} because no verify channel is set up!`); return; } const verifyMessageRepo = getRepository(PostVerifyMessage); const forumsNewsRepo = getRepository(PostedForumNewsItem); if (item.verifyMessage.messageId) { const oldMessage = await this.tryFetchMessage(verifyChannel, item.verifyMessage.messageId); if (oldMessage) await oldMessage.delete(); } const newMessage = await verifyChannel.send(this.toVerifyString(item.id, item.verifyMessage)) as Message; item.verifyMessage.messageId = newMessage.id; await newMessage.react("✅"); await newMessage.react("❌"); const collector = newMessage.createReactionCollector({ filter: this.isVerifyReaction, maxEmojis: 1 }); collector.on("collect", this.collectorFor(collector)); this.reactionCollectors[newMessage.id] = collector; this.verifyMessageIdToPost[newMessage.id] = item.id; item.verifyMessage = await verifyMessageRepo.save(item.verifyMessage); await forumsNewsRepo.save(item); } collectorFor = (collector: ReactionCollector) => async (reaction: MessageReaction): Promise => { const verifyMessageRepo = getRepository(PostVerifyMessage); const postRepo = getRepository(PostedForumNewsItem); const m = reaction.message; collector.stop(); delete this.reactionCollectors[m.id]; const postId = this.verifyMessageIdToPost[m.id]; const post = await postRepo.findOne({ where: { id: postId }, relations: ["verifyMessage"] }); if (!post) throw new Error("Post not found!"); await postRepo.update({ id: post.id }, { verifyMessage: undefined }); if (post.verifyMessage) await verifyMessageRepo.delete({ id: post.verifyMessage.id }); await reaction.message.delete(); if (reaction.emoji.name == "✅") this.sendNews(post); delete this.verifyMessageIdToPost[m.id]; }; async sendNews(item: PostedForumNewsItem): Promise { const channelRepo = getRepository(KnownChannel); const newsPostRepo = getRepository(PostedForumNewsItem); const outChannel = await channelRepo.findOne({ where: { channelType: NEWS_FEED_CHANNEL } }); if (!outChannel) return; const sentMessage = await this.postNewsItem(outChannel.channelId, item); item.postedMessageId = sentMessage?.id; item.verifyMessage = undefined; await newsPostRepo.save(item); } isVerifyReaction = (reaction: MessageReaction, user: User): boolean => { return (reaction.emoji.name == "✅" || reaction.emoji.name == "❌") && !user.bot; } async tryFetchMessage(channel?: Channel, messageId?: string): Promise { if(!channel || !messageId) return null; try { if (!(channel instanceof TextChannel)) return null; return await channel.messages.fetch(messageId); } catch (error) { return null; } } get shouldVerify(): boolean { return this.verifyChannelId != undefined; } async postNewsItem(channel: string, item: PostedForumNewsItem): Promise { if (!item.verifyMessage) throw new Error("No message to send!"); const newsMessage = this.toNewsString(item.verifyMessage); const ch = client.bot.channels.resolve(channel); if (!(ch instanceof TextChannel)) return null; if (item.postedMessageId) { const message = await this.tryFetchMessage(ch, item.postedMessageId); if (message) return await message.edit(newsMessage); else return await ch.send(newsMessage) as Message; } else return await ch.send(newsMessage) as Message; } toNewsString(item: PostVerifyMessage): string { return `**${item.title}** Posted by ${item.author} ${item.link} ${item.text}`; } toVerifyString(postId: string, item: PostVerifyMessage): string { return `[${item.isNew ? "🆕 ADD" : "✏️ EDIT"}] Post ID: **${postId}** ${this.toNewsString(item)} React with ✅ (approve) or ❌ (deny).`; } @Command({ type: "mention", pattern: /^edit (\d+)\s+((.*[\n\r]*)+)$/i }) async editPreview({ message, contents }: ICommandData): Promise { if (message.channel.id != this.verifyChannelId) return; const match = contents as RegExpMatchArray; const id = match[1]; const newContents = match[2].trim(); const repo = getRepository(PostedForumNewsItem); const verifyRepo = getRepository(PostVerifyMessage); const post = await repo.findOne({ where: { id: id }, relations: ["verifyMessage"] }); if (!post || !post.verifyMessage) { message.reply({ content: `no unapproved news items with id ${id}!`, failIfNotExists: false }); return; } const editMsg = await this.tryFetchMessage(client.bot.channels.resolve(this.verifyChannelId) ?? undefined, post.verifyMessage.messageId); if (!editMsg) { message.reply({ content: `no verify message found for ${id}! This is a bug: report to horse.`, failIfNotExists: false }); return; } post.verifyMessage.text = newContents; await verifyRepo.save(post.verifyMessage); await editMsg.edit(this.toVerifyString(post.id, post.verifyMessage)); await message.delete(); } async start(): Promise { const repo = getRepository(KnownChannel); const verifyChannel = await repo.findOne({ channelType: NEWS_POST_VERIFY_CHANNEL }); if (!verifyChannel) return; this.verifyChannelId = verifyChannel.channelId; const user = await client.forum.getMe(); this.botUserId = user.user_id; await this.initPendingReactors(); interval(this.checkFeeds, RSS_UPDATE_INTERVAL_MIN * 60 * 1000); } }