news_aggregator.ts 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. import TurndownService, { Options } from "turndown";
  2. import interval from "interval-promise";
  3. import { client } from "../client";
  4. import sha1 from "sha1";
  5. import * as path from "path";
  6. import * as fs from "fs";
  7. import { IAggregator, NewsPostItem, INewsPostData } from "./aggregators/aggregator";
  8. import { ICommand } from "./command";
  9. import { RichEmbed, TextChannel, Message, Channel } from "discord.js";
  10. import { getRepository } from "typeorm";
  11. import { KnownChannel } from "../entity/KnownChannel";
  12. import { AggroNewsItem } from "../entity/AggroNewsItem";
  13. const UPDATE_INTERVAL = 5;
  14. const MAX_PREVIEW_LENGTH = 300;
  15. const aggregators : IAggregator[] = [];
  16. let aggregateChannelID : string = null;
  17. const AGGREGATOR_MANAGER_CHANNEL = "aggregatorManager";
  18. // TODO: Run BBCode converter instead
  19. const turndown = new TurndownService();
  20. turndown.addRule("image", {
  21. filter: "img",
  22. replacement: () => ""
  23. });
  24. turndown.addRule("link", {
  25. filter: (node : HTMLElement, opts: Options) => node.nodeName === "A" &&node.getAttribute("href") != null,
  26. replacement: (content: string, node: HTMLElement) => node.getAttribute("href")
  27. });
  28. function markdownify(htmStr: string) {
  29. return turndown.turndown(htmStr)/*.replace(/( {2}\n|\n\n){2,}/gm, "\n").replace(link, "")*/;
  30. }
  31. async function checkFeeds() {
  32. console.log(`Aggregating feeds on ${new Date().toISOString()}`);
  33. let aggregatorJobs = [];
  34. for(let aggregator of aggregators) {
  35. aggregatorJobs.push(aggregator.aggregate());
  36. }
  37. let aggregatedItems = await Promise.all(aggregatorJobs);
  38. for(let itemSet of aggregatedItems) {
  39. for(let item of itemSet) {
  40. let itemObj = {
  41. ...item,
  42. cacheMessageId: null,
  43. postedMessageId: null
  44. } as NewsPostItem;
  45. itemObj.contents = markdownify(item.contents);
  46. itemObj.hash = sha1(itemObj.contents);
  47. await addNewsItem(itemObj);
  48. }
  49. }
  50. }
  51. function clipText(text: string) {
  52. if(text.length <= MAX_PREVIEW_LENGTH)
  53. return text;
  54. return `${text.substring(0, MAX_PREVIEW_LENGTH)}...`;
  55. }
  56. // TODO: Replace with proper forum implementation
  57. async function addNewsItem(item: NewsPostItem) {
  58. let repo = getRepository(AggroNewsItem);
  59. let newsItem = await repo.findOne({
  60. where: { feedName: item.feedId, newsId: item.newsId }
  61. });
  62. if(newsItem) {
  63. // No changes, skip
  64. if(newsItem.hash == item.hash)
  65. return;
  66. else
  67. await deleteCacheMessage(newsItem.editMessageId);
  68. } else {
  69. newsItem = repo.create({
  70. newsId: item.newsId,
  71. feedName: item.feedId,
  72. hash: item.hash
  73. });
  74. }
  75. let ch = client.channels.get(aggregateChannelID);
  76. if(!(ch instanceof TextChannel))
  77. return;
  78. let msg = await ch.send(new RichEmbed({
  79. title: item.title,
  80. url: item.link,
  81. color: item.embedColor,
  82. timestamp: new Date(),
  83. description: clipText(item.contents),
  84. author: {
  85. name: item.author
  86. },
  87. footer: {
  88. text: "NoctBot News Aggregator"
  89. }
  90. })) as Message;
  91. newsItem.editMessageId = msg.id;
  92. await repo.save(newsItem);
  93. }
  94. async function deleteCacheMessage(messageId: string) {
  95. let ch = client.channels.get(aggregateChannelID);
  96. if(!(ch instanceof TextChannel))
  97. return;
  98. let msg = await tryFetchMessage(ch, messageId);
  99. if(msg)
  100. await msg.delete();
  101. }
  102. async function tryFetchMessage(channel : TextChannel, messageId: string) {
  103. try {
  104. return await channel.fetchMessage(messageId);
  105. }catch(error){
  106. return null;
  107. }
  108. }
  109. function initAggregators() {
  110. let aggregatorsPath = path.join(path.dirname(module.filename), "aggregators");
  111. let files = fs.readdirSync(aggregatorsPath);
  112. for(let file of files) {
  113. let ext = path.extname(file);
  114. let name = path.basename(file);
  115. if(name == "aggregator.js")
  116. continue;
  117. if(ext != ".js")
  118. continue;
  119. let obj = require(path.resolve(aggregatorsPath, file)).default as IAggregator;
  120. if(obj)
  121. aggregators.push(obj);
  122. if(obj.init)
  123. obj.init();
  124. }
  125. }
  126. export default {
  127. onStart : async () => {
  128. let repo = getRepository(KnownChannel);
  129. let ch = await repo.findOne({
  130. where: { channelType: AGGREGATOR_MANAGER_CHANNEL }
  131. });
  132. aggregateChannelID = ch.channelId;
  133. initAggregators();
  134. interval(checkFeeds, UPDATE_INTERVAL * 60 * 1000);
  135. }
  136. } as ICommand;