news_aggregator.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. import TurndownService, { Options } from "turndown";
  2. import interval from "interval-promise";
  3. import { client, FORUMS_DOMAIN, BotClient } from "../client";
  4. import sha1 from "sha1";
  5. import * as path from "path";
  6. import * as fs from "fs";
  7. import { HTML2BBCode } from "html2bbcode";
  8. import { Dict } from "../util";
  9. import { IAggregator, NewsPostItem } from "./aggregators/aggregator";
  10. import { TextChannel, Message, Channel, ReactionCollector, MessageReaction, User, Collector, MessageEmbed } from "discord.js";
  11. import { getRepository, IsNull, Not } from "typeorm";
  12. import { KnownChannel } from "@shared/db/entity/KnownChannel";
  13. import { AggroNewsItem } from "@shared/db/entity/AggroNewsItem";
  14. import { v3beta1 } from "@google-cloud/translate";
  15. import { CommandSet } from "src/model/command";
  16. const { TranslationServiceClient } = v3beta1;
  17. const UPDATE_INTERVAL = process.env.NODE_ENV == "dev" ? 60 : 5;
  18. const MAX_PREVIEW_LENGTH = 300;
  19. const AGGREGATOR_MANAGER_CHANNEL = "aggregatorManager";
  20. const FORUMS_STAGING_ID = 54;
  21. const FORUMS_NEWS_ID = 49;
  22. @CommandSet
  23. export class NewsAggregator {
  24. tlClient = new TranslationServiceClient();
  25. aggregators: IAggregator[] = [];
  26. aggregateChannelID?: string;
  27. bbCodeParser = new HTML2BBCode();
  28. turndown = new TurndownService();
  29. reactionCollectors: Dict<ReactionCollector> = {};
  30. verifyMessageIdToPost: Dict<AggroNewsItem> = {};
  31. constructor() {
  32. this.turndown.addRule("image", {
  33. filter: "img",
  34. replacement: () => ""
  35. });
  36. this.turndown.addRule("link", {
  37. filter: (node: HTMLElement, opts: Options) => node.nodeName === "A" && node.getAttribute("href") != null,
  38. replacement: (content: string, node: Node) => (node instanceof HTMLElement ? node.getAttribute("href") : null) ?? ""
  39. });
  40. }
  41. checkFeeds = async () => {
  42. console.log(`Aggregating feeds on ${new Date().toISOString()}`);
  43. let aggregatorJobs = [];
  44. for (let aggregator of this.aggregators) {
  45. aggregatorJobs.push(aggregator.aggregate());
  46. }
  47. let aggregatedItems = await Promise.all(aggregatorJobs);
  48. for (let itemSet of aggregatedItems) {
  49. for (let item of itemSet) {
  50. let itemObj = {
  51. ...item,
  52. cacheMessageId: undefined,
  53. postedMessageId: undefined
  54. } as NewsPostItem;
  55. itemObj.hash = sha1(itemObj.contents);
  56. await this.addNewsItem(itemObj);
  57. }
  58. }
  59. }
  60. clipText(text: string) {
  61. if (text.length <= MAX_PREVIEW_LENGTH)
  62. return text;
  63. return `${text.substring(0, MAX_PREVIEW_LENGTH)}...`;
  64. }
  65. async addNewsItem(item: NewsPostItem) {
  66. if (!this.aggregateChannelID)
  67. return;
  68. let repo = getRepository(AggroNewsItem);
  69. let ch = client.bot.channels.resolve(this.aggregateChannelID);
  70. if (!(ch instanceof TextChannel))
  71. return;
  72. let isNew = true;
  73. let newsItem = await repo.findOne({
  74. where: { feedName: item.feedId, newsId: item.newsId }
  75. });
  76. if (newsItem) {
  77. if (process.env.IGNORE_CHANGED_NEWS === "TRUE") {
  78. newsItem.hash = item.hash ?? "";
  79. await repo.save(newsItem);
  80. return;
  81. }
  82. // No changes, skip
  83. if (newsItem.hash == item.hash)
  84. return;
  85. else if(newsItem.editMessageId)
  86. await this.deleteCacheMessage(newsItem.editMessageId);
  87. isNew = false;
  88. } else {
  89. newsItem = repo.create({
  90. newsId: item.newsId,
  91. feedName: item.feedId,
  92. hash: item.hash
  93. });
  94. }
  95. if (item.needsTranslation && process.env.GOOGLE_APP_ID)
  96. try {
  97. let request = {
  98. parent: this.tlClient.locationPath(process.env.GOOGLE_APP_ID, "global"),
  99. contents: [item.title, item.contents],
  100. mimeType: "text/html",
  101. sourceLanguageCode: "ja",
  102. targetLanguageCode: "en"
  103. };
  104. let [res] = await this.tlClient.translateText(request);
  105. let [translatedTitle, translatedContents] = res.translations ?? [undefined, undefined];
  106. if (translatedTitle?.translatedText && translatedContents?.translatedText) {
  107. item.title = translatedTitle.translatedText;
  108. item.contents = translatedContents.translatedText;
  109. }
  110. } catch (err) {
  111. console.log(`Failed to translate because ${err}`);
  112. }
  113. item.contents = this.bbCodeParser.feed(item.contents).toString();
  114. if (!newsItem.forumsEditPostId) {
  115. let createResponse = await client.forum.createThread(FORUMS_STAGING_ID, item.title, item.contents);
  116. newsItem.forumsEditPostId = createResponse.thread.thread_id;
  117. } else if(newsItem.forumsNewsPostId){
  118. await client.forum.postReply(newsItem.forumsNewsPostId, item.contents);
  119. }
  120. let msg = await ch.send(new MessageEmbed({
  121. title: item.title,
  122. url: item.link,
  123. color: item.embedColor,
  124. timestamp: new Date(),
  125. description: `${(isNew ? "**[NEW]**" : "**[EDIT]**")}\n[**Edit on forums**](${FORUMS_DOMAIN}/index.php?threads/.${newsItem.forumsEditPostId}/)`,
  126. author: {
  127. name: item.author
  128. },
  129. footer: {
  130. text: "NoctBot News Aggregator"
  131. }
  132. })) as Message;
  133. newsItem.editMessageId = msg.id;
  134. await msg.react("✅");
  135. await msg.react("❌");
  136. let collector = msg.createReactionCollector(this.isVerifyReaction, { maxEmojis: 1 });
  137. collector.on("collect", this.collectReaction)
  138. this.reactionCollectors[msg.id] = collector;
  139. this.verifyMessageIdToPost[msg.id] = newsItem;
  140. await repo.save(newsItem);
  141. }
  142. isVerifyReaction(reaction: MessageReaction, user: User) {
  143. return (reaction.emoji.name == "✅" || reaction.emoji.name == "❌") && !user.bot && user.id != client.botUser.id;
  144. }
  145. collectReaction = async (reaction: MessageReaction, collector: Collector<string, MessageReaction>) => {
  146. let repo = getRepository(AggroNewsItem);
  147. let m = reaction.message;
  148. collector.stop();
  149. delete this.reactionCollectors[m.id];
  150. let post = this.verifyMessageIdToPost[m.id];
  151. if(!post.forumsEditPostId) {
  152. throw new Error("No forum edit post found!");
  153. }
  154. if (reaction.emoji.name == "✅") {
  155. let res = await client.forum.getThread(post.forumsEditPostId);
  156. let forumPost = await client.forum.getPost(res.thread.first_post_id);
  157. if (!post.forumsNewsPostId) {
  158. let newThread = await client.forum.createThread(FORUMS_NEWS_ID, res.thread.title, forumPost.message);
  159. post.forumsNewsPostId = newThread.thread.thread_id;
  160. } else {
  161. let curThread = await client.forum.editThread(post.forumsNewsPostId, {
  162. title: res.thread.title
  163. });
  164. await client.forum.editPost(curThread.thread.first_post_id, {
  165. message: forumPost.message
  166. });
  167. }
  168. }
  169. await client.forum.deleteThread(post.forumsEditPostId);
  170. await repo.update({ newsId: post.newsId, feedName: post.feedName }, {
  171. editMessageId: undefined,
  172. forumsEditPostId: undefined,
  173. forumsNewsPostId: post.forumsNewsPostId });
  174. await reaction.message.delete();
  175. delete this.verifyMessageIdToPost[m.id];
  176. };
  177. async deleteCacheMessage(messageId: string) {
  178. if(!this.aggregateChannelID)
  179. return;
  180. let ch = client.bot.channels.resolve(this.aggregateChannelID);
  181. if (!(ch instanceof TextChannel))
  182. return;
  183. let msg = await this.tryFetchMessage(ch, messageId);
  184. if (msg)
  185. await msg.delete();
  186. }
  187. async tryFetchMessage(channel: Channel, messageId: string) {
  188. try {
  189. if (!(channel instanceof TextChannel))
  190. return null;
  191. return await channel.messages.fetch(messageId);
  192. } catch (error) {
  193. return null;
  194. }
  195. }
  196. initAggregators() {
  197. let aggregatorsPath = path.join(path.dirname(module.filename), "aggregators");
  198. let files = fs.readdirSync(aggregatorsPath);
  199. for (let file of files) {
  200. let ext = path.extname(file);
  201. let name = path.basename(file);
  202. if (name == "aggregator.js")
  203. continue;
  204. if (ext != ".js")
  205. continue;
  206. let obj = require(path.resolve(aggregatorsPath, file)).default as IAggregator;
  207. if (obj)
  208. this.aggregators.push(obj);
  209. if (obj.init)
  210. obj.init();
  211. }
  212. }
  213. async initPendingReactors() {
  214. if(!this.aggregateChannelID)
  215. return;
  216. let verifyChannel = client.bot.channels.resolve(this.aggregateChannelID);
  217. if(!verifyChannel)
  218. throw new Error("Couldn't find verify channel!");
  219. let repo = getRepository(AggroNewsItem);
  220. let pendingVerifyMessages = await repo.find({
  221. where: { editMessageId: Not(IsNull()) }
  222. });
  223. for (let msg of pendingVerifyMessages) {
  224. let m = await this.tryFetchMessage(verifyChannel, msg.editMessageId!);
  225. if (!m) {
  226. await repo.update({ feedName: msg.feedName, newsId: msg.newsId }, { editMessageId: undefined });
  227. continue;
  228. }
  229. let collector = m.createReactionCollector(this.isVerifyReaction, { maxEmojis: 1 });
  230. collector.on("collect", this.collectReaction);
  231. this.reactionCollectors[m.id] = collector;
  232. this.verifyMessageIdToPost[m.id] = msg;
  233. }
  234. }
  235. async onStart() {
  236. let repo = getRepository(KnownChannel);
  237. let ch = await repo.findOne({
  238. where: { channelType: AGGREGATOR_MANAGER_CHANNEL }
  239. });
  240. if (!ch)
  241. return;
  242. this.aggregateChannelID = ch.channelId;
  243. await this.initPendingReactors();
  244. this.initAggregators();
  245. interval(this.checkFeeds, UPDATE_INTERVAL * 60 * 1000);
  246. }
  247. }