forums_news_checker.ts 13 KB

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