├── .dockerignore ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── contributor_list.yml │ ├── fly.yml │ └── lint.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── bot.py ├── config.py ├── contributing.md ├── database ├── __init__.py ├── campus.py ├── group.py ├── person │ ├── __init__.py │ ├── person.py │ └── queries │ │ └── search_people.sql ├── preloaded.py ├── sql │ ├── __init__.py │ ├── delete.py │ ├── insert.py │ ├── select.py │ └── util.py └── sql_fetcher.py ├── fly.toml ├── modules ├── __init__.py ├── calendar │ ├── __init__.py │ ├── calendar.py │ ├── calendar_creator.py │ ├── calendar_embedder.py │ ├── calendar_service.py │ ├── cog.py │ ├── course_mentions.py │ ├── event.py │ └── md_html_converter.py ├── course_management │ ├── __init__.py │ ├── cog.py │ ├── course_activator.py │ ├── course_adder.py │ ├── course_deleter.py │ └── util.py ├── create_group │ ├── __init__.py │ ├── cog.py │ ├── group_channel_creator.py │ ├── group_creator.py │ └── new_group.py ├── drive │ ├── __init__.py │ ├── cog.py │ └── drive_service.py ├── email_registry │ ├── __init__.py │ ├── categoriser.py │ ├── cog.py │ ├── email_adder.py │ ├── person_adder.py │ ├── person_embedder.py │ ├── person_finder.py │ ├── person_remover.py │ ├── queries │ │ ├── categorise_person.sql │ │ └── decategorise_person.sql │ └── weighted_set.py ├── error │ ├── __init__.py │ ├── cog.py │ ├── error_handler.py │ ├── error_logger.py │ ├── friendly_error.py │ └── quiet_warning.py ├── graduation │ ├── __init__.py │ ├── cog.py │ └── graduation.py ├── join │ ├── __init__.py │ ├── assigner.py │ └── cog.py ├── markdown │ ├── __init__.py │ ├── cog.py │ ├── formatting_tip.py │ └── tip_formatter.py ├── new_user │ ├── __init__.py │ ├── cog.py │ └── greeter.py ├── ping │ ├── __init__.py │ ├── cog.py │ └── responses.txt ├── search │ ├── __init__.py │ ├── cog.py │ └── search_functions.py └── xkcd │ ├── __init__.py │ ├── cog.py │ ├── comic.py │ ├── xkcd_embedder.py │ └── xkcd_fetcher.py ├── profile.svg ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── runtime.txt ├── style_guide.md └── utils ├── __init__.py ├── embedder.py ├── ids.csv ├── mention.py ├── reactions.py ├── scheduler ├── __init__.py ├── event.py ├── func_instance.py └── scheduler.py └── utils.py /.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .gitignore 2 | # Byte-compiled / optimized / DLL files 3 | **/__pycache__ 4 | **/*.py[cod] 5 | **/*$py.class 6 | 7 | # C extensions 8 | **/*.so 9 | 10 | # Distribution / packaging 11 | **/.Python 12 | **/build 13 | **/develop-eggs 14 | **/dist 15 | **/downloads 16 | **/eggs 17 | **/.eggs 18 | **/lib 19 | **/lib64 20 | **/parts 21 | **/sdist 22 | **/var 23 | **/wheels 24 | **/pip-wheel-metadata 25 | **/share/python-wheels 26 | **/*.egg-info 27 | **/.installed.cfg 28 | **/*.egg 29 | **/MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | **/*.manifest 35 | **/*.spec 36 | 37 | # Installer logs 38 | **/pip-log.txt 39 | **/pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | **/htmlcov 43 | **/.tox 44 | **/.nox 45 | **/.coverage 46 | **/.coverage.* 47 | **/.cache 48 | **/nosetests.xml 49 | **/coverage.xml 50 | **/*.cover 51 | **/*.py,cover 52 | **/.hypothesis 53 | **/.pytest_cache 54 | 55 | # Translations 56 | **/*.mo 57 | **/*.pot 58 | 59 | # Django stuff: 60 | **/*.log 61 | **/local_settings.py 62 | **/db.sqlite3 63 | **/db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | **/instance 67 | **/.webassets-cache 68 | 69 | # Scrapy stuff: 70 | **/.scrapy 71 | 72 | # Sphinx documentation 73 | **/docs/_build 74 | 75 | # PyBuilder 76 | **/target 77 | 78 | # Jupyter Notebook 79 | **/.ipynb_checkpoints 80 | 81 | # IPython 82 | **/profile_default 83 | **/ipython_config.py 84 | 85 | # pyenv 86 | **/.python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # celery beat schedule file 96 | **/celerybeat-schedule 97 | 98 | # SageMath parsed files 99 | **/*.sage.py 100 | 101 | # Environments 102 | **/.env 103 | **/.venv 104 | **/env 105 | **/venv 106 | **/ENV 107 | **/env.bak 108 | **/venv.bak 109 | 110 | # Spyder project settings 111 | **/.spyderproject 112 | **/.spyproject 113 | 114 | # Rope project settings 115 | **/.ropeproject 116 | 117 | # mkdocs documentation 118 | site 119 | 120 | # mypy 121 | **/.mypy_cache 122 | **/.dmypy.json 123 | **/dmypy.json 124 | **/mypy.ini 125 | **/.mypy.ini 126 | 127 | # Pyre type checker 128 | **/.pyre 129 | **/.pyre_configuration 130 | **/.watchmanconfig 131 | 132 | # IDE stuff 133 | **/.vscode 134 | **/.idea 135 | **/.DS_Store 136 | 137 | # Google OAUTH 138 | **/jct-compsci-esp-*.json 139 | 140 | # SQL stuff 141 | **/*.session.sql 142 | 143 | # Fly.io 144 | abbot-engelborg-db 145 | 146 | # flyctl launch added from .venv/.gitignore 147 | # created by virtualenv automatically 148 | .venv/**/* 149 | fly.toml 150 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: pip 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | -------------------------------------------------------------------------------- /.github/workflows/contributor_list.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | workflow_dispatch: 6 | 7 | jobs: 8 | contrib-readme-job: 9 | runs-on: ubuntu-latest 10 | name: Contributors-Readme-Action 11 | steps: 12 | - name: Contributors List 13 | uses: akhilmhdh/contributors-readme-action@v2.2 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.PAT_TOKEN }} 16 | with: 17 | commit_message: Updated contributors list 18 | -------------------------------------------------------------------------------- /.github/workflows/fly.yml: -------------------------------------------------------------------------------- 1 | name: Fly Deploy 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | env: 8 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 9 | 10 | jobs: 11 | deploy: 12 | name: Deploy app 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - uses: superfly/flyctl-actions/setup-flyctl@master 17 | - run: flyctl deploy --remote-only 18 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | pre-commit: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v3 18 | 19 | - name: Set up python 3.10 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: "3.10" 23 | cache: pip 24 | 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install -r requirements.txt 28 | python -m pip install -r requirements-dev.txt 29 | 30 | - name: Run pre-commit 31 | uses: pre-commit/action@v3.0.0 32 | 33 | mypy: 34 | runs-on: ubuntu-latest 35 | 36 | steps: 37 | - name: Checkout code 38 | uses: actions/checkout@v3 39 | 40 | - name: Set up python 3.10 41 | uses: actions/setup-python@v4 42 | with: 43 | python-version: "3.10" 44 | cache: pip 45 | 46 | - name: Install dependencies 47 | run: | 48 | python -m pip install -r requirements.txt 49 | python -m pip install -r requirements-dev.txt 50 | 51 | - name: Run mypy 52 | run: task mypy -------------------------------------------------------------------------------- /.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 | # celery beat schedule file 95 | celerybeat-schedule 96 | 97 | # SageMath parsed files 98 | *.sage.py 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | mypy.ini 124 | .mypy.ini 125 | 126 | # Pyre type checker 127 | .pyre/ 128 | .pyre_configuration 129 | .watchmanconfig 130 | 131 | # IDE stuff 132 | .vscode/ 133 | .idea/ 134 | .DS_Store 135 | 136 | # Google OAUTH 137 | jct-compsci-esp-*.json 138 | 139 | # SQL stuff 140 | *.session.sql 141 | 142 | # Fly.io 143 | abbot-engelborg-db -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ## Pre-commit setup 2 | 3 | ci: 4 | autofix_commit_msg: | 5 | style: auto fixes from pre-commit hooks 6 | 7 | repos: 8 | - repo: https://github.com/pycqa/isort 9 | rev: 6.0.1 10 | hooks: 11 | - id: isort 12 | args: ["--profile", "black"] 13 | name: Running isort in all files. 14 | 15 | - repo: https://github.com/psf/black 16 | rev: 25.1.0 17 | hooks: 18 | - id: black 19 | name: Running black in all files. 20 | 21 | - repo: https://github.com/pre-commit/pre-commit-hooks 22 | rev: v5.0.0 23 | hooks: 24 | - id: check-ast 25 | name: Check if python files are valid syntax for the ast parser 26 | - id: check-case-conflict 27 | name: Check for case conflict on file names for case insensitive systems. 28 | - id: check-merge-conflict 29 | name: Check for merge conflict syntax. 30 | - id: check-toml 31 | name: Check TOML files for valid syntax. 32 | - id: check-yaml 33 | name: Check YAML files for valid syntax. 34 | - id: debug-statements 35 | name: Check for debug statements. 36 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Citizen Code of Conduct 2 | 3 | ## 1. Purpose 4 | 5 | A primary goal of Jct Discord Bot is to be inclusive to the largest number of contributors, with the most varied and diverse backgrounds possible. As such, we are committed to providing a friendly, safe and welcoming environment for all, regardless of gender, sexual orientation, ability, ethnicity, socioeconomic status, and religion (or lack thereof). 6 | 7 | This code of conduct outlines our expectations for all those who participate in our community, as well as the consequences for unacceptable behavior. 8 | 9 | We invite all those who participate in Jct Discord Bot to help us create safe and positive experiences for everyone. 10 | 11 | ## 2. Open [Source/Culture/Tech] Citizenship 12 | 13 | A supplemental goal of this Code of Conduct is to increase open [source/culture/tech] citizenship by encouraging participants to recognize and strengthen the relationships between our actions and their effects on our community. 14 | 15 | Communities mirror the societies in which they exist and positive action is essential to counteract the many forms of inequality and abuses of power that exist in society. 16 | 17 | If you see someone who is making an extra effort to ensure our community is welcoming, friendly, and encourages all participants to contribute to the fullest extent, we want to know. 18 | 19 | ## 3. Expected Behavior 20 | 21 | The following behaviors are expected and requested of all community members: 22 | 23 | * Participate in an authentic and active way. In doing so, you contribute to the health and longevity of this community. 24 | * Exercise consideration and respect in your speech and actions. 25 | * Attempt collaboration before conflict. 26 | * Refrain from demeaning, discriminatory, or harassing behavior and speech. 27 | * Be mindful of your surroundings and of your fellow participants. Alert community leaders if you notice a dangerous situation, someone in distress, or violations of this Code of Conduct, even if they seem inconsequential. 28 | * Remember that community event venues may be shared with members of the public; please be respectful to all patrons of these locations. 29 | 30 | ## 4. Unacceptable Behavior 31 | 32 | The following behaviors are considered harassment and are unacceptable within our community: 33 | 34 | * Violence, threats of violence or violent language directed against another person. 35 | * Sexist, racist, homophobic, transphobic, ableist or otherwise discriminatory jokes and language. 36 | * Posting or displaying sexually explicit or violent material. 37 | * Posting or threatening to post other people's personally identifying information ("doxing"). 38 | * Personal insults, particularly those related to gender, sexual orientation, race, religion, or disability. 39 | * Inappropriate photography or recording. 40 | * Inappropriate physical contact. You should have someone's consent before touching them. 41 | * Unwelcome sexual attention. This includes, sexualized comments or jokes; inappropriate touching, groping, and unwelcomed sexual advances. 42 | * Deliberate intimidation, stalking or following (online or in person). 43 | * Advocating for, or encouraging, any of the above behavior. 44 | * Sustained disruption of community events, including talks and presentations. 45 | 46 | ## 5. Weapons Policy 47 | 48 | No weapons will be allowed at Jct Discord Bot events, community spaces, or in other spaces covered by the scope of this Code of Conduct. Weapons include but are not limited to guns, explosives (including fireworks), and large knives such as those used for hunting or display, as well as any other item used for the purpose of causing injury or harm to others. Anyone seen in possession of one of these items will be asked to leave immediately, and will only be allowed to return without the weapon. Community members are further expected to comply with all state and local laws on this matter. 49 | 50 | ## 6. Consequences of Unacceptable Behavior 51 | 52 | Unacceptable behavior from any community member, including sponsors and those with decision-making authority, will not be tolerated. 53 | 54 | Anyone asked to stop unacceptable behavior is expected to comply immediately. 55 | 56 | If a community member engages in unacceptable behavior, the community organizers may take any action they deem appropriate, up to and including a temporary ban or permanent expulsion from the community without warning (and without refund in the case of a paid event). 57 | 58 | ## 7. Reporting Guidelines 59 | 60 | If you are subject to or witness unacceptable behavior, or have any other concerns, please notify a community organizer as soon as possible. jonah@freshidea.com. 61 | 62 | Additionally, community organizers are available to help community members engage with local law enforcement or to otherwise help those experiencing unacceptable behavior feel safe. In the context of in-person events, organizers will also provide escorts as desired by the person experiencing distress. 63 | 64 | ## 8. Addressing Grievances 65 | 66 | If you feel you have been falsely or unfairly accused of violating this Code of Conduct, you should notify with a concise description of your grievance. Your grievance will be handled in accordance with our existing governing policies. 67 | 68 | ## 9. Scope 69 | 70 | We expect all community participants (contributors, paid or otherwise; sponsors; and other guests) to abide by this Code of Conduct in all community venues--online and in-person--as well as in all one-on-one communications pertaining to community business. 71 | 72 | This code of conduct and its related procedures also applies to unacceptable behavior occurring outside the scope of community activities when such behavior has the potential to adversely affect the safety and well-being of community members. 73 | 74 | ## 10. Contact info 75 | 76 | jonah@freshidea.com 77 | 78 | ## 11. License and attribution 79 | 80 | The Citizen Code of Conduct is distributed by [Stumptown Syndicate](http://stumptownsyndicate.org) under a [Creative Commons Attribution-ShareAlike license](http://creativecommons.org/licenses/by-sa/3.0/). 81 | 82 | Portions of text derived from the [Django Code of Conduct](https://www.djangoproject.com/conduct/) and the [Geek Feminism Anti-Harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy). 83 | 84 | _Revision 2.3. Posted 6 March 2017._ 85 | 86 | _Revision 2.2. Posted 4 February 2016._ 87 | 88 | _Revision 2.1. Posted 23 June 2014._ 89 | 90 | _Revision 2.0, adopted by the [Stumptown Syndicate](http://stumptownsyndicate.org) board on 10 January 2013. Posted 17 March 2013._ 91 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10 2 | WORKDIR /bot 3 | COPY requirements.txt /bot/ 4 | RUN pip install -r requirements.txt 5 | COPY . /bot 6 | CMD python bot.py -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 DenverCoder1 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 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: python bot.py -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Abbot Engelborg 2 | 3 | Bot for JCT ESP CompSci Discord server 4 | 5 | ## Contributors 6 | 7 | This bot was created by the contribution of the following members. If you would like to contribute, please be in touch with one of the current contributors and then take a look at [the contributing guide](contributing.md) 8 | 9 | 10 | 11 | 12 | 19 | 26 | 33 | 40 | 47 | 54 | 55 | 62 | 69 |
13 | 14 | DenverCoder1 15 |
16 | Jonah Lawrence 17 |
18 |
20 | 21 | abrahammurciano 22 |
23 | Abraham Murciano 24 |
25 |
27 | 28 | PSilver22 29 |
30 | PSilver22 31 |
32 |
34 | 35 | shirapahmer 36 |
37 | Shira Pahmer 38 |
39 |
41 | 42 | zabrown2000 43 |
44 | Zabrown2000 45 |
46 |
48 | 49 | avipars 50 |
51 | Avi Parshan 52 |
53 |
56 | 57 | benjitusk 58 |
59 | BenjiTusk 60 |
61 |
63 | 64 | etandinnerman 65 |
66 | Etandinnerman 67 |
68 |
70 | 71 | 72 | 73 | ## Issues and Feature Requests 74 | 75 | If you'd like a new feature added to the bot, or you have discovered some misbehavior by it, please feel free to open an issue detailing it in the [GitHub issues tab](https://github.com/DenverCoder1/jct-discord-bot/issues). 76 | 77 | 78 | ## How to use the bot 79 | 80 | [Tutorials Playlist](https://www.youtube.com/playlist?list=PL9DdgseuDZgKrT52rm5VApmGa6q02sqKV) 81 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | 4 | import nextcord 5 | from nextcord.ext import commands 6 | 7 | import config 8 | import database.preloaded 9 | from utils.scheduler import Scheduler 10 | 11 | 12 | async def main(): 13 | # Preload necessary data from the database 14 | await database.preloaded.load() 15 | 16 | # allows privledged intents for monitoring members joining, roles editing, and role assignments (has to be enabled for the bot in Discord dev) 17 | intents = nextcord.Intents.default() 18 | intents.guilds = True 19 | intents.members = True 20 | 21 | activity = nextcord.Game("with students' patience") 22 | 23 | bot = commands.Bot(intents=intents, activity=activity) 24 | 25 | # Get the modules of all cogs whose directory structure is modules//cog.py 26 | for folder in os.listdir("modules"): 27 | if os.path.exists(os.path.join("modules", folder, "cog.py")): 28 | bot.load_extension(f"modules.{folder}.cog") 29 | 30 | @bot.event 31 | async def on_ready(): 32 | """When discord is connected""" 33 | # skip if this function has already run 34 | if config._guild is not None: 35 | return 36 | assert bot.user is not None 37 | print(f"{bot.user.name} has connected to Discord!") 38 | config._guild = bot.get_guild(config.guild_id) 39 | # Start Scheduler 40 | Scheduler(bot) 41 | 42 | # Run Discord bot 43 | await bot.start(config.token) 44 | 45 | 46 | if __name__ == "__main__": 47 | asyncio.run(main()) 48 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional 3 | 4 | import asyncpg 5 | import nextcord 6 | from dotenv.main import load_dotenv 7 | 8 | load_dotenv() 9 | 10 | # Discord setup 11 | token = os.getenv("DISCORD_TOKEN", "") 12 | 13 | guild_id = int(os.getenv("DISCORD_GUILD", "")) 14 | _guild: Optional[nextcord.Guild] = None # To be loaded on ready 15 | 16 | 17 | def guild() -> nextcord.Guild: 18 | assert _guild is not None 19 | return _guild 20 | 21 | 22 | _conn: Optional[asyncpg.Connection] = None # The global connection to the database 23 | 24 | 25 | async def get_connection() -> asyncpg.Connection: 26 | """Get a connection to the database. 27 | 28 | If a connection has not yet been established or the current connection is closed, a new connection will be established. 29 | 30 | Returns: 31 | asyncpg.Connection: The connection to the database. 32 | """ 33 | global _conn 34 | if _conn is None or _conn.is_closed(): 35 | _conn = await asyncpg.connect(os.getenv("DATABASE_URL")) # type: ignore 36 | assert isinstance( 37 | _conn, asyncpg.Connection 38 | ), "A connection to the database could not be established." 39 | return _conn 40 | 41 | 42 | # Google client configuration 43 | google_config = { 44 | "type": "service_account", 45 | "project_id": os.getenv("GOOGLE_PROJECT_ID", ""), 46 | "private_key_id": os.getenv("GOOGLE_PRIVATE_KEY_ID", ""), 47 | "private_key": os.getenv("GOOGLE_PRIVATE_KEY", "").replace("\\n", "\n"), 48 | "client_email": os.getenv("GOOGLE_CLIENT_EMAIL", ""), 49 | "client_id": os.getenv("GOOGLE_CLIENT_ID", ""), 50 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 51 | "token_uri": "https://oauth2.googleapis.com/token", 52 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 53 | "client_x509_cert_url": os.getenv("GOOGLE_CLIENT_X509_CERT_URL"), 54 | } 55 | 56 | # Google Drive folder IDs 57 | drive_folder_id = os.getenv("DRIVE_FOLDER_ID", "") 58 | drive_guidelines_url = os.getenv("DRIVE_GUIDLELINES_URL", "") 59 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contribution Guide 2 | 3 | Please follow the instructions on this guide when making contributions to the JCT Discord Bot. 4 | 5 | ## Contributing 6 | 7 | ### 1 - Creating an Issue 8 | 9 | When making a contribution, whether to fix a bug, or to add a new feature, first **create an issue** if it doesn't already exist, outlining briefly what needs to be accomplished. (Note each issue has an issue number by which we will reference it later on.) 10 | 11 | Once the issue exists, **add any appropriate labels**, then add it to the project **JCT Discord Bot**. (This can be accomplished on the issue's page on GitHub, on the right hand side on desktop, or at the bottom on mobile.) Then you can **assign yourself** to the issue. 12 | 13 | ### 2 - Creating a Branch 14 | 15 | Create a branch for the contribution you are making. If it is a new command you want to add, simply name the branch the same as the command (without the command prefix). If it is a bug you want to fix or something else, come up with some descriptive yet concise name for the branch. 16 | 17 | This name will be referred to as _my_feature_ throughout this guide. 18 | 19 | ### 3 - Writing the Code 20 | 21 | Now you can write your code to implement your feature. You can commit and push to the dedicated branch as often and whenever you like. 22 | 23 | Please check out the [style guide](style_guide.md). 24 | 25 | #### 3.1 - Creating the Cog 26 | 27 | Create a file `/modules/my_feature/cog.py` (where _my_feature_ is the name of your command or feature) for whatever you feature you want to add. Create a class which inherits from `commands.Cog`, and define its constructor as follows. 28 | 29 | ```py 30 | from nextcord.ext import commands 31 | import nextcord 32 | class MyFeatureCog(commands.Cog): 33 | """A cog which has no features and does nothing""" 34 | def __init__(self, bot: commands.Bot): 35 | self.bot = bot 36 | ``` 37 | 38 | Then as methods to that class, add any functions you need for the bot. For example: 39 | 40 | ```py 41 | @nextcord.slash_command() 42 | async def my_feature(self, interaction: nextcord.Interaction[commands.Bot], n: int, word: str): 43 | """A command which takes two arguments and does nothing 44 | 45 | Args: 46 | n: A number with no meaning 47 | word: Any word you like 48 | """ 49 | 50 | # log in console that a ping was received 51 | print('Executing command "my_feature".') 52 | 53 | # reply with a message 54 | await interaction.send(f"Command received with num {n} and word {word}.") 55 | ``` 56 | 57 | Finally, (this part is important,) add a function (outside the class) to add an instance of the class you've created (which is a "cog") to the bot. 58 | 59 | ```py 60 | def setup(bot: commands.Bot): 61 | bot.add_cog(MyFeatureCog(bot)) 62 | ``` 63 | 64 | #### 3.2 - Additional files 65 | 66 | If you need additional files, be it python modules (`.py` files with functions and/or classes) or other files such as `.txt`, `.json`, etc, you can put them in `/modules/my_feature/` 67 | 68 | ##### 3.2.1 - Using Additional Python Files 69 | 70 | Suppose your additional file is `/modules/my_feature/foo.py` and it contains a class or function `bar`. 71 | 72 | You can import the file from anywhere with any one of the following. 73 | 74 | ```py 75 | import modules.my_feature.foo # access bar with: modules.my_feature.foo.bar 76 | import modules.my_feature.foo as foo # access bar with: foo.bar 77 | from modules.my_feature.foo import bar # access bar with: bar 78 | ``` 79 | 80 | ##### 3.2.2 - Using Additional Data Files 81 | 82 | You can add any data files you want to read from your python code in the `/modules/my_feature/` folder. (Let's call one such file `biz.txt`.) To read them from your code, you can access them with the path relative to the repository root. For example: 83 | 84 | ```py 85 | with open('modules/my_feature/biz.txt') as biz: 86 | pass 87 | ``` 88 | 89 | #### 3.3 - Error Handling 90 | 91 | If when trying to have the bot perform some action based on something a user said, you have to inform the user of an error, you can use the `FriendlyError` class to do so as follows: 92 | 93 | ```py 94 | from modules.error.friendly_error import FriendlyError 95 | #... 96 | raise FriendlyError("user friendly error message", interaction, member) 97 | ``` 98 | 99 | where `interaction` is of type `nextcord.Interaction` or `nextcord.abc.Messageable` and `member` is of type `nextcord.Member`. Optionally, you can also pass an internal Exception, if applicable, and the error will be logged to `err.log`. 100 | 101 | When raising a `FriendlyError`, the message passed to it will be sent to the channel provided, tagging `member` if a member was passed. 102 | 103 | ### 4 - Testing your Code 104 | 105 | You may want to add the bot to your own server to test stuff yourself first. To do so, [invite the bot to your server](https://discord.com/api/oauth2/authorize?client_id=796039233789100053&permissions=8&scope=bot). 106 | 107 | To run the bot locally, you may want to first disable the hosted version of the bot, otherwise the bot will react to everything twice. Ask [Jonah Lawrence](https://github.com/DenverCoder1) for permission to manage the hosting service if necessary. 108 | 109 | You will also need the `.env` file in the project's root directory. Again, ask [Jonah Lawrence](https://github.com/DenverCoder1) for this file, or check the pinned messages in the `#jct-bot-development` Discord channel. 110 | 111 | You will need to make sure you have the all the necessary libraries installed. The best way to do this is to install everything you need into a virtual environment. You can do this by typing the following commands in your terminal. 112 | 113 | ``` 114 | python -m venv .venv # creates a virtual environment in a folder called .venv 115 | 116 | # activate the virtual environment (use only one of these two commands) 117 | source .venv/bin/activate # for bash/zsh (you'll need this one if you're on linux or mac, or if you're using bash on windows) 118 | .venv\Scripts\activate # for cmd.exe (you'll probably need this one if you're on windows and don't know what bash is) 119 | 120 | # install everything you need 121 | pip install -r requirements.txt 122 | pip install -r requirements-dev.txt 123 | ``` 124 | 125 | Now you should be able to run the bot locally. Well done! 126 | 127 | ```sh 128 | python bot.py 129 | ``` 130 | 131 | ### 5 - Creating a Pull Request 132 | 133 | Once you have tested your feature and you think it is ready to be deployed, you can go ahead and **create a pull request** on GitHub to merge your branch to the Main branch. 134 | 135 | Start the description of the pull request with the line `Close #N` (where `N` is the number of the issue) in order to link the pull request with the corresponding issue. (This can also be done manually after the PR is created, but it's preferable if you do it this way.) 136 | 137 | Once the pull request is made, or while creating it, **add a reviewer** to your pull request. They will review your changes and additions, and if they approve, you can merge your pull request. 138 | -------------------------------------------------------------------------------- /database/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/database/__init__.py -------------------------------------------------------------------------------- /database/campus.py: -------------------------------------------------------------------------------- 1 | from functools import cached_property 2 | from typing import Collection 3 | 4 | import nextcord 5 | 6 | import config 7 | from database import sql 8 | 9 | 10 | class Campus: 11 | def __init__(self, id: int, name: str, channel_id: int): 12 | self.__id = id 13 | self.__name = name 14 | self.__channel_id = channel_id 15 | 16 | @property 17 | def id(self) -> int: 18 | """The ID of the campus as stored in the database.""" 19 | return self.__id 20 | 21 | @property 22 | def name(self) -> str: 23 | """The name of the Campus. (eg Lev)""" 24 | return self.__name 25 | 26 | @cached_property 27 | def channel(self) -> nextcord.TextChannel: 28 | """The channel associated with this Campus.""" 29 | channel = nextcord.utils.get(config.guild().text_channels, id=self.__channel_id) 30 | assert channel is not None 31 | return channel 32 | 33 | @classmethod 34 | async def get_campus(cls, campus_id: int) -> "Campus": 35 | """Fetch a single campus from the database with the specified id.""" 36 | record = await sql.select.one("campuses", ("id", "name", "channel"), id=campus_id) 37 | assert record is not None 38 | return cls(*record) 39 | 40 | @classmethod 41 | async def get_campuses(cls) -> Collection["Campus"]: 42 | """Fetch a list of campuses from the database.""" 43 | records = await sql.select.many("campuses", ("id", "name", "channel")) 44 | return [cls(*record) for record in records] 45 | 46 | def __eq__(self, other): 47 | """Compares them by ID""" 48 | if isinstance(other, self.__class__): 49 | return self.__id == other.__id 50 | return False 51 | 52 | def __hash__(self): 53 | return hash(self.__id) 54 | -------------------------------------------------------------------------------- /database/group.py: -------------------------------------------------------------------------------- 1 | from functools import cached_property 2 | from typing import Collection 3 | 4 | import nextcord 5 | 6 | import config 7 | from database import sql 8 | from database.campus import Campus 9 | 10 | 11 | class Group: 12 | def __init__(self, id: int, grad_year: int, campus: Campus, role_id: int, calendar: str): 13 | self.__id = id 14 | self.__grad_year = grad_year 15 | self.__campus = campus 16 | self.__role_id = role_id 17 | self.__calendar = calendar 18 | 19 | @property 20 | def id(self) -> int: 21 | """The ID of the group as stored in the database.""" 22 | return self.__id 23 | 24 | @property 25 | def grad_year(self) -> int: 26 | """The year in which this group is to graduate.""" 27 | return self.__grad_year 28 | 29 | @property 30 | def campus(self) -> Campus: 31 | """The campus this Group belongs to""" 32 | return self.__campus 33 | 34 | @cached_property 35 | def role(self) -> nextcord.Role: 36 | """The Role associated with this Group.""" 37 | role = nextcord.utils.get(config.guild().roles, id=self.__role_id) 38 | assert role is not None 39 | return role 40 | 41 | @property 42 | def calendar(self) -> str: 43 | """The calendar id associated with this Group. (Looks like an email address)""" 44 | return self.__calendar 45 | 46 | @property 47 | def name(self) -> str: 48 | """The name of this Group. (eg Lev 2021)""" 49 | return f"{self.campus.name} {self.__grad_year}" 50 | 51 | @classmethod 52 | async def get_group(cls, group_id: int) -> "Group": 53 | """Fetch a group from the database given its ID.""" 54 | record = await sql.select.one( 55 | "groups", ("id", "grad_year", "campus", "role", "calendar"), id=group_id 56 | ) 57 | assert record is not None 58 | return cls(*record) 59 | 60 | @classmethod 61 | async def get_groups(cls) -> Collection["Group"]: 62 | """Fetch a list of groups from the database""" 63 | records = await sql.select.many( 64 | "groups_campuses_view", 65 | ( 66 | "group_id", 67 | "grad_year", 68 | "campus_id", 69 | "campus_name", 70 | "campus_channel", 71 | "role", 72 | "calendar", 73 | ), 74 | ) 75 | return [ 76 | cls( 77 | r["group_id"], 78 | r["grad_year"], 79 | Campus(r["campus_id"], r["campus_name"], r["campus_channel"]), 80 | r["role"], 81 | r["calendar"], 82 | ) 83 | for r in records 84 | ] 85 | 86 | def __eq__(self, other): 87 | """Compares them by ID""" 88 | if isinstance(other, self.__class__): 89 | return self.__id == other.__id 90 | return False 91 | 92 | def __hash__(self): 93 | return hash(self.__id) 94 | -------------------------------------------------------------------------------- /database/person/__init__.py: -------------------------------------------------------------------------------- 1 | from .person import Person 2 | -------------------------------------------------------------------------------- /database/person/person.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, Sequence, Set, Tuple 2 | 3 | import config 4 | from database import sql, sql_fetcher 5 | 6 | 7 | class Person: 8 | def __init__(self, id: int, name: str, emails: str, categories: str) -> None: 9 | self.__id = id 10 | self.__name = name 11 | self.__emails = self.__no_duplicates(emails) 12 | self.__categories = self.__no_duplicates(categories) 13 | 14 | @property 15 | def id(self) -> int: 16 | """The ID of this person as stored in the database.""" 17 | return self.__id 18 | 19 | @property 20 | def name(self) -> str: 21 | """The name of this person.""" 22 | return self.__name 23 | 24 | @property 25 | def emails(self) -> Iterable[str]: 26 | """An iterable containing the email addresses registered to this person.""" 27 | return self.__emails.split(", ") 28 | 29 | @property 30 | def categories(self) -> str: 31 | """A comma separated string of categories this person is associated with.""" 32 | return self.__categories 33 | 34 | @property 35 | def linked_emails(self) -> str: 36 | """A comma separated string of underlined email addresses of this person.""" 37 | return ", ".join([f"__{email}__" for email in self.__emails.split(", ") if email]) 38 | 39 | def __no_duplicates(self, list_as_str: str, sep_in: str = ",", sep_out: str = ", ") -> str: 40 | return ( 41 | sep_out.join({elem.strip() for elem in list_as_str.split(sep_in)}) 42 | if list_as_str 43 | else "" 44 | ) 45 | 46 | @classmethod 47 | async def get_person(cls, person_id: int) -> "Person": 48 | """Searches the database for a person with a given id and returns a Person object.""" 49 | record = await sql.select.one( 50 | "people_view", ("id", "name", "emails", "categories"), id=person_id 51 | ) 52 | assert record is not None 53 | return cls(*record) 54 | 55 | @classmethod 56 | async def get_people(cls) -> Set["Person"]: 57 | """Searches the database for all people and returns a set of Person objects.""" 58 | records = await sql.select.many("people_view", ("name", "emails", "categories")) 59 | return {cls(*record) for record in records} 60 | 61 | @classmethod 62 | async def search_by_name(cls, name: str) -> Sequence[Tuple["Person", float]]: 63 | """Searches the database for all people whose name or surname reasonably match the input and returns a sequence of (person, similarity) pairs sorted by decreasing similarity. 64 | 65 | Args: 66 | name (str): The name of the person to search for. 67 | 68 | Returns: 69 | Sequence[Tuple[Person, float]]: A sequence of results where each result is a tuple of the person that matched as well as a similarity score between 0 and 1. 70 | """ 71 | conn = await config.get_connection() 72 | query = sql_fetcher.fetch("database", "person", "queries", "search_people.sql") 73 | return [(cls(*record[:-1]), record[-1]) for record in await conn.fetch(query, name)] 74 | 75 | @classmethod 76 | async def search_by_channel(cls, channel_id: int) -> Iterable["Person"]: 77 | """ 78 | Searches the database for all people whose channel matches the input and returns an iterable of these. 79 | """ 80 | return await cls.__search_people("person_category_categories_view", channel=channel_id) 81 | 82 | @classmethod 83 | async def search_by_email(cls, email: str) -> Iterable["Person"]: 84 | """ 85 | Searches the database for all people whose email matches the input and returns an iterable of these. 86 | """ 87 | return await cls.__search_people("emails", email=email) 88 | 89 | def __eq__(self, other): 90 | """Compares them by ID""" 91 | if isinstance(other, self.__class__): 92 | return self.__id == other.__id 93 | return False 94 | 95 | def __hash__(self): 96 | return hash(self.__id) 97 | 98 | @classmethod 99 | async def __search_people(cls, table: str, **conditions) -> Iterable["Person"]: 100 | """Searches the database using a given a table and some kwarg conditions and returns a list of people found. 101 | 102 | Args: 103 | table (str): The name of the table to search in. 104 | **conditions: The column names and values that the found records should have. 105 | 106 | Returns: 107 | Iterable[Person]: An iterable of the people found. 108 | """ 109 | records = await sql.select.many(table, ("person",), **conditions) 110 | return {await Person.get_person(record["person"]) for record in records} 111 | -------------------------------------------------------------------------------- /database/person/queries/search_people.sql: -------------------------------------------------------------------------------- 1 | SELECT * 2 | FROM ( 3 | SELECT people.id, 4 | concat(people.name, ' ', people.surname) AS name, 5 | string_agg(emails.email, ', ') AS emails, 6 | string_agg(categories.name, ', ') AS categories, 7 | GREATEST( 8 | similarity(people.name, $1), 9 | similarity(people.surname, $1) 10 | ) AS similarity 11 | FROM people 12 | LEFT JOIN person_category ON people.id = person_category.person 13 | LEFT JOIN categories ON person_category.category = categories.id 14 | LEFT JOIN emails ON people.id = emails.person 15 | GROUP BY people.id 16 | ORDER BY similarity DESC 17 | ) as subquery 18 | WHERE subquery.similarity >= 0.3 19 | -------------------------------------------------------------------------------- /database/preloaded.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module is supposed to contain all the data from the database that would be 3 | required from places where async calls are not allowed. 4 | 5 | If you need to access some data from the database for example in a function 6 | decorator, you can declare it in this file, assign it asynchronously in the 7 | __load() function, then import it wherever you need it. 8 | 9 | Just make sure you call await load() sometime before you use any of the data in 10 | this module. 11 | """ 12 | 13 | from typing import Collection 14 | 15 | from database.campus import Campus 16 | 17 | from .group import Group 18 | 19 | groups: Collection[Group] = [] 20 | campuses: Collection[Campus] = [] 21 | 22 | 23 | async def load(): 24 | global groups, campuses 25 | groups = await Group.get_groups() 26 | campuses = await Campus.get_campuses() 27 | -------------------------------------------------------------------------------- /database/sql/__init__.py: -------------------------------------------------------------------------------- 1 | from . import select 2 | from .delete import delete 3 | from .insert import insert 4 | -------------------------------------------------------------------------------- /database/sql/delete.py: -------------------------------------------------------------------------------- 1 | import config 2 | 3 | from . import util 4 | 5 | 6 | async def delete(table: str, **conditions): 7 | """Delete the rows that satisfy the given conditions from the given table. 8 | 9 | Note, if no kwargs are passed to `conditions`, all rows will be deleted. 10 | 11 | For security reasons it is important that the only user input passed into this function is via the values of `**conditions`. 12 | 13 | Args: 14 | table (str): The name of the table to delete rows from. 15 | **conditions: Keyword arguments specifying constraints on the select statement. For a kwarg A=B, the select statement will only match rows where the column named A has the value B. 16 | """ 17 | columns, values, placeholders = util.prepare_kwargs(conditions) 18 | query = f"DELETE FROM {table}{util.where(columns, placeholders)}" 19 | conn = await config.get_connection() 20 | async with conn.transaction(): 21 | await conn.execute(query, *values) 22 | -------------------------------------------------------------------------------- /database/sql/insert.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | import config 4 | 5 | from . import util 6 | 7 | 8 | async def insert( 9 | table: str, 10 | on_conflict: Optional[str] = None, 11 | returning: Optional[str] = None, 12 | **fields, 13 | ) -> Any: 14 | """Run an insert statement on the given table. 15 | 16 | For security reasons it is important that the only user input passed into this function is via the values of `**fields`. 17 | 18 | Args: 19 | table (str): The name of the table to insert into. 20 | returning (str, optional): The name of the column whose value to return from the inserted row. Commonly this would be the auto-incremented ID but doesn't have to be. By default the function returns None. 21 | fields: The values to insert into the given table. 22 | 23 | Returns: 24 | Any: The value of the column `returning` in the newly inserted row, or None if no column was specified. 25 | """ 26 | keys, values, placeholders = util.prepare_kwargs(fields) 27 | query = ( 28 | f"INSERT INTO {table} ({', '.join(keys)}) VALUES" 29 | f" ({', '.join(placeholders)})" 30 | f" {'ON CONFLICT ' + on_conflict if on_conflict else ''}" 31 | f" {('RETURNING ' + returning) if returning else ''}" 32 | ) 33 | conn = await config.get_connection() 34 | async with conn.transaction(): 35 | return await conn.fetchval(query, *values) 36 | -------------------------------------------------------------------------------- /database/sql/select.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Coroutine, Iterable, List, Optional, Protocol, TypeVar 2 | 3 | import asyncpg 4 | 5 | import config 6 | 7 | from . import util 8 | 9 | 10 | async def many(table: str, columns: Iterable[str] = ("*",), **conditions) -> List[asyncpg.Record]: 11 | """Select all rows in a table matching the kwargs `conditions`. 12 | 13 | For security reasons it is important that the only user input passed into this function is via the values of `**conditions`. 14 | 15 | Args: 16 | table (str): The name of the table to select from. 17 | columns (Iterable[str], optional): The names of columns to select. Defaults to all columns. 18 | **conditions: Keyword arguments specifying constraints on the select statement. For a kwarg A=B, the select statement will only match rows where the column named A has the value B. 19 | 20 | Returns: 21 | List[asyncpg.Record]: A list of the records in the table. 22 | """ 23 | conn = await config.get_connection() 24 | return await __select(table, columns, conn.fetch, **conditions) 25 | 26 | 27 | async def one( 28 | table: str, columns: Iterable[str] = ("*",), **conditions 29 | ) -> Optional[asyncpg.Record]: 30 | """Select a single row from a table matching the kwargs `conditions`. 31 | 32 | For security reasons it is important that the only user input passed into this function is via the values of `**conditions`. 33 | 34 | Args: 35 | table (str): The name of the table to select from. 36 | columns (Iterable[str], optional): The names of the columns to select. Defaults to all columns. 37 | **conditions: Keyword arguments specifying constraints on the select statement. For a kwarg A=B, the select statement will only match rows where the column named A has the value B. 38 | 39 | Returns: 40 | Optional[asyncpg.Record]: The selected row if one was found, or None otherwise. 41 | """ 42 | conn = await config.get_connection() 43 | return await __select(table, columns, conn.fetchrow, **conditions) 44 | 45 | 46 | async def value(table: str, column: str = "*", **conditions) -> Any: 47 | """Select a single cell from a table where the column is the one specified and the row matches the kwargs `conditions`. 48 | 49 | For security reasons it is important that the only user input passed into this function is via the values of `**conditions`. 50 | 51 | Args: 52 | table (str): The name of the table to select from. 53 | column (str, optional): The names of the columns to select. Defaults to the first column. 54 | **conditions: Keyword arguments specifying constraints on the select statement. For a kwarg A=B, the select statement will only match rows where the column named A has the value B. 55 | 56 | Returns: 57 | Optional[Any]: The value of the selected cell if one was found, or None otherwise. 58 | """ 59 | conn = await config.get_connection() 60 | return await __select(table, (column,), conn.fetchval, **conditions) 61 | 62 | 63 | T = TypeVar("T", covariant=True) 64 | 65 | 66 | class Fetcher(Protocol[T]): 67 | def __call__(self, query: str, *values: Any) -> Coroutine[Any, Any, T]: ... 68 | 69 | 70 | async def __select(table: str, columns: Iterable[str], fetcher: Fetcher[T], **conditions) -> T: 71 | filtered_columns, values, placeholders = util.prepare_kwargs(conditions) 72 | query = f"SELECT {', '.join(columns)} FROM {table}{util.where(filtered_columns, placeholders)}" 73 | return await fetcher(query, *values) 74 | -------------------------------------------------------------------------------- /database/sql/util.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Sequence, Tuple 2 | 3 | 4 | def where(columns: Sequence[str], placeholders: Sequence[str]) -> str: 5 | """Construct a where clause where if the ith and jth elements of `columns` are A and B respectively, and X and Y are the ith and jth elements of `placeholders`, the resulting string would look like " WHERE A = X AND B = Y" 6 | 7 | Args: 8 | columns (Sequence[str]): A sequence of column names to be included in the where clause. 9 | placeholders (Sequence[str]): A sequence of placeholders to place as the values of their corresponding columns. 10 | 11 | Returns: 12 | str: A string containing the where clause. 13 | """ 14 | expressions = " AND ".join( 15 | f"{column} = {placeholder}" for column, placeholder in zip(columns, placeholders) 16 | ) 17 | return "" if not columns else f" WHERE {expressions}" 18 | 19 | 20 | def prepare_kwargs(kwargs: Dict[str, Any]) -> Tuple[Sequence[str], Sequence[Any], Sequence[str]]: 21 | """Create three sequences from the provided kwargs. 22 | 23 | The first sequence is the keys of the kwargs. The second sequence is the values. The third sequence is the placeholders for the SQL prepared statements (e.g. $1, $2, etc). 24 | 25 | Args: 26 | kwargs (Dict[str, Any]): A dictionary of keyword arguments. 27 | 28 | Returns: 29 | Tuple[Sequence[str], Sequence[Any], Sequence[str]]: A tuple containing a sequence of the keys from the kwargs in the first index, a sequence of the values from the kwargs in the second index, and a sequence of the placeholders for the SQL prepared statements in the third index. 30 | """ 31 | keys, values, placeholders = ( 32 | zip(*[(key, kwargs[key], f"${index}") for index, key in enumerate(kwargs, 1)]) 33 | if kwargs 34 | else ((), (), ()) 35 | ) 36 | return keys, values, placeholders 37 | -------------------------------------------------------------------------------- /database/sql_fetcher.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import cache 3 | 4 | 5 | @cache 6 | def fetch(*paths: str): 7 | """ 8 | Fetches the SQL code in the file `file_name` located in the given directory. 9 | 10 | :param *paths: Path components, as would be passed to os.path.join(). Relative to `sql_folder` passed to the constructor. 11 | """ 12 | path = os.path.join(".", *paths) 13 | return open(path, "r").read() 14 | 15 | 16 | for path in [ 17 | os.path.join(directory, file_name) 18 | for directory, _, file_names in os.walk(".") 19 | for file_name in file_names 20 | if file_name[-4:] == ".sql" 21 | ]: 22 | fetch(path) # load all sql files on initialisation 23 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for abbot-engelborg on 2023-07-04T15:37:48-06:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "abbot-engelborg" 7 | primary_region = "cdg" 8 | -------------------------------------------------------------------------------- /modules/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/__init__.py -------------------------------------------------------------------------------- /modules/calendar/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/calendar/__init__.py -------------------------------------------------------------------------------- /modules/calendar/calendar.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Iterable, Optional 2 | 3 | import nextcord 4 | from nextcord.ext import commands 5 | 6 | from database.group import Group 7 | from modules.error.friendly_error import FriendlyError 8 | from utils.utils import one 9 | 10 | 11 | class Calendar: 12 | """Calendar object to store information about a Google Calendar""" 13 | 14 | def __init__(self, id: str, name: str): 15 | """Create a calendar object from a calendar id and name""" 16 | self.__id = id 17 | self.__name = name 18 | 19 | @property 20 | def id(self) -> str: 21 | """The ID of the Google calendar""" 22 | return self.__id 23 | 24 | @property 25 | def name(self) -> str: 26 | """The name of the calendar""" 27 | return self.__name 28 | 29 | def add_url(self) -> str: 30 | """The url to add the calendar to Google Calendar""" 31 | return ( 32 | "https://calendar.google.com/calendar/render" 33 | f"?cid=https://www.google.com/calendar/feeds/{self.__id}" 34 | "/public/basic" 35 | ) 36 | 37 | def view_url(self, timezone: str) -> str: 38 | """The url to view the calendar""" 39 | return f"https://calendar.google.com/calendar/u/0/embed?src={self.__id}&ctz={timezone}" 40 | 41 | def ical_url(self) -> str: 42 | """The iCal url for the calendar""" 43 | return ( 44 | "https://calendar.google.com/calendar/ical" 45 | f"/{self.__id.replace('@','%40')}/public/basic.ics" 46 | ) 47 | 48 | @classmethod 49 | def from_dict(cls, details: Dict[str, str]) -> "Calendar": 50 | """Create a calendar from a JSON object as returned by the Calendar API""" 51 | return cls(id=details["id"], name=details["summary"]) 52 | 53 | @classmethod 54 | async def get_calendar( 55 | cls, 56 | interaction: nextcord.Interaction[commands.Bot], 57 | groups: Optional[Iterable[Group]] = None, 58 | group_id: Optional[int] = None, 59 | ephemeral: bool = False, 60 | ) -> "Calendar": 61 | """Returns Calendar given a Discord member or a specified group id 62 | 63 | Args: 64 | interaction (nextcord.Interaction): The interaction object to use to report errors. 65 | groups (Optional[Iterable[Group]], optional): The groups who might own the calendar. Defaults to all of them. 66 | group_id (Optional[int], optional): The group id which owns the calendar we seek. Defaults to the user's group, if he has only one. 67 | ephemeral: Whether to use ephemeral messages when sending errors. Defaults to False. 68 | 69 | Returns: 70 | The calendar object. 71 | """ 72 | groups = groups or await Group.get_groups() 73 | if group_id: 74 | # get the group specified by the user given the group id 75 | group = one(group for group in groups if group.id == group_id) 76 | else: 77 | # get the group from the user's role 78 | member_groups = ( 79 | [group for group in groups if group.role in interaction.user.roles] 80 | if isinstance(interaction.user, nextcord.Member) 81 | else [] 82 | ) 83 | # no group roles found 84 | if not member_groups: 85 | raise FriendlyError( 86 | "Could not find your class role.", 87 | interaction, 88 | interaction.user, 89 | ephemeral=ephemeral, 90 | ) 91 | # multiple group roles found 92 | if len(member_groups) > 1: 93 | raise FriendlyError( 94 | "You must specify which calendar since you have multiple class roles.", 95 | interaction, 96 | interaction.user, 97 | ephemeral=ephemeral, 98 | ) 99 | # only one group found 100 | group = one(member_groups) 101 | # return calendar for the group 102 | return cls(id=group.calendar, name=group.name) 103 | -------------------------------------------------------------------------------- /modules/calendar/calendar_creator.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | from database.campus import Campus 4 | from modules.calendar.calendar import Calendar 5 | 6 | from .calendar_service import CalendarService 7 | 8 | 9 | class CalendarCreator: 10 | def __init__(self, service: CalendarService): 11 | self.__service = service 12 | 13 | async def create_group_calendars(self, year: int) -> Dict[Campus, Calendar]: 14 | """Create a calendar for each campus. 15 | 16 | Args: 17 | year (int): The year to create the calendars for. 18 | 19 | Returns: 20 | Dict[int, Calendar]: A dict mapping from campus ID to the newly created calendar objects. 21 | """ 22 | return { 23 | campus: self.__service.create_calendar(f"JCT CompSci {campus.name} {year}") 24 | for campus in await Campus.get_campuses() 25 | } 26 | -------------------------------------------------------------------------------- /modules/calendar/calendar_embedder.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from typing import Dict, Generator, Optional, Sequence 5 | 6 | import nextcord 7 | from more_itertools import peekable 8 | from nextcord.ext import commands 9 | 10 | from modules.error.friendly_error import FriendlyError 11 | from utils import utils 12 | from utils.embedder import MAX_EMBED_DESCRIPTION_LENGTH, build_embed 13 | from utils.reactions import wait_for_reaction 14 | from utils.utils import one 15 | 16 | from .calendar import Calendar 17 | from .event import Event 18 | 19 | 20 | class CalendarEmbedder: 21 | def __init__(self, bot: commands.Bot, timezone: str): 22 | self.bot = bot 23 | self.timezone = timezone 24 | # emoji list for enumerating events 25 | self.number_emoji = ( 26 | "0️⃣", 27 | "1️⃣", 28 | "2️⃣", 29 | "3️⃣", 30 | "4️⃣", 31 | "5️⃣", 32 | "6️⃣", 33 | "7️⃣", 34 | "8️⃣", 35 | "9️⃣", 36 | ) 37 | 38 | async def embed_event_pages( 39 | self, 40 | interaction: nextcord.Interaction[commands.Bot], 41 | events_list: Sequence[Event], 42 | query: str, 43 | results_per_page: int, 44 | calendar: Calendar, 45 | ): 46 | """Embed page of events and wait for reactions to continue to new pages""" 47 | # peekable generator for events 48 | events = peekable(events_list) 49 | # set start index 50 | page_num = 1 51 | # message reference for editing 52 | message: Optional[nextcord.InteractionMessage] = None 53 | while True: 54 | try: 55 | # create embed 56 | embed = self.embed_event_list( 57 | title=f"📅 Upcoming Events for {calendar.name}", 58 | events=events, 59 | calendar=calendar, 60 | description=f'Showing results for "{query}"' if query else "", 61 | page_num=page_num, 62 | max_results=results_per_page, 63 | ) 64 | message = await interaction.edit_original_message(embed=embed) 65 | # set emoji and page based on whether there are more events 66 | if events: 67 | next_emoji = "⏬" 68 | page_num += 1 69 | else: 70 | # if only 1 page, exit the loop 71 | if page_num == 1: 72 | break 73 | # add reaction for starting over from page 1 74 | next_emoji = "⤴️" 75 | page_num = 1 76 | # reset the peekable generator 77 | events = peekable(events_list) 78 | # wait for author to respond to go to next page 79 | await wait_for_reaction( 80 | bot=self.bot, 81 | message=message, 82 | emoji_list=[next_emoji], 83 | allowed_users=( 84 | [interaction.user] 85 | if isinstance(interaction.user, nextcord.Member) 86 | else None 87 | ), 88 | ) 89 | # time window exceeded 90 | except FriendlyError: 91 | break 92 | 93 | async def get_event_choice( 94 | self, 95 | interaction: nextcord.Interaction[commands.Bot], 96 | events_list: Sequence[Event], 97 | calendar: Calendar, 98 | query: str, 99 | action: str, 100 | ) -> Event: 101 | """ 102 | If there are no events, throws an error. 103 | If there are multiple events, embed list of events and wait for reaction to select an event. 104 | If there is one event, return it. 105 | """ 106 | # no events found 107 | if not events_list: 108 | raise FriendlyError( 109 | f'No events were found for "{query}".', interaction, interaction.user 110 | ) 111 | # if only 1 event found, get the event at index 0 112 | if len(events_list) == 1: 113 | return one(events_list) 114 | # create a peekable iterable of events 115 | events = peekable(events_list) 116 | # multiple events found 117 | embed = self.embed_event_list( 118 | title=f"⚠ Multiple events were found.", 119 | events=events, 120 | calendar=calendar, 121 | description=( 122 | f"Please specify which event you would like to {action}." 123 | f'\n\nShowing results for "{query}"' 124 | ), 125 | colour=nextcord.Colour.gold(), 126 | enumeration=self.number_emoji, 127 | ) 128 | await interaction.send(embed=embed) 129 | message = await interaction.original_message() 130 | # get the number of events that were displayed 131 | next_event = events.peek(None) 132 | num_events = events_list.index(next_event) if next_event else len(events_list) 133 | # ask user to pick an event with emojis 134 | selection_index = await wait_for_reaction( 135 | bot=self.bot, 136 | message=message, 137 | emoji_list=self.number_emoji[:num_events], 138 | allowed_users=( 139 | [interaction.user] if isinstance(interaction.user, nextcord.Member) else None 140 | ), 141 | ) 142 | # get the event selected by the user 143 | return events_list[selection_index] 144 | 145 | def embed_event_list( 146 | self, 147 | title: str, 148 | events: peekable[Event], 149 | calendar: Calendar, 150 | description: str = "", 151 | colour: nextcord.Colour = nextcord.Colour.blue(), 152 | enumeration: Sequence[str] = (), 153 | page_num: Optional[int] = None, 154 | max_results: int = 10, 155 | ) -> nextcord.Embed: 156 | """Generates an embed with event summaries, links, and dates for each event in the given list 157 | 158 | Arguments: 159 | 160 | :param title: :class:`str` the title to display at the top 161 | :param events: :class:`peekable[Event]` the events to display 162 | :param calendar: :class:`Calendar` the calendar the events are from 163 | :param description: :class:`Optional[str]` the description to embed below the title 164 | :param colour: :class:`Optional[nextcord.Colour]` the embed colour 165 | :param enumeration: :class:`Optional[Iterable[str]]` list of emojis to display alongside events (for reaction choices) 166 | :param page_num: :class:`Optional[int]` page number to display in the footer 167 | :param max_results: :class:`int` maximum results to display in the list 168 | """ 169 | # get calendar links 170 | links = self.__calendar_links(calendar) 171 | # limit max results to the number of emojis in the enumeration 172 | max_results = min(max_results, len(enumeration)) if enumeration else max_results 173 | # set initial description if available 174 | description = f"{description}\n" if description else "" 175 | # check if events peekable is empty 176 | description += "No events found.\n" if not events else "" 177 | # create iterator for enumeration 178 | enumerator = iter(enumeration) 179 | # add events to embed 180 | for i in range(max_results): 181 | event = events.peek(None) 182 | # if event is None, no more events 183 | if not event: 184 | break 185 | # get event details and add enumeration emoji if available 186 | event_details = f"\n{next(enumerator, '')} {self.__format_event(event)}" 187 | # make sure embed doesn't exceed max length (unless it won't fit on its own page) 188 | if len(description + event_details + links) > MAX_EMBED_DESCRIPTION_LENGTH and i > 0: 189 | break 190 | # add event to embed 191 | description += event_details 192 | # consume event 193 | next(events) 194 | # add links for viewing and editing on Google Calendar 195 | description += links 196 | # hide page number if page one and no more results 197 | if page_num == 1 and not events: 198 | page_num = None 199 | # add page number and timezone info 200 | footer = self.__footer_text(page_num=page_num) 201 | return build_embed(title=title, description=description, footer=footer, colour=colour) 202 | 203 | def embed_links( 204 | self, 205 | title: str, 206 | links: Dict[str, str], 207 | colour: nextcord.Colour = nextcord.Colour.dark_blue(), 208 | ) -> nextcord.Embed: 209 | """Embed a list of links given a mapping of link text to urls""" 210 | # add links to embed 211 | description = (f"\n**[{text}]({url})**" for text, url in links.items()) 212 | return build_embed(title=title, description="\n".join(description), colour=colour) 213 | 214 | def embed_event( 215 | self, 216 | title: str, 217 | event: Event, 218 | calendar: Calendar, 219 | colour: nextcord.Colour = nextcord.Colour.green(), 220 | ) -> nextcord.Embed: 221 | """Embed an event with the summary, link, and dates""" 222 | # add overview of event to the embed 223 | description = self.__format_event(event) 224 | # add links for viewing and editing on Google Calendar 225 | description += self.__calendar_links(calendar) 226 | # add timezone info 227 | footer = self.__footer_text() 228 | return build_embed(title=title, description=description, footer=footer, colour=colour) 229 | 230 | def __format_paragraph(self, text: str, limit: int = 100) -> str: 231 | """Trims a string of text to approximately `limit` displayed characters, 232 | but preserves links using markdown if they get cut off""" 233 | text = text.replace("
", "\n") 234 | # if limit is in the middle of a link, let the whole link through (shortened reasonably) 235 | for match in self.__match_md_links(text): 236 | # increase limit by the number of hidden characters 237 | limit += len(f"[]({match.group(2)})") 238 | # if match extends beyond the limit, move limit to the end of the match 239 | if match.end() > limit: 240 | limit = match.end() if match.start() < limit else limit 241 | break 242 | return utils.trim(text, limit) 243 | 244 | def __format_event(self, event: Event) -> str: 245 | """Format event as a markdown linked summary and the dates below""" 246 | info = f"**[{event.title}]({event.link})**\n" 247 | info += f"{event.relative_date_range_str()}\n" 248 | if event.description: 249 | info += f"{self.__format_paragraph(event.description)}\n" 250 | if event.location: 251 | info += f":round_pushpin: {self.__format_paragraph(event.location)}\n" 252 | return info 253 | 254 | def __calendar_links(self, calendar: Calendar) -> str: 255 | """Return text with links to view or edit the Google Calendar""" 256 | return ( 257 | f"\n[👀 View events]({calendar.view_url(self.timezone)}) | [✏️ Edit" 258 | f" with Google]({calendar.add_url()}) (use `/calendar grant` for access)" 259 | ) 260 | 261 | def __footer_text(self, page_num: Optional[int] = None) -> str: 262 | """Return text about timezone to display at end of embeds with dates""" 263 | page_num_text = f"Page {page_num} | " if page_num is not None else "" 264 | timezone_text = f"Times are shown for {self.timezone}" 265 | return page_num_text + timezone_text 266 | 267 | __MD_LINK_REGEX = re.compile( 268 | # Group 1: The label 269 | # Group 2: The full URL including any title text 270 | # Group 3: The full URL without the title text 271 | r"\[(.*?[^\\])\]\(((https?:\/\/\S+).*?)\)" 272 | ) 273 | 274 | @classmethod 275 | def __match_md_links(cls, text: str) -> Generator[re.Match, None, None]: 276 | start = 0 277 | match = cls.__MD_LINK_REGEX.search(text) 278 | while match: 279 | yield match 280 | start = match.end() 281 | match = cls.__MD_LINK_REGEX.search(text, pos=start) 282 | -------------------------------------------------------------------------------- /modules/calendar/calendar_service.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Dict, Optional, Sequence 3 | 4 | from google.oauth2 import service_account 5 | from googleapiclient.discovery import build 6 | from thefuzz import fuzz 7 | 8 | import config 9 | from utils.utils import parse_date 10 | 11 | from .calendar import Calendar 12 | from .event import Event 13 | from .md_html_converter import html_to_md, md_to_html 14 | 15 | 16 | class CalendarService: 17 | def __init__(self, timezone: str): 18 | SCOPES = ["https://www.googleapis.com/auth/calendar"] 19 | self.creds = service_account.Credentials.from_service_account_info( 20 | config.google_config, scopes=SCOPES 21 | ) 22 | self.service = build("calendar", "v3", credentials=self.creds) 23 | self.timezone = timezone 24 | 25 | def get_links(self, calendar: Calendar) -> Dict[str, str]: 26 | """Get a dict of links for adding and viewing a given Google Calendar""" 27 | return { 28 | "Add to Google Calendar": calendar.add_url(), 29 | "View the Events": calendar.view_url(self.timezone), 30 | "iCal Format": calendar.ical_url(), 31 | } 32 | 33 | def fetch_upcoming( 34 | self, 35 | calendar_id: str, 36 | query: str = "", 37 | page_token: Optional[str] = None, 38 | max_results: int = 100, 39 | ) -> Sequence[Event]: 40 | """Fetch upcoming events from the calendar""" 41 | # get the current date and time ('Z' indicates UTC time) 42 | now = datetime.utcnow().isoformat() + "Z" 43 | # fetch results from the calendar API 44 | events_result = ( 45 | self.service.events() 46 | .list( 47 | calendarId=calendar_id, 48 | timeMin=now, 49 | maxResults=max_results, 50 | singleEvents=True, 51 | orderBy="startTime", 52 | pageToken=page_token, 53 | ) 54 | .execute() 55 | ) 56 | # return list of events 57 | events = events_result.get("items", []) 58 | # filter by search term 59 | clean_query = query.lower().replace(" ", "") 60 | # convert dicts to Event objects 61 | converted_events = tuple( 62 | self.__dict_to_event(dict_) 63 | for dict_ in events 64 | if clean_query in dict_["summary"].lower().replace(" ", "") 65 | or fuzz.token_set_ratio(query, dict_["summary"]) > 75 66 | ) 67 | # return events and the next page's token 68 | return converted_events 69 | 70 | def add_event( 71 | self, 72 | calendar_id: str, 73 | summary: str, 74 | start: str, 75 | end: Optional[str], 76 | description: str, 77 | location: str, 78 | ) -> Event: 79 | """Add an event to the calendar given the id, summary, start time, 80 | and optionally, the end time, location and description.""" 81 | all_day = False 82 | # parse start date 83 | start_date = parse_date(start, future=True) 84 | # check start date 85 | if start_date is None: 86 | raise ValueError(f'Start date "{start}" could not be parsed.') 87 | # parse end date 88 | if end is not None: 89 | end_date = parse_date(end, future=True, base=start_date) 90 | else: 91 | # if no end date was specified, use the start time 92 | end_date = start_date 93 | # if words suggest no time was specified, make it an all day event 94 | time_words = (" at ", " from ", "am ", " midnight ", ":") 95 | if start_date.time() == datetime.min.time() and not any( 96 | word in f" {start} " for word in time_words 97 | ): 98 | all_day = True 99 | # check end date 100 | if end_date is None: 101 | raise ValueError(f'End date "{end}" could not be parsed.') 102 | # if the end date is before the start date, update the date to starting date 103 | if end_date < start_date: 104 | raise ValueError("End date must be before start date.") 105 | # create request body 106 | event_details = { 107 | "summary": summary, 108 | "location": location, 109 | "description": md_to_html(description), 110 | "start": ( 111 | { 112 | "dateTime": start_date.isoformat("T", "seconds"), 113 | "timeZone": self.timezone, 114 | } 115 | if not all_day 116 | else {"date": start_date.date().isoformat()} 117 | ), 118 | "end": ( 119 | { 120 | "dateTime": end_date.isoformat("T", "seconds"), 121 | "timeZone": self.timezone, 122 | } 123 | if not all_day 124 | else {"date": end_date.date().isoformat()} 125 | ), 126 | } 127 | # Add event to the calendar 128 | event = self.service.events().insert(calendarId=calendar_id, body=event_details).execute() 129 | return self.__dict_to_event(event) 130 | 131 | def delete_event(self, calendar_id: str, event: Event) -> None: 132 | """Delete an event from a calendar given the calendar id and event object""" 133 | # delete event 134 | response = self.service.events().delete(calendarId=calendar_id, eventId=event.id).execute() 135 | # response should be empty if successful 136 | if response != "": 137 | raise ConnectionError("Couldn't delete event.", response) 138 | 139 | def update_event( 140 | self, 141 | calendar_id: str, 142 | event: Event, 143 | new_summary: Optional[str] = None, 144 | new_start: Optional[str] = None, 145 | new_end: Optional[str] = None, 146 | new_description: Optional[str] = None, 147 | new_location: Optional[str] = None, 148 | ) -> Event: 149 | """Update an event from a calendar given the calendar id, event object, and parameters to update""" 150 | # parse new start date if provided 151 | new_start_date = parse_date(new_start, base=event.start) or event.start 152 | # if the start time is changed, the end time will move with it if it's not specified 153 | start_delta = new_start_date - event.start 154 | # parse new end date if provided 155 | new_end_date = parse_date( 156 | new_end, 157 | base=(new_start_date if new_start_date != event.start else event.end), 158 | ) or (event.end + start_delta) 159 | # check that new time range is valid 160 | if new_end_date < new_start_date: 161 | raise ValueError("The start time must come before the end time.") 162 | # check if event is all day 163 | all_day = ( 164 | new_start_date.time() == datetime.min.time() 165 | and new_end_date.time() == datetime.min.time() 166 | and new_start_date != new_end_date 167 | ) 168 | # create request body 169 | event_details = { 170 | "summary": new_summary or event.title, 171 | "location": new_location or event.location or "", 172 | "description": md_to_html(new_description or event.description or ""), 173 | "start": ( 174 | { 175 | "dateTime": new_start_date.isoformat("T", "seconds"), 176 | "timeZone": self.timezone, 177 | } 178 | if not all_day 179 | else {"date": new_start_date.date().isoformat()} 180 | ), 181 | "end": ( 182 | { 183 | "dateTime": new_end_date.isoformat("T", "seconds"), 184 | "timeZone": self.timezone, 185 | } 186 | if not all_day 187 | else {"date": new_end_date.date().isoformat()} 188 | ), 189 | } 190 | # update the event 191 | updated_event = ( 192 | self.service.events() 193 | .update(calendarId=calendar_id, eventId=event.id, body=event_details) 194 | .execute() 195 | ) 196 | return self.__dict_to_event(updated_event) 197 | 198 | def create_calendar(self, summary: str) -> Calendar: 199 | """Creates a new public calendar on the service account given the name 200 | Returns the calendar object""" 201 | # create the calendar 202 | calendar = {"summary": summary, "timeZone": self.timezone} 203 | created_calendar = Calendar.from_dict( 204 | self.service.calendars().insert(body=calendar).execute() 205 | ) 206 | # make calendar public 207 | rule = {"scope": {"type": "default"}, "role": "reader"} 208 | self.service.acl().insert(calendarId=created_calendar.id, body=rule).execute() 209 | # return the calendar object 210 | return created_calendar 211 | 212 | def add_manager(self, calendar_id: str, email: str) -> bool: 213 | """Gives write access to a user for a calendar given an email address""" 214 | rule = { 215 | "scope": { 216 | "type": "user", 217 | "value": email, 218 | }, 219 | "role": "writer", 220 | } 221 | created_rule = self.service.acl().insert(calendarId=calendar_id, body=rule).execute() 222 | # returns True if the rule was applied successfully 223 | return created_rule["id"] == f"user:{email}" 224 | 225 | def __dict_to_event(self, details: Dict[str, Any]) -> Event: 226 | """Create an event from a JSON object as returned by the Calendar API""" 227 | desc = details.get("description") 228 | return Event( 229 | event_id=details["id"], 230 | link=details["htmlLink"], 231 | title=details["summary"], 232 | all_day=("date" in details["start"]), 233 | location=details.get("location"), 234 | description=html_to_md(desc) if desc else None, 235 | start=self.__get_endpoint_datetime(details, "start"), 236 | end=self.__get_endpoint_datetime(details, "end"), 237 | ) 238 | 239 | def __get_endpoint_datetime(self, details: Dict[str, Any], endpoint: str) -> datetime: 240 | """Returns a datetime given 'start' or 'end' as the endpoint""" 241 | dt = parse_date( 242 | details[endpoint].get("dateTime") or details[endpoint]["date"], 243 | from_tz=details[endpoint].get("timeZone") or self.timezone, 244 | to_tz=self.timezone, 245 | ) 246 | assert dt is not None 247 | return dt 248 | -------------------------------------------------------------------------------- /modules/calendar/cog.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import nextcord 4 | from nextcord.ext import commands 5 | 6 | import config 7 | from database import preloaded 8 | from database.group import Group 9 | from utils.embedder import embed_success 10 | from utils.utils import is_email 11 | 12 | from ..error.friendly_error import FriendlyError 13 | from . import course_mentions 14 | from .calendar import Calendar 15 | from .calendar_creator import CalendarCreator 16 | from .calendar_embedder import CalendarEmbedder 17 | from .calendar_service import CalendarService 18 | 19 | 20 | class CalendarCog(commands.Cog): 21 | """Display and update Google Calendar events""" 22 | 23 | def __init__(self, bot: commands.Bot): 24 | self.bot = bot 25 | timezone = "Asia/Jerusalem" 26 | self.embedder = CalendarEmbedder(bot, timezone) 27 | self.service = CalendarService(timezone) 28 | self.creator = CalendarCreator(self.service) 29 | 30 | @nextcord.slash_command(guild_ids=[config.guild_id]) 31 | async def calendar(self, interaction: nextcord.Interaction[commands.Bot]): 32 | """This is a base command for all calendar commands and is not invoked""" 33 | pass 34 | 35 | @calendar.subcommand(name="links") 36 | async def calendar_links( 37 | self, 38 | interaction: nextcord.Interaction[commands.Bot], 39 | group_id: Optional[int] = nextcord.SlashOption( 40 | name="class_name", 41 | choices={group.name: group.id for group in preloaded.groups}, 42 | ), 43 | ): 44 | """Get the links to add or view the calendar 45 | 46 | Args: 47 | group_id: Calendar to show links for (eg. Lev 2023). Leave blank if you have only one class role. 48 | """ 49 | # get calendar from selected class_role or author 50 | calendar = await Calendar.get_calendar(interaction, await Group.get_groups(), group_id) 51 | # fetch links for calendar 52 | links = self.service.get_links(calendar) 53 | embed = self.embedder.embed_links(f"🔗 Calendar Links for {calendar.name}", links) 54 | await interaction.send(embed=embed) 55 | 56 | @calendar.subcommand(name="events") 57 | async def events( 58 | self, 59 | interaction: nextcord.Interaction[commands.Bot], 60 | query: str = "", 61 | results_per_page: int = 5, 62 | group_id: Optional[int] = nextcord.SlashOption( 63 | name="class_name", 64 | choices={group.name: group.id for group in preloaded.groups}, 65 | ), 66 | ): 67 | """Display upcoming events from the Google Calendar 68 | 69 | Args: 70 | query: Query or channel mention to search for within event titles (if not specified, 71 | shows all events) 72 | results_per_page: Number of events to display per page. (Default: 5) 73 | group_id: Calendar to show events for (eg. Lev 2023). Leave blank if you have only one 74 | class role. 75 | """ 76 | await interaction.response.defer() 77 | # get calendar from selected class_role or author 78 | calendar = await Calendar.get_calendar(interaction, await Group.get_groups(), group_id) 79 | # convert channel mentions to full names 80 | full_query = await course_mentions.replace_channel_mentions(query) 81 | # fetch upcoming events 82 | events = self.service.fetch_upcoming(calendar.id, full_query) 83 | # display events and allow showing more with reactions 84 | await self.embedder.embed_event_pages( 85 | interaction, events, full_query, results_per_page, calendar 86 | ) 87 | 88 | @calendar.subcommand(name="add") 89 | async def event_add( 90 | self, 91 | interaction: nextcord.Interaction[commands.Bot], 92 | title: str, 93 | start: str, 94 | end: Optional[str] = None, 95 | description: str = "", 96 | location: str = "", 97 | group_id: Optional[int] = nextcord.SlashOption( 98 | name="class_name", 99 | choices={group.name: group.id for group in preloaded.groups}, 100 | ), 101 | ): 102 | """Add an event to the Google Calendar 103 | 104 | Args: 105 | title: Title of the event (eg. "HW 1 #statistics") 106 | start: The start date of the event in Israel Time (eg. "April 15, 2pm") 107 | end: The end date of the event in Israel Time (eg. "3pm") 108 | description: The description of the event (eg. "Submission box: https://moodle.jct.ac.il/123") 109 | location: The location of the event (eg. "Brause 305") 110 | group_id: Calendar to add event to (eg. Lev 2023). Leave blank if you have only one class role. 111 | """ 112 | await interaction.response.defer() 113 | # replace channel mentions with course names 114 | title = await course_mentions.replace_channel_mentions(title) 115 | description = await course_mentions.replace_channel_mentions(description) 116 | location = await course_mentions.replace_channel_mentions(location) 117 | # get calendar from selected class_role or author 118 | calendar = await Calendar.get_calendar(interaction, await Group.get_groups(), group_id) 119 | try: 120 | event = self.service.add_event(calendar.id, title, start, end, description, location) 121 | except ValueError as error: 122 | raise FriendlyError(str(error), interaction, interaction.user, error) 123 | embed = self.embedder.embed_event( 124 | f":white_check_mark: Event added to {calendar.name} calendar successfully", 125 | event, 126 | calendar, 127 | ) 128 | await interaction.send(embed=embed) 129 | 130 | @calendar.subcommand(name="update") 131 | async def event_update( 132 | self, 133 | interaction: nextcord.Interaction[commands.Bot], 134 | query: str, 135 | title: Optional[str] = None, 136 | start: Optional[str] = None, 137 | end: Optional[str] = None, 138 | description: Optional[str] = None, 139 | location: Optional[str] = None, 140 | group_id: Optional[int] = nextcord.SlashOption( 141 | name="class_name", 142 | choices={group.name: group.id for group in preloaded.groups}, 143 | ), 144 | ): 145 | """Update an event in the Google Calendar 146 | 147 | Args: 148 | query: Query or channel mention to search for within event titles 149 | title: New title of the event (eg. "HW 1 #statistics"). ${title} refers to old title. 150 | start: New start date of the event in Israel Time (eg. "April 15, 2pm") 151 | end: New end date of the event in Israel Time (eg. "3pm") 152 | description: eg. "[Submission](https://...)". ${description} refers to old description. Use 153 | \\n for newlines.") 154 | location: New location of the event (eg. "Brause 305"). ${location} refers to old location. 155 | group_id: Calendar to update event in (eg. Lev 2023). Leave blank if you have only one class role. 156 | """ 157 | await interaction.response.defer() 158 | # replace channel mentions with course names 159 | query = await course_mentions.replace_channel_mentions(query) 160 | # get calendar from selected class_role or author 161 | calendar = await Calendar.get_calendar(interaction, await Group.get_groups(), group_id) 162 | # get a list of upcoming events 163 | events = self.service.fetch_upcoming(calendar.id, query) 164 | # get event to update 165 | event_to_update = await self.embedder.get_event_choice( 166 | interaction, events, calendar, query, "update" 167 | ) 168 | # replace channel mentions and variables 169 | if title: 170 | title = (await course_mentions.replace_channel_mentions(title)).replace( 171 | "${title}", event_to_update.title 172 | ) 173 | if description: 174 | description = ( 175 | (await course_mentions.replace_channel_mentions(description)) 176 | .replace("${description}", event_to_update.description or "") 177 | .replace("\\n", "\n") 178 | ) 179 | if location: 180 | location = (await course_mentions.replace_channel_mentions(location)).replace( 181 | "${location}", event_to_update.location or "" 182 | ) 183 | try: 184 | event = self.service.update_event( 185 | calendar.id, 186 | event_to_update, 187 | title, 188 | start, 189 | end, 190 | description, 191 | location, 192 | ) 193 | except ValueError as error: 194 | raise FriendlyError(error.args[0], interaction, interaction.user, error) 195 | embed = self.embedder.embed_event( 196 | ":white_check_mark: Event updated successfully", event, calendar 197 | ) 198 | # edit message if sent already, otherwise send 199 | await interaction.edit_original_message(embed=embed) 200 | 201 | @calendar.subcommand(name="delete") 202 | async def event_delete( 203 | self, 204 | interaction: nextcord.Interaction[commands.Bot], 205 | query: str, 206 | group_id: Optional[int] = nextcord.SlashOption( 207 | name="class_name", 208 | choices={group.name: group.id for group in preloaded.groups}, 209 | ), 210 | ): 211 | """Delete an event from the Google Calendar 212 | 213 | Args: 214 | query: Query or channel mention to search for within event titles 215 | group_id: Calendar to delete event from (eg. Lev 2023). Leave blank if you have only one class role. 216 | """ 217 | await interaction.response.defer() 218 | # replace channel mentions with course names 219 | query = await course_mentions.replace_channel_mentions(query) 220 | # get calendar from selected class_role or author 221 | calendar = await Calendar.get_calendar(interaction, await Group.get_groups(), group_id) 222 | # fetch upcoming events 223 | events = self.service.fetch_upcoming(calendar.id, query) 224 | # get event to delete 225 | event_to_delete = await self.embedder.get_event_choice( 226 | interaction, events, calendar, query, "delete" 227 | ) 228 | # delete event 229 | try: 230 | self.service.delete_event(calendar.id, event_to_delete) 231 | except ConnectionError as error: 232 | raise FriendlyError(error.args[0], interaction, interaction.user, error) 233 | embed = self.embedder.embed_event("🗑 Event deleted successfully", event_to_delete, calendar) 234 | # edit message if sent already, otherwise send 235 | await interaction.edit_original_message(embed=embed) 236 | 237 | @calendar.subcommand(name="grant") 238 | async def calendar_grant( 239 | self, 240 | interaction: nextcord.Interaction[commands.Bot], 241 | email: str, 242 | group_id: Optional[int] = nextcord.SlashOption( 243 | name="class_name", 244 | choices={group.name: group.id for group in preloaded.groups}, 245 | ), 246 | ): 247 | """Add a Google account as a manager of your class's calendar 248 | 249 | Args: 250 | email: The email address of the Google account to add 251 | group_id: Calendar to get access to (eg. Lev 2023). Leave blank if you have only one class role. 252 | """ 253 | await interaction.response.defer(ephemeral=True) 254 | # get calendar from selected class_role or author 255 | calendar = await Calendar.get_calendar( 256 | interaction, 257 | await Group.get_groups(), 258 | group_id, 259 | ephemeral=True, 260 | ) 261 | # validate email address 262 | if not is_email(email): 263 | raise FriendlyError( 264 | "Invalid email address", 265 | interaction, 266 | interaction.user, 267 | ephemeral=True, 268 | ) 269 | # add manager to calendar 270 | if self.service.add_manager(calendar.id, email): 271 | embed = embed_success(f":office_worker: Successfully added manager to {calendar.name}.") 272 | await interaction.send(embed=embed, ephemeral=True) 273 | return 274 | raise FriendlyError( 275 | "An error occurred while applying changes.", 276 | interaction, 277 | interaction.user, 278 | ephemeral=True, 279 | ) 280 | 281 | 282 | # setup functions for bot 283 | def setup(bot): 284 | bot.add_cog(CalendarCog(bot)) 285 | -------------------------------------------------------------------------------- /modules/calendar/course_mentions.py: -------------------------------------------------------------------------------- 1 | import config 2 | from database import sql 3 | from utils.mention import decode_channel_mention 4 | 5 | 6 | async def get_channel_full_name(channel_id: int) -> str: 7 | """Searches the database for the course name given the channel id 8 | 9 | Args: 10 | channel_id (int): The ID of the channel to search for. 11 | 12 | Returns: 13 | str: The name of the course linked to the channel, or the name of the channel if it doesn't belong to a course. 14 | """ 15 | name = await sql.select.value("categories", "name", channel=channel_id) 16 | if name: 17 | return name 18 | channel = config.guild().get_channel(channel_id) 19 | return channel.name if channel else f"<#{channel_id}>" 20 | 21 | 22 | async def map_channel_mention(word: str) -> str: 23 | """given a word in a string, return the channel name 24 | if it is a channel mention, otherwise return the original word""" 25 | channel_id = decode_channel_mention(word) 26 | # convert mention to full name if word is a mention 27 | if channel_id: 28 | return await get_channel_full_name(channel_id) 29 | # not a channel mention 30 | return word 31 | 32 | 33 | async def replace_channel_mentions(text: str) -> str: 34 | return " ".join( 35 | [ 36 | await map_channel_mention(word) 37 | for word in text.replace("<", " <").replace(">", "> ").split() 38 | ] 39 | ) 40 | -------------------------------------------------------------------------------- /modules/calendar/event.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from typing import Optional 3 | 4 | from utils.utils import format_date 5 | 6 | 7 | class Event: 8 | """Event object to store data about a Google Calendar event""" 9 | 10 | def __init__( 11 | self, 12 | event_id: str, 13 | link: str, 14 | title: str, 15 | location: Optional[str], 16 | description: Optional[str], 17 | all_day: bool, 18 | start: datetime, 19 | end: datetime, 20 | ): 21 | self.__id = event_id 22 | self.__link = link 23 | self.__title = title 24 | self.__location = location 25 | self.__description = description 26 | self.__all_day = all_day 27 | self.__start = start.replace(tzinfo=None) 28 | self.__end = end.replace(tzinfo=None) 29 | 30 | @property 31 | def id(self) -> str: 32 | """Returns the event id""" 33 | return self.__id 34 | 35 | @property 36 | def link(self) -> str: 37 | """Returns the link to the event in Google Calendar""" 38 | return self.__link 39 | 40 | @property 41 | def title(self) -> str: 42 | """Returns the title of the event""" 43 | return self.__title 44 | 45 | @property 46 | def location(self) -> Optional[str]: 47 | """Returns the location of the event""" 48 | return self.__location 49 | 50 | @property 51 | def description(self) -> Optional[str]: 52 | """Returns the description of the event""" 53 | return self.__description 54 | 55 | @property 56 | def all_day(self) -> bool: 57 | """Returns whether or not the event is an all day event""" 58 | return self.__all_day 59 | 60 | @property 61 | def start(self) -> datetime: 62 | """Returns the start date as a datetime object""" 63 | return self.__start 64 | 65 | @property 66 | def end(self) -> datetime: 67 | """Returns the end date as a datetime object""" 68 | return self.__end 69 | 70 | @property 71 | def __one_day(self) -> bool: 72 | """Returns whether or not the event is a one day event""" 73 | return self.all_day and self.end - self.start <= timedelta(days=1) 74 | 75 | def relative_date_range_str(self, base=datetime.now()) -> str: 76 | """Returns a formatted string of the start to end date range""" 77 | start_str = self.__relative_start_str(base=base) 78 | end_str = self.__relative_end_str(base=self.start) 79 | # all day event 80 | if self.__one_day: 81 | return f"{start_str} - All day" 82 | # include end time if it is not the same as the start time 83 | return f"{start_str} - {end_str}" if end_str else start_str 84 | 85 | def __relative_start_str(self, base=datetime.now()) -> str: 86 | """Returns a formatted string of the start date""" 87 | return format_date(self.start, all_day=self.all_day, base=base) or "Today" 88 | 89 | def __relative_end_str(self, base=datetime.now()) -> str: 90 | """Returns a formatted string of the end date""" 91 | end_date = self.end 92 | # use previous day if end of multi-day, all-day event 93 | if self.all_day and not self.__one_day: 94 | end_date -= timedelta(days=1) 95 | return format_date(end_date, all_day=self.all_day, base=base) 96 | -------------------------------------------------------------------------------- /modules/calendar/md_html_converter.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import lxml 4 | import lxml.html 5 | from markdown import markdown 6 | from markdownify import markdownify 7 | 8 | 9 | def md_to_html(md: str) -> str: 10 | return markdown(md) 11 | 12 | 13 | def html_to_md(html: str) -> str: 14 | root = lxml.html.fromstring(html) 15 | __format_links(root) 16 | return markdownify(lxml.html.tostring(root, encoding="unicode", method="html")).strip() 17 | 18 | 19 | def __format_links(root: lxml.html.HtmlElement) -> None: 20 | """ 21 | Set title attributes of tags to match href if not yet defined. 22 | Shorten links. 23 | Modifies existing tree under root. 24 | """ 25 | for a in root.iter(tag="a"): 26 | __default_link_title(a) 27 | __shorten_link(a) 28 | 29 | 30 | def __default_link_title(a: lxml.html.HtmlElement) -> None: 31 | """Sets title attributes of tags to match href if title attribute is not set.""" 32 | if not a.get("title", None): 33 | a.set("title", a.get("href", "")) 34 | 35 | 36 | # Group 1: The domain + up to 16 more characters 37 | # Group 2: All leftover characters, else empty string 38 | __SHORT_LINK_REGEX = re.compile("https?://(?:www.)?([^/?#]*.{,16})(.*)") 39 | 40 | 41 | def __shorten_link(a: lxml.html.HtmlElement) -> None: 42 | """If a link's label is the same as its URL, the label is shortened to the domain plus 16 characters and an elipsis where appropriate.""" 43 | if a.text == a.get("href", ""): 44 | match = __SHORT_LINK_REGEX.match(a.text or "") 45 | assert match is not None 46 | a.text = f"{match.group(1)}..." if match.group(2) else "" # type: ignore 47 | -------------------------------------------------------------------------------- /modules/course_management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/course_management/__init__.py -------------------------------------------------------------------------------- /modules/course_management/cog.py: -------------------------------------------------------------------------------- 1 | import nextcord 2 | from nextcord.ext import commands, tasks 3 | from nextcord.ext.application_checks import has_permissions 4 | 5 | import config 6 | from modules.course_management import util 7 | from utils.embedder import embed_success 8 | 9 | from . import course_activator, course_adder, course_deleter 10 | 11 | 12 | class CourseManagerCog(commands.Cog): 13 | def __init__(self, bot: commands.Bot): 14 | self.sort_courses_categories.start() 15 | self.bot = bot 16 | 17 | @nextcord.slash_command(guild_ids=[config.guild_id]) 18 | async def course(self, interaction: nextcord.Interaction[commands.Bot]): 19 | """This is a base command for all course commands and is not invoked""" 20 | pass 21 | 22 | @course.subcommand(name="add") 23 | @has_permissions(manage_channels=True) 24 | async def add_course( 25 | self, 26 | interaction: nextcord.Interaction[commands.Bot], 27 | course_name: str, 28 | channel_name: str, 29 | professors: str = "", 30 | ): 31 | """Add a new course to the database and create a channel for it. 32 | 33 | Args: 34 | course_name: The full name of the course. (eg. "Advanced Object Oriented Programming and Design") 35 | channel_name: The name of the channel. (eg. object-oriented-programming) (default is course name) 36 | professors: A comma separated string of names of professors who teach the course. (eg. "shahar golan, eitan") 37 | """ 38 | await interaction.response.defer() 39 | professors_split = [professor.strip() for professor in professors.split(",")] 40 | if not channel_name: 41 | channel_name = course_name 42 | channel = await course_adder.add_course( 43 | interaction, course_name, professors_split, channel_name 44 | ) 45 | await interaction.send( 46 | embed=embed_success("Nice! You created a course channel.", channel.mention) 47 | ) 48 | 49 | @course.subcommand(name="delete") 50 | @has_permissions(manage_channels=True) 51 | async def delete_course( 52 | self, interaction: nextcord.Interaction[commands.Bot], channel: nextcord.TextChannel 53 | ): 54 | """Delete course from the database and delete its channel. (For discontinued courses). 55 | 56 | Args: 57 | channel: The channel corresponding to the course you want to delete. 58 | """ 59 | await interaction.response.defer() 60 | await course_deleter.delete_course(interaction, channel) 61 | await interaction.send( 62 | embed=embed_success( 63 | "Well done... All evidence of that course has been deleted from the" 64 | " face of the earth. Hope you're proud of yourself." 65 | ) 66 | ) 67 | 68 | @course.subcommand(name="activate") 69 | @has_permissions(manage_channels=True) 70 | async def activate_course( 71 | self, interaction: nextcord.Interaction[commands.Bot], course: nextcord.TextChannel 72 | ): 73 | """Move an inactive course channel to the active courses list. 74 | 75 | Args: 76 | course: The channel corresponding to the course you want to activate. 77 | """ 78 | await interaction.response.defer() 79 | await course_activator.activate_course(interaction, course) 80 | await interaction.send(embed=embed_success(f"Successfully activated #{course.name}.")) 81 | 82 | @course.subcommand(name="deactivate") 83 | @has_permissions(manage_channels=True) 84 | async def deactivate_course( 85 | self, interaction: nextcord.Interaction[commands.Bot], course: nextcord.TextChannel 86 | ): 87 | """Move an active course channel to the inactive courses list. 88 | 89 | Args: 90 | course: The channel corresponding to the course you want to deactivate. 91 | """ 92 | await interaction.response.defer() 93 | await course_activator.deactivate_course(interaction, course) 94 | await interaction.send(embed=embed_success(f"Successfully deactivated #{course.name}.")) 95 | 96 | @course.subcommand(name="deactivate-all") 97 | @has_permissions(manage_channels=True) 98 | async def deactivate_all_courses(self, interaction: nextcord.Interaction[commands.Bot]): 99 | """Move all active course channels to the inactive courses list.""" 100 | await interaction.response.defer() 101 | await course_activator.deactivate_all_courses(interaction) 102 | await interaction.send(embed=embed_success("Successfully deactivated all courses.")) 103 | 104 | @course.subcommand(name="sort") 105 | @has_permissions(manage_channels=True) 106 | async def sort_courses(self, interaction: nextcord.Interaction[commands.Bot]): 107 | """Sort all course channels alphabetically.""" 108 | await interaction.response.defer() 109 | await util.sort_courses() 110 | await interaction.send( 111 | embed=embed_success( 112 | "I'm pretty bad at sorting asynchronously, but I think I did it right..." 113 | ) 114 | ) 115 | 116 | @tasks.loop(hours=24) 117 | async def sort_courses_categories(self): 118 | await util.sort_courses() 119 | 120 | @sort_courses_categories.before_loop 121 | async def before_sort(self): 122 | await self.bot.wait_until_ready() 123 | 124 | 125 | def setup(bot): 126 | bot.add_cog(CourseManagerCog(bot)) 127 | -------------------------------------------------------------------------------- /modules/course_management/course_activator.py: -------------------------------------------------------------------------------- 1 | import nextcord 2 | from nextcord.ext import commands 3 | 4 | import config 5 | from modules.course_management.util import ( 6 | ACTIVE_COURSES_CATEGORY, 7 | INACTIVE_COURSES_CATEGORY, 8 | sort_single_course, 9 | ) 10 | from modules.error.friendly_error import FriendlyError 11 | from utils.utils import get_discord_obj, get_id 12 | 13 | 14 | async def activate_course( 15 | interaction: nextcord.Interaction[commands.Bot], channel: nextcord.TextChannel 16 | ): 17 | """Move a course from the inactive courses category to the active one.""" 18 | await __move_course(interaction, channel, active=True) 19 | 20 | 21 | async def deactivate_course( 22 | interaction: nextcord.Interaction[commands.Bot], channel: nextcord.TextChannel 23 | ): 24 | """Move a course from the active courses category to the inactive one.""" 25 | await __move_course(interaction, channel, active=False) 26 | 27 | 28 | async def deactivate_all_courses(interaction: nextcord.Interaction[commands.Bot]): 29 | """Move all active courses from the active courses to the inactive one.""" 30 | category: nextcord.CategoryChannel = get_discord_obj( 31 | config.guild().categories, ACTIVE_COURSES_CATEGORY 32 | ) 33 | for channel in category.text_channels: 34 | await deactivate_course(interaction, channel) 35 | 36 | 37 | async def __move_course( 38 | interaction: nextcord.Interaction[commands.Bot], 39 | channel: nextcord.TextChannel, 40 | active: bool, 41 | ): 42 | source_label = INACTIVE_COURSES_CATEGORY if active else ACTIVE_COURSES_CATEGORY 43 | target_label = ACTIVE_COURSES_CATEGORY if active else INACTIVE_COURSES_CATEGORY 44 | if not channel.category_id == get_id(source_label): 45 | raise FriendlyError( 46 | f"You can only {'activate' if active else 'deactivate'} a course which is" 47 | f" in the {'inactive' if active else 'active'} courses category", 48 | interaction, 49 | interaction.user, 50 | ) 51 | category = get_discord_obj(config.guild().categories, target_label) 52 | await channel.edit(category=category) 53 | await sort_single_course(channel) 54 | -------------------------------------------------------------------------------- /modules/course_management/course_adder.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | import nextcord 4 | import nextcord.utils 5 | from asyncpg.exceptions import UniqueViolationError 6 | from nextcord.abc import Messageable 7 | from nextcord.ext import commands 8 | 9 | import config 10 | from database import sql, sql_fetcher 11 | from modules.course_management.util import ACTIVE_COURSES_CATEGORY, sort_single_course 12 | from utils import embedder 13 | from utils.utils import get_discord_obj 14 | 15 | from ..email_registry import categoriser, person_finder 16 | from ..error.friendly_error import FriendlyError 17 | 18 | 19 | async def add_course( 20 | interaction: nextcord.Interaction[commands.Bot], 21 | course_name: str, 22 | professors: Iterable[str], 23 | channel_name: str, 24 | ): 25 | channel = await __create_channel(interaction, channel_name, course_name) 26 | await __add_to_database(interaction, channel, course_name) 27 | await __link_professors(interaction, channel, professors) 28 | return channel 29 | 30 | 31 | async def __create_channel( 32 | interaction: nextcord.Interaction[commands.Bot], 33 | channel_name: str, 34 | course_name: str = "", 35 | ) -> nextcord.TextChannel: 36 | # find courses category 37 | category = get_discord_obj(config.guild().categories, ACTIVE_COURSES_CATEGORY) 38 | 39 | # make sure the channel doesn't already exist 40 | if nextcord.utils.get(category.text_channels, name=channel_name) is not None: 41 | raise FriendlyError( 42 | "this channel already exists. Please try again.", 43 | interaction, 44 | interaction.user, 45 | ) 46 | 47 | new_channel = await category.create_text_channel( 48 | channel_name, 49 | topic=f"Here you can discuss anything related to the course {course_name}.", 50 | ) 51 | await sort_single_course(new_channel) 52 | return new_channel 53 | 54 | 55 | async def __add_to_database( 56 | interaction: nextcord.Interaction[commands.Bot], 57 | channel: nextcord.TextChannel, 58 | course_name: str, 59 | ): 60 | try: 61 | await sql.insert("categories", returning="id", name=course_name, channel=channel.id) 62 | except UniqueViolationError as e: 63 | await channel.delete( 64 | reason="The command that created this channel ultimately failed, so it was deleted." 65 | ) 66 | raise FriendlyError( 67 | "A course with this name already exists in the database.", 68 | description=( 69 | "If a channel for this is missing, then the existing course" 70 | " will have to be deleted from the database manually. Ask a" 71 | " bot dev to do this." 72 | ), 73 | sender=interaction, 74 | inner=e, 75 | ) 76 | 77 | 78 | async def __link_professors( 79 | interaction: nextcord.Interaction[commands.Bot], 80 | channel: nextcord.TextChannel, 81 | professors: Iterable[str], 82 | ): 83 | for professor_name in professors: 84 | try: 85 | professor = await person_finder.search_one(interaction, professor_name) 86 | await categoriser.categorise_person(interaction, professor.id, (channel.mention,)) 87 | except FriendlyError: 88 | await interaction.send( 89 | embed=embedder.embed_warning( 90 | title=( 91 | f'Unable to determine who you meant by "{professor_name}".' 92 | " I will skip linking this professor to the course." 93 | ), 94 | description=( 95 | "To link the professor yourself, first add them with" 96 | " `/email person add` if they're not in the system, then" 97 | " link them with `/email person link`" 98 | ), 99 | ) 100 | ) 101 | -------------------------------------------------------------------------------- /modules/course_management/course_deleter.py: -------------------------------------------------------------------------------- 1 | import nextcord 2 | from nextcord.ext import commands 3 | 4 | from database import sql 5 | from modules.course_management.util import is_course 6 | 7 | from ..error.friendly_error import FriendlyError 8 | 9 | 10 | async def delete_course( 11 | interaction: nextcord.Interaction[commands.Bot], channel: nextcord.TextChannel 12 | ): 13 | await __delete_channel(interaction, channel) 14 | await __delete_from_database(channel.id) 15 | 16 | 17 | async def __delete_channel( 18 | interaction: nextcord.Interaction[commands.Bot], channel: nextcord.TextChannel 19 | ): 20 | if is_course(channel): 21 | await channel.delete() 22 | else: 23 | raise FriendlyError( 24 | "You must provide a channel in the (active or inactive) courses category.", 25 | interaction, 26 | interaction.user, 27 | ) 28 | 29 | 30 | async def __delete_from_database(channel_id: int): 31 | await sql.delete("categories", channel=channel_id) 32 | -------------------------------------------------------------------------------- /modules/course_management/util.py: -------------------------------------------------------------------------------- 1 | import nextcord 2 | 3 | import config 4 | from utils.utils import get_discord_obj, get_id 5 | 6 | ACTIVE_COURSES_CATEGORY = "ACTIVE_COURSES_CATEGORY" 7 | INACTIVE_COURSES_CATEGORY = "INACTIVE_COURSES_CATEGORY" 8 | 9 | 10 | def is_course(channel: nextcord.TextChannel) -> bool: 11 | return channel is not None and channel.category_id in { 12 | get_id(ACTIVE_COURSES_CATEGORY), 13 | get_id(INACTIVE_COURSES_CATEGORY), 14 | } 15 | 16 | 17 | async def sort_courses() -> None: 18 | """Sort the courses in the given category alphabetically.""" 19 | for label in {ACTIVE_COURSES_CATEGORY, INACTIVE_COURSES_CATEGORY}: 20 | category = get_discord_obj(config.guild().categories, label) 21 | if not category.text_channels: 22 | return 23 | start_position = category.text_channels[0].position 24 | for i, channel in enumerate(sorted(category.text_channels, key=lambda c: c.name)): 25 | await channel.edit(position=start_position + i) 26 | 27 | 28 | async def sort_single_course(channel: nextcord.TextChannel) -> None: 29 | """Reposition a single course within its category. This function assumes that the rest of the categories are in sorted order; if thry aren't, use the alternative function `sort_courses` 30 | 31 | Args: 32 | channel (nextcord.TextChannel): The channel to sort within its category. 33 | """ 34 | assert channel.category is not None 35 | for other_channel in channel.category.text_channels: 36 | if other_channel != channel and other_channel.name > channel.name: 37 | await channel.edit(position=other_channel.position) 38 | break 39 | -------------------------------------------------------------------------------- /modules/create_group/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/create_group/__init__.py -------------------------------------------------------------------------------- /modules/create_group/cog.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from nextcord.ext import commands 4 | 5 | from utils.scheduler import Scheduler 6 | 7 | from . import group_channel_creator 8 | from .group_creator import create_groups 9 | 10 | 11 | class CreateGroupCog(commands.Cog): 12 | """Creates roles for each year and campus.""" 13 | 14 | @Scheduler.schedule() 15 | async def on_new_academic_year(self): 16 | """Create roles for lev and tal of the new year.""" 17 | year = datetime.datetime.now().year + 3 18 | # Create group objects for each campus of the new year 19 | groups = await create_groups(year) 20 | # Create a channel for all the groups of the new year 21 | await group_channel_creator.create_group_channel( 22 | f"🧮︱{year}-all", 23 | [group.role for group in groups], 24 | "Here you can discuss links and info relevant for students from all" 25 | " campuses in your year.", 26 | ) 27 | 28 | 29 | # This function will be called when this extension is loaded. It is necessary to add these functions to the bot. 30 | def setup(bot: commands.Bot): 31 | bot.add_cog(CreateGroupCog()) 32 | -------------------------------------------------------------------------------- /modules/create_group/group_channel_creator.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | import nextcord 4 | 5 | import config 6 | from utils import utils 7 | 8 | 9 | async def create_group_channel( 10 | name: str, roles: Iterable[nextcord.Role], description: str = "" 11 | ) -> nextcord.TextChannel: 12 | overwrites = {config.guild().default_role: nextcord.PermissionOverwrite(view_channel=False)} 13 | for role in roles: 14 | overwrites[role] = nextcord.PermissionOverwrite(view_channel=True) 15 | 16 | category = utils.get_discord_obj(config.guild().categories, "CLASS_CHAT_CATEGORY") 17 | return await category.create_text_channel( 18 | name=name, 19 | overwrites=overwrites, 20 | position=len(category.channels) + category.channels[0].position, 21 | topic=description, 22 | ) 23 | -------------------------------------------------------------------------------- /modules/create_group/group_creator.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | from modules.calendar.calendar_creator import CalendarCreator 4 | from modules.calendar.calendar_service import CalendarService 5 | 6 | from .new_group import NewGroup 7 | 8 | 9 | async def create_groups(year: int) -> Iterable[NewGroup]: 10 | calendar_creator = CalendarCreator(CalendarService("Asia/Jerusalem")) 11 | calendars = await calendar_creator.create_group_calendars(year) 12 | groups = [NewGroup(campus, year, calendar) for campus, calendar in calendars.items()] 13 | for new_group in groups: 14 | await new_group.add_to_system() 15 | return groups 16 | -------------------------------------------------------------------------------- /modules/create_group/new_group.py: -------------------------------------------------------------------------------- 1 | import nextcord 2 | 3 | import config 4 | from database import sql 5 | from database.campus import Campus 6 | from database.group import Group 7 | from modules.calendar.calendar import Calendar 8 | 9 | from . import group_channel_creator 10 | 11 | 12 | class NewGroup: 13 | def __init__(self, campus: Campus, year: int, calendar: Calendar): 14 | self.__campus = campus 15 | self.__year = year 16 | self.__calendar = calendar 17 | self.__role: nextcord.Role 18 | self.__channel: nextcord.TextChannel 19 | 20 | @property 21 | def campus(self) -> Campus: 22 | """The campus that this new group belongs to.""" 23 | return self.__campus 24 | 25 | @property 26 | def year(self) -> int: 27 | """The new group's graduation year'.""" 28 | return self.__year 29 | 30 | @property 31 | def role(self) -> nextcord.Role: 32 | """The new group's newly created role.""" 33 | if not self.__role: 34 | raise AttributeError() 35 | return self.__role 36 | 37 | @property 38 | def channel(self) -> nextcord.TextChannel: 39 | """The new group's newly created role.""" 40 | if not self.__channel: 41 | raise AttributeError() 42 | return self.__channel 43 | 44 | @property 45 | def calendar(self) -> Calendar: 46 | """The new group's calendar.""" 47 | return self.__calendar 48 | 49 | async def add_to_system(self): 50 | await self.__create_role() 51 | await self.__move_role() 52 | await self.__create_group_channel() 53 | await self.__add_to_campus_channel() 54 | await self.__add_to_database() 55 | 56 | def __get_colour(self) -> nextcord.Colour: 57 | colours = [ 58 | nextcord.Colour.from_rgb(255, 77, 149), 59 | nextcord.Colour.from_rgb(235, 154, 149), 60 | nextcord.Colour.from_rgb(75, 147, 213), 61 | nextcord.Colour.from_rgb(110, 213, 144), 62 | ] 63 | return colours[self.__year % len(colours)] 64 | 65 | async def __create_role(self): 66 | self.__role = await config.guild().create_role( 67 | name=f"{self.__campus.name} {self.__year}", 68 | permissions=nextcord.Permissions.none(), 69 | colour=self.__get_colour(), 70 | hoist=True, 71 | mentionable=True, 72 | ) 73 | 74 | async def __move_role(self): 75 | roles = [group.role for group in await Group.get_groups()] 76 | positions = [role.position for role in roles] 77 | new_position = min(positions) - 1 78 | position_dict = {self.role: new_position} 79 | await config.guild().edit_role_positions(position_dict) # type: ignore 80 | 81 | async def __create_group_channel(self): 82 | self.__channel = await group_channel_creator.create_group_channel( 83 | f"📚︱{self.__year}-{self.__campus.name.lower()}", 84 | [self.__role], 85 | "Here you can discuss schedules, links, and courses your class is taking.", 86 | ) 87 | 88 | async def __add_to_campus_channel(self): 89 | await self.__campus.channel.set_permissions( 90 | target=self.__role, 91 | view_channel=True, 92 | ) 93 | 94 | async def __add_to_database(self): 95 | await sql.insert( 96 | "groups", 97 | grad_year=self.year, 98 | campus=self.campus.id, 99 | role=self.role.id, 100 | calendar=self.calendar.id, 101 | ) 102 | -------------------------------------------------------------------------------- /modules/drive/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/drive/__init__.py -------------------------------------------------------------------------------- /modules/drive/cog.py: -------------------------------------------------------------------------------- 1 | import nextcord 2 | from nextcord.ext import commands 3 | 4 | import config 5 | from modules.drive.drive_service import DriveService 6 | from utils.embedder import embed_success 7 | from utils.utils import is_email 8 | 9 | from ..error.friendly_error import FriendlyError 10 | 11 | 12 | class DriveCog(commands.Cog): 13 | """Manage the ESP Google Drive""" 14 | 15 | def __init__(self, bot: commands.Bot): 16 | self.bot = bot 17 | self.service = DriveService(folder_id=config.drive_folder_id) 18 | 19 | @nextcord.slash_command(guild_ids=[config.guild_id]) 20 | async def drive(self, interaction: nextcord.Interaction[commands.Bot]): 21 | """This is a base command for all Google Drive commands and is not invoked""" 22 | pass 23 | 24 | @drive.subcommand(name="link") 25 | async def drive_link(self, interaction: nextcord.Interaction[commands.Bot]): 26 | """Get the link to view the ESP Google Drive folder""" 27 | folder_link = f"https://drive.google.com/drive/u/0/folders/{config.drive_folder_id}" 28 | await interaction.send( 29 | embed=embed_success( 30 | title=":link: Google Drive Link", 31 | description=( 32 | f"<:google_drive:785981940289372170> [Computer Science Resources JCT ESP All Courses]({folder_link})\n\n" 33 | f"Use the {self.drive_grant.get_mention(interaction.guild)} command to add yourself as a manager of the Google Drive." 34 | ), 35 | url=folder_link, 36 | ) 37 | ) 38 | 39 | @drive.subcommand(name="grant") 40 | async def drive_grant(self, interaction: nextcord.Interaction[commands.Bot], email: str): 41 | """Add a Google account as a manager of the ESP Google Drive 42 | 43 | Args: 44 | email: The email address of the Google account to add 45 | """ 46 | await interaction.response.defer(ephemeral=True) 47 | # validate email address 48 | if not is_email(email): 49 | raise FriendlyError( 50 | "Invalid email address", 51 | interaction, 52 | interaction.user, 53 | ephemeral=True, 54 | ) 55 | # create message 56 | info_message = ( 57 | f"Thanks for helping keep the Drive up to date! ❤️\n\n" 58 | f"You now have access to upload any tests, assignments, or other documents that may be useful to other students.\n\n" 59 | f"Please read through the [Drive Guidelines]({config.drive_guidelines_url}) for more information on keeping the Drive organized." 60 | ) 61 | if email.endswith("@g.jct.ac.il"): 62 | info_message += ( 63 | "\n\n⚠️ Note: You are using a JCT email address, since it is unclear whether this email address is permanent, " 64 | "we recommend that you use a personal email address instead to avoid files potentially being lost in the future." 65 | ) 66 | email_message = info_message.replace("[Drive Guidelines]", "Drive Guidelines ") 67 | # add manager to Drive 68 | try: 69 | self.service.add_manager(email=email, email_message=email_message) 70 | except Exception as e: 71 | raise FriendlyError( 72 | "An error occurred while applying changes.", 73 | interaction, 74 | interaction.user, 75 | ephemeral=True, 76 | ) 77 | # send success message 78 | embed = embed_success( 79 | title=f":office_worker: Successfully added {email} as an editor to the ESP Google Drive.", 80 | description=info_message, 81 | ) 82 | await interaction.send(embed=embed, ephemeral=True) 83 | 84 | 85 | # setup functions for bot 86 | def setup(bot): 87 | bot.add_cog(DriveCog(bot)) 88 | -------------------------------------------------------------------------------- /modules/drive/drive_service.py: -------------------------------------------------------------------------------- 1 | from google.oauth2 import service_account 2 | from googleapiclient.discovery import build 3 | 4 | import config 5 | 6 | 7 | class DriveService: 8 | def __init__(self, folder_id: str): 9 | SCOPES = ["https://www.googleapis.com/auth/drive"] 10 | self.creds = service_account.Credentials.from_service_account_info( 11 | config.google_config, scopes=SCOPES 12 | ) 13 | self.service = build("drive", "v3", credentials=self.creds) 14 | self.folder_id = folder_id 15 | 16 | def add_manager(self, email: str, email_message: str) -> None: 17 | """Gives write access to the Google Drive folder given an email address 18 | 19 | Args: 20 | email: The email address of the Google account to add 21 | email_message: The message to send to the email address 22 | 23 | Raises: 24 | errors.HttpError: If an error occurs while creating the permission 25 | AssertionError: If the created permission does not match the email address 26 | """ 27 | rule = { 28 | "role": "writer", 29 | "type": "user", 30 | "emailAddress": email, 31 | } 32 | created_permission = ( 33 | self.service.permissions() 34 | .create( 35 | fileId=self.folder_id, 36 | body=rule, 37 | fields="emailAddress", 38 | emailMessage=email_message, 39 | ) 40 | .execute() 41 | ) 42 | assert created_permission["emailAddress"] == email 43 | -------------------------------------------------------------------------------- /modules/email_registry/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/email_registry/__init__.py -------------------------------------------------------------------------------- /modules/email_registry/categoriser.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | import nextcord 4 | from nextcord.ext import commands 5 | 6 | import config 7 | from database import sql_fetcher 8 | from database.person import Person 9 | from utils.mention import decode_channel_mention 10 | 11 | from ..error.friendly_error import FriendlyError 12 | 13 | 14 | async def categorise_person( 15 | interaction: nextcord.Interaction[commands.Bot], 16 | person_id: int, 17 | channel_mentions: Iterable[str], 18 | ) -> Person: 19 | """Adds the person to the categories linked to the channels mentioned. Returns the updated person.""" 20 | return await __add_remove_categories( 21 | interaction, "categorise_person.sql", person_id, channel_mentions 22 | ) 23 | 24 | 25 | async def decategorise_person( 26 | interaction: nextcord.Interaction[commands.Bot], 27 | person_id: int, 28 | channel_mentions: Iterable[str], 29 | ) -> Person: 30 | """Removes the person from the categories linked to the channels mentioned. Returns the updated person.""" 31 | return await __add_remove_categories( 32 | interaction, "decategorise_person.sql", person_id, channel_mentions 33 | ) 34 | 35 | 36 | async def __add_remove_categories( 37 | interaction: nextcord.Interaction[commands.Bot], 38 | sql_file: str, 39 | person_id: int, 40 | channel_mentions: Iterable[str], 41 | ) -> Person: 42 | conn = await config.get_connection() 43 | query = sql_fetcher.fetch("modules", "email_registry", "queries", sql_file) 44 | async with conn.transaction(): 45 | for channel in channel_mentions: 46 | channel_id = decode_channel_mention(channel) 47 | if channel_id is None: 48 | raise FriendlyError( 49 | f'Expected a channel mention in place of "{channel}".', 50 | interaction, 51 | interaction.user, 52 | ) 53 | await conn.execute(query, person_id, channel_id) 54 | return await Person.get_person(person_id) 55 | -------------------------------------------------------------------------------- /modules/email_registry/cog.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Coroutine, Iterable, Optional 2 | 3 | import nextcord 4 | from nextcord.channel import TextChannel 5 | from nextcord.ext import application_checks, commands 6 | 7 | import config 8 | from database.person import Person 9 | from modules.email_registry import person_remover 10 | from utils.embedder import embed_success 11 | from utils.mention import extract_channel_mentions 12 | 13 | from ..error.friendly_error import FriendlyError 14 | from . import categoriser, email_adder, person_adder, person_embedder, person_finder 15 | 16 | 17 | class EmailRegistryCog(commands.Cog): 18 | """Update and retrieve faculty emails from the registry""" 19 | 20 | def __init__(self, bot: commands.Bot): 21 | self.bot = bot 22 | 23 | @nextcord.slash_command(name="email", guild_ids=[config.guild_id]) 24 | async def email(self, interaction: nextcord.Interaction[commands.Bot]): 25 | """This is a base command for the email registr and is not invoked""" 26 | pass 27 | 28 | @email.subcommand(name="of") 29 | async def get_email( 30 | self, 31 | interaction: nextcord.Interaction[commands.Bot], 32 | name: Optional[str] = None, 33 | channel: Optional[nextcord.TextChannel] = None, 34 | ): 35 | """Get the email address of the person you search for. 36 | 37 | Args: 38 | name: First name, last name, or both. (eg. moti) 39 | channel: Mention a course the professor teaches. (eg. #automata) 40 | """ 41 | await interaction.response.defer() 42 | people = await person_finder.search( 43 | name, 44 | channel 45 | or ( 46 | interaction.channel 47 | if isinstance(interaction.channel, nextcord.TextChannel) 48 | else None 49 | ), 50 | ) 51 | people = {person for person in people if person.emails} 52 | if not people: 53 | if name or channel: 54 | raise FriendlyError( 55 | "The email you are looking for aught to be here... But it isn't." 56 | " Perhaps the archives are incomplete.", 57 | interaction, 58 | description="🥚 Can i offer you a nice egg in this trying time?", 59 | ) 60 | raise FriendlyError( 61 | "Please specify the professor's name or channel of the email you are" 62 | " looking for.", 63 | interaction, 64 | image="https://media.discordapp.net/attachments/798518399842910228/849023621460131872/EmailSlashCommand.gif", 65 | ) 66 | embeds = person_embedder.gen_embeds(people) 67 | await interaction.send(embeds=embeds) 68 | 69 | @email.subcommand(name="add") 70 | async def add_email( 71 | self, 72 | interaction: nextcord.Interaction[commands.Bot], 73 | email: str, 74 | name: Optional[str] = None, 75 | channel: Optional[nextcord.TextChannel] = None, 76 | ): 77 | """Add the email of a professor with this command. 78 | 79 | Args: 80 | email: The email address you wish to add to the person. 81 | name: First name, last name, or both. (eg. moti) 82 | channel: Mention a course the professor teaches. (eg. #automata) 83 | """ 84 | await interaction.response.defer() 85 | # search for professor's details 86 | person = await person_finder.search_one(interaction, name, channel) 87 | # add the emails to the database 88 | person = await email_adder.add_email(person, email, interaction) 89 | await interaction.send(embed=person_embedder.gen_embed(person)) 90 | 91 | @email.subcommand(name="remove") 92 | @application_checks.has_guild_permissions(manage_roles=True) 93 | async def remove_email(self, interaction: nextcord.Interaction[commands.Bot], email: str): 94 | """Remove the email of a professor with this command. 95 | 96 | Args: 97 | email: The email address you wish to remove from its owner. 98 | """ 99 | await interaction.response.defer() 100 | # search for professor's details 101 | person = await person_finder.search_one(interaction, email=email) 102 | # add/remove the emails to the database 103 | person = await email_adder.remove_email(person, email) 104 | await interaction.send(embed=person_embedder.gen_embed(person)) 105 | 106 | @email.subcommand(name="person") 107 | async def add_person( 108 | self, 109 | interaction: nextcord.Interaction[commands.Bot], 110 | first_name: str, 111 | last_name: str, 112 | email: Optional[str] = None, 113 | channels: str = "", 114 | ): 115 | """Add a faculty member to the email registry. 116 | 117 | Args: 118 | first_name: The first name of the person you want to add. 119 | last_name: The last name of the person you want to add. 120 | email: The email address of the person you want to add. 121 | channels: Mention the channels this person is associated with. 122 | """ 123 | await interaction.response.defer() 124 | person = await person_adder.add_person( 125 | first_name, last_name, extract_channel_mentions(channels), interaction 126 | ) 127 | if email is not None: 128 | person = await email_adder.add_email(person, email, interaction) 129 | await interaction.send(embed=person_embedder.gen_embed(person)) 130 | 131 | @email.subcommand(name="remove") 132 | @application_checks.has_guild_permissions(manage_roles=True) 133 | async def remove_person( 134 | self, 135 | interaction: nextcord.Interaction[commands.Bot], 136 | name: Optional[str] = None, 137 | channel: Optional[TextChannel] = None, 138 | email: Optional[str] = None, 139 | ): 140 | """Remove a faculty member from the email registry. 141 | 142 | Args: 143 | name: The name of the person you want to remove. 144 | channel: A channel this person is associated with. 145 | email: The email address of the person you want to remove. 146 | """ 147 | 148 | await interaction.response.defer() 149 | person = await person_remover.remove_person(interaction, name, channel, email) 150 | await interaction.send( 151 | embed=embed_success(f"Successfully removed {person.name} from the system.") 152 | ) 153 | 154 | @email.subcommand(name="link") 155 | @application_checks.has_guild_permissions(manage_roles=True) 156 | async def link_person_to_category( 157 | self, 158 | interaction: nextcord.Interaction[commands.Bot], 159 | name_or_email: str, 160 | channel_mentions: str, 161 | ): 162 | """Link a person to a category (for example a professor to a course they teach). 163 | 164 | Args: 165 | name_or_email: First name, last name, or both, (eg. moti). Alternatively, you may use the person's email. 166 | channel_mentions: Mention one or more course channels the professor teaches. (eg. #automata #computability) 167 | """ 168 | 169 | await self.__link_unlink( 170 | interaction, name_or_email, channel_mentions, categoriser.categorise_person 171 | ) 172 | 173 | @email.subcommand(name="unlink") 174 | @application_checks.has_guild_permissions(manage_roles=True) 175 | async def unlink_person_from_category( 176 | self, 177 | interaction: nextcord.Interaction[commands.Bot], 178 | name_or_email: str, 179 | channel_mentions: str, 180 | ): 181 | """Unlink a person from a category (for example a professor from a course they no longer teach). 182 | 183 | Args: 184 | name_or_email: First name, last name, or both, (eg. moti). Alternatively, you may use the person's email. 185 | channel_mentions: Mention the course channels to unlink from the specified person. (eg. #automata #tcp-ip) 186 | """ 187 | 188 | await self.__link_unlink( 189 | interaction, name_or_email, channel_mentions, categoriser.decategorise_person 190 | ) 191 | 192 | async def __link_unlink( 193 | self, 194 | interaction: nextcord.Interaction[commands.Bot], 195 | name_or_email: str, 196 | channel_mentions: str, 197 | func: Callable[[nextcord.Interaction, int, Iterable[str]], Coroutine[Any, Any, Person]], 198 | ): 199 | await interaction.response.defer() 200 | person = await person_finder.search_one( 201 | interaction, name=name_or_email, email=name_or_email 202 | ) 203 | person = await func(interaction, person.id, extract_channel_mentions(channel_mentions)) 204 | await interaction.send(embed=person_embedder.gen_embed(person)) 205 | 206 | 207 | # This function will be called when this extension is loaded. It is necessary to add these functions to the bot. 208 | def setup(bot: commands.Bot): 209 | bot.add_cog(EmailRegistryCog(bot)) 210 | -------------------------------------------------------------------------------- /modules/email_registry/email_adder.py: -------------------------------------------------------------------------------- 1 | import nextcord 2 | from asyncpg.exceptions import CheckViolationError, UniqueViolationError 3 | 4 | from database import sql 5 | from database.person import Person 6 | 7 | from ..error.friendly_error import FriendlyError 8 | 9 | 10 | async def add_email(person: Person, email: str, sender: nextcord.Interaction) -> Person: 11 | """Add an email address to the database. 12 | 13 | Args: 14 | person: The person who owns the email address. 15 | email: The email address to add 16 | sender: The object which errors will be sent to. 17 | 18 | Returns: 19 | Person: The person object with the email address added. 20 | """ 21 | try: 22 | await sql.insert( 23 | "emails", 24 | on_conflict="(person, email) DO NOTHING", 25 | person=person.id, 26 | email=email, 27 | ) 28 | return await Person.get_person(person.id) 29 | except UniqueViolationError as e: 30 | raise FriendlyError( 31 | f"Ignoring request to add {email} to {person.name}; it is already in the" " system.", 32 | sender=sender, 33 | inner=e, 34 | ) 35 | except CheckViolationError as e: 36 | raise FriendlyError( 37 | f'"{email}" is not a valid email address.', 38 | sender=sender, 39 | inner=e, 40 | ) 41 | 42 | 43 | async def remove_email(person: Person, email: str) -> Person: 44 | """Remove an email address from the database. 45 | 46 | Args: 47 | person (Person): The person who the email address belongs to. 48 | email (str): The email address to be removed from the specified person. 49 | 50 | Returns: 51 | Person: The new person object without the email address that was removed. 52 | """ 53 | await sql.delete("emails", person=person.id, email=email.strip()) 54 | return await Person.get_person(person.id) 55 | -------------------------------------------------------------------------------- /modules/email_registry/person_adder.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | import nextcord 4 | from nextcord.ext import commands 5 | 6 | from database import sql 7 | from database.person import Person 8 | 9 | from . import categoriser 10 | 11 | 12 | async def add_person( 13 | name: str, 14 | surname: str, 15 | channel_mentions: Iterable[str], 16 | interaction: nextcord.Interaction[commands.Bot], 17 | ) -> Person: 18 | person_id = await sql.insert( 19 | "people", returning="id", name=name.capitalize(), surname=surname.capitalize() 20 | ) 21 | return await categoriser.categorise_person(interaction, person_id, channel_mentions) 22 | -------------------------------------------------------------------------------- /modules/email_registry/person_embedder.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable 2 | 3 | import nextcord 4 | 5 | from database.person import Person 6 | from utils.embedder import build_embed 7 | 8 | 9 | def gen_embed(person: Person): 10 | return build_embed( 11 | title=person.name, 12 | description=f"{person.linked_emails}\n{person.categories}".strip() or "No info found.", 13 | colour=nextcord.Colour.teal(), 14 | ) 15 | 16 | 17 | def gen_embeds(people: Iterable[Person]): 18 | return [gen_embed(person) for person in people] 19 | -------------------------------------------------------------------------------- /modules/email_registry/person_finder.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Set 2 | 3 | import nextcord 4 | from nextcord.ext import commands 5 | 6 | from database.person import Person 7 | from utils.utils import one 8 | 9 | from ..error.friendly_error import FriendlyError 10 | from .weighted_set import WeightedSet 11 | 12 | __search_weights = { 13 | "word": 2, 14 | "channel": 1, 15 | "email": 5, 16 | } 17 | 18 | 19 | async def search( 20 | name: Optional[str] = None, 21 | channel: Optional[nextcord.TextChannel] = None, 22 | email: Optional[str] = None, 23 | ) -> Set[Person]: 24 | """returns a list of people who best match the name and channel""" 25 | weights = WeightedSet() 26 | 27 | # add the people belonging to the category of the given channel (if any) 28 | if channel: 29 | for person in await Person.search_by_channel(channel.id): 30 | weights[person] += __search_weights["channel"] 31 | 32 | # search their name 33 | if name: 34 | for word in name.split(): 35 | for person, similarity in await Person.search_by_name(word): 36 | weights[person] += __search_weights["word"] * similarity 37 | 38 | # add the people who have the email mentioned 39 | if email: 40 | for person in await Person.search_by_email(email): 41 | weights[person] += __search_weights["email"] 42 | 43 | return weights.heaviest_items() 44 | 45 | 46 | async def search_one( 47 | sender: nextcord.Interaction[commands.Bot], 48 | name: Optional[str] = None, 49 | channel: Optional[nextcord.TextChannel] = None, 50 | email: Optional[str] = None, 51 | ) -> Person: 52 | """ 53 | Returns a single person who best match the query, or raise a FriendlyError if it couldn't find exactly one. 54 | 55 | :param sender: An object with the send method where friendly errors will be sent to. 56 | :type sender: nextcord.Interaction 57 | :param name: The name of the person you want to search for (first, last, or both). 58 | :type name: Optional[str] 59 | :param channel: A channel the person is linked to. 60 | :type channel: Optional[nextcord.TextChannel] 61 | :param email: The email of the person you want to search for. 62 | :type email: Optional[str] 63 | """ 64 | people = await search(name, channel, email) 65 | if not people: 66 | raise FriendlyError( 67 | f"Unable to find someone who matches your query. Check your spelling or" 68 | f" try a different query. If you still can't find them, You can add" 69 | f" them with `/email person add`.", 70 | sender, 71 | ) 72 | if len(people) > 1: 73 | raise FriendlyError( 74 | "I cannot accurately determine which of these people you're" 75 | " referring to. Please provide a more specific query.\n" 76 | + ", ".join(person.name for person in people), 77 | sender, 78 | ) 79 | return one(people) 80 | -------------------------------------------------------------------------------- /modules/email_registry/person_remover.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import nextcord 4 | from nextcord.channel import TextChannel 5 | from nextcord.ext import commands 6 | 7 | from database import sql 8 | from database.person import Person 9 | from modules.email_registry.person_finder import search_one 10 | 11 | 12 | async def remove_person( 13 | interaction: nextcord.Interaction[commands.Bot], 14 | name: Optional[str] = None, 15 | channel: Optional[TextChannel] = None, 16 | email: Optional[str] = None, 17 | ) -> Person: 18 | person = await search_one(interaction, name, channel, email) 19 | await sql.delete("people", id=person.id) 20 | return person 21 | -------------------------------------------------------------------------------- /modules/email_registry/queries/categorise_person.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO person_category (person, category) 2 | VALUES ($1, 3 | (SELECT id 4 | FROM categories 5 | WHERE channel = $2)) 6 | -------------------------------------------------------------------------------- /modules/email_registry/queries/decategorise_person.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM person_category 2 | WHERE 3 | person = $1 4 | AND 5 | category = (SELECT id 6 | FROM categories 7 | WHERE channel = $2) 8 | -------------------------------------------------------------------------------- /modules/email_registry/weighted_set.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import Any, DefaultDict, Set 3 | 4 | 5 | class WeightedSet: 6 | def __init__(self) -> None: 7 | self.weights: DefaultDict[Any, float] = defaultdict(float) 8 | 9 | def __getitem__(self, item: Any) -> float: 10 | return self.weights[item] 11 | 12 | def __setitem__(self, item: Any, weight: float) -> None: 13 | self.weights[item] = weight 14 | 15 | def heaviest_items(self) -> Set[Any]: 16 | """Finds all the items with maximal weight""" 17 | items = set() 18 | max_weight = 0.0 19 | for item, weight in self.weights.items(): 20 | if weight > max_weight: 21 | items = {item} 22 | max_weight = weight 23 | elif weight == max_weight: 24 | items.add(item) 25 | return items 26 | -------------------------------------------------------------------------------- /modules/error/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/error/__init__.py -------------------------------------------------------------------------------- /modules/error/cog.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import nextcord 4 | from nextcord.ext import commands 5 | 6 | import config 7 | from utils import utils 8 | 9 | from .error_handler import ErrorHandler 10 | from .error_logger import ErrorLogger 11 | 12 | 13 | class ErrorLogCog(commands.Cog): 14 | """Show recent error logs""" 15 | 16 | def __init__(self, bot: commands.Bot): 17 | self.logger = ErrorLogger("err.log", utils.get_id("BOT_LOG_CHANNEL")) 18 | self.handler = ErrorHandler(self.logger) 19 | 20 | @bot.event 21 | async def on_error(event: str, *args, **kwargs): 22 | _, error, _ = sys.exc_info() 23 | if error: 24 | await self.handler.handle(error) 25 | 26 | @bot.event 27 | async def on_application_command_error( 28 | interaction: nextcord.Interaction[commands.Bot], error: Exception 29 | ): 30 | await self.handler.handle(error, interaction=interaction) 31 | 32 | @nextcord.slash_command(name="logs", guild_ids=[config.guild_id]) 33 | async def logs(self, interaction: nextcord.Interaction[commands.Bot], num_lines: int = 50): 34 | """Show recent logs from err.log. 35 | 36 | Args: 37 | num_lines: The number of lines to show (default is 50). 38 | """ 39 | await interaction.send(self.logger.read_logs(num_lines)) 40 | 41 | 42 | # setup functions for bot 43 | def setup(bot: commands.Bot): 44 | cog = ErrorLogCog(bot) 45 | bot.add_cog(cog) 46 | -------------------------------------------------------------------------------- /modules/error/error_handler.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import nextcord 4 | from nextcord.ext import application_checks, commands 5 | 6 | from .error_logger import ErrorLogger 7 | from .friendly_error import FriendlyError 8 | from .quiet_warning import QuietWarning 9 | 10 | 11 | class ErrorHandler: 12 | """ 13 | Class that handles raised exceptions 14 | """ 15 | 16 | def __init__(self, error_logger: ErrorLogger) -> None: 17 | self.logger = error_logger 18 | 19 | async def handle( 20 | self, 21 | error: BaseException, 22 | message: Optional[nextcord.Message] = None, 23 | interaction: Optional[nextcord.Interaction[commands.Bot]] = None, 24 | ): 25 | """Given an error, will handle it appropriately""" 26 | error = getattr(error, "original", error) 27 | if isinstance(error, FriendlyError): 28 | await self.__handle_friendly(error, message) 29 | 30 | elif isinstance(error, QuietWarning): 31 | self.__handle_quiet_warning(error) 32 | 33 | else: 34 | self.logger.log_to_file(error, message) 35 | user_error, to_log = self.__user_error_message(error) 36 | if to_log: 37 | await self.logger.log_to_channel(error, message) 38 | if interaction is not None: 39 | friendly_err = FriendlyError( 40 | user_error, 41 | interaction, 42 | interaction.user, 43 | error, 44 | ) 45 | await self.handle(friendly_err, interaction=interaction) 46 | elif message is not None: 47 | friendly_err = FriendlyError( 48 | user_error, 49 | message.channel, 50 | message.author, 51 | error, 52 | ) 53 | await self.handle(friendly_err, message) 54 | 55 | async def __handle_friendly( 56 | self, error: FriendlyError, message: Optional[nextcord.Message] = None 57 | ): 58 | if error.inner: 59 | self.logger.log_to_file(error.inner, message) 60 | await error.reply() 61 | 62 | def __handle_quiet_warning(self, warning: QuietWarning): 63 | self.logger.log_to_file(warning) 64 | 65 | def __user_error_message(self, error: BaseException): 66 | """Given an error, will return a user-friendly string, and whether or not to log the error in the channel""" 67 | if isinstance(error, application_checks.ApplicationMissingPermissions): 68 | return ( 69 | "You are missing the following permissions required to run the" 70 | " command:" 71 | f' {", ".join(str(perm) for perm in error.missing_permissions)}.', 72 | False, 73 | ) 74 | elif isinstance(error, application_checks.ApplicationMissingRole): 75 | return f"You do not have the required role to run this command.", False 76 | elif isinstance(error, nextcord.ApplicationCheckFailure): 77 | return f"Error while executing the command.", True 78 | else: 79 | return f"An unknown error occurred.", True 80 | -------------------------------------------------------------------------------- /modules/error/error_logger.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from datetime import datetime 3 | from typing import Optional 4 | 5 | import nextcord 6 | from nextcord.ext import commands 7 | 8 | import config 9 | 10 | 11 | class ErrorLogger: 12 | def __init__(self, log_file: str, log_channel_id: int) -> None: 13 | self.log_file = log_file 14 | self.log_channel_id = log_channel_id 15 | 16 | def log_to_file(self, error: BaseException, message: Optional[nextcord.Message] = None): 17 | """appends the date and logs text to a file""" 18 | with open(self.log_file, "a", encoding="utf-8") as f: 19 | # write the current time and log text at end of file 20 | f.write(str(datetime.now()) + "\n") 21 | f.write(self.__get_err_text(error, message) + "\n") 22 | f.write("--------------------------\n") 23 | 24 | async def log_to_channel( 25 | self, error: BaseException, message: Optional[nextcord.Message] = None 26 | ): 27 | log_channel = config.guild().get_channel(self.log_channel_id) 28 | assert isinstance(log_channel, nextcord.TextChannel) 29 | if message is None: 30 | await log_channel.send(f"```{self.__get_err_text(error)}```") 31 | else: 32 | channel = ( 33 | message.channel.mention 34 | if isinstance(message.channel, nextcord.TextChannel) 35 | else "DM" 36 | ) 37 | await log_channel.send( 38 | f"Error triggered by {message.author.mention} in" 39 | f" {channel}\n```{self.__get_err_text(error, message)}```" 40 | ) 41 | 42 | def __get_err_text(self, error: BaseException, message: Optional[nextcord.Message] = None): 43 | description = "".join( 44 | traceback.format_exception(error.__class__, error, error.__traceback__) 45 | ) 46 | if message is None: 47 | return description 48 | return self.__attach_context(description, message) 49 | 50 | def __attach_context(self, description: str, message: nextcord.Message): 51 | """returns human readable command error for logging in log channel""" 52 | return ( 53 | f"Author:\n{message.author} ({message.author.display_name})\n\n" 54 | f"Channel:\n{message.channel}\n\n" 55 | f"Message:\n{message.content}\n\n" 56 | f"{description}\n" 57 | ) 58 | 59 | def read_logs(self, n_lines, char_lim: int = 2000) -> str: 60 | try: 61 | with open(self.log_file, "r", encoding="utf-8") as f: 62 | # read logs file 63 | lines = f.readlines() 64 | last_n_lines = "".join(lines[-n_lines:]) 65 | # trim the logs if too long 66 | if len(last_n_lines) > char_lim - 10: 67 | last_n_lines = f"․․․\n{last_n_lines[-(char_lim - 10):]}" 68 | return f"```{last_n_lines}```" 69 | except FileNotFoundError: 70 | return ( 71 | "https://tenor.com/view/nothing-to-see-here-explosion-explode-bomb-fire-gif-4923610" 72 | ) 73 | -------------------------------------------------------------------------------- /modules/error/friendly_error.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | import nextcord 4 | from nextcord.ext import commands 5 | 6 | import utils.embedder 7 | 8 | 9 | class FriendlyError(Exception): 10 | """ 11 | An error type that will be sent back to the user who triggered it when raised. 12 | Should be initialised with as helpful error messages as possible, since these will be shown to the user 13 | 14 | Attributes 15 | ---------- 16 | msg: :class:`str` 17 | The message to display to the user. 18 | sender: Union[:class:`nextcord.abc.Messageable`, :class:`nextcord.Interaction`] 19 | An object which can be used to call send (TextChannel or SlashContext). 20 | member: Optional[:class:`Member`] 21 | The member who caused the error. 22 | inner: Optional[:class:`Exception`] 23 | An exception that caused the error. 24 | description: Optional[:class:`str`] 25 | Description for the FriendlyError embed. 26 | image: Optional[:class:`str`] 27 | Image for the FriendlyError embed. 28 | ephemeral: :class:`bool` 29 | Whether the message is ephemeral, which means message content will only be seen to the author. 30 | """ 31 | 32 | def __init__( 33 | self, 34 | msg: str, 35 | sender: Union[nextcord.abc.Messageable, nextcord.Interaction[commands.Bot]], 36 | member: Union[nextcord.Member, nextcord.User, None] = None, 37 | inner: Optional[BaseException] = None, 38 | description: Optional[str] = None, 39 | image: Optional[str] = None, 40 | ephemeral: bool = False, 41 | ): 42 | self.sender = sender 43 | self.member = member 44 | self.inner = inner 45 | self.description = description 46 | self.image = image 47 | self.ephemeral = ephemeral 48 | super().__init__(self.__mention() + msg) 49 | 50 | def __mention(self) -> str: 51 | return f"Sorry {self.member.display_name}, " if self.member else "" 52 | 53 | async def reply(self): 54 | embed = utils.embedder.embed_error( 55 | str(self), description=self.description, image=self.image 56 | ) 57 | if isinstance(self.sender, nextcord.Interaction): 58 | await self.sender.send(embed=embed, ephemeral=self.ephemeral) 59 | else: 60 | await self.sender.send(embed=embed) 61 | -------------------------------------------------------------------------------- /modules/error/quiet_warning.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class QuietWarning(Exception): 5 | """ 6 | An error type that will be logged quietly in the log file. 7 | """ 8 | 9 | def __init__( 10 | self, 11 | msg: str, 12 | inner: Optional[Exception] = None, 13 | ): 14 | self.inner = inner 15 | super().__init__(msg) 16 | -------------------------------------------------------------------------------- /modules/graduation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/graduation/__init__.py -------------------------------------------------------------------------------- /modules/graduation/cog.py: -------------------------------------------------------------------------------- 1 | from nextcord.ext import commands 2 | 3 | from modules.graduation import graduation 4 | from utils.scheduler import Scheduler 5 | 6 | 7 | class GraduationCog(commands.Cog): 8 | """Performs the required housekeeping when a class graduates.""" 9 | 10 | @Scheduler.schedule() 11 | async def on_winter_semester_start(self): 12 | groups = await graduation.get_graduating_groups() 13 | await graduation.add_alumni_role(groups) 14 | 15 | 16 | # This function will be called when this extension is loaded. It is necessary to add these functions to the bot. 17 | def setup(bot: commands.Bot): 18 | bot.add_cog(GraduationCog()) 19 | -------------------------------------------------------------------------------- /modules/graduation/graduation.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Iterable 3 | 4 | import config 5 | from database.group import Group 6 | from utils.utils import get_discord_obj 7 | 8 | 9 | async def get_graduating_groups() -> Iterable[Group]: 10 | """Get the channel IDs of the graduating groups.""" 11 | return [group for group in await Group.get_groups() if group.grad_year == datetime.now().year] 12 | 13 | 14 | async def add_alumni_role(groups: Iterable[Group]): 15 | """Add the alumni role to all members of the given groups only if they have no other group roles""" 16 | alumni_role = get_discord_obj(config.guild().roles, "ALUMNI_ROLE") 17 | group_roles = {group.role for group in await Group.get_groups()} 18 | for group in groups: 19 | for member in group.role.members: 20 | if len(group_roles.intersection(member.roles)) == 1: 21 | await member.add_roles(alumni_role) 22 | -------------------------------------------------------------------------------- /modules/join/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/join/__init__.py -------------------------------------------------------------------------------- /modules/join/assigner.py: -------------------------------------------------------------------------------- 1 | import random 2 | from functools import cache 3 | 4 | import nextcord 5 | from pyluach.dates import HebrewDate 6 | 7 | import config 8 | from database import sql 9 | from utils import utils 10 | 11 | 12 | @cache 13 | def __unassigned_role() -> nextcord.Role: 14 | role = config.guild().get_role(utils.get_id("UNASSIGNED_ROLE")) 15 | assert role is not None 16 | return role 17 | 18 | 19 | @cache 20 | def __student_role() -> nextcord.Role: 21 | role = config.guild().get_role(utils.get_id("STUDENT_ROLE")) 22 | assert role is not None 23 | return role 24 | 25 | 26 | @cache 27 | def __welcome_channel() -> nextcord.TextChannel: 28 | channel = config.guild().get_channel(utils.get_id("OFF_TOPIC_CHANNEL")) 29 | assert isinstance(channel, nextcord.TextChannel) 30 | return channel 31 | 32 | 33 | async def assign(member: nextcord.Member, name: str, campus_id: int, year: int): 34 | """Assigns a user who joined the server. 35 | 36 | Sets the user's nickname to their full name, adds the role for the class they're in, and replaces the unassigned role with the assigned role. Following this, it welcomes the user in #off-topic. 37 | 38 | Args: 39 | member (nextcord.Member): The member to assign. 40 | name (str): The member's full name. 41 | campus_id (int): The ID of the campus they study in. 42 | year (int): The index of the year they're in. This should be a number from 1 to 4. 43 | """ 44 | if __unassigned_role() in member.roles: 45 | await member.edit(nick=name) 46 | await __add_role(member, campus_id, year) 47 | await member.add_roles(nextcord.Object(__student_role().id)) 48 | await member.remove_roles(nextcord.Object(__unassigned_role().id)) 49 | await server_welcome(member) 50 | 51 | 52 | async def __add_role(member: nextcord.Member, campus_id: int, year: int): 53 | """adds the right role to the user that used the command""" 54 | today = HebrewDate.today() 55 | assert today is not None 56 | last_elul_year = today.year if today.month == 6 else today.year - 1 57 | last_elul = HebrewDate(last_elul_year, 6, 1) 58 | base_year = last_elul.to_pydate().year 59 | grad_year = base_year + 4 - year 60 | role_id = await sql.select.value("groups", "role", campus=campus_id, grad_year=grad_year) 61 | await member.add_roles(nextcord.Object(role_id)) 62 | 63 | 64 | async def server_welcome(member: nextcord.Member): 65 | # Sets the channel to the welcome channel and sends a message to it 66 | welcome_emojis = ["🎉", "👋", "🌊", "🔥", "😎", "👏", "🎊", "🥳", "🙌", "✨", "⚡"] 67 | random_emoji = random.choice(welcome_emojis) 68 | nth = utils.ordinal(len(__student_role().members)) 69 | await __welcome_channel().send( 70 | f"Everyone welcome our {nth} student {member.mention} to the" 71 | f" server! Welcome! {random_emoji}" 72 | ) 73 | -------------------------------------------------------------------------------- /modules/join/cog.py: -------------------------------------------------------------------------------- 1 | import nextcord 2 | from nextcord.ext import application_checks, commands 3 | 4 | import config 5 | from database import preloaded 6 | from utils import embedder, utils 7 | 8 | from . import assigner 9 | 10 | 11 | class JoinCog(commands.Cog): 12 | """Join command to get new users information and place them in the right roles""" 13 | 14 | def __init__(self, bot: commands.Bot): 15 | self.bot = bot 16 | self.assigner = None 17 | 18 | @nextcord.slash_command("join", guild_ids=[config.guild_id]) 19 | @application_checks.has_role(utils.get_id("UNASSIGNED_ROLE")) 20 | async def join( 21 | self, 22 | interaction: nextcord.Interaction[commands.Bot], 23 | first_name: str, 24 | last_name: str, 25 | campus: int = nextcord.SlashOption( 26 | choices={campus.name: campus.id for campus in preloaded.campuses} 27 | ), 28 | year: int = nextcord.SlashOption(choices={f"Year {i}": i for i in range(1, 5)}), 29 | ): 30 | """Join command to get new users' information and place them in the right roles. 31 | 32 | Args: 33 | first_name: Your first name. 34 | last_name: Your last name. 35 | campus: Your campus (Lev or Tal). 36 | year: Your year. 37 | """ 38 | assert isinstance( 39 | interaction.user, nextcord.Member 40 | ), "Interaction user is not a guild member" 41 | assert interaction.application_command 42 | await interaction.response.defer() 43 | await assigner.assign( 44 | interaction.user, 45 | f"{first_name.title()} {last_name.title()}", 46 | campus, 47 | year, 48 | ) 49 | await interaction.send( 50 | embeds=[ 51 | embedder.embed_success( 52 | title=( 53 | f"**{interaction.user.display_name}** used" 54 | f" **/{interaction.application_command.name}**" 55 | ) 56 | ) 57 | ] 58 | ) 59 | 60 | 61 | # setup functions for bot 62 | def setup(bot: commands.Bot): 63 | bot.add_cog(JoinCog(bot)) 64 | -------------------------------------------------------------------------------- /modules/markdown/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/markdown/__init__.py -------------------------------------------------------------------------------- /modules/markdown/cog.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import nextcord 4 | from nextcord.ext import commands 5 | 6 | import config 7 | from modules.markdown import tip_formatter 8 | 9 | 10 | class FormattingCog(commands.Cog): 11 | """Display markdown tips for Discord messages""" 12 | 13 | def __init__(self, bot): 14 | self.bot = bot 15 | 16 | @nextcord.slash_command(name="markdown", guild_ids=[config.guild_id]) 17 | async def markdown( 18 | self, 19 | interaction: nextcord.Interaction[commands.Bot], 20 | format: Optional[str] = nextcord.SlashOption( 21 | choices={tip_formatter.formats[key].name: key for key in tip_formatter.formats} 22 | ), 23 | ): 24 | """Command to display markdown tips for Discord messages. 25 | 26 | Args: 27 | format (str): The format to display information about (default is all). 28 | """ 29 | if not format: 30 | message = tip_formatter.all_markdown_tips() 31 | else: 32 | message = tip_formatter.individual_info(format) 33 | await interaction.send(message) 34 | 35 | 36 | # setup functions for bot 37 | def setup(bot): 38 | bot.add_cog(FormattingCog(bot)) 39 | -------------------------------------------------------------------------------- /modules/markdown/formatting_tip.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from utils.utils import blockquote 4 | 5 | 6 | class Tip: 7 | def __init__( 8 | self, 9 | name: str, 10 | preview: str, 11 | escaped: str, 12 | header: Optional[str] = None, 13 | footer: Optional[str] = None, 14 | ): 15 | self.name = name 16 | self.preview = preview 17 | self.escaped = escaped 18 | self.header = header or self.__header() 19 | self.footer = footer or self.__footer() 20 | 21 | def short_message(self) -> str: 22 | return f"{self.preview}\n{blockquote(self.escaped)}" 23 | 24 | def long_message(self) -> str: 25 | return f"{self.header}\n\n{blockquote(self.escaped)}\n\n{self.footer}" 26 | 27 | def __header(self) -> str: 28 | return f"Did you know you can format your message with {self.preview}?" 29 | 30 | def __footer(self) -> str: 31 | return "Copy the snippet into a message replacing the text with your own." 32 | -------------------------------------------------------------------------------- /modules/markdown/tip_formatter.py: -------------------------------------------------------------------------------- 1 | from nextcord.ext import commands 2 | 3 | from .formatting_tip import Tip 4 | 5 | formats = { 6 | "italics": Tip("𝘐𝘵𝘢𝘭𝘪𝘤𝘴", "*italics*", "\\*italics* or \\_italics_"), 7 | "bold": Tip("𝗕𝗼𝗹𝗱", "**bold text**", "\\**bold text**"), 8 | "underline": Tip("U͟n͟d͟e͟r͟l͟i͟n͟e͟", "__underline__", "\\__underline__"), 9 | "strikethrough": Tip("S̶t̶r̶i̶k̶e̶t̶h̶r̶o̶u̶g̶h̶", "~~strikethrough~~", "\\~~strikethrough~~"), 10 | "spoiler": Tip("███████ (Spoiler)", "||spoiler|| (click to reveal)", "\\||spoiler||"), 11 | "inline": Tip("𝙸𝚗𝚕𝚒𝚗𝚎 𝚌𝚘𝚍𝚎", "`inline code`", "\\`inline code`"), 12 | "codeblock": Tip( 13 | "𝙱𝚕𝚘𝚌𝚔 𝚌𝚘𝚍𝚎", 14 | '```cpp\ncout << "This is a code block" << endl;\n```', 15 | "\\```cpp\n// replace this with your code\n```", 16 | footer=( 17 | "Copy the snippet above into a message and insert your code in the" 18 | " middle. You can also change the syntax highlighting language by" 19 | " replacing `cpp` with another language, for example, `js`, `py`," 20 | " or `java`." 21 | ), 22 | ), 23 | } 24 | 25 | 26 | def all_markdown_tips() -> str: 27 | """return a list of all markdown tips""" 28 | message = "**__Markdown Tips__**\n\n" 29 | for format in formats: 30 | message += formats[format].short_message() + "\n\n" 31 | return message 32 | 33 | 34 | def individual_info(format: str) -> str: 35 | """return info for given format""" 36 | return formats[format].long_message() 37 | -------------------------------------------------------------------------------- /modules/new_user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/new_user/__init__.py -------------------------------------------------------------------------------- /modules/new_user/cog.py: -------------------------------------------------------------------------------- 1 | import nextcord 2 | from nextcord.ext import commands 3 | 4 | import config 5 | 6 | from .greeter import Greeter 7 | 8 | 9 | class NewUserCog(commands.Cog): 10 | """Ask members who join to use the join command""" 11 | 12 | def __init__(self, bot: commands.Bot): 13 | self.bot = bot 14 | self.greeter = Greeter(bot) 15 | 16 | @commands.Cog.listener() 17 | async def on_member_join(self, member: nextcord.Member): 18 | """Ask members who join to use the join command.""" 19 | 20 | # if joined a different guild, skip welcoming 21 | if member.guild != config.guild(): 22 | return 23 | 24 | print(f"{member.name} joined the server.") 25 | 26 | await self.greeter.give_initial_role(member) 27 | 28 | if not member.bot: 29 | await self.greeter.server_greet(member) 30 | await self.greeter.private_greet(member) 31 | 32 | 33 | # setup functions for bot 34 | def setup(bot): 35 | bot.add_cog(NewUserCog(bot)) 36 | -------------------------------------------------------------------------------- /modules/new_user/greeter.py: -------------------------------------------------------------------------------- 1 | import nextcord 2 | from nextcord.ext import commands 3 | 4 | import config 5 | from utils import utils 6 | 7 | 8 | class Greeter: 9 | """Greets new users and instructs them on how to use the join command.""" 10 | 11 | def __init__(self, bot: commands.Bot) -> None: 12 | self.bot = bot 13 | 14 | def __intro_channel(self): 15 | return config.guild().get_channel(utils.get_id("INTRO_CHANNEL")) 16 | 17 | async def give_initial_role(self, member: nextcord.Member): 18 | label = "BOT_ROLE" if member.bot else "UNASSIGNED_ROLE" 19 | await member.add_roles(nextcord.Object(utils.get_id(label))) 20 | 21 | async def server_greet(self, member: nextcord.Member): 22 | channel = self.__intro_channel() 23 | assert isinstance(channel, nextcord.TextChannel) 24 | await utils.delayed_send(channel, 4, f"Hey {member.mention}!") 25 | await utils.delayed_send(channel, 8, "Welcome to the server!") 26 | await utils.delayed_send( 27 | channel, 8, "Please type `/join` and enter the details it asks you for" 28 | ) 29 | await utils.delayed_send( 30 | channel, 8, "If you have any trouble tag @Admin and tell them your problem" 31 | ) 32 | await utils.delayed_send(channel, 6, "But just so you dont have to, here's a GIF!") 33 | await utils.delayed_send(channel, 5, "https://i.imgur.com/5So77B6.gif") 34 | 35 | async def private_greet(self, member: nextcord.Member): 36 | """privately messages the user who joined""" 37 | channel = self.__intro_channel() 38 | assert isinstance(channel, nextcord.TextChannel) 39 | await member.send( 40 | utils.remove_tabs( 41 | f""" 42 | Hey, {member.mention}! Welcome! 43 | 44 | Please type head over to the {channel.mention} channel and follow instructions there. 45 | """ 46 | ) 47 | ) 48 | -------------------------------------------------------------------------------- /modules/ping/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/ping/__init__.py -------------------------------------------------------------------------------- /modules/ping/cog.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import os 3 | import random 4 | import socket 5 | 6 | import nextcord 7 | from nextcord.ext import commands 8 | 9 | import config 10 | 11 | 12 | class PingCog(commands.Cog): 13 | """A command which simply acknowledges the user's ping""" 14 | 15 | def __init__(self, bot: commands.Bot): 16 | self.bot = bot 17 | with open(os.path.join("modules", "ping", "responses.txt")) as responses: 18 | self.lines = responses.readlines() 19 | 20 | @nextcord.slash_command(name="ping", guild_ids=[config.guild_id]) 21 | async def ping(self, interaction: nextcord.Interaction[commands.Bot]): 22 | """Responds with a random acknowledgement""" 23 | await interaction.send( 24 | f"**{getpass.getuser()} @ {socket.gethostname()} $** {random.choice(self.lines)}" 25 | ) 26 | 27 | 28 | # This function will be called when this extension is loaded. It is necessary to add these functions to the bot. 29 | def setup(bot: commands.Bot): 30 | bot.add_cog(PingCog(bot)) 31 | -------------------------------------------------------------------------------- /modules/ping/responses.txt: -------------------------------------------------------------------------------- 1 | _Read this in Michael Scott's voice:_ Waaaazzzzzzzzaaaaaaaaaappppppp!!!!!!! 2 | Who do you think you are, puny human, to be pinging me? 3 | Pong. 4 | Hey! 5 | The cake is a lie. 6 | Don't you have anything better to do than to annoy me? 7 | This server here is made out of server. 8 | This is where the fun begins. 9 | Hello. I am JCTP0, Student-Cyborg relations. 10 | Do you want to buy a duck? 11 | This is the way. 12 | Have I ever told you about Ahsoka Tano? 13 | Have you ever heard of the tragedy of Darth Plaguis the Wise? 14 | Do you want to develop an app? 15 | I ***am*** the senate. 16 | I hate sand. 17 | Fly you fools! 18 | https://media1.tenor.com/images/d89144301cb60bd5ef2349a7edc434d5/tenor.gif?itemid=20035933 19 | https://media1.tenor.com/images/8a316a1d83b57d5a432155dbed822fac/tenor.gif?itemid=20035825 20 | https://media1.tenor.com/images/1d2cd1588ec8bfb226774feed893770c/tenor.gif?itemid=20035851 21 | https://media1.tenor.com/images/be4f0bee5a1a86fca35a77f134673ed6/tenor.gif?itemid=13234517 22 | https://tenor.com/view/hello-there-hi-there-greetings-gif-9442662 23 | https://media1.tenor.com/images/f0ffecbedfc317e68473e2ebce918f01/tenor.gif?itemid=4829511 24 | https://media1.tenor.com/images/974e01c737fa0c183657685d4ce88b70/tenor.gif?itemid=11489315 25 | https://media1.tenor.com/images/7624bdc2551883848224d26ab5e303d9/tenor.gif?itemid=20035980 26 | https://media1.tenor.com/images/28e0f875fd1b2106f12eb7958f0d9476/tenor.gif?itemid=16576180 27 | https://media1.tenor.com/images/e749b8c18afb2f490223b2e7ea763f51/tenor.gif?itemid=20036012 28 | https://media1.tenor.com/images/606e54783ef6ce9bd22c4192b4246fe5/tenor.gif?itemid=20036077 -------------------------------------------------------------------------------- /modules/search/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/search/__init__.py -------------------------------------------------------------------------------- /modules/search/cog.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | import nextcord 4 | from googlesearch import search 5 | from nextcord.ext import commands 6 | 7 | import config 8 | import modules.search.search_functions as sf 9 | 10 | from ..error.friendly_error import FriendlyError 11 | 12 | 13 | class SearchCog(commands.Cog): 14 | """Searches Google for links and includes summaries from Wikipedia when relevant""" 15 | 16 | def __init__(self, bot): 17 | self.bot = bot 18 | self.last_paragraph = {} 19 | 20 | @nextcord.slash_command(name="search", guild_ids=[config.guild_id]) 21 | async def search(self, interaction: nextcord.Interaction[commands.Bot], query: str): 22 | """Search the web for anything you want. 23 | 24 | Args: 25 | query (str): The query to search for. 26 | """ 27 | if query == "who asked": 28 | await interaction.send( 29 | "After a long and arduous search, I have found the answer to your" 30 | " question: Nobody. Nobody asked." 31 | ) 32 | return 33 | await interaction.response.defer() 34 | links: List[str] = [link for link in search(query) if link.startswith("http")] 35 | if not links: 36 | raise FriendlyError("No results found", interaction) 37 | wiki_links = [link for link in links if "wikipedia.org" in link[:30]] 38 | wiki_intro = ( 39 | sf.get_wiki_intro(wiki_links[0]) if wiki_links and wiki_links[0] != links[0] else None 40 | ) 41 | await interaction.send(sf.format_message(query, links[0], wiki_intro)) 42 | 43 | 44 | # setup functions for bot 45 | def setup(bot): 46 | bot.add_cog(SearchCog(bot)) 47 | -------------------------------------------------------------------------------- /modules/search/search_functions.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | import requests 4 | from bs4 import BeautifulSoup, Tag 5 | from bs4.element import ResultSet 6 | 7 | from utils.utils import remove_tabs 8 | 9 | 10 | def remove_citations(wiki_paragraph): 11 | """Removes citations from a string from a wiki""" 12 | while True: 13 | start_of_brackets = wiki_paragraph.find("[") 14 | end_of_brackets = wiki_paragraph.find("]") 15 | 16 | if start_of_brackets != -1 and end_of_brackets != -1: 17 | wiki_paragraph = ( 18 | wiki_paragraph[:start_of_brackets] + wiki_paragraph[end_of_brackets + 1 :] 19 | ) 20 | else: 21 | break 22 | return wiki_paragraph 23 | 24 | 25 | def get_wiki(url: str) -> ResultSet: 26 | """Does basic setup and formatting of a given wiki page. Returns the plain text of the article.""" 27 | wiki_info = requests.get(url) 28 | page = BeautifulSoup(wiki_info.content, "html.parser") 29 | body = page.find(id="bodyContent") 30 | assert isinstance(body, Tag) 31 | wiki_html = body.find_all("p") 32 | return wiki_html 33 | 34 | 35 | def get_wiki_intro(wiki_link: str) -> str: 36 | """Finds the into to the wiki from the text of the wiki""" 37 | wiki = get_wiki(wiki_link) 38 | 39 | # iterate through the paragraphs of the wiki 40 | for par_obj in wiki: 41 | paragraph = par_obj.get_text() 42 | 43 | # can remove bad articles by checking the length of the current paragraph 44 | if len(paragraph) < 150: 45 | continue 46 | 47 | return f"\n> {remove_citations(paragraph).strip()}\n~ Wikipedia (<{wiki_link}>).\n" 48 | 49 | return "" 50 | 51 | 52 | def format_message(query: str, url: str, wiki_string: Optional[str] = None) -> str: 53 | """Formats the message""" 54 | return remove_tabs(f"Search results for: {query}\n{wiki_string or ''}\n{url}") 55 | -------------------------------------------------------------------------------- /modules/xkcd/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/modules/xkcd/__init__.py -------------------------------------------------------------------------------- /modules/xkcd/cog.py: -------------------------------------------------------------------------------- 1 | import nextcord 2 | from nextcord.ext import commands 3 | 4 | import config 5 | 6 | from ..error.friendly_error import FriendlyError 7 | from .xkcd_embedder import XKCDEmbedder 8 | from .xkcd_fetcher import XKCDFetcher 9 | 10 | 11 | class XKCDCog(commands.Cog): 12 | """Displays the latest xkcd comic, random comics, or comics for your search terms""" 13 | 14 | def __init__(self, bot: commands.Bot): 15 | self.bot = bot 16 | self.xkcd_fetcher = XKCDFetcher() 17 | self.xkcd_embedder = XKCDEmbedder() 18 | 19 | @nextcord.slash_command(name="xkcd", guild_ids=[config.guild_id]) 20 | async def xkcd(self, interaction: nextcord.Interaction[commands.Bot]): 21 | """This is a base command for all xkcd commands and is not invoked""" 22 | pass 23 | 24 | @xkcd.subcommand(name="latest") 25 | async def latest(self, interaction: nextcord.Interaction[commands.Bot]): 26 | """Displays the latest xkcd comic""" 27 | await interaction.response.defer() 28 | try: 29 | comic = self.xkcd_fetcher.get_latest() 30 | except ConnectionError as e: 31 | raise FriendlyError( 32 | e.args[0], 33 | interaction, 34 | inner=e, 35 | description="Please try again later.", 36 | ) 37 | await interaction.send(embed=self.xkcd_embedder.gen_embed(comic)) 38 | 39 | @xkcd.subcommand(name="random") 40 | async def random(self, interaction: nextcord.Interaction[commands.Bot]): 41 | """Displays a random xkcd comic""" 42 | await interaction.response.defer() 43 | try: 44 | comic = self.xkcd_fetcher.get_random() 45 | except ConnectionError as e: 46 | raise FriendlyError( 47 | e.args[0], 48 | interaction, 49 | inner=e, 50 | description="Please try again later.", 51 | ) 52 | await interaction.send(embed=self.xkcd_embedder.gen_embed(comic)) 53 | 54 | @xkcd.subcommand(name="get") 55 | async def get( 56 | self, 57 | interaction: nextcord.Interaction[commands.Bot], 58 | number: int = nextcord.SlashOption(name="id"), 59 | ): 60 | """Gets a specific xkcd comic 61 | 62 | Args: 63 | number: The ID of the comic to display 64 | """ 65 | await interaction.response.defer() 66 | try: 67 | comic = self.xkcd_fetcher.get_comic_by_id(number) 68 | except ConnectionError as e: 69 | raise FriendlyError( 70 | e.args[0], 71 | interaction, 72 | inner=e, 73 | description="Please try again later, or try a different comic.", 74 | ) 75 | 76 | await interaction.send(embed=self.xkcd_embedder.gen_embed(comic)) 77 | 78 | @xkcd.subcommand(name="search") 79 | async def search(self, interaction: nextcord.Interaction[commands.Bot], query: str): 80 | """Searches for a relevant xkcd comic 81 | 82 | Args: 83 | query: The query to search for 84 | """ 85 | await interaction.response.defer() 86 | try: 87 | comic = self.xkcd_fetcher.search_relevant(query) 88 | except ConnectionError as e: 89 | raise FriendlyError( 90 | e.args[0], 91 | interaction, 92 | inner=e, 93 | description="Please try again later.", 94 | ) 95 | 96 | await interaction.send(embed=self.xkcd_embedder.gen_embed(comic)) 97 | 98 | 99 | def setup(bot: commands.Bot): 100 | bot.add_cog(XKCDCog(bot)) 101 | -------------------------------------------------------------------------------- /modules/xkcd/comic.py: -------------------------------------------------------------------------------- 1 | class Comic: 2 | def __init__(self, num: int, title: str, alt: str, img: str) -> None: 3 | self.num = num 4 | self.title = title 5 | self.alt = alt 6 | self.img = img 7 | -------------------------------------------------------------------------------- /modules/xkcd/xkcd_embedder.py: -------------------------------------------------------------------------------- 1 | import nextcord 2 | 3 | from utils.embedder import build_embed 4 | 5 | from .comic import Comic 6 | 7 | 8 | class XKCDEmbedder: 9 | def gen_embed(self, comic: Comic) -> nextcord.Embed: 10 | embed = build_embed( 11 | title=f"{comic.num}: {comic.title}", 12 | footer=comic.alt, 13 | url=f"https://xkcd.com/{comic.num}", 14 | image=comic.img, 15 | ) 16 | return embed 17 | -------------------------------------------------------------------------------- /modules/xkcd/xkcd_fetcher.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import requests 4 | 5 | from .comic import Comic 6 | 7 | 8 | class XKCDFetcher: 9 | def get_comic_by_id(self, comic_id: int) -> Comic: 10 | """returns a Comic object of an xkcd comic given its id""" 11 | response = requests.get(f"https://xkcd.com/{comic_id}/info.0.json") 12 | if response.status_code != 200: 13 | # xkcd API did not return a 200 response code 14 | if response.status_code == 404: 15 | raise ConnectionError("That comic does not exist... yet.") 16 | raise ConnectionError(f"An unknown error occurred: HTTP {response.status_code}") 17 | # load json from response 18 | comic = response.json() 19 | return Comic(comic["num"], comic["safe_title"], comic["alt"], comic["img"]) 20 | 21 | def get_latest(self) -> Comic: 22 | """returns a Comic object of the latest xkcd comic""" 23 | response = requests.get("https://xkcd.com/info.0.json") 24 | if response.status_code != 200: 25 | # xkcd API did not return a 200 response code 26 | raise ConnectionError(f"Failed to fetch the latest comic: HTTP {response.status_code}") 27 | # load json from response 28 | comic = response.json() 29 | return Comic(comic["num"], comic["safe_title"], comic["alt"], comic["img"]) 30 | 31 | def get_latest_num(self) -> int: 32 | """returns the comic number of the latest xkcd comic""" 33 | comic = self.get_latest() 34 | return comic.num 35 | 36 | def get_random(self) -> Comic: 37 | """returns a Comic object for a random xkcd comic""" 38 | random.seed() 39 | latest_num = self.get_latest_num() 40 | random_id = random.randrange(1, latest_num + 1) 41 | return self.get_comic_by_id(random_id) 42 | 43 | def search_relevant(self, search: str) -> Comic: 44 | relevant_xkcd_url = "https://relevant-xkcd-backend.herokuapp.com/search" 45 | body = {"search": search} 46 | response = requests.post(relevant_xkcd_url, data=body) 47 | data = response.json() 48 | if response.status_code != 200 and data["message"]: 49 | # Relevant xkcd did not return a 200 response code 50 | raise ConnectionError("Failed to fetch search results.") 51 | elif len(data["results"]) == 0: 52 | # Relevant xkcd did not return any results 53 | raise ConnectionError("No results found.") 54 | comic = data["results"][0] 55 | return Comic(comic["number"], comic["title"], comic["titletext"], comic["image"]) 56 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = [ 3 | "setuptools>=42", 4 | "wheel" 5 | ] 6 | build-backend = "setuptools.build_meta" 7 | 8 | 9 | [project] 10 | name = "jct-discord-bot" 11 | 12 | 13 | [tool.black] 14 | line-length = 100 15 | target-version = ["py310"] 16 | 17 | 18 | [tool.isort] 19 | profile = "black" 20 | py_version = 310 21 | line_length = 100 22 | combine_as_imports = true 23 | filter_files = true 24 | 25 | 26 | [tool.mypy] 27 | python_version = "3.10" 28 | namespace_packages = true 29 | 30 | 31 | [[tool.mypy.overrides]] 32 | module = [ 33 | "asyncpg", 34 | "asyncpg.exceptions", 35 | "bs4", 36 | "bs4.element", 37 | "google.oauth2", 38 | "googleapiclient.discovery", 39 | "googlesearch", 40 | "lxml", 41 | "lxml.html", 42 | "markdownify", 43 | "pyluach", 44 | "pyluach.dates", 45 | "thefuzz", 46 | ] 47 | ignore_missing_imports = true 48 | 49 | 50 | [tool.taskipy.tasks] 51 | black = { cmd = "task lint black", help = "Run black" } 52 | isort = { cmd = "task lint isort", help = "Run isort" } 53 | lint = { cmd = "pre-commit run --all-files", help = "Check all files for linting errors" } 54 | precommit = { cmd = "pre-commit install --install-hooks", help = "Install the precommit hook" } 55 | mypy = { cmd = "mypy .", help = "Run mypy" } 56 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | black 2 | mypy 3 | pre-commit 4 | taskipy 5 | types-Markdown 6 | types-dateparser 7 | types-requests -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | nextcord==2.6.0 2 | python-dotenv>=0.21.1,<2 3 | googlesearch-python>=1.1.0,<2 4 | beautifulsoup4>=4.9.3,<5 5 | google-api-python-client>=2.52.0,<3 6 | google-auth-httplib2>=0.1.0,<1 7 | google-auth-oauthlib>=0.5.3,<2 8 | dateparser>=1.1.7,<2 9 | pyluach>=1.4.2,<3 10 | thefuzz>=0.19.0,<1 11 | python-Levenshtein>=0.20.9,<1 12 | more-itertools>=9.0.0,<11 13 | markdown>=3.4.1,<4 14 | markdownify>=0.11.6,<1 15 | lxml>=4.9.2,<5 16 | asyncpg>=0.27.0,<1 -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.10.8 2 | -------------------------------------------------------------------------------- /style_guide.md: -------------------------------------------------------------------------------- 1 | # Style Guide 2 | 3 | Please follow the guidelines stated here when contributing to this repository. 4 | 5 | ## Spacing 6 | 7 | - Leave two empty lines between functions outside of a class. 8 | - Leave one empty line between functions inside of a class. 9 | - Leave one empty line between logical blocks of code in the same function. 10 | 11 | ## Naming 12 | 13 | - Use `snake_case` for files, folders, and variables. 14 | - Use `PascalCase` for classes. 15 | - Use `kebab-case` for branch names. 16 | 17 | ## Auto Formatting 18 | 19 | If you want your IDE to help you format your code, search how to set up your IDE to use the `black` formatter. 20 | 21 | The formatting and linting dependencies can be installed all at once with `pip install -r requirements-dev.txt`. 22 | 23 | To run `black` and `isort` on all files, run `task lint`. 24 | -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DenverCoder1/jct-discord-bot/67af73fa05afda73973d8843c1a66c6bacc5ceaf/utils/__init__.py -------------------------------------------------------------------------------- /utils/embedder.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | import nextcord 4 | 5 | from utils import utils 6 | 7 | MAX_EMBED_DESCRIPTION_LENGTH = 4096 8 | MAX_EMBED_FIELD_TITLE_LENGTH = 256 9 | MAX_EMBED_FIELD_FOOTER_LENGTH = 2048 10 | 11 | 12 | def embed_success( 13 | title: str, 14 | description: Optional[str] = None, 15 | footer: Optional[str] = None, 16 | url: Optional[str] = None, 17 | image: Optional[str] = None, 18 | ) -> nextcord.Embed: 19 | """Embed a success message and an optional description, footer, and url""" 20 | return build_embed(title, description, footer, url, nextcord.Colour.green(), image) 21 | 22 | 23 | def embed_warning( 24 | title: str, 25 | description: Optional[str] = None, 26 | footer: Optional[str] = None, 27 | url: Optional[str] = None, 28 | image: Optional[str] = None, 29 | ) -> nextcord.Embed: 30 | """Embed a warning message and an optional description, footer, and url""" 31 | return build_embed(title, description, footer, url, nextcord.Colour.gold(), image) 32 | 33 | 34 | def embed_error( 35 | title: str, 36 | description: Optional[str] = None, 37 | footer: Optional[str] = None, 38 | url: Optional[str] = None, 39 | image: Optional[str] = None, 40 | ) -> nextcord.Embed: 41 | """Embed an error message and an optional description, footer, and url""" 42 | return build_embed(title, description, footer, url, nextcord.Colour.red(), image) 43 | 44 | 45 | def build_embed( 46 | title: str, 47 | description: Optional[str] = None, 48 | footer: Optional[str] = None, 49 | url: Optional[str] = None, 50 | colour: nextcord.Colour = nextcord.Colour.blurple(), 51 | image: Optional[str] = None, 52 | ) -> nextcord.Embed: 53 | """Embed a message and an optional description, footer, and url""" 54 | # create the embed 55 | embed = nextcord.Embed( 56 | title=utils.trim(title, MAX_EMBED_FIELD_TITLE_LENGTH), url=url, colour=colour 57 | ) 58 | if description: 59 | embed.description = utils.trim(description, MAX_EMBED_DESCRIPTION_LENGTH) 60 | if footer: 61 | embed.set_footer(text=utils.trim(footer, MAX_EMBED_FIELD_FOOTER_LENGTH)) 62 | if image: 63 | embed.set_image(url=image) 64 | return embed 65 | -------------------------------------------------------------------------------- /utils/ids.csv: -------------------------------------------------------------------------------- 1 | LABEL,"ID" 2 | JCT_GUILD,785207994429866014 3 | INTRO_CHANNEL,785232784225861643 4 | OFF_TOPIC_CHANNEL,785216114305007648 5 | BOT_LOG_CHANNEL,797855736751063042 6 | CHANNEL_DIRECTORY_CHANNEL,829043976781824081 7 | ADMIN_ROLE,785230259917160448 8 | UNASSIGNED_ROLE,791466289997938708 9 | STUDENT_ROLE,791469302796648448 10 | ALUMNI_ROLE,785252483437035542 11 | BOT_ROLE,785526800524378152 12 | CLASS_CHAT_CATEGORY,785207994429866016 13 | ACTIVE_COURSES_CATEGORY,785222073739771904 14 | INACTIVE_COURSES_CATEGORY,879699759856754718 15 | -------------------------------------------------------------------------------- /utils/mention.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Iterable, Optional, Tuple 3 | 4 | 5 | def decode_mention(mention: str) -> Tuple[Optional[str], Optional[int]]: 6 | """returns whether mention is a member mention or a channel mention (or neither) as well as the id of the mentioned object""" 7 | match = re.search(r"<(#|@)!?(\d+)>", mention) 8 | if match is None: 9 | return None, None 10 | else: 11 | groups = match.groups() 12 | return "channel" if groups[0] == "#" else "member", int(groups[1]) 13 | 14 | 15 | def decode_channel_mention(mention: str) -> Optional[int]: 16 | """returns the id of a mentioned channel or none if it isn't a channel mention.""" 17 | mention_type, mention_id = decode_mention(mention) 18 | return mention_id if mention_type == "channel" else None 19 | 20 | 21 | def decode_member_mention(mention: str) -> Optional[int]: 22 | """returns the id of a mentioned channel or none if it isn't a channel mention.""" 23 | mention_type, mention_id = decode_mention(mention) 24 | return mention_id if mention_type == "member" else None 25 | 26 | 27 | def extract_mentions(string: str, filter_function=None) -> Iterable[str]: 28 | filter_function = filter_function or (lambda x: x) 29 | return [match for match in re.findall(r"<[^>]+>", string) if filter_function(match)] 30 | 31 | 32 | def extract_channel_mentions(string: str) -> Iterable[str]: 33 | return extract_mentions(string, decode_channel_mention) 34 | 35 | 36 | def extract_member_mentions(string: str) -> Iterable[str]: 37 | return extract_mentions(string, decode_member_mention) 38 | -------------------------------------------------------------------------------- /utils/reactions.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Collection, Optional, Sequence 3 | 4 | import nextcord 5 | from nextcord.errors import NotFound 6 | from nextcord.ext import commands 7 | 8 | from modules.error.friendly_error import FriendlyError 9 | from utils.utils import one 10 | 11 | 12 | async def wait_for_reaction( 13 | bot: commands.Bot, 14 | message: nextcord.Message, 15 | emoji_list: Sequence[str], 16 | allowed_users: Optional[Collection[nextcord.Member]] = None, 17 | timeout: int = 60, 18 | ) -> int: 19 | """Add reactions to message and wait for user to react with one. 20 | Returns the index of the selected emoji (integer in range 0 to len(emoji_list) - 1) 21 | 22 | Arguments: 23 | : str - the bot user 24 | : str - the message to apply reactions to 25 | : Iterable[str] - list of emojis as strings to add as reactions 26 | [allowed_users]: Iterable[nextcord.Member] - if specified, only reactions from these users are accepted 27 | [timeout]: int - number of seconds to wait before timing out 28 | """ 29 | 30 | def validate_reaction(reaction: nextcord.Reaction, user: nextcord.Member) -> bool: 31 | """Validates that: 32 | - The reaction is on the message currently being checked 33 | - The emoji is one of the emojis on the list 34 | - The reaction is not a reaction by the bot 35 | - The user who reacted is one of the allowed users 36 | """ 37 | return ( 38 | reaction.message.id == message.id 39 | and str(reaction.emoji) in emoji_list 40 | and user != bot.user 41 | and (allowed_users is None or user in allowed_users) 42 | ) 43 | 44 | # add reactions to the message 45 | for emoji in emoji_list: 46 | await message.add_reaction(emoji) 47 | 48 | try: 49 | # wait for reaction (returns reaction and user) 50 | reaction, _ = await bot.wait_for("reaction_add", check=validate_reaction, timeout=timeout) 51 | except asyncio.TimeoutError as error: 52 | try: 53 | # clear reactions 54 | await message.clear_reactions() 55 | except NotFound: 56 | # do nothing if message was deleted 57 | pass 58 | # raise timeout error as friendly error 59 | raise FriendlyError( 60 | f"You did not react within {timeout} seconds", 61 | message.channel, 62 | one(allowed_users) if allowed_users and len(allowed_users) == 1 else None, 63 | error, 64 | ) 65 | else: 66 | # clear reactions 67 | await message.clear_reactions() 68 | # return the index of the emoji selection 69 | return emoji_list.index(str(reaction.emoji)) 70 | -------------------------------------------------------------------------------- /utils/scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | from .scheduler import Scheduler 2 | -------------------------------------------------------------------------------- /utils/scheduler/event.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import DefaultDict, List 3 | 4 | from nextcord.ext import commands 5 | 6 | from .func_instance import FuncInstance 7 | 8 | 9 | class Event: 10 | def __init__(self) -> None: 11 | self.func_instances: DefaultDict[int, List[FuncInstance]] = defaultdict(list) 12 | 13 | def add_function(self, func_instance: FuncInstance, dependency_index: int = 0): 14 | self.func_instances[dependency_index].append(func_instance) 15 | 16 | async def fire(self, bot: commands.Bot): 17 | for dependency_index in sorted(self.func_instances): 18 | for instance in self.func_instances[dependency_index]: 19 | await instance.call(bot.cogs) 20 | -------------------------------------------------------------------------------- /utils/scheduler/func_instance.py: -------------------------------------------------------------------------------- 1 | from typing import Mapping, Optional, Tuple 2 | 3 | from nextcord.ext import commands 4 | 5 | 6 | class FuncInstance: 7 | """ 8 | Represents an instance of a function call. I.e. a function with its arguments 9 | """ 10 | 11 | def __init__(self, func, args: Tuple = (), kwargs: Optional[Mapping] = None) -> None: 12 | self.func = func 13 | self.args = args 14 | self.kwargs = kwargs or {} 15 | 16 | async def call(self, cogs: Mapping[str, commands.Cog]): 17 | for cog_name in cogs: 18 | try: 19 | if getattr(cogs[cog_name], self.func.__name__).__func__ == self.func: 20 | await self.func(cogs[cog_name], *self.args, **self.kwargs) 21 | except AttributeError: 22 | pass 23 | -------------------------------------------------------------------------------- /utils/scheduler/scheduler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import datetime as dt 3 | import threading 4 | from typing import Dict 5 | 6 | from nextcord.ext import commands 7 | from pyluach import dates 8 | 9 | from .event import Event 10 | from .func_instance import FuncInstance 11 | 12 | 13 | class Scheduler: 14 | events: Dict[str, Event] = { 15 | "on_new_academic_year": Event(), 16 | "on_winter_semester_start": Event(), 17 | } 18 | 19 | @staticmethod 20 | def schedule(dependency_index: int = 0): 21 | """ 22 | Use the decorator @Scheduler.schedule() to make your function run at certain events. 23 | If you require your function to run after any other which is also scheduled for the same event, pass an use @Scheduler.schedule(n) where n is greater than the number passed to the scheduler for the function which yours depends on (default is 0). 24 | 25 | Available events are: 26 | - on_new_academic_year 27 | - on_winter_semester_start 28 | 29 | Note: You must name the function the same as the event name. 30 | """ 31 | 32 | def decorator(func): 33 | Scheduler.events[func.__name__].add_function(FuncInstance(func), dependency_index) 34 | return func 35 | 36 | return decorator 37 | 38 | def __init__(self, bot: commands.Bot) -> None: 39 | self.bot = bot 40 | self.__await_new_academic_year() 41 | self.__await_winter_semester_start() 42 | 43 | def __await_new_academic_year(self): 44 | """Event that will run every year on Av 26 at 4pm""" 45 | secs = self.__secs_to_heb_date(5, 26, 16) 46 | self.__await_event(secs, "on_new_academic_year", self.__await_new_academic_year) 47 | 48 | def __await_winter_semester_start(self): 49 | """Event that will run every year on Tishri 18 at 4pm""" 50 | secs = self.__secs_to_heb_date(7, 18, 16) 51 | self.__await_event(secs, "on_winter_semester_start", self.__await_winter_semester_start) 52 | 53 | def __await_event(self, secs: float, event_name: str, on_complete): 54 | if threading.TIMEOUT_MAX > secs: 55 | threading.Timer( 56 | secs, 57 | asyncio.run_coroutine_threadsafe, 58 | args=( 59 | self.__trigger_event(event_name, on_complete), 60 | asyncio.get_running_loop(), 61 | ), 62 | ).start() 63 | 64 | def __secs_to_heb_date( 65 | self, h_month: int, h_day: int, hour: int = 0, min: int = 0, sec: int = 0 66 | ): 67 | """Returns the number of seconds from now until the next time a hebrew date occurs""" 68 | now = dt.datetime.now() 69 | h_year = dates.HebrewDate.today().year 70 | h_trigger_date = dates.HebrewDate(h_year, h_month, h_day) 71 | 72 | # the datetime when new academic year should be triggered 73 | dt_trigger = dt.datetime.combine(h_trigger_date.to_pydate(), dt.time(hour, min, sec)) 74 | 75 | # if we're past dt_trigger but before Rosh Hashana change trigger to next year 76 | if now > dt_trigger: 77 | h_trigger_date = dates.HebrewDate(h_year + 1, h_month, h_day) 78 | dt_trigger = dt.datetime.combine(h_trigger_date.to_pydate(), dt.time(hour, min, sec)) 79 | 80 | return (dt_trigger - now).total_seconds() 81 | 82 | async def __trigger_event(self, name: str, on_complete): 83 | print(f"Triggering event: {name}") 84 | await Scheduler.events[name].fire(self.bot) 85 | on_complete() 86 | -------------------------------------------------------------------------------- /utils/utils.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import os 3 | import re 4 | from asyncio import sleep 5 | from datetime import datetime, timedelta 6 | from typing import Any, Dict, Iterable, Optional, TypeVar 7 | 8 | import dateparser 9 | import nextcord 10 | from nextcord.abc import Messageable 11 | 12 | 13 | class IdNotFoundError(Exception): 14 | def __init__(self, *args: object) -> None: 15 | super().__init__(*args) 16 | 17 | 18 | def get_discord_obj(iterable, label: str) -> Any: 19 | obj = nextcord.utils.get(iterable, id=get_id(label)) 20 | if not obj: 21 | raise IdNotFoundError() 22 | return obj 23 | 24 | 25 | def get_id(label: str) -> int: 26 | """gets the id of an object that has the given label in the CSV file""" 27 | with open(os.path.join("utils", "ids.csv")) as csv_file: 28 | csv_reader = csv.reader(csv_file, delimiter=",") 29 | for row in csv_reader: 30 | if row[0] == label: 31 | return int(row[1]) 32 | raise IdNotFoundError(f"There is not ID labeled {label} in ids.csv") 33 | 34 | 35 | def remove_tabs(string: str) -> str: 36 | """removed up to limit_per_line (default infinitely many) tabs from the beginning of each line of string""" 37 | return re.sub(r"\n\t*", "\n", string).strip() 38 | 39 | 40 | def blockquote(string: str) -> str: 41 | """Add blockquotes to a string""" 42 | # inserts > at the start of string and after new lines 43 | # as long as it is not at the end of the string 44 | return re.sub(r"(^|\n)(?!$)", r"\1> ", string.strip()) 45 | 46 | 47 | def ordinal(n: int): 48 | return "%d%s" % (n, "tsnrhtdd"[(n // 10 % 10 != 1) * (n % 10 < 4) * n % 10 :: 4]) 49 | 50 | 51 | def is_email(email: str) -> bool: 52 | return bool(re.search(r"^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$", email)) 53 | 54 | 55 | def parse_date( 56 | date_str: Optional[str] = None, 57 | from_tz: Optional[str] = None, 58 | to_tz: Optional[str] = None, 59 | future: Optional[bool] = None, 60 | base: datetime = datetime.now(), 61 | ) -> Optional[datetime]: 62 | """Returns datetime object for given date string 63 | Arguments: 64 | :param date_str: :class:`Optional[str]` date string to parse 65 | :param from_tz: :class:`Optional[str]` string representing the timezone to interpret the date as (eg. "Asia/Jerusalem") 66 | :param to_tz: :class:`Optional[str]` string representing the timezone to return the date in (eg. "Asia/Jerusalem") 67 | :param future: :class:`Optional[bool]` set to true to prefer dates from the future when parsing 68 | :param base: :class:`datetime` datetime representing where dates should be parsed relative to 69 | """ 70 | if date_str is None: 71 | return None 72 | # set dateparser settings 73 | settings: Dict[str, Any] = { 74 | "RELATIVE_BASE": base.replace(tzinfo=None), 75 | **({"TIMEZONE": from_tz} if from_tz else {}), 76 | **({"TO_TIMEZONE": to_tz} if to_tz else {}), 77 | **({"PREFER_DATES_FROM": "future"} if future else {}), 78 | } 79 | # parse the date with dateparser 80 | date = dateparser.parse(date_str, settings=settings) # type: ignore 81 | # make times PM if time is early in the day, base is PM, and no indication that AM was specified 82 | if ( 83 | date 84 | and date.hour < 8 # hour is before 8:00 85 | and base.hour >= 12 # relative base is PM 86 | and not "am" in date_str.lower() # am is not specified 87 | and not re.match(r"^2\d{3}-[01]\d-[0-3]\d\S*$", date_str) # not in iso format 88 | ): 89 | date += timedelta(hours=12) 90 | # return the datetime object 91 | return date 92 | 93 | 94 | def format_date(date: datetime, base: datetime = datetime.now(), all_day: bool = False) -> str: 95 | """Convert dates to a specified format 96 | Arguments: 97 | : The date to format 98 | [base]: When the date or time matches the info from base, it will be skipped. 99 | This helps avoid repeated info when formatting time ranges. 100 | [all_day]: If set to true, the time of the day will not be included 101 | """ 102 | date_format = "" 103 | # include the date if the date is different from the base 104 | if date.date() != base.date(): 105 | # %a = Weekday (eg. "Mon"), %d = Day (eg. "01"), %b = Month (eg. "Sep") 106 | date_format = "%a %d %b" 107 | # include the year if the date is in a different year 108 | if date.year != base.year: 109 | # %Y = Year (eg. "2021") 110 | date_format += " %Y" 111 | # include the time if it is not an all day event and not the same as the base 112 | if not all_day and date != base: 113 | # %I = Hours (12-hour clock), %M = Minutes, %p = AM or PM 114 | date_format += " %I:%M %p" 115 | # format the date and remove leading zeros and trailing spaces 116 | return date.strftime(date_format).replace(" 0", " ").strip() 117 | 118 | 119 | T = TypeVar("T") 120 | 121 | 122 | def one(iterable: Iterable[T]) -> T: 123 | """Returns a single element from an iterable or raises StopIteration if it was empty.""" 124 | return next(iter(iterable)) 125 | 126 | 127 | def trim(text: str, limit: int) -> str: 128 | return text[: limit - 3].strip() + "..." if len(text) > limit else text 129 | 130 | 131 | async def delayed_send( 132 | messageable: Messageable, 133 | seconds: float, 134 | content: Optional[str] = None, 135 | *, 136 | embeds: Optional[list[nextcord.Embed]] = None, 137 | files: Optional[list[nextcord.File]] = None, 138 | ): 139 | async with messageable.typing(): 140 | await sleep(seconds) 141 | await messageable.send( 142 | content, 143 | embeds=embeds or [], 144 | files=files or [], 145 | ) 146 | --------------------------------------------------------------------------------