news_aggregator.ts 11 KB

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