forums_news_checker.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import TurndownService, { Options } from "turndown";
  2. import interval from "interval-promise";
  3. import { client, forumClient, 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 = null;
  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: HTMLElement) => node.getAttribute("href")
  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 forumClient.getForumThreads(NEWS_FORUM_ID);
  45. for (let thread of [...forumThreads.threads, ...forumThreads.sticky]) {
  46. let firstPost = await forumClient.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. itemObj.verifyMessage.isNew = false;
  79. itemObj.hash = newHash;
  80. }
  81. else
  82. continue;
  83. }
  84. if (!this.shouldVerify() || firstPost.user_id == this.botUserId)
  85. await this.sendNews(itemObj);
  86. else
  87. await this.addVerifyMessage(itemObj);
  88. }
  89. } catch (err) {
  90. console.log(`Failed to check forums because ${err}`);
  91. }
  92. }
  93. async initPendingReactors() {
  94. let verifyChannel = client.channels.resolve(this.verifyChannelId);
  95. let repo = getRepository(PostedForumNewsItem);
  96. let verifyMessageRepo = getRepository(PostVerifyMessage);
  97. let pendingVerifyMessages = await repo.find({
  98. where: { verifyMessage: Not(IsNull()) },
  99. select: ["id"],
  100. relations: ["verifyMessage"]
  101. });
  102. for (let msg of pendingVerifyMessages) {
  103. let m = await this.tryFetchMessage(verifyChannel, msg.verifyMessage.messageId);
  104. if (!m) {
  105. await repo.update({ id: msg.id }, { verifyMessage: null });
  106. await verifyMessageRepo.delete(msg.verifyMessage);
  107. continue;
  108. }
  109. let collector = m.createReactionCollector(this.isVerifyReaction, { maxEmojis: 1 });
  110. collector.on("collect", this.collectReaction);
  111. this.reactionCollectors[m.id] = collector;
  112. this.verifyMessageIdToPost[m.id] = msg.id;
  113. }
  114. }
  115. async addVerifyMessage(item: PostedForumNewsItem) {
  116. let verifyChannel = client.channels.resolve(this.verifyChannelId) as TextChannel;
  117. if (!verifyChannel) {
  118. console.log(`Skipping adding item ${item.id} because no verify channel is set up!`);
  119. return;
  120. }
  121. let verifyMessageRepo = getRepository(PostVerifyMessage);
  122. let forumsNewsRepo = getRepository(PostedForumNewsItem);
  123. if (item.verifyMessage.messageId) {
  124. let oldMessage = await this.tryFetchMessage(verifyChannel, item.verifyMessage.messageId);
  125. if (oldMessage)
  126. await oldMessage.delete();
  127. }
  128. let newMessage = await verifyChannel.send(this.toVerifyString(item.id, item.verifyMessage)) as Message;
  129. item.verifyMessage.messageId = newMessage.id;
  130. await newMessage.react("✅");
  131. await newMessage.react("❌");
  132. let collector = newMessage.createReactionCollector(this.isVerifyReaction, { maxEmojis: 1 });
  133. collector.on("collect", this.collectReaction)
  134. this.reactionCollectors[newMessage.id] = collector;
  135. this.verifyMessageIdToPost[newMessage.id] = item.id;
  136. item.verifyMessage = await verifyMessageRepo.save(item.verifyMessage);
  137. await forumsNewsRepo.save(item);
  138. }
  139. collectReaction = async (reaction: MessageReaction, collector: Collector<string, MessageReaction>) => {
  140. let verifyMessageRepo = getRepository(PostVerifyMessage);
  141. let postRepo = getRepository(PostedForumNewsItem);
  142. let m = reaction.message;
  143. collector.stop();
  144. delete this.reactionCollectors[m.id];
  145. let postId = this.verifyMessageIdToPost[m.id];
  146. let post = await postRepo.findOne({
  147. where: { id: postId },
  148. relations: ["verifyMessage"]
  149. });
  150. await postRepo.update({ id: post.id }, { verifyMessage: null });
  151. await verifyMessageRepo.delete({ id: post.verifyMessage.id });
  152. await reaction.message.delete();
  153. if (reaction.emoji.name == "✅")
  154. this.sendNews(post);
  155. delete this.verifyMessageIdToPost[m.id];
  156. }
  157. async sendNews(item: PostedForumNewsItem) {
  158. let channelRepo = getRepository(KnownChannel);
  159. let newsPostRepo = getRepository(PostedForumNewsItem);
  160. let outChannel = await channelRepo.findOne({
  161. where: { channelType: NEWS_FEED_CHANNEL }
  162. });
  163. let sentMessage = await this.postNewsItem(outChannel.channelId, item);
  164. item.postedMessageId = sentMessage.id;
  165. item.verifyMessage = null;
  166. await newsPostRepo.save(item);
  167. }
  168. isVerifyReaction = (reaction: MessageReaction, user: User) => {
  169. return (reaction.emoji.name == "✅" || reaction.emoji.name == "❌") && !user.bot;
  170. }
  171. async tryFetchMessage(channel: Channel, messageId: string) {
  172. try {
  173. if (!(channel instanceof TextChannel))
  174. return null;
  175. return await channel.messages.fetch(messageId);
  176. } catch (error) {
  177. return null;
  178. }
  179. }
  180. shouldVerify() {
  181. return this.verifyChannelId != null;
  182. }
  183. async postNewsItem(channel: string, item: PostedForumNewsItem): Promise<Message | null> {
  184. let newsMessage = this.toNewsString(item.verifyMessage);
  185. let ch = client.channels.resolve(channel);
  186. if (!(ch instanceof TextChannel))
  187. return null;
  188. if (item.postedMessageId) {
  189. let message = await this.tryFetchMessage(ch, item.postedMessageId);
  190. if (message)
  191. return await message.edit(newsMessage);
  192. else
  193. return await ch.send(newsMessage) as Message;
  194. }
  195. else
  196. return await ch.send(newsMessage) as Message;
  197. }
  198. toNewsString(item: PostVerifyMessage) {
  199. return `**${item.title}**
  200. Posted by ${item.author}
  201. ${item.link}
  202. ${item.text}`;
  203. }
  204. toVerifyString(postId: string, item: PostVerifyMessage) {
  205. return `[${item.isNew ? "🆕 ADD" : "✏️ EDIT"}]
  206. Post ID: **${postId}**
  207. ${this.toNewsString(item)}
  208. React with ✅ (approve) or ❌ (deny).`;
  209. }
  210. @Command({
  211. pattern: /^edit (\d+)\s+((.*[\n\r]*)+)$/i
  212. })
  213. async editPreview(msg: Message, contests: string, match: RegExpMatchArray) {
  214. if (msg.channel.id != this.verifyChannelId)
  215. return;
  216. let id = match[1];
  217. let newContents = match[2].trim();
  218. let repo = getRepository(PostedForumNewsItem);
  219. let verifyRepo = getRepository(PostVerifyMessage);
  220. let post = await repo.findOne({
  221. where: { id: id },
  222. relations: ["verifyMessage"]
  223. });
  224. if (!post || !post.verifyMessage) {
  225. msg.channel.send(`${msg.author.toString()} No unapproved news items with id ${id}!`);
  226. return;
  227. }
  228. let editMsg = await this.tryFetchMessage(client.channels.resolve(this.verifyChannelId), post.verifyMessage.messageId);
  229. if (!editMsg) {
  230. msg.channel.send(`${msg.author.toString()} No verify message found for ${id}! This is a bug: report to horse.`);
  231. return;
  232. }
  233. post.verifyMessage.text = newContents;
  234. await verifyRepo.save(post.verifyMessage);
  235. await editMsg.edit(this.toVerifyString(post.id, post.verifyMessage));
  236. await msg.delete();
  237. }
  238. async onStart() {
  239. let repo = getRepository(KnownChannel);
  240. let verifyChannel = await repo.findOne({
  241. channelType: NEWS_POST_VERIFY_CHANNEL
  242. });
  243. if (!verifyChannel)
  244. return;
  245. this.verifyChannelId = verifyChannel.channelId;
  246. let user = await forumClient.getMe();
  247. this.botUserId = user.user_id;
  248. await this.initPendingReactors();
  249. interval(this.checkFeeds, RSS_UPDATE_INTERVAL_MIN * 60 * 1000);
  250. }
  251. };