forums_news_checker.ts 10 KB

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