forums_news_checker.ts 13 KB

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