news_aggregator.ts 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. import TurndownService 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 { HTML2BBCode } from "html2bbcode";
  8. import { IAggregator, NewsPostItem } from "./aggregators/aggregator";
  9. import { TextChannel, Message, MessageEmbed } from "discord.js";
  10. import { getRepository, } from "typeorm";
  11. import { KnownChannel } from "@shared/db/entity/KnownChannel";
  12. import { AggroNewsItem } from "@shared/db/entity/AggroNewsItem";
  13. import { logger } from "src/logging";
  14. import { Plugin } from "src/model/plugin";
  15. import { assertOk, tryDo } from "@shared/common/async_utils";
  16. const UPDATE_INTERVAL = process.env.NODE_ENV == "dev" ? 60 : 5;
  17. const AGGREGATOR_MANAGER_CHANNEL = "aggregatorManager";
  18. @Plugin
  19. export class NewsAggregator {
  20. aggregators: IAggregator[] = [];
  21. aggregateChannelID?: string;
  22. bbCodeParser = new HTML2BBCode();
  23. turndown = new TurndownService();
  24. constructor() {
  25. this.turndown.addRule("image", {
  26. filter: "img",
  27. replacement: () => ""
  28. });
  29. this.turndown.addRule("link", {
  30. filter: (node: HTMLElement) => node.nodeName === "A" && node.getAttribute("href") != null,
  31. replacement: (content: string, node: Node) => (node as HTMLElement).getAttribute("htef") ?? ""
  32. });
  33. }
  34. checkFeeds = async (): Promise<void> => {
  35. logger.info(`Aggregating feeds on ${new Date().toISOString()}`);
  36. const aggregatorJobs = [];
  37. for (const aggregator of this.aggregators) {
  38. aggregatorJobs.push(aggregator.aggregate());
  39. }
  40. const aggregatedItems = await Promise.all(aggregatorJobs);
  41. for (const itemSet of aggregatedItems) {
  42. for (const item of itemSet) {
  43. const itemObj = {
  44. ...item,
  45. cacheMessageId: undefined,
  46. postedMessageId: undefined
  47. } as NewsPostItem;
  48. itemObj.hash = sha1(itemObj.contents);
  49. await this.addNewsItem(itemObj);
  50. }
  51. }
  52. }
  53. async addNewsItem(item: NewsPostItem): Promise<void> {
  54. if (!this.aggregateChannelID)
  55. return;
  56. const repo = getRepository(AggroNewsItem);
  57. const ch = client.bot.channels.resolve(this.aggregateChannelID);
  58. if (!(ch instanceof TextChannel))
  59. return;
  60. let isNew = true;
  61. let newsItem = await repo.findOne({
  62. where: { feedName: item.feedId, newsId: item.newsId }
  63. });
  64. if (newsItem) {
  65. if (process.env.IGNORE_CHANGED_NEWS === "TRUE") {
  66. newsItem.hash = item.hash ?? "";
  67. await repo.save(newsItem);
  68. return;
  69. }
  70. // No changes, skip
  71. if (newsItem.hash == item.hash)
  72. return;
  73. else if(newsItem.editMessageId)
  74. await this.deleteCacheMessage(newsItem.editMessageId);
  75. isNew = false;
  76. } else {
  77. newsItem = repo.create({
  78. newsId: item.newsId,
  79. feedName: item.feedId,
  80. hash: item.hash
  81. });
  82. }
  83. const msg = await assertOk(ch.send(new MessageEmbed({
  84. title: `${(isNew ? "**[NEW]**" : "**[EDIT]**")} ${item.title}`,
  85. url: item.link,
  86. color: item.embedColor,
  87. timestamp: new Date(),
  88. author: {
  89. name: item.author
  90. },
  91. footer: {
  92. text: "NoctBot News Aggregator"
  93. }
  94. }))) as Message;
  95. newsItem.editMessageId = msg.id;
  96. await repo.save(newsItem);
  97. }
  98. async deleteCacheMessage(messageId: string): Promise<void> {
  99. if(!this.aggregateChannelID)
  100. return;
  101. const ch = client.bot.channels.resolve(this.aggregateChannelID);
  102. if (!(ch instanceof TextChannel))
  103. return;
  104. const msg = await tryDo(ch.messages.fetch(messageId));
  105. if (msg.ok) {
  106. const deleteResult = await tryDo(msg.result.delete());
  107. if (!deleteResult.ok)
  108. logger.error("NewsAggregator: failed to delete message %s: %s", messageId, deleteResult.error);
  109. }
  110. else
  111. logger.error("NewsAggregator: failed to fetch messate %s: %s", this.aggregateChannelID, msg.error);
  112. }
  113. initAggregators(): void {
  114. const aggregatorsPath = path.join(path.dirname(module.filename), "aggregators");
  115. const files = fs.readdirSync(aggregatorsPath);
  116. for (const file of files) {
  117. const ext = path.extname(file);
  118. const name = path.basename(file);
  119. if (name == "aggregator.js")
  120. continue;
  121. if (ext != ".js")
  122. continue;
  123. // eslint-disable-next-line @typescript-eslint/no-var-requires
  124. const obj = require(path.resolve(aggregatorsPath, file)).default as IAggregator;
  125. if (obj)
  126. this.aggregators.push(obj);
  127. if (obj.init)
  128. obj.init();
  129. }
  130. }
  131. async start(): Promise<void> {
  132. const repo = getRepository(KnownChannel);
  133. const ch = await repo.findOne({
  134. where: { channelType: AGGREGATOR_MANAGER_CHANNEL }
  135. });
  136. if (!ch)
  137. return;
  138. this.aggregateChannelID = ch.channelId;
  139. this.initAggregators();
  140. interval(this.checkFeeds, UPDATE_INTERVAL * 60 * 1000);
  141. }
  142. }