news_aggregator.ts 9.1 KB

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