diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8822ba2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9abb677 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +BOT_TOKEN=myBotToken +TWITTER_CONSUMER_KEY=myTwitterConsumerKey +TWITTER_CONSUMER_SECRET=myTwitterConsumerSecret +TWITTER_ACCOUNTS=NFLResearch,RapSheet,AdamSchefter,SomeOtherCoolAccountName +FANTASY_LEAGUE_ID=myFantasyLeagueId \ No newline at end of file diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..d293973 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,3 @@ +node_modules +lib +dist diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..fcda453 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": [ + "@typescript-eslint", + "jest" + ], + "extends": [ + "airbnb-typescript/base", + "plugin:jest/all" + ], + "parserOptions": { + "project": "tsconfig.json" + }, + "rules": { + "class-methods-use-this": 0 + } +} diff --git a/.gitignore b/.gitignore index d831fcc..9d45aa7 100644 --- a/.gitignore +++ b/.gitignore @@ -70,3 +70,5 @@ typings/ # SonarScanner properties /sonar-project.properties .vscode/launch.json + +/dist diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d55b787 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + "eslint.validate": ["javascript", "typescript", "typescriptreact"], + "eslint.alwaysShowStatus": true, +} \ No newline at end of file diff --git a/README.md b/README.md index 4fe2e6c..9270add 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ My motto to create this bot came from my frustration to find a reliable service ## Requirements -The only requirement it is at least a Node version that bundles the ```promisify``` utility (*i.e.* Node 8) +The only requirement it is at least a Node version that bundles the ```promisify``` utility (*i.e.* Node 12) ## Using the bot @@ -24,7 +24,7 @@ In order to use this bot you need to create a .ENV file with two specific keys: You will need to apply for a key in [Twitter](https://developer.twitter.com/en/apply-for-access.html). After set this .ENV file with these five keys, just run the command ```npm install && npm start```. -This bot was built on top of NodeJS v8.9.4. +This bot was built on top of NodeJS v12. ## Know Issues @@ -38,10 +38,10 @@ If you plan to serve this bot to different users and not just for yourself you g * Automatized tests that covers at least 80%; * Persist the chat ids; -* More elegant message about Fantasy League transactions; +* ~~More elegant message about Fantasy League transactions;~~ * Allow to add more than one fantasy league to update about it. * Aggregate posts from [NFL @ Instagram](https://www.instagram.com/nfl/); * Add Web User Interface to set the tokens and leagues ids; * Dockerfile aiming a simple way to deploy the bot. * ~~Aggregate tweets from [@NFLResearch](https://twitter.com/NFLResearch) for real time statistics about the games~~ -* ~~Build a more elegant message, possibly using MarkDown instead using images with caption.~~ \ No newline at end of file +* ~~Build a more elegant message, possibly using MarkDown instead using images with caption.~~ diff --git a/config/bot.js b/config/bot.js deleted file mode 100644 index 5230ac0..0000000 --- a/config/bot.js +++ /dev/null @@ -1,7 +0,0 @@ -const dotenv = require('dotenv') -dotenv.config() - -module.exports = { - env: process.env.BOT_ENV, - token: process.env.BOT_TOKEN -} diff --git a/config/fantasy.js b/config/fantasy.js deleted file mode 100644 index b51c29c..0000000 --- a/config/fantasy.js +++ /dev/null @@ -1,9 +0,0 @@ -const dotenv = require('dotenv') -dotenv.config() - -module.exports = { - league: { - id: process.env.FANTASY_LEAGUE_ID, - url: "https://fantasy.nfl.com/league/" - } -} diff --git a/config/index.js b/config/index.js deleted file mode 100644 index efd1986..0000000 --- a/config/index.js +++ /dev/null @@ -1,9 +0,0 @@ -const botConfig = require('./bot') -const fantasyConfig = require('./fantasy') -const twitterConfig = require('./twitter') - -module.exports = { - bot: botConfig, - twitter: twitterConfig, - fantasy: fantasyConfig, -} diff --git a/config/twitter.js b/config/twitter.js deleted file mode 100644 index c2fbcea..0000000 --- a/config/twitter.js +++ /dev/null @@ -1,10 +0,0 @@ -const dotenv = require('dotenv') -dotenv.config() - -module.exports = { - consumer: { - key: process.env.TWITTER_CONSUMER_KEY, - secret: process.env.TWITTER_CONSUMER_SECRET - }, - accounts: process.env.TWITTER_ACCOUNTS -} diff --git a/consumers/fantasy/index.js b/consumers/fantasy/index.js deleted file mode 100644 index 40fdb2f..0000000 --- a/consumers/fantasy/index.js +++ /dev/null @@ -1,30 +0,0 @@ -const axios = require('axios') -const config = require('../../config') -const $ = require('cheerio') - -const fantasyLeagueURL = `${config.fantasy.league.url}${config.fantasy.league.id}` - -const getFantasyLeagueTransaction = async () => { - let transactions = [] - - let response = await axios(fantasyLeagueURL); - - let videoRegex = /View\sVideos/gi - let newsRegex = /View\sNews/gi - let spaceRegex = /(.)\1{4,}/gi - - let rawTransactions = ($('.textWrap p', response.data)) - - rawTransactions.each((index, item) => { - let transaction = $(item).text() - - transaction = transaction.replace(videoRegex, '').replace(newsRegex, '').replace(spaceRegex, ' ') - transactions.push(transaction) - }) - - return transactions; -} - -module.exports = { - getFantasyLeagueTransaction -} diff --git a/consumers/nfl/index.js b/consumers/nfl/index.js deleted file mode 100644 index b9a1395..0000000 --- a/consumers/nfl/index.js +++ /dev/null @@ -1,54 +0,0 @@ -const axios = require('axios') -const $ = require('cheerio') -const rotowireLatestNewsUrl = "https://www.rotowire.com/football/news.php" - -/** - * Get the most recent news from Rotowire - * @returns {Array} Array of formatted news object - */ -const getNews = async () => { - let response = await axios(rotowireLatestNewsUrl); - return formatRotowireNews($('.news-update', response.data)) -} - -/** - * Format a raw news array - * @param {Array} rawRotowireNewsArray Cheerio object array representing the recent news - */ -function formatRotowireNews(rawRotowireNewsArray) { - return rawRotowireNewsArray.map((_, item) => createFormattedRotowireNews(item)).toArray(); -} - -/** - * Create a formatted news object - * @param {Object} rawRotowireNews Cheerio object representing the whole news - * @returns {Object} Formatted news object - */ -function createFormattedRotowireNews(rawRotowireNews) { - return { - headline: getNewsHeadline(rawRotowireNews), - body: getNewsBody(rawRotowireNews) - } -} - -/** - * Get the formatted Rotowire News headline - * @param {Object} rawRotowireNews Cheerio object representing the whole news - * @returns {String} The formatted news headline - */ -function getNewsHeadline(rawRotowireNews) { - return `${$(".news-update__player-link", rawRotowireNews).text()} - ${$(".news-update__headline", rawRotowireNews).text()}` -} - -/** - * Get the formatted Rotowire news body - * @param {Object} rawRotowireNews Cheerio object representing the whole news - * @returns {String} The formatted news body - */ -function getNewsBody(rawRotowireNews) { - return `${$(".news-update__news", rawRotowireNews).text()}` -} - -module.exports = { - getNews -} diff --git a/consumers/twitter/index.js b/consumers/twitter/index.js deleted file mode 100644 index 31bd04d..0000000 --- a/consumers/twitter/index.js +++ /dev/null @@ -1,57 +0,0 @@ -const config = require('../../config') -const Twit = require('twit') -const { promisify } = require('util') - -const T = new Twit({ - consumer_key: config.twitter.consumer.key, - consumer_secret: config.twitter.consumer.secret, - app_only_auth: true -}) - -/** - * Get the last 10 tweets from a specific user - * @param {String} username Twitter's username - */ -async function getTweetsFrom(username) { - let promisifyGet = promisify(T.get.bind(T)); - - return await promisifyGet("statuses/user_timeline", - { - screen_name: username, - count: 10, - exclude_replies: true, - include_rts: false, - tweet_mode: "extended", - trim_user: true - }).catch(err => { - console.log("Error while getting new tweets", err) - return err - }) -} - -/** - * Get the Twitter accounts to check - * @returns {Array} Array fo Twitter accounts to check - */ -function getTwitterAccounts() { - return config.twitter.accounts.split(',').map(account => account.trim()); -} - -/** - * Get tweets from specific accounts - * @returns {Array} Array of tweets from the specific accounts - */ -const getTweets = async () => { - let tweets = new Array(); - let accounts = getTwitterAccounts(); - - await Promise.all(accounts.map(async (account) => { - tweets.push(await getTweetsFrom(account)); - })); - - return tweets.flat(); -} - -module.exports = { - getTweets -} diff --git a/index.js b/index.js deleted file mode 100644 index 8a03569..0000000 --- a/index.js +++ /dev/null @@ -1,51 +0,0 @@ -const config = require('./config') -const util = require('./util') - -const Telegraf = require('telegraf') -const bot = new Telegraf(config.bot.token) - -const Telegram = require('telegraf/telegram') -const mailman = new Telegram(config.bot.token) - -const schedule = require('node-schedule') - -const NFLNews = require('./consumers/nfl') -const Twitter = require('./consumers/twitter') -const Fantasy = require('./consumers/fantasy') -const BotManager = require('./manager') - -let News = [] -let Tweets = [] -let FantasyTransactions = [] -let ActiveChats = new Set() - -/** - * Update the database with the latest news and Tweets - * @param fireDate Date The GMT date that the function was executed - */ -async function updateAndSend(fireDate) { - let updatedNflNews = await NFLNews.getNews() - let updatedTweets = await Twitter.getTweets() - let updatedFantasyTransactions = await Fantasy.getFantasyLeagueTransaction() - - util.sendUpdatedContent(News, updatedNflNews, "news", ActiveChats, mailman) - util.sendUpdatedContent(Tweets, updatedTweets, "twitter", ActiveChats, mailman) - util.sendUpdatedContent(FantasyTransactions, updatedFantasyTransactions, "fantasy", ActiveChats, mailman) - - News = updatedNflNews - Tweets = updatedTweets - FantasyTransactions = updatedFantasyTransactions - - console.log(`Update contents at ${fireDate}`) -} - -// Update the news every minute -schedule.scheduleJob('* * * * *', fireDate => updateAndSend(fireDate)); - -// Setup all bot actions -BotManager.setup(bot, mailman, ActiveChats, News, Tweets, FantasyTransactions) - -// Start the bot -bot.startPolling() - -console.log("Bot started...") diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..6c083e1 --- /dev/null +++ b/index.ts @@ -0,0 +1,11 @@ +import Telegram from 'telegraf/telegram'; +import Telegraf from 'telegraf'; +import { TelegrafContext } from 'telegraf/typings/context'; +import BotManager from './src/application/BotManager'; +import Configuration from './src/config/Configuration'; + +const bot: Telegraf = new Telegraf(Configuration.bot.token); +const mailman = new Telegram(Configuration.bot.token); +const botManager: BotManager = new BotManager(bot, mailman); + +botManager.start(); diff --git a/manager/actions.js b/manager/actions.js deleted file mode 100644 index e465d58..0000000 --- a/manager/actions.js +++ /dev/null @@ -1,73 +0,0 @@ -const util = require('../util') - -function start(ctx) { - ctx.replyWithMarkdown("Hey, if you want to receive news about the NFL just send me the command" + - "`/firstdown` and I will start to send you the latest news about the league." + - "I'm powered by [NewsAPI.org](https://newsapi.org)") -} - -function help(ctx) { - ctx.replyWithMarkdown("*Hello there*. I can help you to keep up informed about the NFL." + - "I understand the following actions:\n\n" + - "`/firstdown` I will put you in my mailing list and will send you every update about the league\n" + - "`/fumble` I will remove you from my mailing list and you will not receive my updates") -} - -function addUser(ctx, chatsArray) { - let chatId = ctx.message.chat.id - chatsArray.add(chatId) - ctx.reply(`Gotcha ${ctx.message.chat.first_name}! From now on you will receive news about NFL as soon them are ` + - `published 👌`) - console.log(`New client ${chatId} added`) -} - -function removeUser(ctx, chatsArray) { - let chatId = ctx.message.chat.id - chatsArray.delete(chatId) - ctx.replyWithMarkdown("Ok then, you will not hear from me anymore 😭\n" + - "If you change your mind, just send me `/firstdown` again 😉") - console.log(`Chat ${chatId} removed from list`) -} - -function sendLatest(ctx, mailman, newsArray, tweetsArray, fantasyArray) { - if (newsArray.length === 0 || tweetsArray.length === 0 || fantasyArray.length === 0) - ctx.reply("Sorry, I don't have news/tweets/fantasy transactions yet 😥") - else - { - util.sendNewsArticle(mailman, ctx.message.chat.id, newsArray[0]) - util.sendTweet(mailman, ctx.message.chat.id, tweetsArray[0]) - util.sendFantasyTransaction(mailman, ctx.message.chat.id, fantasyArray[0]) - } -} - -function daBears(ctx) { - ctx.reply('GO BEARS! 🐻⬇️') -} - -const global = bot => { - bot.start((ctx) => start(ctx)) - bot.help((ctx) => help(ctx)) -} - -const commands = (bot, mailman, chatsArray, newsArray, tweetsArray, fantasyArray) => { - // Insert a client in the mailing list - bot.command("firstdown", (ctx) => addUser(ctx, chatsArray)) - - // Remove a client from the mailing list - bot.command("fumble", (ctx) => removeUser(ctx, chatsArray)) - - // Reply the client with the latest news, tweet ans fantasy transaction - bot.command('latest', (ctx) => sendLatest(ctx, mailman, newsArray, tweetsArray, fantasyArray)) -} - -const hearings = (bot) => { - // Easter egg - bot.hears('Da Bears', (ctx) => daBears(ctx)) -} - - -module.exports = { - global, - commands, - hearings -} diff --git a/manager/index.js b/manager/index.js deleted file mode 100644 index a47d2f6..0000000 --- a/manager/index.js +++ /dev/null @@ -1,11 +0,0 @@ -const actions = require('./actions') - -const setup = (bot, mailman, chatsArray, newsArticlesArray, tweetsArray, fantasyArray) => { - actions.global(bot) - actions.commands(bot, mailman, chatsArray, newsArticlesArray, tweetsArray, fantasyArray) - actions.hearings(bot) -} - -module.exports = { - setup -} diff --git a/package.json b/package.json index 6f23e82..c473f84 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,42 @@ { "name": "nfl-telegram-bot", - "version": "1.0.0", + "version": "2.0.0", "description": "A Telegram bot that provides the latest news about the NFL", "repository": "github:jpmoura/nfl-news-for-telegram", - "main": "index.js", + "main": "index.ts", "scripts": { - "start": "node index.js", - "test": "echo \"Error: no test specified\" && exit 1" + "start": "node ./dist/index.js", + "tsc": "tsc" }, "keywords": [ "NFL", "telegram", "bot", - "news" + "news", + "fantasy" ], - "author": "João Pedro Santos de Moura (https://github.com/jpmoura)", + "author": "João Pedro Santos de Moura (https://github.com/jpmoura)", "license": "MIT", "dependencies": { + "@types/cheerio": "^0.22.22", + "@types/dotenv": "^8.2.0", + "@types/node-schedule": "^1.3.1", + "@types/twit": "^2.2.28", "axios": "^0.18.0", "cheerio": "^1.0.0-rc.2", "dotenv": "^6.1.0", "node-schedule": "^1.3.0", "telegraf": "^3.25.0", "twit": "^2.2.11" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^4.8.1", + "@typescript-eslint/parser": "^4.8.1", + "eslint": "^7.14.0", + "eslint-config-airbnb-typescript": "^12.0.0", + "eslint-plugin-import": "^2.22.1", + "eslint-plugin-jest": "^24.1.3", + "jest": "^26.6.3", + "typescript": "^4.1.2" } } diff --git a/src/application/BotManager.ts b/src/application/BotManager.ts new file mode 100644 index 0000000..e59dc89 --- /dev/null +++ b/src/application/BotManager.ts @@ -0,0 +1,93 @@ +import Telegraf, { Telegram } from 'telegraf'; +import { TelegrafContext } from 'telegraf/typings/context'; +import * as schedule from 'node-schedule'; +import CommandService from './service/interaction/CommandService'; +import GlobalService from './service/interaction/GlobalService'; +import HearingService from './service/interaction/HearingService'; +import UpdateService from './service/update/UpdateService'; +import MessageService from './service/message/MessageService'; +import News from '../domain/model/News'; + +export default class BotManager { + private news = new Map(); + + private readonly chats: Set = new Set(); + + private readonly bot: Telegraf; + + private readonly globalService: GlobalService; + + private readonly commandService: CommandService; + + private readonly hearingService: HearingService; + + private readonly updateService: UpdateService; + + private readonly messageService: MessageService; + + constructor(bot: Telegraf, mailman: Telegram) { + this.bot = bot; + this.globalService = new GlobalService(); + this.commandService = new CommandService(this.chats, mailman); + this.hearingService = new HearingService(); + this.updateService = new UpdateService(); + this.messageService = new MessageService(mailman); + } + + private setupGlobal(): void { + this.bot.start((ctx) => this.globalService.start(ctx)); + this.bot.help((ctx) => this.globalService.help(ctx)); + } + + private setupCommands(): void { + this.bot.command('firstdown', (ctx) => this.commandService.addUser(ctx)); + this.bot.command('fumble', (ctx) => this.commandService.removeUser(ctx)); + this.bot.command('latest', (ctx) => this.commandService.sendLatest(ctx, this.news)); + } + + private setupHearings(): void { + this.bot.hears('Da Bears', (ctx) => this.hearingService.daBears(ctx)); + } + + private setup() { + this.setupGlobal(); + this.setupCommands(); + this.setupHearings(); + schedule.scheduleJob('* * * * *', (fireDate: Date) => this.update(fireDate)); + } + + private getDiffNews(updatedNews: Array): Array { + const diffNews = []; + + updatedNews.forEach((element: News) => { + if (!this.news.has(element.hashCode)) { + diffNews.push(element); + } + }); + + return diffNews; + } + + private broadcast(news: Array) { + console.log(`Sending new ${news.length} itens`); + + news.forEach((specificNews) => { + this.chats.forEach((chat) => { + this.messageService.send(chat, specificNews); + }); + }); + } + + async update(firedAt: Date) { + const updatedNews = await this.updateService.update(firedAt); + const diffNews: Array = this.getDiffNews(updatedNews); + this.broadcast(diffNews); + this.news = new Map(updatedNews.map((news: News) => [news.hashCode, news])); + } + + start() { + this.setup(); + this.bot.startPolling(); + console.log('Bot started'); + } +} diff --git a/src/application/service/interaction/CommandService.ts b/src/application/service/interaction/CommandService.ts new file mode 100644 index 0000000..b046107 --- /dev/null +++ b/src/application/service/interaction/CommandService.ts @@ -0,0 +1,38 @@ +import { Telegram } from 'telegraf'; +import { TelegrafContext } from 'telegraf/typings/context'; +import { Message } from 'telegraf/typings/telegram-types'; +import ICommandService from '../../../domain/interface/application/service/interaction/ICommandService'; +import News from '../../../domain/model/News'; +import MessageService from '../message/MessageService'; + +export default class CommandService implements ICommandService { + private readonly messageService: MessageService; + + private readonly chats: Set; + + constructor(chats: Set, mailman: Telegram) { + this.chats = chats; + this.messageService = new MessageService(mailman); + } + + addUser(ctx: TelegrafContext): Promise { + this.chats.add(ctx.message.chat.id); + console.log(`New client ${ctx.message.chat.id} added`); + return ctx.reply(`Gotcha ${ctx.message.chat.first_name}! From now on you will receive news about NFL as soon them are published 👌`); + } + + removeUser(ctx: TelegrafContext): Promise { + this.chats.delete(ctx.message.chat.id); + console.log(`Chat ${ctx.message.chat.id} removed from list`); + return ctx.replyWithMarkdown('Ok then, you will not hear from me anymore 😭\n' + + 'If you change your mind, just send me `/firstdown` again 😉'); + } + + sendLatest(ctx: TelegrafContext, news: Map): Promise { + if (news.size > 0) { + return this.messageService.send(ctx.message.chat.id, news.values().next().value); + } + + return ctx.reply("Sorry, I don't have news/tweets/fantasy transactions yet 😥"); + } +} diff --git a/src/application/service/interaction/GlobalService.ts b/src/application/service/interaction/GlobalService.ts new file mode 100644 index 0000000..758824a --- /dev/null +++ b/src/application/service/interaction/GlobalService.ts @@ -0,0 +1,17 @@ +import { TelegrafContext } from 'telegraf/typings/context'; +import IGlobalService from '../../../domain/interface/application/service/interaction/IGlobalService'; + +export default class GlobalService implements IGlobalService { + start(ctx: TelegrafContext): void { + ctx.replyWithMarkdown('Hey, if you want to receive news about the NFL just send me the command' + + '`/firstdown` and I will start to send you the latest news about the league.' + + "I'm powered by [NewsAPI.org](https://newsapi.org)"); + } + + help(ctx: TelegrafContext): void { + ctx.replyWithMarkdown('*Hello there*. I can help you to keep up informed about the NFL.' + + 'I understand the following actions:\n\n' + + '`/firstdown` I will put you in my mailing list and will send you every update about the league\n' + + '`/fumble` I will remove you from my mailing list and you will not receive my updates'); + } +} diff --git a/src/application/service/interaction/HearingService.ts b/src/application/service/interaction/HearingService.ts new file mode 100644 index 0000000..f2acf84 --- /dev/null +++ b/src/application/service/interaction/HearingService.ts @@ -0,0 +1,7 @@ +import { TelegrafContext } from 'telegraf/typings/context'; + +export default class HearingService { + daBears(ctx: TelegrafContext): void { + ctx.reply('GO BEARS! 🐻⬇️'); + } +} diff --git a/src/application/service/message/MessageService.ts b/src/application/service/message/MessageService.ts new file mode 100644 index 0000000..a68a58e --- /dev/null +++ b/src/application/service/message/MessageService.ts @@ -0,0 +1,16 @@ +import { Telegram } from 'telegraf'; +import { Message } from 'telegraf/typings/telegram-types'; +import News from '../../../domain/model/News'; +import MessageRepository from '../../../infra/repository/message/MessageRepository'; + +export default class MessageService { + private messageRepository: MessageRepository; + + constructor(mailman: Telegram) { + this.messageRepository = new MessageRepository(mailman); + } + + send(chatId: string | number, news: News): Promise { + return this.messageRepository.send(chatId, news.toString()); + } +} diff --git a/src/application/service/update/UpdateService.ts b/src/application/service/update/UpdateService.ts new file mode 100644 index 0000000..06d6d6f --- /dev/null +++ b/src/application/service/update/UpdateService.ts @@ -0,0 +1,27 @@ +import IUpdateService from '../../../domain/interface/application/service/update/IUpdateService'; +import News from '../../../domain/model/News'; +import FantasyLeagueTransactionRepository from '../../../infra/repository/fantasy/FantasyLeagueTransactionRepository'; +import RotowireRepository from '../../../infra/repository/news/RotowireRepository'; +import TwitterRepository from '../../../infra/repository/twitter/TwitterRepository'; + +export default class UpdateService implements IUpdateService { + private readonly fantasyRepository = new FantasyLeagueTransactionRepository(); + + private readonly twitterRepository = new TwitterRepository(); + + private readonly rotowireRepository = new RotowireRepository(); + + async update(moment: Date): Promise> { + let news: Array = []; + + const transactions = await this.fantasyRepository.list(); + const tweets = await this.twitterRepository.list(); + const rotowireNews = await this.rotowireRepository.list(); + + news = news.concat(transactions).concat(tweets).concat(rotowireNews); + + console.log(`News updated at ${moment}`); + + return news; + } +} diff --git a/src/config/BotConfig.ts b/src/config/BotConfig.ts new file mode 100644 index 0000000..b2b463e --- /dev/null +++ b/src/config/BotConfig.ts @@ -0,0 +1,9 @@ +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export default class BotConfig { + env: string | undefined = process.env.BOT_ENV; + + token: string | undefined = process.env.BOT_TOKEN; +} diff --git a/src/config/Configuration.ts b/src/config/Configuration.ts new file mode 100644 index 0000000..75e74bf --- /dev/null +++ b/src/config/Configuration.ts @@ -0,0 +1,11 @@ +import BotConfig from './BotConfig'; +import TwitterConfig from './twitter/TwitterConfig'; +import FantasyConfig from './fantasy/FantasyConfig'; + +export default class Configuration { + static bot: BotConfig = new BotConfig(); + + static twitter: TwitterConfig = new TwitterConfig(); + + static fantasy: FantasyConfig = new FantasyConfig(); +} diff --git a/src/config/fantasy/FantasyConfig.ts b/src/config/fantasy/FantasyConfig.ts new file mode 100644 index 0000000..9bfa534 --- /dev/null +++ b/src/config/fantasy/FantasyConfig.ts @@ -0,0 +1,5 @@ +import League from './FantasyLeague'; + +export default class FantasyConfig { + league: League = new League(); +} diff --git a/src/config/fantasy/FantasyLeague.ts b/src/config/fantasy/FantasyLeague.ts new file mode 100644 index 0000000..567a56c --- /dev/null +++ b/src/config/fantasy/FantasyLeague.ts @@ -0,0 +1,9 @@ +import * as dotenv from 'dotenv'; + +dotenv.config(); + +export default class League { + id: string | undefined = process.env.FANTASY_LEAGUE_ID; + + url: string = 'https://fantasy.nfl.com/league/'; +} diff --git a/src/config/twitter/Consumer.ts b/src/config/twitter/Consumer.ts new file mode 100644 index 0000000..d2f1fb8 --- /dev/null +++ b/src/config/twitter/Consumer.ts @@ -0,0 +1,5 @@ +export default class Consumer { + key: string | undefined = process.env.TWITTER_CONSUMER_KEY; + + secret: string | undefined = process.env.TWITTER_CONSUMER_SECRET; +} diff --git a/src/config/twitter/TwitterConfig.ts b/src/config/twitter/TwitterConfig.ts new file mode 100644 index 0000000..1bf9db6 --- /dev/null +++ b/src/config/twitter/TwitterConfig.ts @@ -0,0 +1,10 @@ +import * as dotenv from 'dotenv'; +import Consumer from './Consumer'; + +dotenv.config(); + +export default class TwitterConfig { + consumer: Consumer = new Consumer(); + + accounts: string | undefined = process.env.TWITTER_ACCOUNTS; +} diff --git a/src/domain/enum/NewsSource.ts b/src/domain/enum/NewsSource.ts new file mode 100644 index 0000000..036ab4b --- /dev/null +++ b/src/domain/enum/NewsSource.ts @@ -0,0 +1,7 @@ +enum NewsSource { + Twitter, + FantasyLeague, + Rotowire, +} + +export default NewsSource; diff --git a/src/domain/interface/application/service/interaction/ICommandService.ts b/src/domain/interface/application/service/interaction/ICommandService.ts new file mode 100644 index 0000000..36973ff --- /dev/null +++ b/src/domain/interface/application/service/interaction/ICommandService.ts @@ -0,0 +1,9 @@ +import { TelegrafContext } from 'telegraf/typings/context'; +import { Message } from 'telegraf/typings/telegram-types'; +import News from '../../../../model/News'; + +export default interface ICommandService { + addUser(ctx: TelegrafContext): Promise + removeUser(ctx: TelegrafContext): Promise + sendLatest(ctx: TelegrafContext, news: Map): Promise +} diff --git a/src/domain/interface/application/service/interaction/IGlobalService.ts b/src/domain/interface/application/service/interaction/IGlobalService.ts new file mode 100644 index 0000000..3853083 --- /dev/null +++ b/src/domain/interface/application/service/interaction/IGlobalService.ts @@ -0,0 +1,6 @@ +import { TelegrafContext } from 'telegraf/typings/context'; + +export default interface IGlobalService { + start(ctx: TelegrafContext): void; + help(ctx: TelegrafContext): void; +} diff --git a/src/domain/interface/application/service/interaction/IHearingService.ts b/src/domain/interface/application/service/interaction/IHearingService.ts new file mode 100644 index 0000000..c06ce2a --- /dev/null +++ b/src/domain/interface/application/service/interaction/IHearingService.ts @@ -0,0 +1,5 @@ +import { TelegrafContext } from 'telegraf/typings/context'; + +export default interface IHearingService { + daBears(ctx: TelegrafContext): void +} diff --git a/src/domain/interface/application/service/message/IMessageService.ts b/src/domain/interface/application/service/message/IMessageService.ts new file mode 100644 index 0000000..7977ede --- /dev/null +++ b/src/domain/interface/application/service/message/IMessageService.ts @@ -0,0 +1,6 @@ +import { Message } from 'telegraf/typings/telegram-types'; +import News from '../../../../model/News'; + +export default interface IMessageService { + send(chatId: string | number, news: News): Promise; +} diff --git a/src/domain/interface/application/service/update/IUpdateService.ts b/src/domain/interface/application/service/update/IUpdateService.ts new file mode 100644 index 0000000..86ea688 --- /dev/null +++ b/src/domain/interface/application/service/update/IUpdateService.ts @@ -0,0 +1,5 @@ +import News from '../../../../model/News'; + +export default interface IUpdateService { + update(moment: Date): Promise> +} diff --git a/src/domain/interface/infra/repository/IRepository.ts b/src/domain/interface/infra/repository/IRepository.ts new file mode 100644 index 0000000..5121c4f --- /dev/null +++ b/src/domain/interface/infra/repository/IRepository.ts @@ -0,0 +1,3 @@ +export default interface IRepository { + list(): Promise>; +} diff --git a/src/domain/interface/infra/repository/message/IMessageRepository.ts b/src/domain/interface/infra/repository/message/IMessageRepository.ts new file mode 100644 index 0000000..d4a8be9 --- /dev/null +++ b/src/domain/interface/infra/repository/message/IMessageRepository.ts @@ -0,0 +1,5 @@ +import { Message } from 'telegraf/typings/telegram-types'; + +export default interface IMessageRepository { + send(chatId: string | number, message: string): Promise; +} diff --git a/src/domain/model/News.ts b/src/domain/model/News.ts new file mode 100644 index 0000000..f2eadbb --- /dev/null +++ b/src/domain/model/News.ts @@ -0,0 +1,24 @@ +import * as crypto from 'crypto'; +import NewsSource from '../enum/NewsSource'; + +export default class News { + headline: string; + + body: string; + + source: NewsSource; + + constructor(headline: string, body: string, source: NewsSource) { + this.headline = headline; + this.body = body; + this.source = source; + } + + get hashCode(): string { + return crypto.createHash('md5').update(this.body).digest('hex'); + } + + toString(): string { + return `${this.headline}\n\n${this.body}`; + } +} diff --git a/src/infra/repository/fantasy/FantasyLeagueTransactionRepository.ts b/src/infra/repository/fantasy/FantasyLeagueTransactionRepository.ts new file mode 100644 index 0000000..dbe9d9a --- /dev/null +++ b/src/infra/repository/fantasy/FantasyLeagueTransactionRepository.ts @@ -0,0 +1,34 @@ +import axios, { AxiosResponse } from 'axios'; +import cheerio from 'cheerio'; +import IRepository from '../../../domain/interface/infra/repository/IRepository'; +import Configuration from '../../../config/Configuration'; +import NewsSource from '../../../domain/enum/NewsSource'; +import News from '../../../domain/model/News'; + +export default class FantasyLeagueTransactionRepository +implements IRepository { + private getFantasyLeagueUrl(): string { + return `${Configuration.fantasy.league.url}${Configuration.fantasy.league.id}`; + } + + async list(): Promise> { + const transactions: Array = []; + + const response: AxiosResponse = await axios(this.getFantasyLeagueUrl()); + + const videoRegex: RegExp = /View\sVideos/gi; + const newsRegex: RegExp = /View\sNews/gi; + const spaceRegex: RegExp = /(.)\1{4,}/gi; + + const $: cheerio.Root = cheerio.load(response.data); + const rawTransactions: cheerio.Cheerio = ($('.textWrap p')); + + rawTransactions.each((_: any, item: any) => { + let transaction: string = $(item).text(); + transaction = transaction.replace(videoRegex, '').replace(newsRegex, '').replace(spaceRegex, ' '); + transactions.push(new News('New Transaction on Fantasy League 🔛', transaction, NewsSource.FantasyLeague)); + }); + + return transactions; + } +} diff --git a/src/infra/repository/message/MessageRepository.ts b/src/infra/repository/message/MessageRepository.ts new file mode 100644 index 0000000..ef4fdf2 --- /dev/null +++ b/src/infra/repository/message/MessageRepository.ts @@ -0,0 +1,15 @@ +import { Telegram } from 'telegraf'; +import { Message } from 'telegraf/typings/telegram-types'; +import IMessageRepository from '../../../domain/interface/infra/repository/message/IMessageRepository'; + +export default class MessageRepository implements IMessageRepository { + private mailman: Telegram; + + constructor(mailman: Telegram) { + this.mailman = mailman; + } + + async send(chatId: string | number, message: string): Promise { + return this.mailman.sendMessage(chatId, message, { parse_mode: 'Markdown' }); + } +} diff --git a/src/infra/repository/news/RotowireRepository.ts b/src/infra/repository/news/RotowireRepository.ts new file mode 100644 index 0000000..866c2fe --- /dev/null +++ b/src/infra/repository/news/RotowireRepository.ts @@ -0,0 +1,42 @@ +import axios, { AxiosResponse } from 'axios'; +import cheerio from 'cheerio'; +import NewsSource from '../../../domain/enum/NewsSource'; +import IRepository from '../../../domain/interface/infra/repository/IRepository'; +import News from '../../../domain/model/News'; + +export default class RotowireRepository implements IRepository { + private rotowireLatestNewsUrl = 'https://www.rotowire.com/football/news.php'; + + private getHeadline(rawRotowireNews: cheerio.Element): string { + const $: cheerio.Root = cheerio.load(rawRotowireNews); + return `Rotowire reports 📃 ${$('.news-update__player-link', rawRotowireNews).text()} - ${$('.news-update__headline', rawRotowireNews).text()}`; + } + + private getBody(rawRotowireNews: cheerio.Element): string { + const $: cheerio.Root = cheerio.load(rawRotowireNews); + return `${$('.news-update__news', rawRotowireNews).text()}`; + } + + private createNews(rawRotowireNews: cheerio.Element): News { + return new News( + this.getHeadline(rawRotowireNews), + this.getBody(rawRotowireNews), + NewsSource.Rotowire, + ); + } + + private toNews(rawNews: cheerio.Cheerio): Array { + const rotowireNews: Array = []; + + rawNews.map((_, item: cheerio.Element) => rotowireNews.push(this.createNews(item))); + + return rotowireNews; + } + + async list(): Promise> { + const response: AxiosResponse = await axios(this.rotowireLatestNewsUrl); + const $: cheerio.Root = cheerio.load(response.data); + const rawNews: cheerio.Cheerio = $('.news-update'); + return this.toNews(rawNews); + } +} diff --git a/src/infra/repository/twitter/TwitterRepository.ts b/src/infra/repository/twitter/TwitterRepository.ts new file mode 100644 index 0000000..66267de --- /dev/null +++ b/src/infra/repository/twitter/TwitterRepository.ts @@ -0,0 +1,54 @@ +import Twit from 'twit'; +import { promisify } from 'util'; +import Configuration from '../../../config/Configuration'; +import NewsSource from '../../../domain/enum/NewsSource'; +import IRepository from '../../../domain/interface/infra/repository/IRepository'; +import News from '../../../domain/model/News'; + +export default class TwitterRepository implements IRepository { + private T: Twit = new Twit({ + consumer_key: Configuration.twitter.consumer.key, + consumer_secret: Configuration.twitter.consumer.secret, + app_only_auth: true, + }); + + private getTweetsFrom(username: string): Promise { + const promisifyGet = promisify(this.T.get.bind(this.T)); + + return promisifyGet('statuses/user_timeline', + { + screen_name: username, + count: 10, + exclude_replies: true, + include_rts: false, + tweet_mode: 'extended', + trim_user: true, + }).catch((err: Error) => { + console.log('Error while getting new tweets', err); + return err; + }); + } + + private getTwitterAccounts(): Array { + return Configuration.twitter.accounts.split(',').map((account: string) => account.trim()); + } + + async list(): Promise> { + const tweets: Array = []; + const accounts: Array = this.getTwitterAccounts(); + + await Promise.all(accounts.map(async (account) => { + const accountTweets: Array = await this.getTweetsFrom(account); + + accountTweets.forEach((tweet) => { + tweets.push(new News( + `New Tweet from @${account} 🐦`, + tweet.truncated ? tweet.text : tweet.full_text, + NewsSource.Twitter, + )); + }); + })); + + return tweets; + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d678a26 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["ES2017"], + "module": "CommonJS", + "removeComments": true, + "moduleResolution": "node", + "noUnusedLocals": true, + "noUnusedParameters": true, + "sourceMap": true, + "target": "ES2017", + "outDir": "dist", + "esModuleInterop": true, + }, + "include": ["./**/*.ts"], + "exclude": [ + "node_modules/**/*", + ".webpack/**/*", + "_warmup/**/*", + ".vscode/**/*" + ] +} diff --git a/util/index.js b/util/index.js deleted file mode 100644 index 4f1e1b8..0000000 --- a/util/index.js +++ /dev/null @@ -1,132 +0,0 @@ -const SourceID = { - twitter : "id", - news: "headline", -} - -/** - * Send a article to a specific chat with its image and title - * @param mailman Telegram Telegram object that will send the updated content - * @param chatID String|Integer The chat ID that will receive the article - * @param article Object The object that represent article - */ -const sendNewsArticle = (mailman, chatID, article) => { - mailman.sendMessage( - chatID, - `${article.headline}\n\n${article.body}`, - {parse_mode: "Markdown"}) - .catch(err => { - console.log(`Error sending article`, err.message) - }) -} - -/** - * Send a tweet content to a specific chat - * @param mailman Telegram Telegram object that will send the updated content - * @param chatId Integer Chat that will receive the tweet - * @param tweet Object Tweet object from the Twitter API - */ -const sendTweet = (mailman, chatId, tweet) => { - let text - - if (tweet.truncated === true) - text = tweet.text - else - text = tweet["full_text"] - - mailman.sendMessage(chatId, text, {parse_mode: "Markdown"}) - .catch(err => { - console.log(`Error sending tweet`, err.message) - }) -} - -/** - * Send a fantasy transaction to a specific chat - * @param mailman Telegram Telegram object that will send the updated content - * @param chatId Integer Chat that will receive the tweet - * @param transaction String Transaction description from fantasy league - */ -const sendFantasyTransaction = (mailman, chatId, transaction) => { - mailman.sendMessage(chatId, `Fantasy transaction: ${transaction}`) - .catch(err => { - console.log(`Error sending transaction`, err.message) - }) -} - -function defineComparisonKey(object, mediaType) { - if(mediaType === "fantasy") - return object - else - return object[SourceID[mediaType]] -} - -/** - * - * @param currentContentArray {Array} Array with the old content - * @param newContentArray {Array} Array with the new content - * @param mediaType string Media type related to the content - * @param chatsSet {Set} Array with all active chats ids - * @param mailman Telegram Telegram object that will send the updated content - * @return {Array} - */ -const sendUpdatedContent = (currentContentArray, newContentArray, mediaType, chatsSet, mailman) => { - let updatedContent = [] - let oldContents = currentContentArray - let sendFunction = null - let source = null - - switch (mediaType) { - case "twitter": - sendFunction = sendTweet - source = "tweets" - break - case "news": - sendFunction = sendNewsArticle - source = "articles" - break - case "fantasy": - sendFunction = sendFantasyTransaction - source = "fantasy league" - break - } - - if (oldContents.length === 0) - updatedContent = newContentArray - else - { - newContentArray.forEach(newContent => { - let isOld = false - - oldContents.forEach(oldContent => { - let oldContentKey = defineComparisonKey(oldContent, mediaType) - let newContentKey = defineComparisonKey(newContent, mediaType) - - if (oldContentKey === newContentKey) - isOld = true - }) - - if (!isOld) - updatedContent.push(newContent) - }) - } - - if (updatedContent.length === 0) - console.log(`Nothing new found on ${mediaType}`) - else - { - console.log(`Sending ${updatedContent.length} new ${source} to ${chatsSet.size} clients`) - updatedContent.forEach(content => { - chatsSet.forEach(chatID => { - sendFunction(mailman, chatID, content) - }) - }) - } - - return updatedContent -} - -module.exports = { - sendNewsArticle, - sendTweet, - sendFantasyTransaction, - sendUpdatedContent -}