forums_news_checker.ts 11 KB

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