├── .github └── workflows │ └── python.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── env_file_variables.txt ├── remind ├── __main__.py ├── cogs │ ├── logging.py │ ├── meta.py │ └── reminders.py ├── constants.py └── util │ ├── clist_api.py │ ├── discord_common.py │ ├── paginator.py │ └── rounds.py ├── requirements.txt └── run.sh /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Python Linting 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Python Style Checker 17 | uses: andymckay/pycodestyle-action@0.1.3 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .env2 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | 132 | data/ 133 | logs/ 134 | .vscode/ 135 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7 2 | RUN mkdir /app 3 | WORKDIR /app 4 | COPY . . 5 | RUN pip install -r requirements.txt 6 | CMD ["/app/run.sh"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Aryan Choudhary 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Remind [![GitHub stars](https://img.shields.io/github/stars/aryanc403/remind.svg?style=social&label=Star&maxAge=2592000)](https://github.com/aryanc403/remind/stargazers/) 2 | 3 | [![made-with-python](https://img.shields.io/badge/Made%20with-Python-1f425f.svg)](https://www.python.org/) 4 | 5 | A discord bot that sends reminders for future contests using [clist](https://clist.by/) API. 6 | 7 | ## The future of remind bot 8 | Major dependency of this project [discord.py](https://github.com/Rapptz/discord.py) is now deprecated and would no longer be maintained. There are some forks of [TLE](https://github.com/cheran-senthil/TLE) bot which uses this repo as cog and probably they will update it. New changes introduced by discord would eventually break almost all features. 9 | Rather than reinventing the same wheel again I would prefer to work on something else and learn something new. 10 | 11 | ## Installation 12 | 13 | > **Use Python 3.7 or later.** 14 | 15 | Clone the repository: 16 | 17 | ```bash 18 | git clone https://github.com/aryanc403/remind 19 | ``` 20 | 21 | ### Dependencies 22 | 23 | Now all dependencies need to be installed. 24 | 25 | Dependencies are listed in [requirements.txt](requirements.txt). 26 | 27 | ```bash 28 | pip install -r requirements.txt 29 | ``` 30 | 31 | ### Final steps 32 | 33 | To start `remind`, fill up the variables in [env_file_variables.txt](env_file_variables.txt) and rename it to `.env`. 34 | 35 | You will need to setup a bot on your server before continuing. Follow the directions [here](https://github.com/reactiflux/discord-irc/wiki/Creating-a-discord-bot-&-getting-a-token). Following this, you should have your bot appearing in your server and you should have the Discord bot token. 36 | 37 | You will need [clist.by](https://clist.by/) api key for updation of contest list. You can find it [here](https://clist.by/api/v1/doc/) after creating an account. 38 | 39 | You can also setup a logger channel that logs warnings by assigning the enviornment variable `LOGGING_COG_CHANNEL_ID`. But this is optional. 40 | 41 | ```bash 42 | ./run.sh 43 | ``` 44 | 45 | #### Deployment 46 | 47 | If you want to just host bot then you can skip installing dependencies and just follow [Final steps](#Final-steps) and just install Docker [Dockerfile](Dockerfile) will take care of rest. 48 | 49 | ## Contributing 50 | 51 | ### Linting and Formatting 52 | 53 | We are using [autopep8](https://github.com/hhatto/autopep8) for formatting the code. 54 | 55 | `pycodestyle .` must generate no errors before accepting the PR. 56 | Use `autopep8 --in-place --aggressive --aggressive ` for formatting before sending a PR. 57 | 58 | ## Credits 59 | 60 | Shoutout to [TLE](https://github.com/cheran-senthil/TLE) developers for the idea and initial contributions to this bot. 61 | Their bot used to remind about Codeforces contests and we enhanced it for all other judges. 62 | 63 | ## License 64 | [![MIT License](https://img.shields.io/apm/l/atomic-design-ui.svg?)](https://github.com/aryanc403/remind/blob/master/LICENSE) 65 | 66 | -------------------------------------------------------------------------------- /env_file_variables.txt: -------------------------------------------------------------------------------- 1 | BOT_TOKEN_REMIND="" 2 | CLIST_API_TOKEN="username=&api_key=" 3 | #LOGGING_COG_CHANNEL_ID="" 4 | SUPER_USERS="49859,49860,49858" 5 | #REMIND_MODERATOR_ROLE="" 6 | -------------------------------------------------------------------------------- /remind/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | import discord 4 | import logging 5 | from logging.handlers import TimedRotatingFileHandler 6 | from os import environ 7 | from remind import constants 8 | 9 | from discord.ext import commands 10 | from dotenv import load_dotenv 11 | from pathlib import Path 12 | from remind.util import discord_common 13 | from remind.util import clist_api 14 | 15 | 16 | def setup(): 17 | # Make required directories. 18 | for path in constants.ALL_DIRS: 19 | os.makedirs(path, exist_ok=True) 20 | 21 | # logging to console and file on daily interval 22 | logging.basicConfig( 23 | format='{asctime}:{levelname}:{name}:{message}', 24 | style='{', 25 | datefmt='%d-%m-%Y %H:%M:%S', 26 | level=logging.INFO, 27 | handlers=[ 28 | logging.StreamHandler(), 29 | TimedRotatingFileHandler( 30 | constants.LOG_FILE_PATH, 31 | when='D', 32 | backupCount=3, 33 | utc=True)]) 34 | 35 | 36 | def main(): 37 | load_dotenv() 38 | 39 | token = os.getenv('BOT_TOKEN_REMIND') 40 | if not token: 41 | logging.error('Token required') 42 | return 43 | 44 | super_users_str = os.getenv('SUPER_USERS') 45 | if not super_users_str: 46 | logging.error('Superusers required') 47 | return 48 | constants.SUPER_USERS = list(map(int, super_users_str.split(","))) 49 | 50 | remind_moderator_role = os.getenv('REMIND_MODERATOR_ROLE') 51 | if remind_moderator_role: 52 | constants.REMIND_MODERATOR_ROLE = remind_moderator_role 53 | 54 | setup() 55 | 56 | intents = discord.Intents.default() 57 | intents.members = True 58 | bot = commands.Bot( 59 | command_prefix=commands.when_mentioned_or('t;'), 60 | intents=intents) 61 | 62 | cogs = [file.stem for file in Path('remind', 'cogs').glob('*.py')] 63 | for extension in cogs: 64 | bot.load_extension(f'remind.cogs.{extension}') 65 | logging.info(f'Cogs loaded: {", ".join(bot.cogs)}') 66 | 67 | def no_dm_check(ctx): 68 | if ctx.guild is None: 69 | raise commands.NoPrivateMessage('Private messages not permitted.') 70 | return True 71 | 72 | # Restrict bot usage to inside guild channels only. 73 | bot.add_check(no_dm_check) 74 | 75 | @discord_common.on_ready_event_once(bot) 76 | async def init(): 77 | clist_api.cache() 78 | asyncio.create_task(discord_common.presence(bot)) 79 | 80 | bot.add_listener(discord_common.bot_error_handler, name='on_command_error') 81 | bot.run(token) 82 | 83 | 84 | if __name__ == '__main__': 85 | main() 86 | -------------------------------------------------------------------------------- /remind/cogs/logging.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | 5 | from discord.ext import commands 6 | from remind.util import discord_common 7 | 8 | root_logger = logging.getLogger() 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class Logging(commands.Cog, logging.Handler): 13 | def __init__(self, bot, channel_id): 14 | logging.Handler.__init__(self) 15 | self.bot = bot 16 | self.channel_id = channel_id 17 | self.queue = asyncio.Queue() 18 | self.task = None 19 | self.logger = logging.getLogger(self.__class__.__name__) 20 | 21 | @commands.Cog.listener() 22 | @discord_common.once 23 | async def on_ready(self): 24 | self.task = asyncio.create_task(self._log_task()) 25 | width = 79 26 | stars = f'`{"*" * width}`' 27 | msg = f'`***{"Bot running":^{width - 6}}***`' 28 | self.logger.log(level=100, msg=stars) 29 | self.logger.log(level=100, msg=msg) 30 | self.logger.log(level=100, msg=stars) 31 | 32 | async def _log_task(self): 33 | while True: 34 | record = await self.queue.get() 35 | channel = self.bot.get_channel(self.channel_id) 36 | if channel is None: 37 | # Channel no longer exists. 38 | root_logger.removeHandler(self) 39 | self.logger.warning( 40 | 'Logging channel not available,' 41 | 'disabling Discord log handler.') 42 | break 43 | try: 44 | msg = self.format(record) 45 | await channel.send(msg) 46 | except BaseException: 47 | self.handleError(record) 48 | 49 | # logging.Handler overrides below. 50 | 51 | def emit(self, record): 52 | self.queue.put_nowait(record) 53 | 54 | def close(self): 55 | if self.task: 56 | self.task.cancel() 57 | 58 | 59 | def setup(bot): 60 | logging_cog_channel_id = os.environ.get('LOGGING_COG_CHANNEL_ID') 61 | if logging_cog_channel_id is None: 62 | logger.info( 63 | 'Skipping installation of logging cog' 64 | 'as logging channel is not provided.') 65 | return 66 | 67 | logging_cog = Logging(bot, int(logging_cog_channel_id)) 68 | logging_cog.setLevel(logging.WARNING) 69 | logging_cog.setFormatter( 70 | logging.Formatter( 71 | fmt='{asctime}:{levelname}:{name}:{message}', 72 | style='{', 73 | datefmt='%d-%m-%Y %H:%M:%S')) 74 | root_logger.addHandler(logging_cog) 75 | bot.add_cog(logging_cog) 76 | -------------------------------------------------------------------------------- /remind/cogs/meta.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import sys 4 | import time 5 | import textwrap 6 | 7 | from discord.ext import commands 8 | from remind.util.discord_common import pretty_time_format 9 | from remind.util import clist_api 10 | from remind import constants 11 | 12 | RESTART = 42 13 | 14 | 15 | # Adapted from numpy sources. 16 | # https://github.com/numpy/numpy/blob/master/setup.py#L64-85 17 | def git_history(): 18 | def _minimal_ext_cmd(cmd): 19 | # construct minimal environment 20 | env = {} 21 | for k in ['SYSTEMROOT', 'PATH']: 22 | v = os.environ.get(k) 23 | if v is not None: 24 | env[k] = v 25 | # LANGUAGE is used on win32 26 | env['LANGUAGE'] = 'C' 27 | env['LANG'] = 'C' 28 | env['LC_ALL'] = 'C' 29 | out = subprocess.Popen( 30 | cmd, 31 | stdout=subprocess.PIPE, 32 | env=env).communicate()[0] 33 | return out 34 | try: 35 | out = _minimal_ext_cmd(['git', 'rev-parse', '--abbrev-ref', 'HEAD']) 36 | branch = out.strip().decode('ascii') 37 | out = _minimal_ext_cmd(['git', 'log', '--oneline', '-5']) 38 | history = out.strip().decode('ascii') 39 | return ( 40 | 'Branch:\n' + 41 | textwrap.indent(branch, ' ') + 42 | '\nCommits:\n' + 43 | textwrap.indent(history, ' ') 44 | ) 45 | except OSError: 46 | return "Fetching git info failed" 47 | 48 | 49 | def check_if_superuser(ctx): 50 | return ctx.author.id in constants.SUPER_USERS 51 | 52 | 53 | class Meta(commands.Cog): 54 | def __init__(self, bot): 55 | self.bot = bot 56 | self.start_time = time.time() 57 | 58 | @commands.group(brief='Bot control', invoke_without_command=True) 59 | async def meta(self, ctx): 60 | """Command the bot or get information about the bot.""" 61 | await ctx.send_help(ctx.command) 62 | 63 | @meta.command(brief='Restarts Remind') 64 | @commands.check(check_if_superuser) 65 | async def restart(self, ctx): 66 | """Restarts the bot.""" 67 | # Really, we just exit with a special code 68 | # the magic is handled elsewhere 69 | await ctx.send('Restarting...') 70 | os._exit(RESTART) 71 | 72 | @meta.command(brief='Kill Remind') 73 | @commands.check(check_if_superuser) 74 | async def kill(self, ctx): 75 | """Restarts the bot.""" 76 | await ctx.send('Dying...') 77 | os._exit(0) 78 | 79 | @meta.command(brief='Is Remind up?') 80 | async def ping(self, ctx): 81 | """Replies to a ping.""" 82 | start = time.perf_counter() 83 | message = await ctx.send(':ping_pong: Pong!') 84 | end = time.perf_counter() 85 | duration = (end - start) * 1000 86 | content = f'REST API latency: {int(duration)}ms\n' 87 | f'Gateway API latency: {int(self.bot.latency * 1000)}ms' 88 | await message.edit(content=content) 89 | 90 | @meta.command(brief='Get git information') 91 | async def git(self, ctx): 92 | """Replies with git information.""" 93 | await ctx.send('```yaml\n' + git_history() + '```') 94 | 95 | @meta.command(brief='Prints bot uptime') 96 | async def uptime(self, ctx): 97 | """Replies with how long Remind has been up.""" 98 | await ctx.send('Remind has been running for ' + 99 | pretty_time_format(time.time() - self.start_time)) 100 | 101 | @meta.command(brief='Print bot guilds') 102 | @commands.check(check_if_superuser) 103 | async def guilds(self, ctx): 104 | "Replies with info on the bot's guilds" 105 | msg = [f'Guild ID: {guild.id} | Name: {guild.name}' 106 | f'| Owner: {guild.owner.id} | Icon: {guild.icon_url}' 107 | for guild in self.bot.guilds] 108 | await ctx.send('```' + '\n'.join(msg) + '```') 109 | 110 | @meta.command(brief='Forcefully reset contests') 111 | @commands.has_any_role('Admin', constants.REMIND_MODERATOR_ROLE) 112 | async def resetcache(self, ctx): 113 | "Resets contest cache." 114 | try: 115 | clist_api.cache(True) 116 | await ctx.send('```Cache reset completed. ' 117 | 'Restart to reschedule all contest reminders.' 118 | '```') 119 | except BaseException: 120 | await ctx.send('```' + 'Cache reset failed.' + '```') 121 | 122 | 123 | def setup(bot): 124 | bot.add_cog(Meta(bot)) 125 | -------------------------------------------------------------------------------- /remind/cogs/reminders.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | import functools 4 | import json 5 | import pickle 6 | import logging 7 | import time 8 | import datetime as dt 9 | from pathlib import Path 10 | from recordtype import recordtype 11 | import pytz 12 | import copy 13 | 14 | from collections import defaultdict 15 | from collections import namedtuple 16 | 17 | import discord 18 | from discord.ext import commands 19 | import os 20 | 21 | from remind.util.rounds import Round 22 | from remind.util import discord_common 23 | from remind.util import paginator 24 | from remind import constants 25 | from remind.util import clist_api as clist 26 | 27 | _CONTESTS_PER_PAGE = 5 28 | _CONTEST_PAGINATE_WAIT_TIME = 5 * 60 29 | _FINISHED_CONTESTS_LIMIT = 5 30 | _CONTEST_REFRESH_PERIOD = 10 * 60 # seconds 31 | _GUILD_SETTINGS_BACKUP_PERIOD = 6 * 60 * 60 # seconds 32 | 33 | _PYTZ_TIMEZONES_GIST_URL = ('https://gist.github.com/heyalexej/' 34 | '8bf688fd67d7199be4a1682b3eec7568') 35 | 36 | 37 | class RemindersCogError(commands.CommandError): 38 | pass 39 | 40 | 41 | def _contest_start_time_format(contest, tz): 42 | start = contest.start_time.replace(tzinfo=dt.timezone.utc).astimezone(tz) 43 | return f'{start.strftime("%d %b %y, %H:%M")} {tz}' 44 | 45 | 46 | def _contest_duration_format(contest): 47 | duration_days, duration_hrs, duration_mins, _ = discord_common.time_format( 48 | contest.duration.total_seconds()) 49 | duration = f'{duration_hrs}h {duration_mins}m' 50 | if duration_days > 0: 51 | duration = f'{duration_days}d ' + duration 52 | return duration 53 | 54 | 55 | def _get_formatted_contest_desc( 56 | start, 57 | duration, 58 | url, 59 | max_duration_len): 60 | em = '\N{EN SPACE}' 61 | sq = '\N{WHITE SQUARE WITH UPPER RIGHT QUADRANT}' 62 | desc = (f'`{em}{start}{em}|' 63 | f'{em}{duration.rjust(max_duration_len, em)}{em}|' 64 | f'{em}`[`link {sq}`]({url} "Link to contest page")') 65 | return desc 66 | 67 | 68 | def _get_embed_fields_from_contests(contests, localtimezone): 69 | infos = [(contest.name, 70 | _contest_start_time_format(contest, 71 | localtimezone), 72 | _contest_duration_format(contest), 73 | contest.url) for contest in contests] 74 | max_duration_len = max(len(duration) for _, _, duration, _ in infos) 75 | 76 | fields = [] 77 | for name, start, duration, url in infos: 78 | value = _get_formatted_contest_desc( 79 | start, duration, url, max_duration_len) 80 | fields.append((name, value)) 81 | return fields 82 | 83 | 84 | async def _send_reminder_at(channel, role, contests, before_secs, send_time, 85 | localtimezone: pytz.timezone): 86 | delay = send_time - dt.datetime.utcnow().timestamp() 87 | if delay <= 0: 88 | return 89 | await asyncio.sleep(delay) 90 | values = discord_common.time_format(before_secs) 91 | 92 | def make(value, label): 93 | tmp = f'{value} {label}' 94 | return tmp if value == 1 else tmp + 's' 95 | 96 | labels = 'day hr min sec'.split() 97 | before_str = ' '.join(make(value, label) 98 | for label, value in zip(labels, values) if value > 0) 99 | desc = f'About to start in {before_str}' 100 | embed = discord_common.color_embed(description=desc) 101 | for name, value in _get_embed_fields_from_contests( 102 | contests, localtimezone): 103 | embed.add_field(name=name, value=value) 104 | await channel.send(role.mention, embed=embed) 105 | 106 | _WEBSITE_ALLOWED_PATTERNS = defaultdict(list) 107 | _WEBSITE_ALLOWED_PATTERNS['codeforces.com'] = [''] 108 | _WEBSITE_ALLOWED_PATTERNS['codechef.com'] = [ 109 | 'lunch', 'cook', 'rated'] 110 | _WEBSITE_ALLOWED_PATTERNS['atcoder.jp'] = [ 111 | 'abc:', 'arc:', 'agc:', 'grand', 'beginner', 'regular'] 112 | _WEBSITE_ALLOWED_PATTERNS['topcoder.com'] = ['srm', 'tco'] 113 | _WEBSITE_ALLOWED_PATTERNS['codingcompetitions.withgoogle.com'] = [''] 114 | _WEBSITE_ALLOWED_PATTERNS['facebook.com/hackercup'] = [''] 115 | _WEBSITE_ALLOWED_PATTERNS['codedrills.io'] = [''] 116 | 117 | _WEBSITE_DISALLOWED_PATTERNS = defaultdict(list) 118 | _WEBSITE_DISALLOWED_PATTERNS['codeforces.com'] = [ 119 | 'wild', 'fools', 'kotlin', 'unrated'] 120 | _WEBSITE_DISALLOWED_PATTERNS['codechef.com'] = ['unrated'] 121 | _WEBSITE_DISALLOWED_PATTERNS['atcoder.jp'] = [] 122 | _WEBSITE_DISALLOWED_PATTERNS['topcoder.com'] = [] 123 | _WEBSITE_DISALLOWED_PATTERNS['codingcompetitions.withgoogle.com'] = [ 124 | 'registration'] 125 | _WEBSITE_DISALLOWED_PATTERNS['facebook.com/hackercup'] = [] 126 | _WEBSITE_DISALLOWED_PATTERNS['codedrills.io'] = [] 127 | 128 | _SUPPORTED_WEBSITES = [ 129 | 'codeforces.com', 130 | 'codechef.com', 131 | 'atcoder.jp', 132 | 'topcoder.com', 133 | 'codingcompetitions.withgoogle.com', 134 | 'facebook.com/hackercup', 135 | 'codedrills.io' 136 | ] 137 | 138 | GuildSettings = recordtype( 139 | 'GuildSettings', [ 140 | ('channel_id', None), ('role_id', None), 141 | ('before', None), ('localtimezone', pytz.timezone('UTC')), 142 | ('website_allowed_patterns', defaultdict(list)), 143 | ('website_disallowed_patterns', defaultdict(list))]) 144 | 145 | 146 | def get_default_guild_settings(): 147 | allowed_patterns = copy.deepcopy(_WEBSITE_ALLOWED_PATTERNS) 148 | disallowed_patterns = copy.deepcopy(_WEBSITE_DISALLOWED_PATTERNS) 149 | settings = GuildSettings() 150 | settings.website_allowed_patterns = allowed_patterns 151 | settings.website_disallowed_patterns = disallowed_patterns 152 | return settings 153 | 154 | 155 | class Reminders(commands.Cog): 156 | def __init__(self, bot): 157 | self.bot = bot 158 | self.future_contests = None 159 | self.contest_cache = None 160 | self.active_contests = None 161 | self.finished_contests = None 162 | self.start_time_map = defaultdict(list) 163 | self.task_map = defaultdict(list) 164 | # Maps guild_id to `GuildSettings` 165 | self.guild_map = defaultdict(get_default_guild_settings) 166 | self.last_guild_backup_time = -1 167 | 168 | self.member_converter = commands.MemberConverter() 169 | self.role_converter = commands.RoleConverter() 170 | 171 | self.logger = logging.getLogger(self.__class__.__name__) 172 | 173 | @commands.Cog.listener() 174 | @discord_common.once 175 | async def on_ready(self): 176 | guild_map_path = Path(constants.GUILD_SETTINGS_MAP_PATH) 177 | try: 178 | with guild_map_path.open('rb') as guild_map_file: 179 | guild_map = pickle.load(guild_map_file) 180 | for guild_id, guild_settings in guild_map.items(): 181 | self.guild_map[guild_id] = \ 182 | GuildSettings(**{key: value 183 | for key, value 184 | in guild_settings._asdict().items() 185 | if key in GuildSettings._fields}) 186 | except BaseException: 187 | pass 188 | asyncio.create_task(self._update_task()) 189 | 190 | async def cog_after_invoke(self, ctx): 191 | self._serialize_guild_map() 192 | self._backup_serialize_guild_map() 193 | self._reschedule_tasks(ctx.guild.id) 194 | 195 | async def _update_task(self): 196 | self.logger.info(f'Updating reminder tasks.') 197 | self._generate_contest_cache() 198 | contest_cache = self.contest_cache 199 | current_time = dt.datetime.utcnow() 200 | 201 | self.future_contests = [ 202 | contest for contest in contest_cache 203 | if contest.start_time > current_time 204 | ] 205 | self.finished_contests = [ 206 | contest for contest in contest_cache 207 | if contest.start_time + 208 | contest.duration < current_time 209 | ] 210 | self.active_contests = [ 211 | contest for contest in contest_cache 212 | if contest.start_time <= current_time <= 213 | contest.start_time + contest.duration 214 | ] 215 | 216 | self.active_contests.sort(key=lambda contest: contest.start_time) 217 | self.finished_contests.sort( 218 | key=lambda contest: contest.start_time + 219 | contest.duration, 220 | reverse=True 221 | ) 222 | self.future_contests.sort(key=lambda contest: contest.start_time) 223 | # Keep most recent _FINISHED_LIMIT 224 | self.finished_contests = \ 225 | self.finished_contests[:_FINISHED_CONTESTS_LIMIT] 226 | self.start_time_map.clear() 227 | for contest in self.future_contests: 228 | self.start_time_map[time.mktime( 229 | contest.start_time.timetuple())].append(contest) 230 | self._reschedule_all_tasks() 231 | await asyncio.sleep(_CONTEST_REFRESH_PERIOD) 232 | asyncio.create_task(self._update_task()) 233 | 234 | def _generate_contest_cache(self): 235 | clist.cache(forced=False) 236 | db_file = Path(constants.CONTESTS_DB_FILE_PATH) 237 | with db_file.open() as f: 238 | data = json.load(f) 239 | contests = [Round(contest) for contest in data['objects']] 240 | self.contest_cache = [ 241 | contest for contest in contests if contest.is_desired( 242 | _WEBSITE_ALLOWED_PATTERNS, 243 | _WEBSITE_DISALLOWED_PATTERNS)] 244 | 245 | def get_guild_contests(self, contests, guild_id): 246 | settings = self.guild_map[guild_id] 247 | _, _, _, _, website_allowed_patterns, website_disallowed_patterns = \ 248 | settings 249 | contests = [contest for contest in contests if contest.is_desired( 250 | website_allowed_patterns, website_disallowed_patterns)] 251 | return contests 252 | 253 | def _reschedule_all_tasks(self): 254 | for guild in self.bot.guilds: 255 | self._reschedule_tasks(guild.id) 256 | 257 | def _reschedule_tasks(self, guild_id): 258 | for task in self.task_map[guild_id]: 259 | task.cancel() 260 | self.task_map[guild_id].clear() 261 | self.logger.info(f'Tasks for guild {guild_id} cleared') 262 | if not self.start_time_map: 263 | return 264 | settings = self.guild_map[guild_id] 265 | if any(setting is None for setting in settings): 266 | return 267 | channel_id, role_id, before, localtimezone, \ 268 | website_allowed_patterns, website_disallowed_patterns = settings 269 | 270 | guild = self.bot.get_guild(guild_id) 271 | channel, role = guild.get_channel(channel_id), guild.get_role(role_id) 272 | for start_time, contests in self.start_time_map.items(): 273 | contests = self.get_guild_contests(contests, guild_id) 274 | if not contests: 275 | continue 276 | for before_mins in before: 277 | before_secs = 60 * before_mins 278 | task = asyncio.create_task( 279 | _send_reminder_at( 280 | channel, 281 | role, 282 | contests, 283 | before_secs, 284 | start_time - 285 | before_secs, localtimezone) 286 | ) 287 | self.task_map[guild_id].append(task) 288 | self.logger.info( 289 | f'{len(self.task_map[guild_id])} ' 290 | f'tasks scheduled for guild {guild_id}') 291 | 292 | @staticmethod 293 | def _make_contest_pages(contests, title, localtimezone): 294 | pages = [] 295 | chunks = paginator.chunkify(contests, _CONTESTS_PER_PAGE) 296 | for chunk in chunks: 297 | embed = discord_common.color_embed() 298 | for name, value in _get_embed_fields_from_contests( 299 | chunk, localtimezone): 300 | embed.add_field(name=name, value=value, inline=False) 301 | pages.append((title, embed)) 302 | return pages 303 | 304 | async def _send_contest_list(self, ctx, contests, *, title, empty_msg): 305 | if contests is None: 306 | raise RemindersCogError('Contest list not present') 307 | if len(contests) == 0: 308 | await ctx.send(embed=discord_common.embed_neutral(empty_msg)) 309 | return 310 | pages = self._make_contest_pages( 311 | contests, title, self.guild_map[ctx.guild.id].localtimezone) 312 | paginator.paginate( 313 | self.bot, 314 | ctx.channel, 315 | pages, 316 | wait_time=_CONTEST_PAGINATE_WAIT_TIME, 317 | set_pagenum_footers=True 318 | ) 319 | 320 | def _serialize_guild_map(self): 321 | out_path = Path(constants.GUILD_SETTINGS_MAP_PATH) 322 | with out_path.open(mode='wb') as out_file: 323 | pickle.dump(self.guild_map, out_file) 324 | 325 | def _backup_serialize_guild_map(self): 326 | current_time_stamp = int(dt.datetime.utcnow().timestamp()) 327 | if current_time_stamp - self.last_guild_backup_time \ 328 | < _GUILD_SETTINGS_BACKUP_PERIOD: 329 | return 330 | self.last_guild_backup_time = current_time_stamp 331 | out_path = Path( 332 | constants.GUILD_SETTINGS_MAP_PATH + 333 | "_" + 334 | str(current_time_stamp)) 335 | with out_path.open(mode='wb') as out_file: 336 | pickle.dump(self.guild_map, out_file) 337 | 338 | @commands.group(brief='Commands for contest reminders', 339 | invoke_without_command=True) 340 | async def remind(self, ctx): 341 | await ctx.send_help(ctx.command) 342 | 343 | @remind.command(brief='Set reminder settings') 344 | @commands.has_any_role('Admin', constants.REMIND_MODERATOR_ROLE) 345 | async def here(self, ctx, role: discord.Role, *before: int): 346 | """Sets reminder channel to current channel, 347 | role to the given role, and reminder 348 | times to the given values in minutes. 349 | 350 | e.g t;remind here @Subscriber 10 60 180 351 | """ 352 | if not role.mentionable: 353 | raise RemindersCogError( 354 | 'The role for reminders must be mentionable') 355 | if not before or any(before_mins < 0 for before_mins in before): 356 | raise RemindersCogError('Please provide valid `before` values') 357 | before = list(before) 358 | before = sorted(before, reverse=True) 359 | self.guild_map[ctx.guild.id].role_id = role.id 360 | self.guild_map[ctx.guild.id].before = before 361 | self.guild_map[ctx.guild.id].channel_id = ctx.channel.id 362 | await ctx.send( 363 | embed=discord_common.embed_success( 364 | 'Reminder settings saved successfully')) 365 | 366 | @remind.command(brief='Resets the judges settings to the default ones') 367 | @commands.has_any_role('Admin', constants.REMIND_MODERATOR_ROLE) 368 | async def reset_judges_settings(self, ctx): 369 | """ Resets the judges settings to the default ones. 370 | """ 371 | _, _, _, _, \ 372 | default_allowed_patterns, default_disallowed_patterns = \ 373 | get_default_guild_settings() 374 | self.guild_map[ctx.guild.id].website_allowed_patterns = \ 375 | default_allowed_patterns 376 | self.guild_map[ctx.guild.id].website_disallowed_patterns = \ 377 | default_disallowed_patterns 378 | await ctx.send(embed=discord_common.embed_success( 379 | 'Succesfully reset the judges settings to the default ones')) 380 | 381 | @remind.command(brief='Show reminder settings') 382 | async def settings(self, ctx): 383 | """Shows the reminders role, channel, times, and timezone settings.""" 384 | settings = self.guild_map[ctx.guild.id] 385 | channel_id, role_id, before, timezone, \ 386 | website_allowed_patterns, website_disallowed_patterns = settings 387 | channel = ctx.guild.get_channel(channel_id) 388 | role = ctx.guild.get_role(role_id) 389 | if channel is None: 390 | raise RemindersCogError('No channel set for reminders') 391 | if role is None: 392 | raise RemindersCogError('No role set for reminders') 393 | if before is None: 394 | raise RemindersCogError('No reminder_times set for reminders') 395 | 396 | subscribed_websites_str = ", ".join( 397 | website for website, 398 | patterns in website_allowed_patterns.items() if patterns) 399 | 400 | before_str = ', '.join(str(before_mins) for before_mins in before) 401 | embed = discord_common.embed_success('Current reminder settings') 402 | embed.add_field(name='Channel', value=channel.mention) 403 | embed.add_field(name='Role', value=role.mention) 404 | embed.add_field(name='Before', 405 | value=f'At {before_str} mins before contest') 406 | embed.add_field(name='Subscribed websites', 407 | value=f'{subscribed_websites_str}') 408 | await ctx.send(embed=embed) 409 | 410 | def _get_remind_role(self, guild): 411 | settings = self.guild_map[guild.id] 412 | _, role_id, _, _, _, _ = settings 413 | if role_id is None: 414 | raise RemindersCogError('No role set for reminders') 415 | role = guild.get_role(role_id) 416 | if role is None: 417 | raise RemindersCogError( 418 | 'The role set for reminders is no longer available.') 419 | return role 420 | 421 | @remind.command(brief='Subscribe to contest reminders') 422 | async def on(self, ctx): 423 | """Subscribes you to contest reminders. 424 | Use 't;remind settings' to see the current settings. 425 | """ 426 | role = self._get_remind_role(ctx.guild) 427 | if role in ctx.author.roles: 428 | embed = discord_common.embed_neutral( 429 | 'You are already subscribed to contest reminders') 430 | else: 431 | await ctx.author.add_roles( 432 | role, reason='User subscribed to contest reminders') 433 | embed = discord_common.embed_success( 434 | 'Successfully subscribed to contest reminders') 435 | await ctx.send(embed=embed) 436 | 437 | @remind.command(brief='Unsubscribe from contest reminders') 438 | async def off(self, ctx): 439 | """Unsubscribes you from contest reminders.""" 440 | role = self._get_remind_role(ctx.guild) 441 | if role not in ctx.author.roles: 442 | embed = discord_common.embed_neutral( 443 | 'You are not subscribed to contest reminders') 444 | else: 445 | await ctx.author.remove_roles( 446 | role, reason='User unsubscribed from contest reminders') 447 | embed = discord_common.embed_success( 448 | 'Successfully unsubscribed from contest reminders') 449 | await ctx.send(embed=embed) 450 | 451 | def _set_guild_setting( 452 | self, 453 | guild_id, 454 | websites, 455 | allowed_patterns, 456 | disallowed_patterns): 457 | 458 | guild_settings = self.guild_map[guild_id] 459 | supported_websites, unsupported_websites = [], [] 460 | for website in websites: 461 | if website not in _SUPPORTED_WEBSITES: 462 | unsupported_websites.append(website) 463 | continue 464 | 465 | guild_settings.website_allowed_patterns[website] = \ 466 | allowed_patterns[website] 467 | guild_settings.website_disallowed_patterns[website] = \ 468 | disallowed_patterns[website] 469 | supported_websites.append(website) 470 | 471 | self.guild_map[guild_id] = guild_settings 472 | return supported_websites, unsupported_websites 473 | 474 | @remind.command(brief='Start contest reminders from websites.') 475 | @commands.has_any_role('Admin', constants.REMIND_MODERATOR_ROLE) 476 | async def subscribe(self, ctx, *websites: str): 477 | """Start contest reminders from websites.""" 478 | 479 | if all(website not in _SUPPORTED_WEBSITES for website in websites): 480 | supported_websites = ", ".join(_SUPPORTED_WEBSITES) 481 | embed = discord_common.embed_alert( 482 | f'None of these websites are supported for contest reminders.' 483 | f'\nSupported websites -\n {supported_websites}.') 484 | else: 485 | guild_id = ctx.guild.id 486 | subscribed, unsupported = self._set_guild_setting( 487 | guild_id, websites, _WEBSITE_ALLOWED_PATTERNS, 488 | _WEBSITE_DISALLOWED_PATTERNS) 489 | subscribed_websites_str = ", ".join(subscribed) 490 | unsupported_websites_str = ", ".join(unsupported) 491 | success_str = f'Successfully subscribed from \ 492 | {subscribed_websites_str} for contest reminders.' 493 | success_str += f'\n{unsupported_websites_str} \ 494 | {"are" if len(unsupported)>1 else "is"} \ 495 | not supported.' if unsupported_websites_str else "" 496 | embed = discord_common.embed_success(success_str) 497 | await ctx.send(embed=embed) 498 | 499 | @remind.command(brief='Stop contest reminders from websites.') 500 | @commands.has_any_role('Admin', constants.REMIND_MODERATOR_ROLE) 501 | async def unsubscribe(self, ctx, *websites: str): 502 | """Stop contest reminders from websites.""" 503 | 504 | if all(website not in _SUPPORTED_WEBSITES for website in websites): 505 | supported_websites = ", ".join(_SUPPORTED_WEBSITES) 506 | embed = discord_common.embed_alert( 507 | f'None of these websites are supported for contest reminders.' 508 | f'\nSupported websites -\n {supported_websites}.') 509 | else: 510 | guild_id = ctx.guild.id 511 | unsubscribed, unsupported = self._set_guild_setting( 512 | guild_id, websites, 513 | defaultdict(list), defaultdict(lambda: [''])) 514 | unsubscribed_websites_str = ", ".join(unsubscribed) 515 | unsupported_websites_str = ", ".join(unsupported) 516 | success_str = f'Successfully unsubscribed from \ 517 | {unsubscribed_websites_str} for contest reminders.' 518 | success_str += f'\n{unsupported_websites_str} \ 519 | {"are" if len(unsupported)>1 else "is"} \ 520 | not supported.' if unsupported_websites_str else "" 521 | embed = discord_common.embed_success(success_str) 522 | await ctx.send(embed=embed) 523 | 524 | @remind.command(brief='Clear all reminder settings') 525 | @commands.has_any_role('Admin', constants.REMIND_MODERATOR_ROLE) 526 | async def clear(self, ctx): 527 | del self.guild_map[ctx.guild.id] 528 | await ctx.send( 529 | embed=discord_common.embed_success('Reminder settings cleared')) 530 | 531 | @commands.command(brief='Set the server\'s timezone', 532 | usage=' ') 533 | @commands.has_any_role('Admin', constants.REMIND_MODERATOR_ROLE) 534 | async def settz(self, ctx, timezone: str): 535 | """Sets the server's timezone to the given timezone. 536 | """ 537 | if not (timezone in pytz.all_timezones): 538 | desc = ('The given timezone is invalid\n\n' 539 | 'Examples of valid timezones:\n\n') 540 | desc += '\n'.join(random.sample(pytz.all_timezones, 5)) 541 | desc += '\n\nAll valid timezones can be found [here]' 542 | desc += f'({_PYTZ_TIMEZONES_GIST_URL})' 543 | raise RemindersCogError(desc) 544 | self.guild_map[ctx.guild.id].localtimezone = pytz.timezone(timezone) 545 | await ctx.send(embed=discord_common.embed_success( 546 | f'Succesfully set the server timezone to {timezone}')) 547 | 548 | @commands.group(brief='Commands for listing contests', 549 | invoke_without_command=True) 550 | async def clist(self, ctx): 551 | await ctx.send_help(ctx.command) 552 | 553 | @clist.command(brief='List future contests') 554 | async def future(self, ctx): 555 | """List future contests.""" 556 | contests = self.get_guild_contests(self.future_contests, ctx.guild.id) 557 | await self._send_contest_list(ctx, contests, 558 | title='Future contests', 559 | empty_msg='No future contests scheduled' 560 | ) 561 | 562 | @clist.command(brief='List active contests') 563 | async def active(self, ctx): 564 | """List active contests.""" 565 | contests = self.get_guild_contests(self.active_contests, ctx.guild.id) 566 | await self._send_contest_list(ctx, contests, 567 | title='Active contests', 568 | empty_msg='No contests currently active' 569 | ) 570 | 571 | @clist.command(brief='List recent finished contests') 572 | async def finished(self, ctx): 573 | """List recently concluded contests.""" 574 | contests = self.get_guild_contests( 575 | self.finished_contests, ctx.guild.id) 576 | await self._send_contest_list(ctx, contests, 577 | title='Recently finished contests', 578 | empty_msg='No finished contests found' 579 | ) 580 | 581 | @discord_common.send_error_if(RemindersCogError) 582 | async def cog_command_error(self, ctx, error): 583 | pass 584 | 585 | 586 | def setup(bot): 587 | bot.add_cog(Reminders(bot)) 588 | -------------------------------------------------------------------------------- /remind/constants.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | DATA_DIR = 'data' 4 | LOGS_DIR = 'logs' 5 | CONTESTS_DB_FILE_PATH = os.path.join(DATA_DIR, 'contests.json') 6 | LOG_FILE_PATH = os.path.join(LOGS_DIR, 'remind.log') 7 | GUILD_SETTINGS_MAP_PATH = os.path.join(DATA_DIR, 'guild_settings_map') 8 | ALL_DIRS = (attrib_value for attrib_name, attrib_value in list( 9 | globals().items()) if attrib_name.endswith('DIR')) 10 | SUPER_USERS = [] 11 | REMIND_MODERATOR_ROLE = "Remind Moderator" 12 | -------------------------------------------------------------------------------- /remind/util/clist_api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import datetime as dt 4 | import requests 5 | import json 6 | 7 | from remind import constants 8 | from discord.ext import commands 9 | 10 | from pathlib import Path 11 | 12 | logger = logging.getLogger(__name__) 13 | URL_BASE = 'https://clist.by/api/v1/contest/' 14 | _CLIST_API_TIME_DIFFERENCE = 30 * 60 # seconds 15 | 16 | 17 | class ClistApiError(commands.CommandError): 18 | """Base class for all API related errors.""" 19 | 20 | def __init__(self, message=None): 21 | super().__init__(message or 'Clist API error') 22 | 23 | 24 | class ClientError(ClistApiError): 25 | """An error caused by a request to the API failing.""" 26 | 27 | def __init__(self): 28 | super().__init__('Error connecting to Clist API') 29 | 30 | 31 | def _query_api(): 32 | clist_token = os.getenv('CLIST_API_TOKEN') 33 | contests_start_time = dt.datetime.utcnow() - dt.timedelta(days=2) 34 | contests_start_time_string = contests_start_time.strftime( 35 | "%Y-%m-%dT%H%%3A%M%%3A%S") 36 | url = URL_BASE + '?limit=200&start__gte=' + \ 37 | contests_start_time_string + '&' + clist_token 38 | 39 | try: 40 | resp = requests.get(url) 41 | if resp.status_code != 200: 42 | raise ClistApiError 43 | return resp.json()['objects'] 44 | except Exception as e: 45 | logger.error(f'Request to Clist API encountered error: {e!r}') 46 | raise ClientError from e 47 | 48 | 49 | def cache(forced=False): 50 | 51 | current_time_stamp = dt.datetime.utcnow().timestamp() 52 | db_file = Path(constants.CONTESTS_DB_FILE_PATH) 53 | 54 | db = None 55 | try: 56 | with db_file.open() as f: 57 | db = json.load(f) 58 | except BaseException: 59 | pass 60 | 61 | last_time_stamp = db['querytime'] if db and db['querytime'] else 0 62 | 63 | if not forced and current_time_stamp - \ 64 | last_time_stamp < _CLIST_API_TIME_DIFFERENCE: 65 | return 66 | 67 | contests = _query_api() 68 | db = {} 69 | db['querytime'] = current_time_stamp 70 | db['objects'] = contests 71 | with open(db_file, 'w') as f: 72 | json.dump(db, f) 73 | -------------------------------------------------------------------------------- /remind/util/discord_common.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import functools 4 | import random 5 | 6 | import discord 7 | from discord.ext import commands 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | _COLORS = (0xFFCA1F, 0x198BCC, 0xFF2020) 12 | _SUCCESS_GREEN = 0x28A745 13 | _ALERT_AMBER = 0xFFBF00 14 | 15 | 16 | def embed_neutral(desc, color=discord.Embed.Empty): 17 | return discord.Embed(description=str(desc), color=color) 18 | 19 | 20 | def embed_success(desc): 21 | return discord.Embed(description=str(desc), color=_SUCCESS_GREEN) 22 | 23 | 24 | def embed_alert(desc): 25 | return discord.Embed(description=str(desc), color=_ALERT_AMBER) 26 | 27 | 28 | def attach_image(embed, img_file): 29 | embed.set_image(url=f'attachment://{img_file.filename}') 30 | 31 | 32 | def color_embed(**kwargs): 33 | return discord.Embed(**kwargs, color=random.choice(_COLORS)) 34 | 35 | 36 | def set_author_footer(embed, user): 37 | embed.set_footer(text=f'Requested by {user}', icon_url=user.avatar_url) 38 | 39 | 40 | def time_format(seconds): 41 | seconds = int(seconds) 42 | days, seconds = divmod(seconds, 86400) 43 | hours, seconds = divmod(seconds, 3600) 44 | minutes, seconds = divmod(seconds, 60) 45 | return days, hours, minutes, seconds 46 | 47 | 48 | def pretty_time_format( 49 | seconds, 50 | *, 51 | shorten=False, 52 | only_most_significant=False, 53 | always_seconds=False): 54 | days, hours, minutes, seconds = time_format(seconds) 55 | timespec = [ 56 | (days, 'day', 'days'), 57 | (hours, 'hour', 'hours'), 58 | (minutes, 'minute', 'minutes'), 59 | ] 60 | timeprint = [(cnt, singular, plural) 61 | for cnt, singular, plural in timespec if cnt] 62 | if not timeprint or always_seconds: 63 | timeprint.append((seconds, 'second', 'seconds')) 64 | if only_most_significant: 65 | timeprint = [timeprint[0]] 66 | 67 | def format_(triple): 68 | cnt, singular, plural = triple 69 | return f'{cnt}{singular[0]}' if shorten \ 70 | else f'{cnt} {singular if cnt == 1 else plural}' 71 | 72 | return ' '.join(map(format_, timeprint)) 73 | 74 | 75 | def send_error_if(*error_cls): 76 | """Decorator for `cog_command_error` methods. 77 | Decorated methods send the error in an alert embed 78 | when the error is an instance of one of the specified errors, 79 | otherwise the wrapped function is invoked. 80 | """ 81 | def decorator(func): 82 | @functools.wraps(func) 83 | async def wrapper(cog, ctx, error): 84 | if isinstance(error, error_cls): 85 | await ctx.send(embed=embed_alert(error)) 86 | error.handled = True 87 | else: 88 | await func(cog, ctx, error) 89 | return wrapper 90 | return decorator 91 | 92 | 93 | def once(func): 94 | """Decorator that wraps the given async function 95 | such that it is executed only once.""" 96 | first = True 97 | 98 | @functools.wraps(func) 99 | async def wrapper(*args, **kwargs): 100 | nonlocal first 101 | if first: 102 | first = False 103 | await func(*args, **kwargs) 104 | 105 | return wrapper 106 | 107 | 108 | def on_ready_event_once(bot): 109 | """Decorator that uses bot.event to set the given function 110 | as the bot's on_ready event handler, 111 | but does not execute it more than once. 112 | """ 113 | def register_on_ready(func): 114 | @bot.event 115 | @once 116 | async def on_ready(): 117 | await func() 118 | 119 | return register_on_ready 120 | 121 | 122 | async def bot_error_handler(ctx, exception): 123 | if getattr(exception, 'handled', False): 124 | # Errors already handled in cogs should have .handled = True 125 | return 126 | 127 | exc_info = type(exception), exception, exception.__traceback__ 128 | logger.exception( 129 | 'Ignoring exception in command {}:'.format( 130 | ctx.command), exc_info=exc_info) 131 | 132 | 133 | async def presence(bot): 134 | await bot.change_presence(activity=discord.Activity( 135 | type=discord.ActivityType.watching, 136 | name='clist.by')) 137 | -------------------------------------------------------------------------------- /remind/util/paginator.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | 4 | _REACT_FIRST = '\N{BLACK LEFT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}' 5 | _REACT_PREV = '\N{BLACK LEFT-POINTING TRIANGLE}' 6 | _REACT_NEXT = '\N{BLACK RIGHT-POINTING TRIANGLE}' 7 | _REACT_LAST = '\N{BLACK RIGHT-POINTING DOUBLE TRIANGLE WITH VERTICAL BAR}' 8 | 9 | 10 | def chunkify(sequence, chunk_size): 11 | """Utility method to split a sequence into fixed size chunks.""" 12 | return [sequence[i: i + chunk_size] 13 | for i in range(0, len(sequence), chunk_size)] 14 | 15 | 16 | class PaginatorError(Exception): 17 | pass 18 | 19 | 20 | class NoPagesError(PaginatorError): 21 | pass 22 | 23 | 24 | class InsufficientPermissionsError(PaginatorError): 25 | pass 26 | 27 | 28 | class Paginated: 29 | def __init__(self, pages): 30 | self.pages = pages 31 | self.cur_page = None 32 | self.message = None 33 | self.reaction_map = { 34 | _REACT_FIRST: functools.partial(self.show_page, 1), 35 | _REACT_PREV: self.prev_page, 36 | _REACT_NEXT: self.next_page, 37 | _REACT_LAST: functools.partial(self.show_page, len(pages)) 38 | } 39 | 40 | async def show_page(self, page_num): 41 | if 1 <= page_num <= len(self.pages): 42 | content, embed = self.pages[page_num - 1] 43 | await self.message.edit(content=content, embed=embed) 44 | self.cur_page = page_num 45 | 46 | async def prev_page(self): 47 | await self.show_page(self.cur_page - 1) 48 | 49 | async def next_page(self): 50 | await self.show_page(self.cur_page + 1) 51 | 52 | async def paginate(self, bot, channel, wait_time): 53 | content, embed = self.pages[0] 54 | self.message = await channel.send(content, embed=embed) 55 | 56 | if len(self.pages) == 1: 57 | # No need to paginate. 58 | return 59 | 60 | self.cur_page = 1 61 | for react in self.reaction_map.keys(): 62 | await self.message.add_reaction(react) 63 | 64 | def check(reaction, user): 65 | return (bot.user != user and 66 | reaction.message.id == self.message.id and 67 | reaction.emoji in self.reaction_map) 68 | 69 | while True: 70 | try: 71 | reaction, user = await bot.wait_for('reaction_add', 72 | timeout=wait_time, 73 | check=check) 74 | await reaction.remove(user) 75 | await self.reaction_map[reaction.emoji]() 76 | except asyncio.TimeoutError: 77 | await self.message.clear_reactions() 78 | break 79 | 80 | 81 | def paginate(bot, channel, pages, *, wait_time, set_pagenum_footers=False): 82 | if not pages: 83 | raise NoPagesError() 84 | permissions = channel.permissions_for(channel.guild.me) 85 | if not permissions.manage_messages: 86 | raise InsufficientPermissionsError( 87 | 'Permission to manage messages required') 88 | if len(pages) > 1 and set_pagenum_footers: 89 | for i, (content, embed) in enumerate(pages): 90 | embed.set_footer(text=f'Page {i + 1} / {len(pages)}') 91 | paginated = Paginated(pages) 92 | asyncio.create_task(paginated.paginate(bot, channel, wait_time)) 93 | -------------------------------------------------------------------------------- /remind/util/rounds.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | 3 | 4 | class Round: 5 | def __init__(self, round): 6 | self.id = round['id'] 7 | self.name = round['event'] 8 | self.start_time = dt.datetime.strptime( 9 | round['start'], '%Y-%m-%dT%H:%M:%S') 10 | self.duration = dt.timedelta(seconds=round['duration']) 11 | self.url = round['href'] 12 | self.website = round['resource']['name'] 13 | self.website_id = round['resource']['id'] 14 | 15 | def __str__(self): 16 | st = "ID = " + str(self.id) + ", " 17 | st += "Name = " + self.name + ", " 18 | st += "Start_time = " + str(self.start_time) + ", " 19 | st += "Duration = " + str(self.duration) + ", " 20 | st += "URL = " + self.url + ", " 21 | st += "Website = " + self.website + ", " 22 | st += "Website_id = " + str(self.website_id) + ", " 23 | st = "(" + st[:-2] + ")" 24 | return st 25 | 26 | def is_desired( 27 | self, 28 | website_allowed_patterns, 29 | website_disallowed_patterns): 30 | for disallowed_pattern in website_disallowed_patterns[self.website]: 31 | if disallowed_pattern in self.name.lower(): 32 | return False 33 | 34 | for allowed_pattern in website_allowed_patterns[self.website]: 35 | if allowed_pattern in self.name.lower(): 36 | return True 37 | return False 38 | 39 | def __repr__(self): 40 | return "Round - " + self.name 41 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dotenv 2 | discord.py 3 | requests 4 | pytz 5 | recordtype 6 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Get to a predictable directory, the directory of this script 4 | cd "$(dirname "$0")" 5 | 6 | while true; do 7 | 8 | git pull 9 | pip install -r requirements.txt 10 | python -m remind 11 | (( $? != 42 )) && break 12 | 13 | echo '===================================================================' 14 | echo '= Restarting =' 15 | echo '===================================================================' 16 | done 17 | --------------------------------------------------------------------------------