├── audio └── gas.ogg ├── .gitignore ├── utils.js ├── msg_handlers ├── README.md ├── botanio.js ├── update_user.js ├── status.js ├── broadcast.js ├── basic.js ├── judges.js ├── contest_list.js └── cf_handles.js ├── html-msg.js ├── contestwatcher.service ├── app.js ├── LICENSE ├── package.json ├── fetch.js ├── fetchers ├── topcoder.js ├── calendar.js ├── rpc.js ├── codechef.js ├── csacademy.js ├── atcoder.js └── codeforces.js ├── db_evolution.js ├── db.js ├── tests ├── fixtures │ ├── users-warn.js │ └── upcoming-regular.js └── alerts.js ├── README.md ├── logger.js ├── alerts.js ├── bot.js ├── messagequeue.js └── judgeAPIs └── cfAPI.js /audio/gas.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maratonusp/contestwatcher/HEAD/audio/gas.ogg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/*.swp 3 | **/*.swo 4 | **/*.lvimrc 5 | users.json 6 | db.json 7 | npm-debug.log 8 | run.log 9 | .idea 10 | *.gz 11 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger'); 2 | let utils = module.exports = {}; 3 | 4 | /* ID of group that controls this bot 5 | * This could be in a environment variable... oh well */ 6 | utils.admin_id = -303504164; 7 | -------------------------------------------------------------------------------- /msg_handlers/README.md: -------------------------------------------------------------------------------- 1 | ## Message handlers 2 | 3 | Each file in this directory handles bot messages, commands, etc. 4 | 5 | To add more, just create a `.js` file. **Note**: It should export a `init()` function, that will be called once automatically, where you can add all the handles to the bot (using `bot.js`). 6 | -------------------------------------------------------------------------------- /html-msg.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger'); 2 | const html_escape = require('true-html-escape').escape; 3 | 4 | var hm = module.exports = {}; 5 | 6 | hm.make_link = function(string, link) { 7 | return '' + html_escape(string) + ''; 8 | } 9 | 10 | hm.escape = html_escape 11 | -------------------------------------------------------------------------------- /contestwatcher.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=ContestWatcherBot 3 | After=network.target 4 | 5 | [Service] 6 | ExecStart=/home/cw/contestwatcher/app.js 7 | Restart=always 8 | User=cw 9 | Group=cw 10 | Environment=PATH=/usr/bin:/usr/local/bin 11 | Environment=TELEGRAM_TOKEN= 12 | WorkingDirectory=/home/cw/contestwatcher 13 | 14 | [Install] 15 | WantedBy=multi-user.target 16 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Sets exception handlers 4 | const logger = require('./logger'); 5 | logger.info("Booting up."); 6 | 7 | // Evolves the database 8 | require('./db_evolution').evolve(); 9 | 10 | // Initializes online judge fetchers 11 | const fetch = require('./fetch').init(); 12 | 13 | // Creates bot and initiales message handlers 14 | const bot = require('./bot'); 15 | const fs = require('fs'); 16 | bot.create_bot(); 17 | fs.readdirSync('./msg_handlers').forEach((file) => { 18 | if(file.endsWith('.js')) 19 | require('./msg_handlers/' + file).init(); 20 | }); 21 | -------------------------------------------------------------------------------- /msg_handlers/botanio.js: -------------------------------------------------------------------------------- 1 | /* Sending data to botanio */ 2 | const logger = require('../logger'); 3 | const botan = require('botanio')(process.env.BOTANIO_TOKEN); 4 | const using_botanio = (process.env.BOTANIO_TOKEN !== undefined); 5 | const Bot = require('../bot'); 6 | logger.info("Using botan.io: " + using_botanio); 7 | 8 | const botanio = module.exports = {}; 9 | 10 | botanio.init = function() { 11 | // botanio tracks commands data 12 | if(using_botanio) { 13 | Bot.bot.onText(/^\/\w+/, (message) => { 14 | const command = message.text.match(/^\/(\w+)/)[1]; 15 | botan.track(message, command, (err, res, body) => { 16 | if(err) 17 | logger.error("Botan.io error: " + err); 18 | }); 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /msg_handlers/update_user.js: -------------------------------------------------------------------------------- 1 | /* Lists useful stats to admin */ 2 | const logger = require('../logger'); 3 | const Bot = require('../bot'); 4 | const db = require('../db'); 5 | const html_msg = require('../html-msg'); 6 | 7 | const update = module.exports = {}; 8 | 9 | update.init = function() { 10 | /* If this command comes from adms, useful stats are returned */ 11 | Bot.bot.onText(/^\/update$/, (message) => { 12 | text = "User already up-to-date!"; 13 | 14 | let user = db.user.get(message.chat); 15 | const is_group = message.chat.type == "group"; 16 | if(user.get('is_group').value() != is_group) { 17 | user.set('is_group', is_group).write(); 18 | text = "User updated! Chat type: " + (is_group ? "group" : "user"); 19 | } 20 | 21 | Bot.sendSimpleHtml(message.chat.id, html_msg.escape(text)); 22 | }); 23 | }; 24 | 25 | // vim:noet 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Victor Sena Molero 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "contestwatcher", 3 | "version": "1.0.0", 4 | "description": "Watches online judges for contests", 5 | "main": "index.js", 6 | "repository": "naumazeredo/contestwatcher", 7 | "devDependencies": { 8 | "chai": "^4.1.2", 9 | "rewire": "^3.0.2", 10 | "sinon": "^4.1.5" 11 | }, 12 | "engines": { 13 | "node": ">= 6.0.0" 14 | }, 15 | "scripts": { 16 | "test": "mocha tests --recursive", 17 | "start": "node app.js" 18 | }, 19 | "keywords": [ 20 | "competitive", 21 | "programming", 22 | "codeforces", 23 | "topcoder", 24 | "codechef", 25 | "csacademy", 26 | "atcoder" 27 | ], 28 | "author": "victorsenam ", 29 | "license": "ISC", 30 | "dependencies": { 31 | "async-semaphore": "^2.0.0", 32 | "botanio": "^0.0.6", 33 | "dateformat": "^2.0.0", 34 | "ical": "^0.6.0", 35 | "jsdom": "^9.11.0", 36 | "lowdb": "^0.16.1", 37 | "moment-timezone": "^0.5.11", 38 | "node-schedule": "^1.2.0", 39 | "node-telegram-bot-api": "^0.30.0", 40 | "true-html-escape": "^1.0.0", 41 | "winston": "^2.4.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /fetch.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger'); 2 | const alerts = require('./alerts'); 3 | const fs = require('fs'); 4 | const schedule = require('node-schedule'); 5 | 6 | // Getting every available fetcher 7 | const fetchers = fs.readdirSync('./fetchers') 8 | .filter((file) => { return file.endsWith('.js'); }) 9 | .map((file) => { return require('./fetchers/' + file); }); 10 | 11 | const fetch = module.exports = {}; 12 | 13 | fetch.upcoming = []; 14 | 15 | fetch.updateUpcoming = function() { 16 | const upcoming = fetch.upcoming; 17 | upcoming.length = 0; 18 | alerts.reset_alerts(); 19 | 20 | fetchers.forEach((fetcher) => { 21 | let contests = []; 22 | fetcher.updateUpcoming(contests).on('end', () => { 23 | logger.info('merging ' + fetcher.name + ' (found: ' + contests.length + ')'); 24 | if(contests.length > 0) { 25 | alerts.add_alerts(contests, fetcher); 26 | 27 | upcoming.push.apply(upcoming, contests); 28 | upcoming.sort((a, b) => { return a.time - b.time; }); 29 | } 30 | }); 31 | }); 32 | } 33 | 34 | fetch.init = function () { 35 | // Makes initial fetch and schedules one for 3am everyday 36 | fetch.updateUpcoming(); 37 | schedule.scheduleJob({ hour: 3, minute: 0, second: 0}, () => { fetch.updateUpcoming(); }); 38 | } 39 | -------------------------------------------------------------------------------- /fetchers/topcoder.js: -------------------------------------------------------------------------------- 1 | const logger = require('../logger'); 2 | const EventEmitter = require('events'); 3 | const ical = require('ical'); 4 | 5 | module.exports = { 6 | name: "topcoder", 7 | updateUpcoming: (upcoming) => { 8 | const emitter = new EventEmitter(); 9 | 10 | ical.fromURL( 11 | 'https://calendar.google.com/calendar/ical/appirio.com_bhga3musitat85mhdrng9035jg%40group.calendar.google.com/public/basic.ics', 12 | {}, 13 | (err, data) => { 14 | upcoming.length = 0; 15 | 16 | for (var key in data) { 17 | if (!data.hasOwnProperty(key)) 18 | continue; 19 | var el = data[key]; 20 | 21 | if (/(SRM|TCO)/g.test(el.summary)) { 22 | var entry = { 23 | judge: 'topcoder', 24 | name: el.summary, 25 | url: 'http://topcoder.com/', 26 | time: new Date(el.start), 27 | duration: (el.end - el.start) / 1000 28 | }; 29 | 30 | var ending = new Date(entry.time); 31 | ending.setSeconds(ending.getSeconds() + entry.duration); 32 | if (ending.getTime() >= Date.now()) 33 | upcoming.push(entry); 34 | } 35 | } 36 | 37 | upcoming.sort( (a, b) => { return a.time - b.time; }); 38 | 39 | emitter.emit('end'); 40 | } 41 | ); 42 | 43 | return emitter; 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /db_evolution.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger'); 2 | const db = require('./db'); 3 | const version = 2; // Increase the version for evolving 4 | 5 | db.low.defaults({ version: 0 }).write(); 6 | 7 | const evolve = () => { 8 | let db_version = db.low.get('version').value(); 9 | logger.info("Database version: " + db_version); 10 | if(db_version < version) { 11 | logger.info("DATABASE WILL EVOLVE"); 12 | 13 | // /migration code starts here 14 | db.low.get('users').value().forEach((record) => { 15 | let id = record.id; 16 | let user = db.user.get_by_id(id); 17 | console.log(user); 18 | if(!user.has('cf_handles').value()) 19 | user.set('cf_handles', []).write(); 20 | }); 21 | 22 | db.low.get('users').value().forEach((record) => { 23 | let id = record.id; 24 | let user = db.user.get_by_id(id); 25 | if(!user.has('is_group').value()) 26 | user.set('is_group', true).write(); 27 | }); 28 | //migration code ends here 29 | 30 | db.low.set('version', version).write(); 31 | logger.info("New database version is: " + version); 32 | } 33 | }; 34 | 35 | module.exports = { 36 | evolve: evolve 37 | }; 38 | -------------------------------------------------------------------------------- /db.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger'); 2 | const lowdb = require('lowdb'); 3 | const low = lowdb('db.json', { 4 | storage: require('lowdb/lib/storages/file-async') 5 | }); 6 | 7 | module.exports = {low: low}; 8 | 9 | module.exports.user = (function () { 10 | var User = {}; 11 | 12 | low.defaults({users: []}).write(); 13 | 14 | User.create = function (chat) { 15 | logger.info('Creating user ' + chat.id); 16 | low.get('users') 17 | .push({ 18 | id: chat.id, 19 | notify: false, 20 | ignore: {calendar: true}, 21 | last_activity: Date.now(), 22 | cf_handles: [], 23 | is_group: chat.type == "group" 24 | }) 25 | .write(); 26 | 27 | return low 28 | .get('users') 29 | .find({id : chat.id}); 30 | }; 31 | 32 | User.get = function (chat) { 33 | var user = low 34 | .get('users') 35 | .find({id : chat.id}); 36 | if (user.isUndefined().value()) 37 | return module.exports.user.create(chat); 38 | return user; 39 | }; 40 | 41 | User.get_by_id = function (id) { 42 | var user = low 43 | .get('users') 44 | .find({id : id}); 45 | return user; 46 | }; 47 | 48 | User.migrate = function (old_id, new_id) { 49 | logger.info('Migrating user ' + old_id + ' to ' + new_id); 50 | var user = User 51 | .get(old_id) 52 | .assign({id : new_id}) 53 | .write(); 54 | }; 55 | 56 | return User; 57 | })(); 58 | 59 | // vim:noet 60 | -------------------------------------------------------------------------------- /tests/fixtures/users-warn.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | // no ignore 3 | { 4 | "id": 0, 5 | "notify": true, 6 | "ignore": {}, 7 | "last_activity": 1515838308761, 8 | "cf_handles": [ "arthur_nascimento" ] 9 | }, 10 | 11 | // one ignore 12 | { 13 | "id": 1, 14 | "notify": true, 15 | "ignore": { 16 | "calendar": true 17 | }, 18 | "last_activity": 1515908594649, 19 | "cf_handles": [] 20 | }, 21 | 22 | // some ignore 23 | { 24 | "id": 2, 25 | "notify": true, 26 | "ignore": { 27 | "codeforces": true, 28 | "topcoder": true, 29 | "calendar": true 30 | }, 31 | "last_activity": 1515925361318, 32 | "cf_handles": [] 33 | }, 34 | 35 | // all ignores 36 | { 37 | "id": 5, 38 | "notify": true, 39 | "ignore": { 40 | "codeforces": true, 41 | "topcoder": true, 42 | "calendar": true, 43 | "atcoder": true, 44 | "codechef": true, 45 | }, 46 | "last_activity": 1515925361318, 47 | "cf_handles": [] 48 | }, 49 | 50 | // notify false 51 | { 52 | "id": 3, 53 | "notify": false, 54 | "ignore": { }, 55 | "last_activity": 1515925361318, 56 | "cf_handles": [] 57 | }, 58 | 59 | // notify false and ignores 60 | { 61 | "id": 4, 62 | "notify": false, 63 | "ignore": { 64 | "codeforces": true, 65 | "topcoder": true, 66 | "calendar": true 67 | }, 68 | "last_activity": 1515925361318, 69 | "cf_handles": [] 70 | }, 71 | ] 72 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ContestWatcherBot 2 | ContestWatcherBot is a Telegram bot that keeps track of all the major online programming contests. It sends you notifications one day and one hour before each event. Try it your self at https://t.me/contestwatcherbot 3 | 4 | ## Contributions 5 | Unfortunately, we have stalled the development for this bot and are not merging pull requests. You are welcome to fork this project and apply any features you wish. 6 | 7 | ## Judges 8 | Currently we support the following websites: 9 | - [AtCoder](https://atcoder.jp/) 10 | - [Codechef](https://www.codechef.com/) 11 | - [Codeforces](http://codeforces.com/) 12 | - [CS Academy](https://csacademy.com/) 13 | - [TopCoder](https://www.topcoder.com) 14 | 15 | You can choose which notifications you will receive by enabling/disabling each website. Use `/help` for more information. 16 | 17 | ## Codeforces integration 18 | 19 | Since Codeforces has a very nice [API](http://codeforces.com/api/help) we can keep track not only of the upcoming contests but also of system testing and rating changes. You can add your Codeforces handle to the bot and it will notify you about system testing and rating changes of the contests that you took part. Use `/help_handles` for more information. 20 | 21 | ## Contributing 22 | 23 | We gladly accept contributions! We plan to write better documentation for the code, but if you feel like you can contribute, you are very welcome to do so :-) 24 | 25 | -------------------------------------------------------------------------------- /fetchers/calendar.js: -------------------------------------------------------------------------------- 1 | const logger = require('../logger'); 2 | const EventEmitter = require('events'); 3 | const ical = require('ical'); 4 | 5 | module.exports = { 6 | name: "calendar", 7 | updateUpcoming: (upcoming) => { 8 | const emitter = new EventEmitter(); 9 | 10 | ical.fromURL( 11 | 'https://calendar.google.com/calendar/ical/t313lnucdcm49hus40p3bjhq44%40group.calendar.google.com/public/basic.ics', 12 | {}, 13 | (err, data) => { 14 | upcoming.length = 0; 15 | 16 | for (var key in data) { 17 | if (!data.hasOwnProperty(key)) 18 | continue; 19 | var el = data[key]; 20 | 21 | var entry = { 22 | judge: 'calendar', 23 | name: el.summary, 24 | url: 'https://calendar.google.com/calendar/embed?src=t313lnucdcm49hus40p3bjhq44%40group.calendar.google.com&ctz=America/Sao_Paulo', 25 | time: new Date(el.start), 26 | duration: (el.end - el.start) / 1000 27 | }; 28 | 29 | var url; 30 | if (typeof el.description !== 'undefined') 31 | url = el.description.split(/\s/g)[0]; 32 | if (typeof url !== 'undefined' && /^http/.test(url)) 33 | entry.url = url; 34 | 35 | var ending = new Date(entry.time); 36 | ending.setSeconds(ending.getSeconds() + entry.duration); 37 | if (ending.getTime() >= Date.now()) 38 | upcoming.push(entry); 39 | } 40 | 41 | upcoming.sort( (a, b) => { return a.time - b.time; }); 42 | 43 | emitter.emit('end'); 44 | } 45 | ); 46 | 47 | return emitter; 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /logger.js: -------------------------------------------------------------------------------- 1 | const winston = require('winston'); 2 | const dateFormat = require('dateformat'); 3 | 4 | /** 5 | * logger.info("message"); 6 | * logger.warn("message"); 7 | * logger.error("message"); 8 | * You can use metadata too 9 | * logger.info("message", {foo: bar}); 10 | * 11 | * For more details: https://github.com/winstonjs/winston/tree/2.x 12 | */ 13 | 14 | function getDate() { 15 | return dateFormat(new Date(), "UTC:[dd/mm/yy HH:MM]"); 16 | } 17 | 18 | function getFormatter(options) { 19 | date = getDate(); 20 | level = options.level.toUpperCase(); 21 | if (options.colorize) 22 | level = winston.config.colorize(options.level, level) 23 | return date + ' ' + level + ': ' + 24 | (options.message ? options.message : '') + 25 | (options.meta && Object.keys(options.meta).length ? '\n'+ JSON.stringify(options.meta) : ''); 26 | } 27 | 28 | winston.remove(winston.transports.Console); 29 | 30 | winston.add(winston.transports.Console, { 31 | level: 'debug', 32 | json: false, 33 | colorize: true, 34 | formatter: getFormatter, 35 | handleExceptions: true, 36 | humanReadableUnhandledException: true 37 | }); 38 | 39 | winston.add(winston.transports.File, { 40 | level: 'debug', 41 | filename: './run.log', 42 | json: false, 43 | colorize: false, 44 | formatter: getFormatter, 45 | maxsize: 100000, // 100 KB 46 | maxFiles: 5, 47 | tailable: true, 48 | zippedArchive: true, 49 | handleExceptions: true, 50 | humanReadableUnhandledException: true 51 | }); 52 | 53 | module.exports = winston; 54 | -------------------------------------------------------------------------------- /fetchers/rpc.js: -------------------------------------------------------------------------------- 1 | const logger = require('../logger'); 2 | const jsdom = require('jsdom') 3 | const EventEmitter = require('events'); 4 | const moment = require('moment-timezone'); 5 | 6 | module.exports = { 7 | name: "RPC", 8 | updateUpcoming: (upcoming) => { 9 | const emitter = new EventEmitter(); 10 | 11 | jsdom.env("http://registro.redprogramacioncompetitiva.com/contests", 12 | ["http://code.jquery.com/jquery.js"], 13 | (err, window) => { 14 | if (err) { 15 | logger.error("Failed on RPC.", err); 16 | return; 17 | } 18 | 19 | const $ = window.$; 20 | const list = $("table:eq(0)").children('tbody').children('tr'); 21 | 22 | list.each(function() { 23 | 24 | const row = $(this).children('td'); 25 | const name = row.eq(0).text(); 26 | const time = row.eq(1).find('time').attr("datetime"); 27 | 28 | contest = { 29 | judge: 'RPC', 30 | name: 'RPC - ' + name, 31 | url: "http://registro.redprogramacioncompetitiva.com/contests", 32 | time: moment.tz(time, 'YYYY-MM-DDTHH:mm:ssZ', 'UTC').toDate(), 33 | duration: 5*3600 34 | } 35 | 36 | upcoming.push(contest); 37 | 38 | }); 39 | 40 | emitter.emit('end'); 41 | }); 42 | 43 | return emitter; 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /msg_handlers/status.js: -------------------------------------------------------------------------------- 1 | /* Lists useful stats to admin */ 2 | const logger = require('../logger'); 3 | const Bot = require('../bot'); 4 | const db = require('../db'); 5 | const utils = require('../utils'); 6 | const html_msg = require('../html-msg'); 7 | 8 | const stat = module.exports = {}; 9 | 10 | stat.init = function() { 11 | /* If this command comes from adms, useful stats are returned */ 12 | Bot.bot.onText(/^\/status(@\w+)*$/, (message) => { 13 | if(message.chat.id != utils.admin_id) return; 14 | let text = Bot.delete_invalid(); 15 | let recent = 0, valid = 0, notified = 0; 16 | const now = Date.now(); 17 | const judges = { 18 | codeforces: 0, 19 | codechef: 0, 20 | topcoder: 0, 21 | csacademy: 0, 22 | atcoder: 0 23 | } 24 | let total_cf_handles = 0; 25 | db.low 26 | .get('users') 27 | .value() 28 | .forEach((user) => { 29 | valid++; 30 | if (user.notify) { 31 | notified++; 32 | Object.keys(judges).forEach((judge) => { 33 | if(!user.ignore[judge]) 34 | judges[judge]++; 35 | }); 36 | } 37 | if(user.cf_handles) 38 | total_cf_handles += user.cf_handles.length; 39 | if (user.last_activity !== undefined && now - user.last_activity < 7 * 24 * 60 * 60 * 1000) 40 | recent++; 41 | }); 42 | text += '\nValid users: ' + valid; 43 | text += '\nUsers with notification on: ' + notified; 44 | Object.keys(judges).forEach((judge) => { 45 | text += "\n" + judge + " notifications on: " + judges[judge]; 46 | }); 47 | text += '\nActive users in the last week: ' + recent; 48 | text += '\nCF handles total: ' + total_cf_handles; 49 | Bot.sendSimpleHtml(message.chat.id, html_msg.escape(text)); 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /fetchers/codechef.js: -------------------------------------------------------------------------------- 1 | const logger = require('../logger'); 2 | const jsdom = require('jsdom') 3 | const EventEmitter = require('events'); 4 | const moment = require('moment-timezone'); 5 | 6 | module.exports = { 7 | name: "codechef", 8 | updateUpcoming: (upcoming) => { 9 | const emitter = new EventEmitter(); 10 | 11 | jsdom.env("https://www.codechef.com/contests", 12 | ["http://code.jquery.com/jquery.js"], 13 | (err, window) => { 14 | if (err) { 15 | logger.error("Failed on CodeChef.", err); 16 | return; 17 | } 18 | const $ = window.$ 19 | const list = $("table.dataTable:eq(0),table.dataTable:eq(1)").children('tbody').children() 20 | upcoming.length = 0; 21 | list.find('a').each((i, x) => { 22 | if ((/Challenge|Cook|Lunchtime/i.test(x.text) && /January|February|March|April|May|June|July|August|September|October|November|December/i.test(x.text)) || /Snackdown/i.test(x.text)) { 23 | const contest = list.eq(i).children(); // contest to be added 24 | const _start = moment.tz(contest.filter('.start_date').text(), 'DD MMM YYYY HH:mm:ss', 'Asia/Colombo'); 25 | const _end = moment.tz(contest.filter('.end_date').text(), 'DD MMM YYYY HH:mm:ss', 'Asia/Colombo'); 26 | if (!_start.isValid() || !_end.isValid()) { 27 | logger.error('Codechef invalid dates for ' + x.text); 28 | logger.error("\t Start: " + _start); 29 | logger.error("\t End: " + _end); 30 | return; 31 | } 32 | const start = _start.toDate(); 33 | const end = _end.toDate(); 34 | if (end.getTime() < Date.now()) return; 35 | upcoming.push({ 36 | judge: 'codechef', 37 | name: x.text, 38 | url: x.href, 39 | time: start, 40 | duration: (end.getTime() - start.getTime()) / 1000 41 | }); 42 | } 43 | }); 44 | 45 | emitter.emit('end'); 46 | }); 47 | 48 | return emitter; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /fetchers/csacademy.js: -------------------------------------------------------------------------------- 1 | const logger = require('../logger'); 2 | const https = require('https'); 3 | const EventEmitter = require('events'); 4 | 5 | module.exports = { 6 | name: "csacademy", 7 | updateUpcoming: (upcoming) => { 8 | const emitter = new EventEmitter(); 9 | 10 | const options = { 11 | hostname: 'csacademy.com', 12 | path: '/contests/', 13 | headers: { 14 | 'x-requested-with': 'XMLHttpRequest' 15 | } 16 | }; 17 | 18 | https.get(options, (res) => { 19 | let error; 20 | 21 | if (res.statusCode !== 200) { 22 | error = new Error('Fetch Failed [' + res.statusCode + '] CSAcademy'); 23 | } 24 | 25 | if (error) { 26 | logger.error(error.message); 27 | res.resume(); // free 28 | return; 29 | } 30 | 31 | res.setEncoding('utf8'); 32 | 33 | let rawStateJSON = ''; 34 | 35 | upcoming.length = 0 36 | res.on('data', (chunk) => rawStateJSON += chunk); 37 | res.on('end', () => { 38 | try { 39 | let stateJSON = JSON.parse(rawStateJSON); 40 | for (contest of stateJSON.state.Contest) { 41 | if(!contest.rated) continue; 42 | 43 | var entry = { 44 | judge: 'csacademy', 45 | name: 'CSAcademy ' + contest.longName, 46 | url: 'https://csacademy.com/contest/' + contest.name, 47 | time: new Date(contest.startTime * 1000), 48 | duration: contest.endTime - contest.startTime 49 | }; 50 | 51 | var ending = new Date(entry.time); 52 | ending.setSeconds(ending.getSeconds() + entry.duration); 53 | if (ending.getTime() >= Date.now()) 54 | upcoming.push(entry); 55 | } 56 | 57 | upcoming.sort( (a,b) => { return a.time - b.time; }); 58 | 59 | emitter.emit('end'); 60 | } catch (e) { 61 | logger.error('Parse Failed CSAcademy\n' + e.message); 62 | } 63 | }); 64 | }).on('error', (e) => { 65 | logger.error('Request Error CSAcademy\n' + e.message); 66 | }); 67 | 68 | return emitter; 69 | } 70 | }; 71 | -------------------------------------------------------------------------------- /msg_handlers/broadcast.js: -------------------------------------------------------------------------------- 1 | /* Commands regarding broadcasting messages from the admins */ 2 | const logger = require('../logger'); 3 | const Bot = require('../bot'); 4 | const db = require('../db'); 5 | const utils = require('../utils'); 6 | 7 | const broadcast = module.exports = {}; 8 | 9 | /* stores messages sent by the last broadcast 10 | * keys = chatIds, values = messageIds */ 11 | let last_broadcast = {}; 12 | 13 | broadcast.init = function() { 14 | const bot = Bot.bot; 15 | 16 | /* If this command comes from adms, replies to them with the same message. 17 | * Used to test if /broadcast is correctly formatted */ 18 | bot.onText(/^\/mock_broadcast(@\w+)* .*$/, (message) => { 19 | if(message.chat.id != utils.admin_id) return; 20 | let text = message.text.slice(message.text.indexOf(' ') + 1); 21 | Bot.sendMessage(message.chat.id, text, { 22 | parse_mode: 'Markdown', 23 | disable_web_page_preview: true 24 | }); 25 | }); 26 | 27 | /* If this command comes from adms, sends the messsage after the command 28 | * to all users */ 29 | bot.onText(/^\/broadcast(@\w+)* .*$/, (message) => { 30 | if(message.chat.id != utils.admin_id) return; 31 | let text = message.text.slice(message.text.indexOf(' ') + 1); 32 | last_broadcast = {}; 33 | db.low 34 | .get('users') 35 | .map('id') 36 | .value() 37 | .forEach((id) => { 38 | Bot.sendMessage(id, text, { 39 | parse_mode: 'Markdown', 40 | disable_web_page_preview: true 41 | }).then((msg) => { 42 | last_broadcast[id] = msg.message_id; 43 | }); 44 | }); 45 | }); 46 | 47 | /* If this command comes from adms, edits the last sent 48 | * broadcast message. Use with care. */ 49 | bot.onText(/^\/edit_broadcast(@\w+)* .*$/, (message) => { 50 | if(message.chat.id != utils.admin_id) return; 51 | let text = message.text.slice(message.text.indexOf(' ') + 1); 52 | db.low 53 | .get('users') 54 | .map('id') 55 | .value() 56 | .forEach((id) => { 57 | if(last_broadcast[id] === undefined) return; 58 | bot.editMessageText(text, { 59 | chat_id: id, 60 | message_id: last_broadcast[id], 61 | parse_mode: 'Markdown', 62 | disable_web_page_preview: true 63 | }); 64 | }); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /alerts.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger') 2 | const schedule = require('node-schedule'); 3 | const bot = require('./bot'); 4 | const db = require('./db'); 5 | const html_msg = require('./html-msg'); 6 | 7 | let event_handlers = [] 8 | 9 | const sec = 1000; 10 | const min = 60 * sec; 11 | const hour = 60 * min; 12 | const day = 24 * hour; 13 | 14 | const alerts = module.exports = {} 15 | 16 | /* Manages contest warning messages */ 17 | const warning_manager = (function () { 18 | var instance = {}; 19 | 20 | instance.buffer = []; 21 | 22 | instance.flush_buffer = function () { 23 | db.low 24 | .get('users') 25 | .value() 26 | .forEach(function (user) { 27 | if (!user.notify) return; 28 | 29 | var message = instance.buffer.reduce((message, warning) => { 30 | var ev = warning.ev; 31 | if (!user.ignore[ev.judge]) 32 | message += html_msg.make_link(ev.name, ev.url) + html_msg.escape(' will start in ' + warning.left + '.') + '\n'; 33 | return message; 34 | }, ""); 35 | 36 | if (message !== "") 37 | bot.sendSimpleHtml(user.id, message); 38 | }); 39 | instance.buffer = []; 40 | }; 41 | 42 | // dummy flush 300000 days from now, never called 43 | instance.next_flush = new schedule.scheduleJob(new Date(Date.now() + 300000 * day), instance.flush_buffer); 44 | instance.next_flush.cancel(); 45 | 46 | instance.add = function(ev, left) { 47 | instance.buffer.push({ ev: ev, left: left }); 48 | instance.next_flush.reschedule(new Date(Date.now() + 30 * 1000)); 49 | } 50 | 51 | return instance; 52 | }()); 53 | 54 | alerts.reset_alerts = () => { 55 | logger.info("Erasing all alerts for upcoming events"); 56 | event_handlers.forEach((handler) => { 57 | if(handler) handler.cancel(); 58 | }); 59 | event_handlers = []; 60 | }; 61 | 62 | alerts.add_alerts = (upcoming, fetcher) => { 63 | logger.info("Registering " + upcoming.length + " " + fetcher.name + " events"); 64 | upcoming.forEach((ev) => { 65 | event_handlers.push(schedule.scheduleJob(new Date(ev.time.getTime() - day), () => { warning_manager.add(ev, '1 day', fetcher); })); 66 | event_handlers.push(schedule.scheduleJob(new Date(ev.time.getTime() - hour), () => { warning_manager.add(ev, '1 hour', fetcher); })); 67 | }); 68 | } 69 | -------------------------------------------------------------------------------- /fetchers/atcoder.js: -------------------------------------------------------------------------------- 1 | const logger = require('../logger'); 2 | const jsdom = require('jsdom') 3 | const EventEmitter = require('events'); 4 | const moment = require('moment-timezone'); 5 | 6 | function isNumeric(string) { 7 | return !isNaN(string); 8 | } 9 | 10 | /* Validate a duration array 11 | Expected format ['HH', 'mm']; 12 | */ 13 | function valid(duration){ 14 | return duration.length == 2 15 | && isNumeric(duration[0]) 16 | && isNumeric(duration[1]) 17 | && parseInt(duration[1]) < 60; 18 | } 19 | 20 | module.exports = { 21 | name: "atcoder", 22 | updateUpcoming: (upcoming) => { 23 | const emitter = new EventEmitter(); 24 | 25 | jsdom.env("https://atcoder.jp/contests", 26 | ["http://code.jquery.com/jquery.js"], 27 | (err, window) => { 28 | if (err) { 29 | logger.error("Failed on AtCoder.", err); 30 | return; 31 | } 32 | upcoming.length = 0; 33 | const $ = window.$; 34 | 35 | /* There's no specific classes or ids for the tables. 36 | We gather information of the table 3 if there is no active contests, otherwise 37 | we gather information fo the table 2. 38 | */ 39 | 40 | //check if there is the active contests table 41 | const isActiveContest = $("table").size() == 4; 42 | const tableToCheck = 1 + isActiveContest; 43 | 44 | var contests = $(`table:eq(${tableToCheck})`).children('tbody').children('tr'); 45 | 46 | contests.each(function (){ 47 | const row = $(this).children('td'); 48 | const name = row.eq(1).find('a').text(); 49 | 50 | /* There's always this practice contest -- deprecated*/ 51 | if(name == 'practice contest') return; 52 | 53 | const start = moment.tz(row.eq(0).find('time').text(), 'YYYY-MM-DD HH:mm:ss', 'Asia/Tokyo'); 54 | const duration = row.eq(2).text().split(':'); /* HH:mm */ 55 | const url = row.eq(1).find('a').attr('href'); 56 | 57 | if(!start.isValid() || !valid(duration)) { 58 | logger.error("AtCoder invalid dates for " + name); 59 | logger.error("\t Start: " + start); 60 | logger.error("\t Duration: " + duration); 61 | return; 62 | } 63 | 64 | upcoming.push({ 65 | judge: 'atcoder', 66 | name: name, 67 | url: 'https://atcoder.jp' + url, 68 | time: start.toDate(), 69 | duration: duration[0] * 3600 + duration[1] * 60 70 | }); 71 | }); 72 | emitter.emit('end'); 73 | }); 74 | 75 | return emitter; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/fixtures/upcoming-regular.js: -------------------------------------------------------------------------------- 1 | var d = new Date(); 2 | 3 | // note that contest times should always be ordered 4 | 5 | module.exports = [ 6 | // running long contest 7 | { judge: 'codechef', 8 | name: 'January Challenge 2018', 9 | url: 'https://www.codechef.com/JAN18', 10 | time: new Date(new Date(d).setHours(d.getHours() - 7)), 11 | duration: 864000 }, 12 | 13 | // running small contest 14 | { judge: 'codeforces', 15 | name: 'Educational Codeforces Round 36 (Rated for Div. 2)', 16 | url: 'http://codeforces.com/contests/915', 17 | time: new Date(new Date(d).setHours(d.getHours() - 1, d.getMinutes() - 10)), 18 | duration: 7200 }, 19 | 20 | // upcoming 1h 21 | { judge: 'atcoder', 22 | name: 'AtCoder Grand Contest 019', 23 | url: 'https://agc019.contest.atcoder.jp', 24 | time: new Date(new Date(d).setHours(d.getHours() + 1)), 25 | duration: 7800 }, 26 | { judge: 'codeforces', 27 | name: 'Some mock codeforces round', 28 | url: 'http://codeforces.com/contests/913', 29 | time: new Date(new Date(d).setHours(d.getHours() + 1)), 30 | duration: 7800 }, 31 | 32 | // upcoming contests from different judges 33 | { judge: 'atcoder', 34 | name: 'AtCoder Grand Contest 020', 35 | url: 'https://agc020.contest.atcoder.jp', 36 | time: new Date(new Date(d).setHours(d.getHours() + 3, d.getMinutes() + 37)), 37 | duration: 7800 }, 38 | { judge: 'csacademy', 39 | name: 'CSAcademy Round #65 (Div. 2 only)', 40 | url: 'https://csacademy.com/contest/round-65', 41 | time: new Date(new Date(d).setHours(d.getHours() + 4)), 42 | duration: 7200 }, 43 | { judge: 'codeforces', 44 | name: 'Codecraft-18 and Codeforces Round #457 (Div. 1 + Div. 2, combined)', 45 | url: 'http://codeforces.com/contests/914', 46 | time: new Date(new Date(d).setHours(d.getHours() + 5)), 47 | duration: 7200 }, 48 | 49 | // upcoming 1 day 50 | { judge: 'atcoder', 51 | name: 'AtCoder Grand Contest 021', 52 | url: 'https://agc21.contest.atcoder.jp', 53 | time: new Date(new Date(d).setHours(d.getHours() + 24)), 54 | duration: 7800 }, 55 | 56 | // same time 57 | { judge: 'topcoder', 58 | name: 'SRM', 59 | url: 'https://topcoder.com', 60 | time: new Date(new Date(d).setHours(d.getHours() + 29)), 61 | duration: 6000 }, 62 | 63 | // repetition 64 | { judge: 'topcoder', 65 | name: 'SRM', 66 | url: 'https://topcoder.com', 67 | time: new Date(new Date(d).setHours(d.getHours() + 29)), 68 | duration: 6000 } 69 | ] 70 | -------------------------------------------------------------------------------- /bot.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger'); 2 | const BotAPI = require('node-telegram-bot-api'); 3 | const process = require('process'); 4 | const html_msg = require('./html-msg'); 5 | const utils = require('./utils'); 6 | const mq = require('./messagequeue'); 7 | 8 | const db = require('./db'); 9 | 10 | var invalid_users = new Set(); 11 | var Bot = module.exports = {}; 12 | 13 | /* Delete invalid users that have blocked the bot */ 14 | Bot.delete_invalid = function() { 15 | let text = "Deleting " + invalid_users.size + " invalid users."; 16 | db.low 17 | .get('users') 18 | .remove((user) => { return invalid_users.has(user.id); }) 19 | .write(); 20 | invalid_users.clear(); 21 | return text; 22 | }; 23 | 24 | Bot.create_bot = function() { 25 | const bot = new BotAPI(process.env.TELEGRAM_TOKEN, {polling: true}); 26 | 27 | const send = function(msg, txt) { 28 | Bot.sendMessage(msg.chat.id, txt, { 29 | parse_mode: 'html', 30 | disable_web_page_preview: true 31 | }); 32 | }; 33 | 34 | Bot.bot = bot; 35 | Bot.mq = new mq.MessageQueue(Bot.sendMessageToTelegram); 36 | 37 | // It keeps rebooting due to OOM. So it's too verbose. 38 | // Bot.sendMessage(utils.admin_id, "Booting up.", {parse_mode: 'html'}); 39 | }; 40 | 41 | // Pushes message to MessageQueue 42 | Bot.sendMessage = function(chatId, text, options) { 43 | Bot.mq.push(chatId, text, options); 44 | }; 45 | 46 | /* Tries to send a message, logging errors. */ 47 | Bot.sendMessageToTelegram = function(message) { 48 | let promise = Bot.bot.sendMessage(message.chat_id, message.text, message.options); 49 | promise.catch((error) => { 50 | logger.error("Error while sending message: " + error.code + "\n" + JSON.stringify(error.response.body)); 51 | logger.error("Original message: " + text); 52 | logger.error("Options: " + JSON.stringify(options)); 53 | const err = error.response.body.error_code; 54 | // if the bot has been "banned" by this chat 55 | if (err === 400 || err === 403) 56 | invalid_users.add(chatId); 57 | }); 58 | return promise; 59 | }; 60 | 61 | /* Sends simple html message */ 62 | Bot.sendSimpleHtml = (chatId, text) => Bot.sendMessage(chatId, text, { 63 | parse_mode: 'html', 64 | disable_web_page_preview: true 65 | }); 66 | 67 | /* Sends simple markdown message */ 68 | Bot.sendSimpleMarkdown = (chatId, text) => Bot.sendMessage(chatId, text, { 69 | parse_mode: 'markdown', 70 | disable_web_page_preview: true 71 | }); 72 | 73 | /* Sends simple plain message */ 74 | Bot.sendSimplePlain = (chatId, text) => Bot.sendMessage(chatId, text, { 75 | disable_web_page_preview: true 76 | }); 77 | 78 | // vim:noet 79 | -------------------------------------------------------------------------------- /messagequeue.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger'); 2 | const db = require('./db'); 3 | 4 | const USER_LIMIT = 30; // 30 messages/sec 5 | const GROUP_LIMIT = 20; // 20 messages/min 6 | 7 | var mq = module.exports = {}; 8 | 9 | // Delays messages based on burst and time limits 10 | class DelayQueue { 11 | constructor(name, callback, burst_limit = 30, time_limit = 1000) { 12 | this.name = name; 13 | this.callback = callback; 14 | this.burst_limit = burst_limit; 15 | this.time_limit = time_limit; 16 | 17 | this.queue = []; 18 | this.sent = []; 19 | this.hibernating = true; 20 | } 21 | 22 | push(message) { 23 | //logger.info("push message to " + this.name + ". [" + this.queue.length + " / " + this.sent.length + "]"); 24 | this.queue.push(message); 25 | if (this.hibernating) 26 | this.run(); 27 | } 28 | 29 | 30 | run() { 31 | //logger.info("queue [" + this.name + "] running!"); 32 | this.hibernating = false; 33 | 34 | let now = Date.now(); 35 | while (this.sent.length && now - this.sent[0] > this.time_limit) this.sent.shift(); 36 | 37 | while (this.queue.length) { 38 | if (this.sent.length >= this.burst_limit) { 39 | let timeout = now - this.sent[0] + this.time_limit; 40 | //logger.info("queue [" + this.name + "] timed-out for " + timeout + "ms."); 41 | setTimeout(this.run.bind(this), timeout); 42 | break; 43 | } 44 | this.sent.push(Date.now()); 45 | this.callback(this.queue.shift()); 46 | } 47 | 48 | if (this.queue.length == 0) { 49 | this.hibernating = true; 50 | //logger.info("queue [" + this.name + "] is hibernating!"); 51 | } 52 | } 53 | } 54 | 55 | 56 | // Manage messages to avoid Telegram flood limits 57 | class MessageQueue { 58 | constructor(callback) { 59 | this.all_queue = new DelayQueue("all_queue", callback, 30, 1000); 60 | this.group_queue = new DelayQueue("group_queue", this.group_to_all.bind(this), 20, 60000); 61 | } 62 | 63 | push(chat_id, text, options) { 64 | //logger.info("message queue push: {" + chat_id + ", \"" + text + "\""); 65 | 66 | let user = db.user.get_by_id(chat_id); 67 | 68 | const message = 69 | { 70 | chat_id : chat_id, 71 | text : text, 72 | options : options 73 | }; 74 | 75 | if (user.get('is_group').value()) 76 | this.group_queue.push(message); 77 | else 78 | this.all_queue.push(message); 79 | } 80 | 81 | group_to_all(message) { 82 | //logger.info("group to all: " + message.text); 83 | this.all_queue.push(message); 84 | } 85 | } 86 | 87 | mq.MessageQueue = MessageQueue; 88 | -------------------------------------------------------------------------------- /msg_handlers/basic.js: -------------------------------------------------------------------------------- 1 | /* Commands /start, /stop and /help */ 2 | const logger = require('../logger'); 3 | const Bot = require('../bot'); 4 | const db = require('../db'); 5 | 6 | const start_stop = module.exports = {}; 7 | 8 | start_stop.init = function() { 9 | 10 | Bot.bot.on('message', (msg) => { 11 | // update database when chat migrates 12 | if (msg.migrate_from_chat_id !== undefined) 13 | db.user.migrate(msg.migrate_from_chat_id, msg.chat.id); 14 | // mark last activity 15 | db.user.get(msg.chat).set('last_activity', Date.now()).write(); 16 | }); 17 | 18 | 19 | Bot.bot.onText(/^\/start(@\w+)*$/, (message) => { 20 | let response = ""; 21 | 22 | let id = message.chat.id; 23 | let user = db.user.get(message.chat); 24 | 25 | if (user.get('notify').value() === true) { 26 | response = "Looks like you're already registered for reminders. "; 27 | } else { 28 | user 29 | .set('notify', true) 30 | .write(); 31 | response = "You have been registered and will receive reminders for the contests! "; 32 | } 33 | response += "Use /stop if you want to stop receiving reminders."; 34 | Bot.sendSimplePlain(message.chat.id, response); 35 | }); 36 | 37 | Bot.bot.onText(/^\/stop(@\w+)*$/m, (message) => { 38 | let response = ""; 39 | 40 | let user = db.user.get(message.chat); 41 | 42 | if (user.get('notify').value() === false) { 43 | response = "Looks like you're already not registered for reminders. "; 44 | } else { 45 | user 46 | .set('notify', false) 47 | .write(); 48 | response = "You have been unregistered and will not receive reminders for the contests :(. "; 49 | } 50 | response += "Use /start if you want to receive reminders."; 51 | Bot.sendSimplePlain(message.chat.id, response); 52 | }); 53 | 54 | Bot.bot.onText(/^\/help(@\w+)*$/m, (message) => { 55 | Bot.sendSimplePlain(message.chat.id, "Hello, I am ContestWatcher Bot :D. I list programming contests from Codeforces, Topcoder, Codechef, CSAcademy and AtCoder.\n\n" + 56 | "You can control me by sending these commands: \n\n" + 57 | "/start - start receiving reminders before the contests. I'll send a reminder 1 day and another 1 hour before each contest.\n" + 58 | "/stop - stop receiving reminders.\n" + 59 | "/upcoming - show the next scheduled contests.\n" + 60 | "/running - show running contests.\n" + 61 | "/help_handles - info on how to add and remove codeforces handles.\n" + 62 | "/refresh - refresh the contest list. This is done automatically once per day.\n" + 63 | "/judges - list supported judges.\n" + 64 | "/enable judge - enable notifications for some judge.\n" + 65 | "/disable judge - disable notifications for some judge.\n" + 66 | "/help - shows this help message."); 67 | }); 68 | 69 | } 70 | -------------------------------------------------------------------------------- /judgeAPIs/cfAPI.js: -------------------------------------------------------------------------------- 1 | const logger = require('../logger'); 2 | const https = require('https'); 3 | const EventEmitter = require('events'); 4 | const schedule = require('node-schedule'); 5 | const qs = require('querystring'); 6 | 7 | const cf_api = module.exports = {} 8 | 9 | /* Calls method name with arguments args (from codeforces API), returns an emitter that calls 'end' returning the parsed JSON when the request ends. The emitter returns 'error' instead if something went wrong */ 10 | cf_api.call_cf_api = function(name, args, retry_times) { 11 | const emitter = new EventEmitter(); 12 | 13 | emitter.on('error', (extra_info) => { 14 | logger.error('Call to ' + name + ' failed. ' + extra_info); 15 | }); 16 | 17 | let try_; 18 | try_= function(times) { 19 | const url = 'https://codeforces.com/api/' + name + '?' + qs.stringify(args); 20 | logger.info('CF request: ' + url); 21 | https.get(url, (res) => { 22 | if (res.statusCode !== 200) { 23 | res.resume(); 24 | if(times > 0) try_(times - 1); 25 | else emitter.emit('error', 'Status Code: ' + res.statusCode); 26 | return; 27 | } 28 | res.setEncoding('utf8'); 29 | 30 | let data = ''; 31 | 32 | res.on('data', (chunk) => data += chunk); 33 | res.on('end', () => { 34 | let obj; 35 | try { 36 | logger.info(data.substr(0, 1000)); 37 | obj = JSON.parse(data); 38 | if (obj.status == "FAILED") { 39 | if(times > 0) try_(times - 1); 40 | else emitter.emit('error', 'Comment: ' + obj.comment); 41 | return; 42 | } 43 | } catch(e) { 44 | if(times > 0) try_(times - 1); 45 | else emitter.emit('error', ''); 46 | return; 47 | } 48 | emitter.emit('end', obj.result); 49 | }).on('error', (e) => { 50 | if(times > 0) try_(times - 1); 51 | else emitter.emit('error', e.message); 52 | }); 53 | }).on('error', (e) => { 54 | if(times > 0) try_(times - 1); 55 | else emitter.emit('error', e.message); 56 | }); 57 | } 58 | try_(retry_times); 59 | 60 | return emitter; 61 | } 62 | 63 | /* Calls cf api function 'name' every 30 seconds until condition is satisfied, and then calls callback. Tries at most for 3 days, if it is not satisfied, then it gives up. */ 64 | cf_api.wait_for_condition_on_api_call = function(name, args, condition, callback) { 65 | const emitter = new EventEmitter(); 66 | let count_calls = 0; 67 | let handle = schedule.scheduleJob('/30 * * * * *', () => { 68 | cf_api.call_cf_api(name, args, 0) 69 | .on('end', (obj) => { 70 | if (condition(obj)) { 71 | handle.cancel(); 72 | callback(obj); 73 | } else if (count_calls++ > 2 * 60 * 24 * 3) // 3 days 74 | handle.cancel(); 75 | }).on('error', () => { 76 | if (count_calls++ > 2 * 60 * 24 * 3) // 3 days 77 | handle.cancel() 78 | }); 79 | }); 80 | } 81 | -------------------------------------------------------------------------------- /msg_handlers/judges.js: -------------------------------------------------------------------------------- 1 | /* Commands regarding dealing with individual judges */ 2 | const logger = require('../logger'); 3 | const Bot = require('../bot'); 4 | const db = require('../db'); 5 | const html_msg = require('../html-msg'); 6 | 7 | const judges = module.exports = {}; 8 | 9 | judges.init = function() { 10 | const bot = Bot.bot; 11 | 12 | /* Enables notifications from a given judge */ 13 | bot.onText(/^\/enable(@\w+)*/m, (message) => { 14 | let pars = message.text.split(' '); 15 | let response = ""; 16 | if (pars.length < 2) { 17 | response = "No judge specified."; 18 | } else { 19 | let user = db.user.get(message.chat); 20 | let judge = pars[1]; 21 | 22 | let ignored = user 23 | .has('ignore.' + judge) 24 | .value(); 25 | 26 | if (ignored === true) { 27 | user 28 | .unset('ignore.' + judge) 29 | .write(); 30 | response = "Ok! Now this judge no longer ignored for you!"; 31 | logger.info("Enable " + judge + " on " + message.chat.id); 32 | } else { 33 | response = "You are not ignoring this judge."; 34 | } 35 | } 36 | 37 | Bot.sendSimpleHtml(message.chat.id, html_msg.escape(response)); 38 | }); 39 | 40 | /* Disables notifications from a given judge */ 41 | bot.onText(/^\/disable(@\w+)*/m, (message) => { 42 | let pars = message.text.split(' '); 43 | let response = ""; 44 | if (pars.length < 2) { 45 | response = "No judge specified."; 46 | } else { 47 | let user = db.user.get(message.chat); 48 | let judge = pars[1]; 49 | 50 | let ignored = user 51 | .has('ignore.' + judge) 52 | .value(); 53 | 54 | if (ignored === false) { 55 | user 56 | .set('ignore.' + judge, true) 57 | .write(); 58 | response = "Ok! Now this judge is now ignored for you!"; 59 | logger.info("Disable " + judge + " on " + message.chat.id); 60 | } else { 61 | response = "You are already ignoring this judge."; 62 | } 63 | } 64 | 65 | Bot.sendSimpleHtml(message.chat.id, html_msg.escape(response)); 66 | }); 67 | 68 | /* List all judges and their status */ 69 | bot.onText(/^\/judges(@\w+)*$/m, (message) => { 70 | let user = db.user.get(message.chat); 71 | 72 | let response = "You can /enable or /disable judges with the commands as you wish. Try typing /enable calendar.\n\n"; 73 | response += "Supported Judges: \n" 74 | 75 | let vals = [ 76 | ['codeforces', ''], 77 | ['topcoder', ''], 78 | ['codechef', ''], 79 | ['csacademy', ''], 80 | ['atcoder', ''], 81 | ['calendar', ' : manually inputed. (codejam, yandex, local events, etc)'] 82 | ]; 83 | 84 | for (let i = 0; i < vals.length; i++) { 85 | let state = user 86 | .has('ignore.' + vals[i][0]) 87 | .value(); 88 | 89 | if (state === true) 90 | response += '[ignored] '; 91 | response += vals[i][0] + vals[i][1] + '\n'; 92 | } 93 | 94 | Bot.sendSimpleHtml(message.chat.id, html_msg.escape(response)); 95 | }); 96 | 97 | } 98 | -------------------------------------------------------------------------------- /tests/alerts.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const expect = chai.expect; 3 | const rewire = require('rewire'); 4 | const sinon = require('sinon'); 5 | const schedule = require('node-schedule'); 6 | 7 | var alerts = rewire('./../alerts'); 8 | var db = require('./../db'); 9 | var bot = require('./../bot'); 10 | 11 | const upcoming_sample = require('./fixtures/upcoming-regular.js'); 12 | const users_sample = require('./fixtures/users-warn.js'); 13 | 14 | const day = 24 * 60 * 60 * 1000; 15 | 16 | describe('warning_manager.flush_buffer', function () { 17 | let warning_manager = alerts.__get__('warning_manager'); 18 | 19 | beforeEach(function() { 20 | warning_manager.buffer = [ 21 | { left: '1 hour', ev: upcoming_sample[2] }, 22 | { left: '1 hour', ev: upcoming_sample[3] }, 23 | { left: '1 day', ev: upcoming_sample[7] } 24 | ]; 25 | 26 | sinon.stub(db.low, 'get'); 27 | db.low.get.withArgs('users').returns({ value: function () { return users_sample; } }); 28 | 29 | sinon.stub(bot, 'sendSimpleHtml'); 30 | }); 31 | 32 | afterEach(function() { 33 | db.low.get.restore(); 34 | bot.sendSimpleHtml.restore(); 35 | }); 36 | 37 | it("should clear buffer", function () { 38 | warning_manager.flush_buffer(); 39 | expect(warning_manager.buffer).to.be.empty; 40 | }); 41 | 42 | it("should send messages only for insterested users", function () { 43 | warning_manager.flush_buffer(); 44 | expect(bot.sendSimpleHtml.calledWith(0)).to.be.true; 45 | expect(bot.sendSimpleHtml.calledWith(1)).to.be.true; 46 | expect(bot.sendSimpleHtml.calledWith(2)).to.be.true; 47 | expect(bot.sendSimpleHtml.calledWith(5)).to.be.false; 48 | expect(bot.sendSimpleHtml.calledWith(3)).to.be.false; 49 | expect(bot.sendSimpleHtml.calledWith(4)).to.be.false; 50 | expect(bot.sendSimpleHtml.callCount).to.eq(3); 51 | }); 52 | 53 | it("should not inform about ignored judges", function () { 54 | warning_manager.flush_buffer(); 55 | expect(bot.sendSimpleHtml.calledWith(4)).to.be.false; 56 | expect(bot.sendSimpleHtml.calledWithMatch(sinon.match.same(2),sinon.match(/codeforces/))).to.be.false; 57 | }); 58 | }); 59 | 60 | describe('warning_manager.add', function () { 61 | let warning_manager = alerts.__get__('warning_manager'); 62 | 63 | beforeEach(function () { 64 | this.clock = sinon.useFakeTimers(); 65 | sinon.stub(warning_manager, 'flush_buffer'); 66 | warning_manager.buffer = []; 67 | 68 | // scheduler binds the function passed as callback, this means stubbing is ignored unless I change the code (bad) or redefine it here 69 | warning_manager.next_flush = new schedule.scheduleJob(new Date(Date.now() + 300000 * day), warning_manager.flush_buffer); 70 | warning_manager.next_flush.cancel(); 71 | }); 72 | 73 | afterEach(function () { 74 | this.clock.restore(); 75 | warning_manager.flush_buffer.restore(); 76 | }); 77 | 78 | // i'm kind of testing the scheduler library in the two tests below 79 | // this is wrong but it's being useful now because their documentation is not perfect 80 | it("should reschedule exactly one flush in 30s", function () { 81 | warning_manager.add({ ev: upcoming_sample[2], left: '1 hour' }); 82 | this.clock.tick(30*1000 + 7); 83 | expect(warning_manager.flush_buffer.calledOnce).to.be.true; 84 | 85 | this.clock.runAll(); 86 | expect(warning_manager.flush_buffer.calledOnce).to.be.true; 87 | }); 88 | 89 | it("should collect each event when they are added togheter", function () { 90 | warning_manager.add({ ev: upcoming_sample[2], left: '1 hour' }); 91 | warning_manager.add({ ev: upcoming_sample[3], left: '1 hour' }); 92 | warning_manager.add({ ev: upcoming_sample[7], left: '1 day' }); 93 | expect(warning_manager.buffer).to.have.lengthOf(3); 94 | 95 | this.clock.tick(30*1000 + 7); 96 | expect(warning_manager.flush_buffer.calledOnce).to.be.true; 97 | 98 | this.clock.runAll(); 99 | expect(warning_manager.flush_buffer.calledOnce).to.be.true; 100 | }); 101 | 102 | it("should be able to reschedule again after the first call", function () { 103 | warning_manager.add({ ev: upcoming_sample[2], left: '1 hour' }); 104 | this.clock.tick(30*1000 + 7); 105 | warning_manager.add({ ev: upcoming_sample[3], left: '1 hour' }); 106 | 107 | this.clock.tick(30*1000 + 7); 108 | expect(warning_manager.flush_buffer.calledTwice).to.be.true; 109 | 110 | this.clock.runAll(); 111 | expect(warning_manager.flush_buffer.calledTwice).to.be.true; 112 | }); 113 | }); 114 | 115 | -------------------------------------------------------------------------------- /msg_handlers/contest_list.js: -------------------------------------------------------------------------------- 1 | /* Commands to deal with the list of contests: upcoming, running, etc. */ 2 | const logger = require('../logger'); 3 | const Bot = require('../bot'); 4 | const html_msg = require('../html-msg'); 5 | const fetch = require('../fetch'); 6 | const db = require('../db'); 7 | 8 | const contest_list = module.exports = {}; 9 | 10 | // returns the timeanddate link for given date 11 | const time_link = function(name, d) { 12 | return "https://www.timeanddate.com/worldclock/fixedtime.html?" + 13 | "msg=" + encodeURIComponent(name) + 14 | "&year=" + d.getUTCFullYear() + 15 | "&month=" + (d.getUTCMonth() + 1).toString() + 16 | "&day=" + d.getUTCDate() + 17 | "&hour=" + d.getUTCHours() + 18 | "&min=" + d.getUTCMinutes() + 19 | "&sec=" + d.getUTCSeconds(); 20 | }; 21 | 22 | /* returns a string of number x with suffix, unless it is 0 23 | * used to print dates */ 24 | const num = function(x, suffix) { 25 | x = Math.floor(x) 26 | if (x == 0) return ""; 27 | return x + suffix; 28 | }; 29 | 30 | 31 | // last time refresh was called 32 | let last_refresh = new Date(0); 33 | 34 | contest_list.init = function() { 35 | const bot = Bot.bot; 36 | 37 | bot.onText(/^\/running(@\w+)*$/, (message) => { 38 | const user = db.user.get(message.chat); 39 | const maxContests = 7; 40 | let validContests = 0; 41 | let result = ""; 42 | 43 | fetch.upcoming.forEach( (entry) => { 44 | if (entry.time.getTime() > Date.now()) 45 | return; 46 | if (entry.time.getTime() + (entry.duration * 1000) < Date.now()) 47 | return; 48 | if (user.has('ignore.' + entry.judge).value() === true) 49 | return; 50 | 51 | validContests++; 52 | 53 | if (validContests <= maxContests) { 54 | const d = entry.duration / 60; 55 | const min = Math.ceil((entry.time.getTime() + entry.duration*1000 - Date.now()) / (1000 * 60)); 56 | result += 57 | html_msg.make_link(entry.name, entry.url) + 58 | html_msg.escape(" (" + Math.floor(d / 60) + "h" + (d % 60 == 0? "" : (d % 60 < 10? "0" : "") + (d % 60).toString())+ ")\nends in ") + 59 | html_msg.make_link(num(min / (60 * 24), 'd ') + num((min / 60) % 24, 'h ') + (min % 60).toString() + "m", time_link(entry.name, entry.time)) + 60 | "\n\n"; 61 | } 62 | }); 63 | 64 | if (maxContests < validContests) 65 | result += html_msg.escape("And other " + (validContests - maxContests) + " running besides those..."); 66 | 67 | if (result == "") 68 | result = html_msg.escape("No running contests :("); 69 | 70 | Bot.sendSimpleHtml(message.chat.id, result); 71 | }); 72 | 73 | bot.onText(/^\/upcoming(@\w+)*$/, (message) => { 74 | const user = db.user.get(message.chat); 75 | const maxContests = 7; 76 | let validContests = 0; 77 | let result = ""; 78 | 79 | fetch.upcoming.forEach( (entry) => { 80 | if (entry.time.getTime() < Date.now()) 81 | return; 82 | if (entry.time.getTime() > Date.now() + 14 * 24 * 60 * 60 * 1000) // at most 14 days 83 | return; 84 | if (user.has('ignore.' + entry.judge).value() === true) 85 | return; 86 | 87 | validContests++; 88 | 89 | if (validContests <= maxContests) { 90 | const d = entry.duration / 60 91 | const min = Math.ceil((entry.time.getTime() - Date.now()) / (1000 * 60)) 92 | result += 93 | html_msg.make_link(entry.name, entry.url) + " " + 94 | html_msg.escape("(" + Math.floor(d / 60) + "h" + (d % 60 == 0? "" : (d % 60 < 10? "0" : "") + (d % 60).toString())+ ")\n") + 95 | "starts in " + html_msg.make_link(num(min / (60 * 24), 'd ') + num((min / 60) % 24, 'h ') + (min % 60).toString() + "m", time_link(entry.name, entry.time)) + 96 | "\n\n"; 97 | } 98 | }); 99 | 100 | if (maxContests < validContests) 101 | result += html_msg.escape("And other " + (validContests - maxContests) + " scheduled in the next 2 weeks..."); 102 | 103 | if (result == "") 104 | result = html_msg.escape("No upcoming contests :("); 105 | 106 | Bot.sendSimpleHtml(message.chat.id, result); 107 | }); 108 | 109 | bot.onText(/^\/refresh(@\w+)*$/, (message) => { 110 | if (Date.now() - last_refresh.getTime() < 1000 * 60 * 10) { 111 | Bot.sendSimpleHtml(message.chat.id, html_msg.escape("Contest list was refreshed less than 10 minutes ago.")); 112 | } else { 113 | fetch.updateUpcoming(); 114 | Bot.sendSimpleHtml(message.chat.id, html_msg.escape("Refreshing contest list... Please wait a bit before using /upcoming.")); 115 | last_refresh = new Date(); 116 | } 117 | }); 118 | 119 | } 120 | -------------------------------------------------------------------------------- /msg_handlers/cf_handles.js: -------------------------------------------------------------------------------- 1 | /* Commands regarding dealing with codeforces handles */ 2 | const logger = require('../logger'); 3 | const EventEmitter = require('events'); 4 | const cfAPI = require('../judgeAPIs/cfAPI'); 5 | const Bot = require('../bot'); 6 | const db = require('../db'); 7 | const html_msg = require('../html-msg'); 8 | 9 | const cf = module.exports = {}; 10 | 11 | const add_handle_reply_msg = "Please send me your handle. :D"; 12 | 13 | /* Adds CF handle to handle list */ 14 | function add_handles(message) { 15 | if(message.text.indexOf(' ') === -1) { 16 | Bot.sendMessage(message.chat.id, add_handle_reply_msg, { 17 | reply_to_message_id: message.message_id, 18 | reply_markup: { 19 | force_reply: true, 20 | selective: true 21 | } 22 | }); 23 | return; 24 | } 25 | const emitter = new EventEmitter(); 26 | const user = db.user.get(message.chat); 27 | 28 | emitter.on('add', (handles, wrong_handles) => { 29 | Array.from(handles).forEach((h) => user.get('cf_handles').push(h).write()); 30 | if (wrong_handles.length == 0) emitter.emit('end', "Handles added successfully :)"); 31 | else { 32 | wrong_handles.sort() 33 | wrong_handles = wrong_handles.map((h) => '' + html_msg.escape(h) + '') 34 | emitter.emit('end', "These handles could not be added: " + wrong_handles.join(', ') + '.') 35 | } 36 | }); 37 | emitter.on('end', (txt, handles) => { 38 | Bot.sendMessage(message.chat.id, txt, { 39 | parse_mode: 'html', 40 | disable_web_page_preview: true 41 | }); 42 | }); 43 | 44 | // Use lowercase handles for comparison 45 | const user_cur = new Set(user.get('cf_handles').value().map(x => x.toLowerCase())); 46 | const allHandles = 47 | Array.from(new Set( 48 | message.text.slice(message.text.indexOf(' ') + 1) 49 | .toLowerCase() 50 | .trim().split(' '))) 51 | .map((h) => h.trim()). 52 | filter((h) => h.length > 0 && !user_cur.has(h)); 53 | 54 | if(allHandles.length === 0) 55 | emitter.emit('end', "No new handles to add."); 56 | else { 57 | if (allHandles.length > 100) { 58 | logger.warn('User ' + message.chat.id + ' tried to add more than 100 handles.'); 59 | emitter.emit('end', "I'm not about to do that."); 60 | } else { 61 | const handles_set = new Set(); 62 | 63 | cfAPI.call_cf_api('user.info', {handles: allHandles.join(';')}, 1).on('error', () => { 64 | var wrong_handles = [] 65 | var handlesToAdd = allHandles.length 66 | emitter.on('check', (handle) => { 67 | cfAPI.call_cf_api('user.info', {handles: handle}, 2).on('error', () => { 68 | wrong_handles.push(handle); 69 | if (--handlesToAdd == 0) emitter.emit('add', handles_set, wrong_handles); 70 | }).on('end', (data) => { 71 | // Adds handle with correct case 72 | handles_set.add(data[0]['handle']) 73 | if (--handlesToAdd == 0) emitter.emit('add', handles_set, wrong_handles); 74 | }); 75 | }); 76 | for (var i in allHandles) { 77 | emitter.emit('check', allHandles[i]); 78 | } 79 | }).on('end', (data) => { 80 | // Adds all handles with correct case 81 | data.forEach(u => handles_set.add(u['handle'])); 82 | emitter.emit('add', handles_set, []) 83 | }) 84 | } 85 | } 86 | } 87 | 88 | /* Lists added CF handles */ 89 | function list_handles(message) { 90 | const user = db.user.get(message.chat); 91 | let msg; 92 | if(!user.has('cf_handles').value() || user.get('cf_handles').size().value() == 0) 93 | msg = "No Codeforces handles."; 94 | else 95 | msg = "Codeforces handles: " + user.get('cf_handles').value().join(', '); 96 | Bot.sendMessage(message.chat.id, msg, {}) 97 | } 98 | 99 | /* Removes CF handle from handle list */ 100 | function rem_handles(message) { 101 | let msg; 102 | if(message.text.indexOf(' ') === -1) 103 | msg = "No handles to remove."; 104 | else { 105 | const user = db.user.get(message.chat); 106 | const hs = new Set(message.text.slice(message.text.indexOf(' ') + 1).toLowerCase().split(' ')) 107 | if(!user.has('cf_handles').value()) 108 | user.set('cf_handles', []).write(); 109 | user.get('cf_handles').remove((h) => hs.has(h.toLowerCase())).write(); 110 | msg = "Handles removed successfully :)"; 111 | } 112 | Bot.sendMessage(message.chat.id, msg, {}); 113 | } 114 | 115 | /* Shows help message for handles */ 116 | function help_handles(message) { 117 | let msg = "You can have a list of codeforces handles to watch. If you have " + 118 | "codeforces notifications enabled, you will be notified about all contests, " + 119 | "but you will only receive information regarding the system testing and " + 120 | "rating changes for contests that some user with handle on " + 121 | "your handle list is participating.\n\n" + 122 | "The following commands are for handling your handles:\n" + 123 | "/add_handles h1 h2 h3 - add codeforces handles to your handle list\n" + 124 | "/rem_handles h1 h2 h3 - remove codeforces handles to your handle list\n" + 125 | "/list_handles - list codeforces handles in your handle list\n"; 126 | Bot.sendMessage(message.chat.id, msg, {}); 127 | } 128 | 129 | cf.init = function() { 130 | const bot = Bot.bot; 131 | 132 | bot.on('message', (msg) => { 133 | // check for reply on handle add 134 | if(msg.reply_to_message) { 135 | let rp = msg.reply_to_message; 136 | if(rp.text === add_handle_reply_msg) { 137 | let cp = JSON.parse(JSON.stringify(msg)); // deep copy 138 | cp.text = "/add_handles " + cp.text; 139 | add_handles(cp); 140 | } 141 | } 142 | }); 143 | 144 | /* CF handles stuff */ 145 | bot.onText(/^\/list_handles(@\w+)*$/, list_handles); 146 | bot.onText(/^\/help_handles(@\w+)*$/, help_handles); 147 | bot.onText(/^\/add_handles(@\w+)* ?.*$/, add_handles); 148 | bot.onText(/^\/rem_handles(@\w+)* ?.*$/, rem_handles); 149 | } 150 | 151 | -------------------------------------------------------------------------------- /fetchers/codeforces.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | const logger = require('../logger'); 3 | const EventEmitter = require('events'); 4 | const schedule = require('node-schedule'); 5 | const bot = require('../bot'); 6 | const db = require('../db'); 7 | const cfAPI = require('../judgeAPIs/cfAPI'); 8 | const html_msg = require('../html-msg'); 9 | 10 | const contest_end_handlers = []; 11 | const in_contest_ids = {}; 12 | 13 | /* Msgs all users in a contest */ 14 | let contest_msg_all = function(msg, contest_id) { 15 | db.low 16 | .get('users') 17 | .reject((user) => { return !user.notify || user.ignore.codeforces || !in_contest_ids[contest_id].has(user.id); }) 18 | .map('id') 19 | .value() 20 | .forEach((id) => { bot.sendSimpleHtml(id, msg); }); 21 | }; 22 | 23 | /* Called when ratings are changed */ 24 | let process_final = function(ratings, ev, contest_id) { 25 | const mp = new Map(); 26 | ratings.forEach((r) => mp.set(r.handle.toLowerCase(), r)); 27 | db.low 28 | .get('users') 29 | .reject((user) => { return !user.notify || user.ignore.codeforces || !in_contest_ids[contest_id].has(user.id); }) 30 | .value() 31 | .forEach((user) => { 32 | let msg = 'Ratings for ' + html_msg.make_link(ev.name, ev.url) + ' are out!'; 33 | let rs = []; // ratings for handles from user 34 | user.cf_handles.forEach((h) => { 35 | if(mp.has(h.toLowerCase())) 36 | rs.push(mp.get(h.toLowerCase())); 37 | }); 38 | if(rs.length === 0) 39 | return; 40 | rs.sort((a, b) => a.rank - b.rank); 41 | rs.forEach((r) => { 42 | let prefix = ""; 43 | if(r.newRating >= r.oldRating) prefix = "+"; 44 | msg += '\n\n' + html_msg.escape(r.handle) + '\n' + html_msg.escape(r.oldRating + ' → '+ r.newRating + ' (' + prefix + (r.newRating - r.oldRating) + ')'); 45 | }); 46 | bot.sendMessage(user.id, msg, { parse_mode: 'html', disable_web_page_preview: true }); 47 | }); 48 | }; 49 | 50 | /* Called when system testing ends, checks for rating changes */ 51 | let process_ratings = function(ev, contest_id) { 52 | contest_msg_all('System testing has finished for ' + html_msg.make_link(ev.name, ev.url) + '. Waiting for rating changes.', contest_id); 53 | cfAPI.wait_for_condition_on_api_call('contest.ratingChanges', {contestId: contest_id}, 54 | /* condition */ (obj) => obj.length > 0, 55 | /* callback */ () => { 56 | let in30s = new Date(Date.now() + 30 * 1000); 57 | schedule.scheduleJob(in30s, () => 58 | cfAPI.call_cf_api('contest.ratingChanges', {contestId: contest_id}, 4) 59 | .on('end', (obj) => process_final(obj, ev, contest_id))); 60 | }); 61 | }; 62 | 63 | /* Called when system testing starts, checks for end of system testing */ 64 | let process_systest = function(ev, contest_id) { 65 | // contest_msg_all('System testing has started for ' + html_msg.make_link(ev.name, ev.url) + '.', contest_id); 66 | cfAPI.wait_for_condition_on_api_call('contest.standings', {contestId: contest_id, from: 1, count: 1}, 67 | /* condition */ (obj) => obj.contest.phase === 'FINISHED', 68 | /* callback */ () => process_ratings(ev, contest_id)); 69 | }; 70 | 71 | /* Called when contest ends, checks for start of system testing */ 72 | let process_contest_end = function(ev, contest_id) { 73 | // contest_msg_all(html_msg.make_link(ev.name, ev.url) + ' has just ended. Waiting for system testing.', contest_id); 74 | cfAPI.wait_for_condition_on_api_call('contest.standings', {contestId: contest_id, from: 1, count: 1}, 75 | /* condition */ (obj) => obj.contest.phase === 'SYSTEM_TEST' || obj.contest.phase === 'FINISHED', 76 | /* callback */ () => process_systest(ev, contest_id)); 77 | }; 78 | 79 | /* Checks if contest really ended (was not extended) and collects participating handles. */ 80 | let prelim_contest_end = function(ev, contest_id) { 81 | in_contest_ids[contest_id] = new Set(); 82 | 83 | /* Deletes this info after 5 days */ 84 | let in5d = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000); 85 | schedule.scheduleJob(in5d, () => delete in_contest_ids[contest_id] ); 86 | 87 | const user_handles = new Set(); 88 | db.low 89 | .get('users') 90 | .map('cf_handles') 91 | .value() 92 | .forEach((hs) => { if(hs) hs.forEach((h) => user_handles.add(h)); }); 93 | logger.info("Total handle count: " + user_handles.size); 94 | cfAPI.call_cf_api('contest.standings', {contestId: contest_id, showUnofficial: true}, 5) 95 | .on('end', (obj) => { 96 | const handles_in_contest = new Set(); 97 | obj.rows.forEach((row) => row.party.members.forEach((m) => { if(user_handles.has(m.handle)) handles_in_contest.add(m.handle); })); 98 | logger.info("CF contest " + ev.name + " has " + handles_in_contest.size + " participants."); 99 | if(handles_in_contest.size === 0) return; 100 | 101 | db.low 102 | .get('users') 103 | .value() 104 | .forEach((user) => { 105 | user.cf_handles.forEach((h) => { if(handles_in_contest.has(h)) in_contest_ids[contest_id].add(user.id); }); 106 | }); 107 | logger.info("CF contest " + ev.name + " has participants from " + in_contest_ids[contest_id].size + " chats."); 108 | 109 | cfAPI.wait_for_condition_on_api_call('contest.standings', {contestId: contest_id, from: 1, count: 1}, 110 | /* condition */ (obj) => obj.contest.phase !== 'BEFORE' && obj.contest.phase !== 'CODING', 111 | /* callback */ () => process_contest_end(ev, contest_id)); 112 | }); 113 | }; 114 | 115 | module.exports = { 116 | name: "codeforces", 117 | updateUpcoming: (upcoming) => { 118 | const emitter = new EventEmitter(); 119 | 120 | contest_end_handlers.forEach((h) => { if (h) h.cancel(); }); 121 | contest_end_handlers.length = 0; 122 | 123 | cfAPI.call_cf_api('contest.list', null, 1).on('end', (parsedData) => { 124 | try { 125 | upcoming.length = 0; 126 | parsedData.forEach( (el) => { 127 | if (el.phase === "BEFORE" || el.phase === "CODING") { 128 | const ev = { 129 | judge: 'codeforces', 130 | name: el.name, 131 | url: 'http://codeforces.com/contests/' + el.id, 132 | time: new Date(el.startTimeSeconds*1000), 133 | duration: el.durationSeconds 134 | }; 135 | upcoming.push(ev); 136 | if (el.type === "CF") 137 | contest_end_handlers.push(schedule.scheduleJob(new Date((el.startTimeSeconds + el.durationSeconds - 60) * 1000), 138 | () => { prelim_contest_end(ev, el.id); })); 139 | } 140 | }); 141 | 142 | upcoming.sort( (a, b) => { return a.time - b.time; }); 143 | 144 | emitter.emit('end'); 145 | } catch (e) { 146 | logger.error('Parse Failed Codeforces\n' + e.message); 147 | } 148 | }); 149 | 150 | return emitter; 151 | } 152 | }; 153 | --------------------------------------------------------------------------------