forums_news_checker.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  1. import TurndownService, { Options } from "turndown";
  2. import interval from "interval-promise";
  3. import { client, FORUMS_DOMAIN } from "../client";
  4. import sha1 from "sha1";
  5. import { TextChannel, Message, ReactionCollector, MessageReaction, Collector, User, Channel } from "discord.js";
  6. import { Dict } from "../util";
  7. import { getRepository, Not, IsNull } from "typeorm";
  8. import { PostedForumNewsItem } from "@shared/db/entity/PostedForumsNewsItem";
  9. import { KnownChannel } from "@shared/db/entity/KnownChannel";
  10. import { PostVerifyMessage } from "@shared/db/entity/PostVerifyMessage";
  11. import { render } from "../bbcode-parser/bbcode-js";
  12. import { CommandSet, Command } from "src/model/command";
  13. const PREVIEW_CHAR_LIMIT = 300;
  14. const NEWS_POST_VERIFY_CHANNEL = "newsPostVerify";
  15. const NEWS_FEED_CHANNEL = "newsFeed";
  16. const RSS_UPDATE_INTERVAL_MIN = process.env.NODE_ENV == "dev" ? 60 : 5;
  17. const NEWS_FORUM_ID = 49;
  18. @CommandSet
  19. export class ForumsNewsChecker {
  20. verifyMessageIdToPost: Dict<string> = {}
  21. reactionCollectors: Dict<ReactionCollector> = {};
  22. verifyChannelId?: string;
  23. botUserId = 0;
  24. turndown = new TurndownService();
  25. constructor() {
  26. this.turndown.addRule("image", {
  27. filter: "img",
  28. replacement: () => ""
  29. });
  30. this.turndown.addRule("link", {
  31. filter: (node: HTMLElement, opts: Options) => node.nodeName === "A" && node.getAttribute("href") != null,
  32. replacement: (content: string, node: Node) => (node instanceof HTMLElement ? node.getAttribute("href") : null) ?? ""
  33. });
  34. }
  35. bbCodeToMarkdown(bbCode: string) {
  36. let html = render(bbCode).replace(/\n/gm, "</br>");
  37. return this.turndown.turndown(html).trim();//.replace(/( {2}\n|\n\n){2,}/gm, "\n");
  38. }
  39. checkFeeds = async () => {
  40. try {
  41. console.log(`Checking feeds on ${new Date().toISOString()}`);
  42. let forumsNewsRepo = getRepository(PostedForumNewsItem);
  43. let postVerifyMessageRepo = getRepository(PostVerifyMessage);
  44. let forumThreads = await client.forum.getForumThreads(NEWS_FORUM_ID);
  45. for (let thread of [...forumThreads.threads, ...forumThreads.sticky]) {
  46. let firstPost = await client.forum.getPost(thread.first_post_id);
  47. let contents = this.bbCodeToMarkdown(firstPost.message);
  48. let itemObj = forumsNewsRepo.create({
  49. id: thread.thread_id.toString(),
  50. hash: sha1(firstPost.message),
  51. verifyMessage: postVerifyMessageRepo.create({
  52. author: thread.username,
  53. link: `${FORUMS_DOMAIN}/index.php?threads/${thread.thread_id}/`,
  54. title: thread.title,
  55. text: `${contents.substr(0, Math.min(contents.length, PREVIEW_CHAR_LIMIT))}...`,
  56. isNew: true
  57. })
  58. });
  59. let postItem = await forumsNewsRepo.findOne({
  60. where: { id: itemObj.id },
  61. relations: ["verifyMessage"]
  62. });
  63. if (postItem) {
  64. if (process.env.IGNORE_CHANGED_NEWS === "TRUE") {
  65. await forumsNewsRepo.update({
  66. id: postItem.id
  67. }, {
  68. hash: itemObj.hash
  69. });
  70. continue;
  71. }
  72. // Add message ID to mark for edit
  73. if (postItem.hash != itemObj.hash) {
  74. let newHash = itemObj.hash;
  75. if (!postItem.verifyMessage)
  76. postItem.verifyMessage = itemObj.verifyMessage;
  77. itemObj = postItem;
  78. if (itemObj.verifyMessage)
  79. itemObj.verifyMessage.isNew = false;
  80. itemObj.hash = newHash;
  81. }
  82. else
  83. continue;
  84. }
  85. if (!this.verifyChannelId || firstPost.user_id == this.botUserId)
  86. await this.sendNews(itemObj);
  87. else
  88. await this.addVerifyMessage(itemObj);
  89. }
  90. } catch (err) {
  91. console.log(`Failed to check forums because ${err}`);
  92. }
  93. }
  94. async initPendingReactors() {
  95. if (!this.verifyChannelId)
  96. return;
  97. let verifyChannel = client.bot.channels.resolve(this.verifyChannelId);
  98. if (!verifyChannel)
  99. return;
  100. let repo = getRepository(PostedForumNewsItem);
  101. let verifyMessageRepo = getRepository(PostVerifyMessage);
  102. let pendingVerifyMessages = await repo.find({
  103. where: { verifyMessage: Not(IsNull()) },
  104. select: ["id"],
  105. relations: ["verifyMessage"]
  106. });
  107. for (let msg of pendingVerifyMessages) {
  108. let m = await this.tryFetchMessage(verifyChannel, msg.verifyMessage!.messageId);
  109. if (!m) {
  110. await repo.update({ id: msg.id }, { verifyMessage: undefined });
  111. await verifyMessageRepo.delete(msg.verifyMessage!);
  112. continue;
  113. }
  114. let collector = m.createReactionCollector(this.isVerifyReaction, { maxEmojis: 1 });
  115. collector.on("collect", this.collectReaction);
  116. this.reactionCollectors[m.id] = collector;
  117. this.verifyMessageIdToPost[m.id] = msg.id;
  118. }
  119. }
  120. async addVerifyMessage(item: PostedForumNewsItem) {
  121. if (!this.verifyChannelId)
  122. return;
  123. if (!item.verifyMessage)
  124. throw new Error("No verify message! This shouldn't happen!");
  125. let verifyChannel = client.bot.channels.resolve(this.verifyChannelId) as TextChannel;
  126. if (!verifyChannel) {
  127. console.log(`Skipping adding item ${item.id} because no verify channel is set up!`);
  128. return;
  129. }
  130. let verifyMessageRepo = getRepository(PostVerifyMessage);
  131. let forumsNewsRepo = getRepository(PostedForumNewsItem);
  132. if (item.verifyMessage.messageId) {
  133. let oldMessage = await this.tryFetchMessage(verifyChannel, item.verifyMessage.messageId);
  134. if (oldMessage)
  135. await oldMessage.delete();
  136. }
  137. let newMessage = await verifyChannel.send(this.toVerifyString(item.id, item.verifyMessage)) as Message;
  138. item.verifyMessage.messageId = newMessage.id;
  139. await newMessage.react("✅");
  140. await newMessage.react("❌");
  141. let collector = newMessage.createReactionCollector(this.isVerifyReaction, { maxEmojis: 1 });
  142. collector.on("collect", this.collectReaction)
  143. this.reactionCollectors[newMessage.id] = collector;
  144. this.verifyMessageIdToPost[newMessage.id] = item.id;
  145. item.verifyMessage = await verifyMessageRepo.save(item.verifyMessage);
  146. await forumsNewsRepo.save(item);
  147. }
  148. collectReaction = async (reaction: MessageReaction, collector: Collector<string, MessageReaction>) => {
  149. let verifyMessageRepo = getRepository(PostVerifyMessage);
  150. let postRepo = getRepository(PostedForumNewsItem);
  151. let m = reaction.message;
  152. collector.stop();
  153. delete this.reactionCollectors[m.id];
  154. let postId = this.verifyMessageIdToPost[m.id];
  155. let post = await postRepo.findOne({
  156. where: { id: postId },
  157. relations: ["verifyMessage"]
  158. });
  159. if (!post)
  160. throw new Error("Post not found!");
  161. await postRepo.update({ id: post.id }, { verifyMessage: undefined });
  162. if (post.verifyMessage)
  163. await verifyMessageRepo.delete({ id: post.verifyMessage.id });
  164. await reaction.message.delete();
  165. if (reaction.emoji.name == "✅")
  166. this.sendNews(post);
  167. delete this.verifyMessageIdToPost[m.id];
  168. }
  169. async sendNews(item: PostedForumNewsItem) {
  170. let channelRepo = getRepository(KnownChannel);
  171. let newsPostRepo = getRepository(PostedForumNewsItem);
  172. let outChannel = await channelRepo.findOne({
  173. where: { channelType: NEWS_FEED_CHANNEL }
  174. });
  175. if (!outChannel)
  176. return;
  177. let sentMessage = await this.postNewsItem(outChannel.channelId, item);
  178. item.postedMessageId = sentMessage?.id;
  179. item.verifyMessage = undefined;
  180. await newsPostRepo.save(item);
  181. }
  182. isVerifyReaction = (reaction: MessageReaction, user: User) => {
  183. return (reaction.emoji.name == "✅" || reaction.emoji.name == "❌") && !user.bot;
  184. }
  185. async tryFetchMessage(channel?: Channel, messageId?: string) {
  186. if(!channel || !messageId)
  187. return null;
  188. try {
  189. if (!(channel instanceof TextChannel))
  190. return null;
  191. return await channel.messages.fetch(messageId);
  192. } catch (error) {
  193. return null;
  194. }
  195. }
  196. get shouldVerify() {
  197. return this.verifyChannelId != undefined;
  198. }
  199. async postNewsItem(channel: string, item: PostedForumNewsItem): Promise<Message | null> {
  200. if (!item.verifyMessage)
  201. throw new Error("No message to send!");
  202. let newsMessage = this.toNewsString(item.verifyMessage);
  203. let ch = client.bot.channels.resolve(channel);
  204. if (!(ch instanceof TextChannel))
  205. return null;
  206. if (item.postedMessageId) {
  207. let message = await this.tryFetchMessage(ch, item.postedMessageId);
  208. if (message)
  209. return await message.edit(newsMessage);
  210. else
  211. return await ch.send(newsMessage) as Message;
  212. }
  213. else
  214. return await ch.send(newsMessage) as Message;
  215. }
  216. toNewsString(item: PostVerifyMessage) {
  217. return `**${item.title}**
  218. Posted by ${item.author}
  219. ${item.link}
  220. ${item.text}`;
  221. }
  222. toVerifyString(postId: string, item: PostVerifyMessage) {
  223. return `[${item.isNew ? "🆕 ADD" : "✏️ EDIT"}]
  224. Post ID: **${postId}**
  225. ${this.toNewsString(item)}
  226. React with ✅ (approve) or ❌ (deny).`;
  227. }
  228. @Command({
  229. pattern: /^edit (\d+)\s+((.*[\n\r]*)+)$/i
  230. })
  231. async editPreview(msg: Message, contests: string, match: RegExpMatchArray) {
  232. if (msg.channel.id != this.verifyChannelId)
  233. return;
  234. let id = match[1];
  235. let newContents = match[2].trim();
  236. let repo = getRepository(PostedForumNewsItem);
  237. let verifyRepo = getRepository(PostVerifyMessage);
  238. let post = await repo.findOne({
  239. where: { id: id },
  240. relations: ["verifyMessage"]
  241. });
  242. if (!post || !post.verifyMessage) {
  243. msg.channel.send(`${msg.author.toString()} No unapproved news items with id ${id}!`);
  244. return;
  245. }
  246. let editMsg = await this.tryFetchMessage(client.bot.channels.resolve(this.verifyChannelId) ?? undefined, post.verifyMessage.messageId);
  247. if (!editMsg) {
  248. msg.channel.send(`${msg.author.toString()} No verify message found for ${id}! This is a bug: report to horse.`);
  249. return;
  250. }
  251. post.verifyMessage.text = newContents;
  252. await verifyRepo.save(post.verifyMessage);
  253. await editMsg.edit(this.toVerifyString(post.id, post.verifyMessage));
  254. await msg.delete();
  255. }
  256. async onStart() {
  257. let repo = getRepository(KnownChannel);
  258. let verifyChannel = await repo.findOne({
  259. channelType: NEWS_POST_VERIFY_CHANNEL
  260. });
  261. if (!verifyChannel)
  262. return;
  263. this.verifyChannelId = verifyChannel.channelId;
  264. let user = await client.forum.getMe();
  265. this.botUserId = user.user_id;
  266. await this.initPendingReactors();
  267. interval(this.checkFeeds, RSS_UPDATE_INTERVAL_MIN * 60 * 1000);
  268. }
  269. };