forums_news_checker.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  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, 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 { logger } from "src/logging";
  13. import { Command, ICommandData, Plugin } from "src/model/plugin";
  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. @Plugin
  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 as HTMLElement).getAttribute("htef") ?? ""
  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: %s", 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.collectorFor(collector));
  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.collectorFor(collector));
  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. collectorFor = (collector: ReactionCollector) =>
  152. async (reaction: MessageReaction): Promise<void> => {
  153. const verifyMessageRepo = getRepository(PostVerifyMessage);
  154. const postRepo = getRepository(PostedForumNewsItem);
  155. const m = reaction.message;
  156. collector.stop();
  157. delete this.reactionCollectors[m.id];
  158. const postId = this.verifyMessageIdToPost[m.id];
  159. const post = await postRepo.findOne({
  160. where: { id: postId },
  161. relations: ["verifyMessage"]
  162. });
  163. if (!post)
  164. throw new Error("Post not found!");
  165. await postRepo.update({ id: post.id }, { verifyMessage: undefined });
  166. if (post.verifyMessage)
  167. await verifyMessageRepo.delete({ id: post.verifyMessage.id });
  168. await reaction.message.delete();
  169. if (reaction.emoji.name == "✅")
  170. this.sendNews(post);
  171. delete this.verifyMessageIdToPost[m.id];
  172. };
  173. async sendNews(item: PostedForumNewsItem): Promise<void> {
  174. const channelRepo = getRepository(KnownChannel);
  175. const newsPostRepo = getRepository(PostedForumNewsItem);
  176. const outChannel = await channelRepo.findOne({
  177. where: { channelType: NEWS_FEED_CHANNEL }
  178. });
  179. if (!outChannel)
  180. return;
  181. const sentMessage = await this.postNewsItem(outChannel.channelId, item);
  182. item.postedMessageId = sentMessage?.id;
  183. item.verifyMessage = undefined;
  184. await newsPostRepo.save(item);
  185. }
  186. isVerifyReaction = (reaction: MessageReaction, user: User): boolean => {
  187. return (reaction.emoji.name == "✅" || reaction.emoji.name == "❌") && !user.bot;
  188. }
  189. async tryFetchMessage(channel?: Channel, messageId?: string): Promise<Message | null> {
  190. if(!channel || !messageId)
  191. return null;
  192. try {
  193. if (!(channel instanceof TextChannel))
  194. return null;
  195. return await channel.messages.fetch(messageId);
  196. } catch (error) {
  197. return null;
  198. }
  199. }
  200. get shouldVerify(): boolean {
  201. return this.verifyChannelId != undefined;
  202. }
  203. async postNewsItem(channel: string, item: PostedForumNewsItem): Promise<Message | null> {
  204. if (!item.verifyMessage)
  205. throw new Error("No message to send!");
  206. const newsMessage = this.toNewsString(item.verifyMessage);
  207. const ch = client.bot.channels.resolve(channel);
  208. if (!(ch instanceof TextChannel))
  209. return null;
  210. if (item.postedMessageId) {
  211. const message = await this.tryFetchMessage(ch, item.postedMessageId);
  212. if (message)
  213. return await message.edit(newsMessage);
  214. else
  215. return await ch.send(newsMessage) as Message;
  216. }
  217. else
  218. return await ch.send(newsMessage) as Message;
  219. }
  220. toNewsString(item: PostVerifyMessage): string {
  221. return `**${item.title}**
  222. Posted by ${item.author}
  223. ${item.link}
  224. ${item.text}`;
  225. }
  226. toVerifyString(postId: string, item: PostVerifyMessage): string {
  227. return `[${item.isNew ? "🆕 ADD" : "✏️ EDIT"}]
  228. Post ID: **${postId}**
  229. ${this.toNewsString(item)}
  230. React with ✅ (approve) or ❌ (deny).`;
  231. }
  232. @Command({
  233. type: "mention",
  234. pattern: /^edit (\d+)\s+((.*[\n\r]*)+)$/i
  235. })
  236. async editPreview({ message, contents }: ICommandData): Promise<void> {
  237. if (message.channel.id != this.verifyChannelId)
  238. return;
  239. const match = contents as RegExpMatchArray;
  240. const id = match[1];
  241. const newContents = match[2].trim();
  242. const repo = getRepository(PostedForumNewsItem);
  243. const verifyRepo = getRepository(PostVerifyMessage);
  244. const post = await repo.findOne({
  245. where: { id: id },
  246. relations: ["verifyMessage"]
  247. });
  248. if (!post || !post.verifyMessage) {
  249. message.reply(`no unapproved news items with id ${id}!`);
  250. return;
  251. }
  252. const editMsg = await this.tryFetchMessage(client.bot.channels.resolve(this.verifyChannelId) ?? undefined, post.verifyMessage.messageId);
  253. if (!editMsg) {
  254. message.reply(`no verify message found for ${id}! This is a bug: report to horse.`);
  255. return;
  256. }
  257. post.verifyMessage.text = newContents;
  258. await verifyRepo.save(post.verifyMessage);
  259. await editMsg.edit(this.toVerifyString(post.id, post.verifyMessage));
  260. await message.delete();
  261. }
  262. async start(): Promise<void> {
  263. const repo = getRepository(KnownChannel);
  264. const verifyChannel = await repo.findOne({
  265. channelType: NEWS_POST_VERIFY_CHANNEL
  266. });
  267. if (!verifyChannel)
  268. return;
  269. this.verifyChannelId = verifyChannel.channelId;
  270. const user = await client.forum.getMe();
  271. this.botUserId = user.user_id;
  272. await this.initPendingReactors();
  273. interval(this.checkFeeds, RSS_UPDATE_INTERVAL_MIN * 60 * 1000);
  274. }
  275. }