├── .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 |
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 |
--------------------------------------------------------------------------------