├── .coveragerc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── grace.yml │ └── greetings.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── Procfile ├── README.md ├── alembic.ini ├── bin ├── __init__.py ├── database.py ├── grace ├── heroku_start.sh └── templates │ ├── default.database.template.cfg │ └── heroku.database.template.cfg ├── bot ├── __init__.py ├── classes │ ├── __init__.py │ ├── recurrence.py │ └── state.py ├── extensions │ ├── __init__.py │ ├── bookmark_cog.py │ ├── code_generator_cog.py │ ├── color_cog.py │ ├── command_error_handler.py │ ├── extension_cog.py │ ├── fun_cog.py │ ├── grace_cog.py │ ├── language_cog.py │ ├── moderation_cog.py │ ├── pun_cog.py │ ├── thank_cog.py │ ├── threads_cog.py │ ├── translator_cog.py │ ├── weather_cog.py │ ├── welcome_cog.py │ └── wikipedia_cog.py ├── grace.py ├── helpers │ ├── __init__.py │ ├── bot_helper.py │ ├── error_helper.py │ ├── github_helper.py │ └── log_helper.py ├── models │ ├── __init__.py │ ├── bot.py │ ├── channel.py │ ├── extension.py │ └── extensions │ │ ├── __init__.py │ │ ├── fun │ │ ├── __init__.py │ │ └── answer.py │ │ ├── language │ │ ├── __init__.py │ │ ├── pun.py │ │ ├── pun_word.py │ │ ├── trigger.py │ │ └── trigger_word.py │ │ ├── thank.py │ │ └── thread.py └── services │ ├── __init__.py │ └── github_service.py ├── config ├── __init__.py ├── application.py ├── config.py ├── database.template.cfg ├── environment.cfg ├── settings.cfg └── utils.py ├── db ├── __init__.py ├── alembic │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions │ │ ├── .gitkeep │ │ ├── 11f3c9cd0977_added_puns_tables.py │ │ ├── 381d2407fcf3_create_thanks_tables.py │ │ ├── 614bb9e370d8_add_settings_for_puns_cooldown.py │ │ └── f8ac0bbc34ac_create_threads.py ├── model.py ├── seed.py └── seeds │ ├── __init__.py │ ├── answer.py │ ├── bot.py │ ├── channels.py │ ├── puns.py │ └── trigger.py ├── docs └── CONTRIBUTING.md ├── lib ├── __init__.py ├── bidirectional_iterator.py ├── config_required.py ├── paged_embeds.py └── timed_view.py ├── license.txt ├── logs └── .gitkeep ├── mypy.ini ├── nixpacks.toml ├── nltk.txt ├── pyproject.toml ├── setup.py └── tests ├── __init__.py ├── config ├── __init__.py ├── test_application.py └── test_config.py └── models ├── __init__.py └── test_extension.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = ./tests/*,./bin/*,./db/seeds/* 3 | skip_empty = true 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug with Grace 4 | title: '' 5 | labels: bug 6 | assignees: PenguinBoi12 7 | --- 8 | 9 | **Describe the bug** 10 | 11 | 12 | **Have you been able to reproduce the bug?** 13 | 24 | 25 | **Screenshots** 26 | 27 | 28 | **Desktop (please complete the following information):** 29 | - OS: [e.g. iOS] 30 | - Browser [e.g. chrome, safari] 31 | - Version [e.g. 22] 32 | 33 | **Additional context** 34 | 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a feature for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | --- 8 | 9 | **Describe the feature** 10 | 11 | 12 | **Additional context** 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/grace.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python 3 | 4 | name: Grace tests 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Set up Python 3.10 24 | uses: actions/setup-python@v3 25 | with: 26 | python-version: "3.10" 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | pip install flake8 pytest 32 | pip install . 33 | 34 | - name: Lint with flake8 35 | run: | 36 | # stop the build if there are Python syntax errors or undefined names 37 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 38 | # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide 39 | flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics 40 | 41 | - name: Test with pytest 42 | run: | 43 | pytest 44 | -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request_target, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | steps: 12 | - uses: actions/first-interaction@v1 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | pr-message: "Congratulation on your first contribution! We truly appricate the time and effort you put into it. We will be looking into it shortly." 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .env 3 | .idea 4 | .fleet 5 | Grace.egg-info 6 | venv 7 | channel_store 8 | build/ 9 | *.db 10 | config/database.cfg 11 | config/settings.cfg 12 | *.log 13 | *.log.* 14 | env/ 15 | lib64 16 | .mypy_cache/ 17 | .pytest_cache 18 | tmp 19 | .coverage -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: bin/heroku_start.sh -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Grace 3 | [![Join on Discord](https://discordapp.com/api/guilds/823178343943897088/widget.png?style=shield)](https://discord.gg/code-society-823178343943897088) 4 | [![Grace tests](https://github.com/Code-Society-Lab/grace/actions/workflows/grace.yml/badge.svg?branch=main)](https://github.com/Code-Society-Lab/grace/actions/workflows/grace.yml) 5 | [![Last Updated](https://img.shields.io/github/last-commit/code-society-lab/grace.svg)](https://github.com/code-society-lab/grace/commits/main) 6 | 7 | Grace is the official Code Society discord bot. The goal is to give our members the opportunity to participate in the 8 | development of the server's bot and contribute to a team project while also improving it. 9 | 10 | --- 11 | 12 | ## Installation 13 | Installing Grace is fairly simple. You can do it in three short step. 14 | 15 | 0. [Install Python and dependencies](#0-install-python-and-dependencies) 16 | 1. [Set up your app and token](#1-set-up-your-app-and-token) 17 | 2. [Start the bot](#2-start-the-bot) 18 | 19 | ### 0. Python and Dependencies 20 | Install [Python](https://www.python.org/downloads/). Python 3.10 or higher is required. 21 | 22 | > We highly recommend that you set up a virtual environment to work on Grace. 23 | > https://packaging.python.org/en/latest/guides/installing-using-pip-and-virtual-environments/ 24 | 25 | In the `grace` directory, open a terminal (Linus/MacOS) or cmd (Windows) and execute `pip install -e .` 26 | (recommended for development) or `pip install .`. 27 | 28 | ### 1. Set up your App and Token 29 | If you did not already do it, [create](https://discord.com/developers/docs/getting-started#creating-an-app) your Discord 30 | bot. Then, create a file called `.env` in the project directory, open it and add 31 | `DISCORD_TOKEN=`. (Replace by your discord token). 32 | 33 | > Do not share that file nor the information inside with anyone. 34 | 35 | ### 2. Start the Bot 36 | The last part is to execute the bot. Execute `grace start -e development` to start Grace in development mode. The rest 37 | of the installation should complete itself and start the bot. 38 | 39 | > If the grace command is unrecognized, be sure that you installed the bot properly. 40 | 41 | ## Script Usage 42 | - **Bot Command(s)**: 43 | - `grace start` : Starts the bot (`ctrl+c` to stop the bot) 44 | - **Database Command(s)**: 45 | - `grace db create` : Creates the database and the tables 46 | - `grace db drop` : Deletes the tables and the database 47 | - `grace db seed` : Seeds the tables (Initialize the default values) 48 | - `grace db reset` : Drop, recreate and seeds the database. 49 | 50 | All commands can take the optional `-e` argument with a string to define the environment.
51 | Available environment: (production [default], development, test) 52 | 53 | > We recommend using "development" (ex. grace start -e development) 54 | --- 55 | 56 | ## Advance configurations 57 | For advance configurations, visit the [wiki](https://github.com/Code-Society-Lab/grace/wiki) 58 | 59 | ## Contribution 60 | As mentioned in the description, we invite everyone to participate in the development of the bot. You can contribute to the project by simply opening an issue, by improving some current features or even by adding your own features. 61 | Before contributing please refer to our [contribution guidelines](https://github.com/Code-Society-Lab/grace/blob/main/docs/CONTRIBUTING.md) and [Code of Conduct for contributor (temporary unavailable)](#). 62 | 63 | --- 64 | 65 | ## Troubleshooting 66 | If you're getting unexpected result, visit the wiki's [troubleshooting](https://github.com/Code-Society-Lab/grace/wiki/Troubleshooting) 67 | page. For any other problems or questions ask us on our [discord server](https://discord.gg/code-society-823178343943897088). 68 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | [DEFAULT] 3 | # path to migration scripts 4 | script_location = db/alembic/ 5 | 6 | [alembic] 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file 10 | # for all available tokens 11 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 12 | 13 | # sys.path path, will be prepended to sys.path if present. 14 | # defaults to the current working directory. 15 | prepend_sys_path = . 16 | 17 | # timezone to use when rendering the date within the migration file 18 | # as well as the filename. 19 | # If specified, requires the python-dateutil library that can be 20 | # installed by adding `alembic[tz]` to the pip requirements 21 | # string value is passed to dateutil.tz.gettz() 22 | # leave blank for localtime 23 | # timezone = 24 | 25 | # max length of characters to apply to the 26 | # "slug" field 27 | # truncate_slug_length = 40 28 | 29 | # set to 'true' to run the environment during 30 | # the 'revision' command, regardless of autogenerate 31 | # revision_environment = false 32 | 33 | # set to 'true' to allow .pyc and .pyo files without 34 | # a source .py file to be detected as revisions in the 35 | # versions/ directory 36 | # sourceless = false 37 | 38 | # version location specification; This defaults 39 | # to db/migrations/versions. When using multiple version 40 | # directories, initial revisions must be specified with --version-path. 41 | # The path separator used here should be the separator specified by "version_path_separator" below. 42 | # version_locations = %(here)s/bar:%(here)s/bat:db/migrations/versions 43 | 44 | # version path separator; As mentioned above, this is the character used to split 45 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 46 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 47 | # Valid values for version_path_separator are: 48 | # 49 | # version_path_separator = : 50 | # version_path_separator = ; 51 | # version_path_separator = space 52 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 53 | 54 | # the output encoding used when revision files 55 | # are written from script.py.mako 56 | # output_encoding = utf-8 57 | 58 | [development] 59 | # This need to exist so the development name work 60 | 61 | [test] 62 | # This need to exist so the dev test work 63 | 64 | [post_write_hooks] 65 | # post_write_hooks defines scripts or Python functions that are run 66 | # on newly generated revision scripts. See the documentation for further 67 | # detail and examples 68 | 69 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 70 | # hooks = black 71 | # black.type = console_scripts 72 | # black.entrypoint = black 73 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 74 | 75 | # Logging configuration 76 | [loggers] 77 | keys = root,sqlalchemy,alembic 78 | 79 | [handlers] 80 | keys = console 81 | 82 | [formatters] 83 | keys = generic 84 | 85 | [logger_root] 86 | level = WARN 87 | handlers = console 88 | qualname = 89 | 90 | [logger_sqlalchemy] 91 | level = WARN 92 | handlers = 93 | qualname = sqlalchemy.engine 94 | 95 | [logger_alembic] 96 | level = INFO 97 | handlers = 98 | qualname = alembic 99 | 100 | [handler_console] 101 | class = StreamHandler 102 | args = (sys.stderr,) 103 | level = NOTSET 104 | formatter = generic 105 | 106 | [formatter_generic] 107 | format = %(levelname)-5.5s [%(name)s] %(message)s 108 | datefmt = %H:%M:%S -------------------------------------------------------------------------------- /bin/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from nltk.downloader import Downloader 3 | from nltk import download, download_shell 4 | 5 | download('vader_lexicon', quiet=True) 6 | except ModuleNotFoundError: 7 | print("nltk module not properly installed") 8 | -------------------------------------------------------------------------------- /bin/database.py: -------------------------------------------------------------------------------- 1 | """Database commands 2 | 3 | This module contains the additional database commands for the `grace` script. 4 | """ 5 | 6 | from logging import warning, critical, info, error 7 | from sqlalchemy.exc import ProgrammingError, SQLAlchemyError, IntegrityError 8 | from bot import app 9 | from db.seed import get_seeds 10 | 11 | 12 | def seed_tables(): 13 | info("Seeding tables...") 14 | 15 | try: 16 | for seed_module in get_seeds(): 17 | try: 18 | info(f"Seeding {seed_module.__name__}") 19 | seed_module.seed_database() 20 | except IntegrityError as e: 21 | warning(f"Error: {e}, Skipping") 22 | pass 23 | 24 | info("Seeding completed successfully") 25 | except ProgrammingError as e: 26 | critical(f"Critical Error: {e}") 27 | 28 | 29 | def create_all(): 30 | if not app.database_exists: 31 | info(f"Creating all...") 32 | 33 | try: 34 | app.create_database() 35 | app.create_tables() 36 | 37 | info("Database created successfully!") 38 | except SQLAlchemyError as e: 39 | critical(f"Critical Error: {e}") 40 | else: 41 | warning("Database already exists") 42 | 43 | 44 | def drop_all(): 45 | if app.database_exists: 46 | warning("Dropping all...") 47 | 48 | try: 49 | app.drop_tables() 50 | app.drop_database() 51 | 52 | info("Database dropped successfully!") 53 | except SQLAlchemyError as e: 54 | critical(f"Critical Error: {e}") 55 | else: 56 | warning("Database doesn't exists") 57 | 58 | 59 | def reset(): 60 | warning("Resetting the database") 61 | 62 | drop_all() 63 | create_all() 64 | seed_tables() 65 | -------------------------------------------------------------------------------- /bin/grace: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Grace Bot script 4 | 5 | This script contains the commands needed to manage the bot. 6 | 7 | Basic Commands: 8 | Bot Command(s): 9 | `start` : Starts the bot (`ctrl+c` to stop the bot) 10 | Database Command(s): 11 | `db create` : Creates the database and the tables 12 | `db delete` : Deletes the tables and the database 13 | `db seed` : Seeds the database tables (Initialize the default values) 14 | 15 | The working environment can be changed by passing `-e` argument with any valid environments. 16 | Available environment are production, development and test. The default one, is production. 17 | 18 | Application commands syncing can be disabled by passing `--no-sync`. By default, commands 19 | will automatically be synced, and it might take some time. It can be useful to disable it 20 | when the application is restarted often, for example when testing a feature, trying to fix a bug, etc. 21 | """ 22 | 23 | import discord 24 | from os import getpid 25 | from argparse import ArgumentParser, BooleanOptionalAction 26 | from bin.database import * 27 | 28 | 29 | APP_INFO = """ 30 | | Discord.py version: {discord_version} 31 | | PID: {pid} 32 | | Environment: {env} 33 | | Using database: {database} with {dialect} 34 | | Syncing command: {command_sync} 35 | Enter CTRL+C to stop 36 | """.rstrip() 37 | 38 | 39 | def _load_application(env, command_sync): 40 | """Set the environment by the given string (Available: 'production', 'development', 'test')""" 41 | app.load(env, command_sync=command_sync) 42 | 43 | info(APP_INFO.format( 44 | discord_version=discord.__version__, 45 | env=env, 46 | pid=getpid(), 47 | database=app.database_infos["database"], 48 | dialect=app.database_infos["dialect"], 49 | command_sync=command_sync, 50 | )) 51 | 52 | 53 | def start(): 54 | import bot.grace 55 | 56 | if not app.database_exists: 57 | create_all() 58 | seed_tables() 59 | 60 | bot.grace.start() 61 | 62 | 63 | def init(): 64 | if not app.database_exists: 65 | create_all() 66 | seed_tables() 67 | 68 | 69 | if __name__ == '__main__': 70 | commands = { 71 | 'start': start, 72 | "db create": create_all, 73 | "db drop": drop_all, 74 | "db seed": seed_tables, 75 | "db reset": reset, 76 | } 77 | parser = ArgumentParser() 78 | 79 | parser.add_argument('command', type=str, nargs="*", help="[CATEGORY (Optional)] [ACTION]") 80 | parser.add_argument( 81 | '-e', 82 | type=str, 83 | choices=["production", "development", "test"], 84 | help="-e environment (The default is production)" 85 | ) 86 | parser.add_argument( 87 | '--sync', 88 | action=BooleanOptionalAction, 89 | help="--sync Enables command sync (default), --no-sync Disables command sync") 90 | 91 | args = parser.parse_args() 92 | command = commands.get(' '.join(args.command)) 93 | environment = args.e or "production" 94 | sync = args.sync 95 | 96 | if sync is None: 97 | sync = True 98 | 99 | if command: 100 | _load_application(environment, sync) 101 | command() 102 | else: 103 | parser.error("Command not recognized") 104 | -------------------------------------------------------------------------------- /bin/heroku_start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | generate_config() { 4 | config_path=/app/config/database.cfg 5 | 6 | if [ ! -f $config_path ]; then 7 | cat /app/bin/templates/heroku.database.template.cfg > $config_path 8 | fi 9 | } 10 | 11 | init() { 12 | table_count=$(psql -qAt "$DATABASE_URL" -c "select count(*) from information_schema.tables where table_schema = 'public';") 13 | 14 | if [ $table_count -eq "0" ]; then 15 | echo "Configuring the database" 16 | 17 | grace db create 18 | grace db seed 19 | else 20 | alembic upgrade head 21 | grace db seed 22 | fi 23 | } 24 | 25 | generate_config 26 | 27 | pip install . 28 | init 29 | grace start -------------------------------------------------------------------------------- /bin/templates/default.database.template.cfg: -------------------------------------------------------------------------------- 1 | ; This file was generated with love by Grace. 2 | [database.production] 3 | url = ${DATABASE_URL} 4 | 5 | [database.development] 6 | adapter = sqlite 7 | database = grace_development.db 8 | 9 | [database.test] 10 | adapter = sqlite 11 | database = grace_test.db -------------------------------------------------------------------------------- /bin/templates/heroku.database.template.cfg: -------------------------------------------------------------------------------- 1 | [database.production] 2 | url = ${DATABASE} 3 | 4 | [database.database] 5 | url = ${DATABASE} 6 | 7 | [database.test] 8 | url = ${DATABASE} -------------------------------------------------------------------------------- /bot/__init__.py: -------------------------------------------------------------------------------- 1 | from config.application import Application 2 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 3 | 4 | 5 | app = Application() 6 | scheduler = AsyncIOScheduler() 7 | -------------------------------------------------------------------------------- /bot/classes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/bot/classes/__init__.py -------------------------------------------------------------------------------- /bot/classes/recurrence.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | 3 | 4 | @unique 5 | class Recurrence(Enum): 6 | NONE = 0 7 | DAILY = 1 8 | WEEKLY = 2 9 | MONTHLY = 3 10 | 11 | def __str__(self): 12 | return self.name.capitalize() 13 | 14 | -------------------------------------------------------------------------------- /bot/classes/state.py: -------------------------------------------------------------------------------- 1 | from enum import Enum, unique 2 | 3 | 4 | @unique 5 | class State(Enum): 6 | DISABLED = 0 7 | ENABLED = 1 8 | 9 | def __str__(self): 10 | return self.name.capitalize() 11 | 12 | -------------------------------------------------------------------------------- /bot/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/bot/extensions/__init__.py -------------------------------------------------------------------------------- /bot/extensions/bookmark_cog.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from discord import Embed, Message, Interaction, File 3 | from discord.app_commands import ContextMenu 4 | from discord.ext.commands import Cog 5 | from bot.grace import Grace 6 | 7 | 8 | class BookmarkCog(Cog): 9 | def __init__(self, bot: Grace) -> None: 10 | self.bot: Grace = bot 11 | 12 | save_message_ctx_menu: ContextMenu = ContextMenu( 13 | name='Save Message', 14 | callback=self.save_message 15 | ) 16 | 17 | self.bot.tree.add_command(save_message_ctx_menu) 18 | 19 | async def get_message_files(self, message: Message) -> List[File]: 20 | """Fetch files from the message attachments 21 | 22 | :param message: Message to fetch files from 23 | :type message: Message 24 | 25 | :return: List of files 26 | :rtype: List[File] 27 | """ 28 | return list(map(lambda attachment: attachment.to_file(), message.attachments)) 29 | 30 | async def save_message(self, interaction: Interaction, message: Message) -> None: 31 | """Saves the message 32 | 33 | :param interaction: ContextMenu command interaction 34 | :type interaction: Interaction 35 | :param message: Message of the interaction 36 | :type message: Message 37 | """ 38 | sent_at: int = int(message.created_at.timestamp()) 39 | files: List[File] = await self.get_message_files(message) 40 | 41 | save_embed: Embed = Embed( 42 | title='Bookmark Info', 43 | color=self.bot.default_color 44 | ) 45 | 46 | save_embed.add_field(name="Sent By", value=message.author, inline=False) 47 | save_embed.add_field(name="Sent At", value=f'', inline=False) 48 | save_embed.add_field(name="Original Message", value=f'[Jump]({message.jump_url})', inline=False) 49 | 50 | await interaction.user.send(embed=save_embed) 51 | await interaction.user.send(message.content, embeds=message.embeds, files=files) 52 | await interaction.response.send_message("Message successfully saved.", ephemeral=True) 53 | 54 | 55 | async def setup(bot: Grace) -> None: 56 | await bot.add_cog(BookmarkCog(bot)) 57 | -------------------------------------------------------------------------------- /bot/extensions/code_generator_cog.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import Cog, hybrid_command 2 | from discord.app_commands import Choice, autocomplete 3 | from discord import Embed, Interaction 4 | from openai.api_resources.completion import Completion 5 | import openai 6 | from lib.config_required import cog_config_required 7 | 8 | 9 | LANGUAGES = [ 10 | "Python", "C", "C++", "Java", "Csharp", "R", "Ruby", "JavaScript", "Swift", 11 | "Go", "Kotlin", "Rust", "PHP", "ObjectiveC", "SQL", "Lisp", "Perl", 12 | "Haskell", "Erlang", "Scala", "Clojure", "Julia", "Elixir", "F#", "Bash" 13 | ] 14 | 15 | 16 | async def language_autocomplete(_: Interaction, current: str) -> list[Choice[str]]: 17 | """Provide autocomplete suggestions for programming languages name. 18 | 19 | :param _: The interaction object. 20 | :type _: Interaction 21 | :param current: The current value of the input field. 22 | :type current: str 23 | :return: A list of `Choice` objects containing languages name. 24 | :rtype: list[Choice[str]] 25 | """ 26 | return [ 27 | Choice(name=lang.capitalize(), value=lang.capitalize()) 28 | for lang in LANGUAGES if current.lower() in lang.lower() 29 | ] 30 | 31 | 32 | @cog_config_required("openai", "api_key", "Generate yours [here](https://beta.openai.com/account/api-keys)") 33 | class CodeGenerator( 34 | Cog, 35 | name="OpenAI", 36 | description="Generate code using OpenAI API by providing a comment and language." 37 | ): 38 | """A Cog that generate code using text.""" 39 | def __init__(self, bot): 40 | self.bot = bot 41 | self.api_key = self.required_config 42 | 43 | @hybrid_command( 44 | name='code', 45 | help='Generate code by providing a comment and language.', 46 | usage="language={programming_language} comment={sentence}" 47 | ) 48 | @autocomplete(language=language_autocomplete) 49 | async def code_generator(self, ctx, *, language: str, comment: str) -> None: 50 | """Generate code using OpenAI API by providing a comment and language. 51 | 52 | :param ctx: The context object. 53 | :type ctx: Context 54 | :param language: The programming language to generate code. 55 | :type language: str 56 | :param comment: The comment to generate code. 57 | :type comment: str 58 | :return: None 59 | """ 60 | # ---- Get you KEY API [here](https://beta.openai.com/account/api-keys) ---- # 61 | openai.api_key = self.api_key 62 | 63 | embed = Embed(color=self.bot.default_color) 64 | await ctx.defer(ephemeral=True) 65 | 66 | response = Completion.create( 67 | model="text-davinci-003", 68 | prompt=f"{comment} in {language}", 69 | temperature=0.7, 70 | max_tokens=256, 71 | top_p=1, 72 | frequency_penalty=0, 73 | presence_penalty=0, 74 | ) 75 | 76 | code_generated = response["choices"][0]["text"] 77 | embed.add_field( 78 | name=comment.capitalize(), 79 | value=f"```{language}{code_generated}``` {ctx.author} | {language}", 80 | inline=False 81 | ) 82 | 83 | await ctx.send(embed=embed) 84 | 85 | 86 | async def setup(bot): 87 | await bot.add_cog(CodeGenerator(bot)) 88 | -------------------------------------------------------------------------------- /bot/extensions/color_cog.py: -------------------------------------------------------------------------------- 1 | import os 2 | from PIL import Image 3 | from discord.ext.commands import Cog, hybrid_group, HybridCommandError, CommandInvokeError, Context 4 | from discord import Embed, File, Color 5 | from bot.helpers.error_helper import send_command_error 6 | from typing import Union, Tuple 7 | 8 | 9 | def get_embed_color(color: Union[Tuple[int, int, int], str]) -> Color: 10 | """Convert a color to an Embed Color object. 11 | 12 | :param color: A tuple of 3 integers in the range 0-255 representing an RGB 13 | color, or a string in the format '#RRGGBB' representing a 14 | hexadecimal color. 15 | :type color: Union[Tuple[int, int, int], str] 16 | :return: An Embed Color object representing the input color. 17 | :rtype: Color 18 | """ 19 | if isinstance(color, tuple): 20 | return Color.from_rgb(*color) 21 | return Color.from_str(color) 22 | 23 | 24 | class ColorCog(Cog, name="Color", description="Collection of commands to bring color in your life."): 25 | """A Discord Cog that provides a set of commands to display colors.""" 26 | def __init__(self, bot): 27 | self.bot = bot 28 | 29 | @hybrid_group(name="color", help="Commands to bring color in your life") 30 | async def color_group(self, ctx: Context) -> None: 31 | """Group command for the color commands. If called without a subcommand, 32 | it sends the help message. 33 | 34 | :param ctx: The context of the command invocation. 35 | :type ctx: Context 36 | """ 37 | if ctx.invoked_subcommand is None: 38 | await ctx.send_help(ctx.command) 39 | 40 | @color_group.group(name="show", help="Commands to display colors.") 41 | async def show_group(self, ctx: Context) -> None: 42 | """Group command for the show subcommands. If called without a subcommand, 43 | it sends the help message. 44 | 45 | :param ctx: The context of the command invocation. 46 | :type ctx: Context 47 | """ 48 | if ctx.invoked_subcommand is None: 49 | await ctx.send_help(ctx.command) 50 | 51 | async def display_color(self, ctx: Context, color: Union[Tuple[int, int, int], str]) -> None: 52 | """Display a color in an embed message. 53 | 54 | :param ctx: The context of the command invocation. 55 | :type ctx: Context 56 | :param color: A tuple of 3 integers in the range 0-255 representing an 57 | RGB color, or a string in the format '#RRGGBB' representing 58 | a hexadecimal color. 59 | :type color: Union[Tuple[int, int, int], str] 60 | """ 61 | colored_image = Image.new('RGB', (200, 200), color) 62 | colored_image.save('color.png') 63 | file = File('color.png') 64 | 65 | embed = Embed( 66 | color=get_embed_color(color), 67 | title='Here goes your color!', 68 | description=f"{color}" 69 | ) 70 | embed.set_image(url="attachment://color.png") 71 | 72 | await ctx.send(embed=embed, file=file) 73 | os.remove('color.png') 74 | 75 | @show_group.command( 76 | name='rgb', 77 | help="Displays the RGB color entered by the user.", 78 | usage="color show rgb {red integer} {green integer} {blue integer}" 79 | ) 80 | async def rgb_command(self, ctx: Context, r: int, g: int, b: int) -> None: 81 | """Display an RGB color in an embed message. 82 | 83 | :param ctx: The context of the command invocation. 84 | :type ctx: Context 85 | :param r: The red component of the color (0-255). 86 | :type r: int 87 | :param g: The green component of the color (0-255). 88 | :type g: int 89 | :param b: The blue component of the color (0-255). 90 | :type b: int 91 | """ 92 | await self.display_color(ctx, (r, g, b)) 93 | 94 | @rgb_command.error 95 | async def rgb_command_error(self, ctx: Context, error: Exception) -> None: 96 | """Event listener for errors that occurred during the execution of the 97 | 'rgb' command. It sends an error message to the user. 98 | 99 | :param ctx: The context of the command invocation. 100 | :type ctx: Context 101 | :param error: The error that was raised during command execution. 102 | :type error: Exception 103 | """ 104 | if isinstance(error, HybridCommandError) or isinstance(error, CommandInvokeError): 105 | await send_command_error(ctx, "Expected rgb color", ctx.command, "244 195 8") 106 | 107 | @show_group.command( 108 | name='hex', 109 | help="Displays the color of the hexcode entered by the user.", 110 | usage="color show hex {hexadecimal string}" 111 | ) 112 | async def hex_command(self, ctx: Context, hex_code: str) -> None: 113 | """Display a color in an embed message using a hexadecimal color code. 114 | 115 | :param ctx: The context of the command invocation. 116 | :type ctx: Context 117 | :param hex_code: A string in the format '#RRGGBB' representing a hexadecimal color. 118 | :type hex_code: str 119 | """ 120 | if not hex_code.startswith('#'): 121 | hex_code = f'#{hex_code}' 122 | await self.display_color(ctx, hex_code) 123 | 124 | @hex_command.error 125 | async def hex_command_error(self, ctx: Context, error: Exception) -> None: 126 | """Event listener for errors that occurred during the execution of the 127 | 'hex' command. It sends an error message to the user. 128 | 129 | :param ctx: The context of the command invocation. 130 | :type ctx: Context 131 | :param error: The error that was raised during command execution. 132 | :type error: Exception 133 | """ 134 | if isinstance(error, HybridCommandError) or isinstance(error, CommandInvokeError): 135 | await send_command_error(ctx, "Expected hexadecimal color", ctx.command, "#F4C308") 136 | 137 | 138 | async def setup(bot): 139 | await bot.add_cog(ColorCog(bot)) 140 | -------------------------------------------------------------------------------- /bot/extensions/command_error_handler.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from logging import warning 3 | from discord.ext.commands import Cog, \ 4 | MissingRequiredArgument, \ 5 | CommandNotFound, \ 6 | MissingPermissions, \ 7 | CommandOnCooldown, \ 8 | DisabledCommand, HybridCommandError, Context 9 | from bot.helpers.error_helper import send_error 10 | from typing import Any, Coroutine, Optional 11 | from discord import Interaction 12 | from lib.config_required import MissingRequiredConfigError 13 | 14 | 15 | class CommandErrorHandler(Cog): 16 | """A Discord Cog that listens for command errors and sends an appropriate message to the user.""" 17 | def __init__(self, bot): 18 | self.bot = bot 19 | 20 | @Cog.listener("on_command_error") 21 | async def get_command_error(self, ctx: Context, error: Exception) -> None: 22 | """Event listener for command errors. It logs the error and sends an appropriate message to the user. 23 | 24 | :param ctx: The context of the command invocation. 25 | :type ctx: Context 26 | :param error: The error that was raised during command execution. 27 | :type error: Exception 28 | """ 29 | warning(f"Error: {error}. Issued by {ctx.author}") 30 | 31 | if isinstance(error, CommandNotFound): 32 | await send_command_help(ctx) 33 | elif isinstance(error, MissingRequiredConfigError): 34 | await send_error(ctx, error) 35 | elif isinstance(error, MissingPermissions): 36 | await send_error(ctx, "You don't have the authorization to use that command.") 37 | elif isinstance(error, CommandOnCooldown): 38 | await send_error(ctx, f"You're on Cooldown, wait {timedelta(seconds=int(error.retry_after))}") 39 | elif isinstance(error, DisabledCommand): 40 | await send_error(ctx, "This command is disabled.") 41 | elif isinstance(error, MissingRequiredArgument): 42 | await send_command_help(ctx) 43 | elif isinstance(error, HybridCommandError): 44 | await self.get_app_command_error(ctx.interaction, error) 45 | 46 | @Cog.listener("on_app_command_error") 47 | async def get_app_command_error(self, interaction: Optional[Interaction], _: Exception) -> None: 48 | """Event listener for command errors that occurred during an interaction. 49 | It sends an error message to the user. 50 | 51 | :param interaction: The interaction where the error occurred. 52 | :type interaction: Interaction 53 | :param _ : The error that was raised during command execution. 54 | :type _: Exception 55 | """ 56 | if interaction and interaction.is_expired(): 57 | await interaction.response.send_message("Interaction failed, please try again later!", ephemeral=True) 58 | 59 | 60 | def send_command_help(ctx: Context) -> Coroutine[Any, Any, Any]: 61 | """Send the help message for the command that raised an error, or 62 | the general help message if no specific command was involved. 63 | 64 | :param ctx: The context of the command invocation. 65 | :type ctx: The context 66 | :return: The help message. 67 | :rtype: Coroutine[Any, Any, Any] 68 | """ 69 | if ctx.command: 70 | return ctx.send_help(ctx.command) 71 | return ctx.send_help() 72 | 73 | 74 | async def setup(bot): 75 | await bot.add_cog(CommandErrorHandler(bot)) -------------------------------------------------------------------------------- /bot/extensions/extension_cog.py: -------------------------------------------------------------------------------- 1 | from discord import Embed 2 | from discord.app_commands import Choice, autocomplete 3 | from discord.ext.commands import Cog, has_permissions, ExtensionAlreadyLoaded, ExtensionNotLoaded, hybrid_group, Context 4 | from emoji import emojize 5 | from bot.classes.state import State 6 | from bot.extensions.command_error_handler import CommandErrorHandler 7 | from bot.models.extension import Extension 8 | from typing import List 9 | 10 | 11 | def extension_autocomplete(state: bool): 12 | """Creates an autocomplete function for extensions based on the provided `state`. 13 | 14 | :param state: The state of the extensions to create the autocomplete function for. 15 | :type state: bool 16 | :return: An autocomplete function. 17 | """ 18 | async def inner_autocomplete(_, current: str) -> List[Choice]: 19 | """Autocomplete function for extensions. 20 | 21 | :param current: The current word being autocompleted. 22 | :type current: str 23 | :return: A list of `Choice` objects for autocompleting the extension names. 24 | :rtype: List[Choice] 25 | """ 26 | def create_choice(extension: Extension) -> Choice: 27 | """Creates a `Choice` object for the provided `extension`. 28 | 29 | :param extension: The extension to create the `Choice` object for. 30 | :type extension: Extension 31 | :return: A `Choice` object for the provided `extension`. 32 | :rtype: Choice 33 | """ 34 | state_emoji = emojize(':green_circle:') if extension.is_enabled() else emojize(':red_circle:') 35 | return Choice(name=f"{state_emoji} {extension.name}", value=extension.module_name) 36 | return list(map(create_choice, Extension.by_state(state).filter(Extension.module_name.ilike(f"%{current}%")))) 37 | return inner_autocomplete 38 | 39 | 40 | class ExtensionCog(Cog, name="Extensions", description="Extensions managing cog"): 41 | """A `Cog` for managing extensions.""" 42 | def __init__(self, bot): 43 | self.bot = bot 44 | 45 | @hybrid_group(name="extension", aliases=["ext", "e"], help="Commands to manage extensions") 46 | @has_permissions(administrator=True) 47 | async def extension_group(self, ctx: Context) -> None: 48 | """The command group for managing extensions. 49 | 50 | :param ctx: The context in which the command was called. 51 | :type ctx: Context 52 | """ 53 | if ctx.invoked_subcommand is None: 54 | await CommandErrorHandler.send_command_help(ctx) 55 | 56 | @extension_group.command(name="list", aliases=["l"], help="Display the list of extensions") 57 | @has_permissions(administrator=True) 58 | async def list_extensions_command(self, ctx: Context) -> None: 59 | """Display the list of extensions in an embed message, indicating their current state (enabled or disabled). 60 | 61 | :param ctx: The context in which the command was called. 62 | :type ctx: Context. 63 | """ 64 | extensions = Extension.all() 65 | 66 | embed = Embed( 67 | color=self.bot.default_color, 68 | title="Extensions" 69 | ) 70 | 71 | for extension in extensions: 72 | state_emoji = emojize(':green_circle:') if extension.is_enabled() else emojize(':red_circle:') 73 | 74 | embed.add_field( 75 | name=f"{state_emoji} {extension.name}", 76 | value=f"**State**: {extension.state}", 77 | inline=False 78 | ) 79 | 80 | if not extensions: 81 | embed.description = "No extension found" 82 | 83 | await ctx.send(embed=embed, ephemeral=True) 84 | 85 | @extension_group.command(name="enable", aliases=["e"], help="Enable a given extension", usage="{extension_id}") 86 | @has_permissions(administrator=True) 87 | @autocomplete(extension_name=extension_autocomplete(State.DISABLED)) 88 | async def enable_extension_command(self, ctx: Context, extension_name: str) -> None: 89 | """Enable a given extension by its module name. 90 | 91 | :param ctx: The context in which the command was called. 92 | :type ctx: Context. 93 | :param extension_name: The module name of the extension to enable. 94 | :type extension_name: str 95 | """ 96 | extension = Extension.get_by(module_name=extension_name) 97 | 98 | if extension: 99 | try: 100 | await self.bot.load_extension(extension.module) 101 | extension.state = State.ENABLED 102 | 103 | extension.save() 104 | await ctx.send(f"**{extension.name}** enabled.", ephemeral=True) 105 | except ExtensionAlreadyLoaded: 106 | await ctx.send(f"**{extension.name}** already enabled", ephemeral=True) 107 | else: 108 | await ctx.send(f"Extension **{extension_name}** not found", ephemeral=True) 109 | 110 | @extension_group.command(name="disable", aliases=["d"], help="Disable a given extension", usage="{extension_id}") 111 | @has_permissions(administrator=True) 112 | @autocomplete(extension_name=extension_autocomplete(State.ENABLED)) 113 | async def disable_extension_command(self, ctx: Context, extension_name: str) -> None: 114 | """Disable a given extension by its module name. 115 | 116 | :param ctx: The context in which the command was called. 117 | :type ctx: Context. 118 | :param extension_name: The module name of the extension to disable. 119 | :type extension_name: str 120 | """ 121 | extension = Extension.get_by(module_name=extension_name) 122 | 123 | if extension: 124 | try: 125 | await self.bot.unload_extension(extension.module) 126 | extension.state = State.DISABLED 127 | 128 | extension.save() 129 | await ctx.send(f"**{extension.name}** disabled.", ephemeral=True) 130 | except ExtensionNotLoaded: 131 | await ctx.send(f"**{extension.name}** already disabled", ephemeral=True) 132 | else: 133 | await ctx.send(f"Extension **{extension_name}** not found", ephemeral=True) 134 | 135 | 136 | async def setup(bot): 137 | await bot.add_cog(ExtensionCog(bot)) 138 | -------------------------------------------------------------------------------- /bot/extensions/fun_cog.py: -------------------------------------------------------------------------------- 1 | from json import loads 2 | from discord.ext.commands.cooldowns import BucketType 3 | from discord.ext.commands import Cog, cooldown, hybrid_group, Context 4 | from discord import Embed, Colour 5 | from requests import get 6 | from random import choice as random_choice 7 | from bot.extensions.command_error_handler import CommandErrorHandler 8 | from bot.models.extensions.fun.answer import Answer 9 | 10 | 11 | class FunCog(Cog, name="Fun", description="Collection of fun commands"): 12 | """A cog containing fun commands.""" 13 | def __init__(self, bot): 14 | self.bot = bot 15 | self.goosed_gif_links = [ 16 | 'https://media.tenor.com/XG_ZOTYukysAAAAC/goose.gif', 17 | 'https://media.tenor.com/pSnSQRfiIP8AAAAd/birds-kid.gif', 18 | 'https://media.tenor.com/GDkgAup55_0AAAAC/duck-bite.gif' 19 | ] 20 | 21 | @hybrid_group(name="fun", help="Fun commands") 22 | async def fun_group(self, ctx: Context) -> None: 23 | """Group of fun commands. 24 | 25 | :param ctx: The context in which the command was called. 26 | :type ctx: Context 27 | """ 28 | if ctx.invoked_subcommand is None: 29 | await CommandErrorHandler.send_command_help(ctx) 30 | 31 | @fun_group.command(name='eightball', aliases=['8ball'], help="Ask a question and be answered.", usage="{question}") 32 | @cooldown(4, 30, BucketType.user) 33 | async def eightball_command(self, ctx: Context, question: str) -> None: 34 | """Ask a question and get an answer. 35 | 36 | :param ctx: The context in which the command was called. 37 | :type ctx: Context 38 | :param question: The question asked by the user. 39 | :type question: str 40 | """ 41 | if question: 42 | answer = random_choice(Answer.all()) 43 | else: 44 | answer = "You need to ask me a question!" 45 | 46 | answer_embed = Embed( 47 | title=f'{ctx.author.name}, Grace says: ', 48 | color=self.bot.default_color, 49 | description=answer.answer, 50 | ) 51 | 52 | await ctx.send(embed=answer_embed) 53 | 54 | @fun_group.command(name='goosed', help='Go goose yourself') 55 | async def goose_command(self, ctx: Context) -> None: 56 | """Send a Goose image. 57 | 58 | :param ctx: The context in which the command was called. 59 | :type ctx: Context 60 | """ 61 | goosed_embed = Embed( 62 | color=self.bot.default_color, 63 | title='**GET GOOSED**', 64 | ) 65 | goosed_embed.set_image(url=random_choice(self.goosed_gif_links)) 66 | await ctx.send(embed=goosed_embed) 67 | 68 | @fun_group.command(name='quote', help='Sends an inspirational quote') 69 | async def quote_command(self, ctx: Context) -> None: 70 | """Generate a random inspirational quote. 71 | 72 | :param ctx: The context in which the command was called. 73 | :type ctx: Context 74 | """ 75 | response = get('https://api.forismatic.com/api/1.0/?method=getQuote&format=json&lang=en') 76 | 77 | if response.ok: 78 | quote = '{quoteText} \n-- {quoteAuthor}'.format(**loads(response.text)) 79 | 80 | embed = Embed( 81 | color=self.bot.default_color, 82 | description=quote, 83 | ) 84 | 85 | await ctx.send(embed=embed) 86 | else: 87 | await ctx.send("Unable to fetch a quote! Try again later.") 88 | 89 | 90 | async def setup(bot): 91 | await bot.add_cog(FunCog(bot)) 92 | -------------------------------------------------------------------------------- /bot/extensions/grace_cog.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import Cog, hybrid_command, Context 2 | from discord.ui import Button, View 3 | from emoji import emojize 4 | from bot.helpers import send_error 5 | from bot.helpers.github_helper import create_contributors_embeds, create_repository_button, available_project_names 6 | from bot.services.github_service import GithubService 7 | from lib.config_required import command_config_required 8 | from lib.paged_embeds import PagedEmbedView 9 | from discord.app_commands import Choice, autocomplete 10 | from discord import Embed, Interaction 11 | 12 | 13 | async def project_autocomplete(_: Interaction, current: str) -> list[Choice[str]]: 14 | """Provide autocomplete suggestions for the Code Society Lab Projects. 15 | 16 | :param _: The interaction object. 17 | :type _: Interaction 18 | :param current: The current value of the input field. 19 | :type current: str 20 | :return: A list of `Choice` objects containing project name. 21 | :rtype: list[Choice[str]] 22 | """ 23 | return [ 24 | Choice(name=project, value=project) 25 | for project in available_project_names() if current.lower() in project.lower() 26 | ] 27 | 28 | 29 | class GraceCog(Cog, name="Grace", description="Default grace commands"): 30 | """A cog that contains default commands for the Grace bot.""" 31 | __CODE_SOCIETY_WEBSITE_BUTTON = Button( 32 | emoji=emojize(":globe_with_meridians:"), 33 | label="Website", 34 | url="https://codesociety.xyz" 35 | ) 36 | 37 | def __init__(self, bot): 38 | self.bot = bot 39 | 40 | @hybrid_command(name='info', help='Show information about the bot') 41 | async def info_command(self, ctx: Context, ephemeral=True) -> None: 42 | """Show information about the bot. 43 | 44 | :param ctx: The context in which the command was called. 45 | :type ctx: Context 46 | :param ephemeral: A flag indicating whether the message should be sent as an ephemeral message. Default is True. 47 | :type ephemeral: bool, optional 48 | """ 49 | if ctx.interaction: 50 | await ctx.interaction.response.defer() 51 | 52 | info_embed = Embed( 53 | color=self.bot.default_color, 54 | title=f"My name is Grace", 55 | description=f"Hi, {ctx.author.mention}. I'm the official **Code Society** Discord Bot.\n", 56 | ) 57 | 58 | info_embed.add_field( 59 | name="Fun fact about me", 60 | value=f"I'm named after [Grace Hopper](https://en.wikipedia.org/wiki/Grace_Hopper) {emojize(':rabbit:')}", 61 | inline=False 62 | ) 63 | 64 | info_embed.add_field( 65 | name=f"{emojize(':test_tube:')} Code Society Lab", 66 | value=f"Contribute to our [projects](https://github.com/Code-Society-Lab/grace)\n", 67 | inline=True 68 | ) 69 | 70 | info_embed.add_field( 71 | name=f"{emojize(':crossed_swords:')} Codewars", 72 | value=f"Set your clan to **CodeSoc**\n", 73 | inline=True 74 | ) 75 | 76 | info_embed.add_field( 77 | name="Need help?", 78 | value=f"Send '{ctx.prefix}help'", 79 | inline=False 80 | ) 81 | 82 | view = PagedEmbedView([info_embed]) 83 | view.add_item(self.__CODE_SOCIETY_WEBSITE_BUTTON) 84 | 85 | if GithubService.can_connect(): 86 | repository = GithubService().get_code_society_lab_repo("grace") 87 | view.add_item(create_repository_button(repository)) 88 | 89 | for embed in create_contributors_embeds(repository): 90 | view.add_embed(embed) 91 | 92 | await view.send(ctx, ephemeral=ephemeral) 93 | 94 | @hybrid_command(name='ping', help='Shows the bot latency') 95 | async def ping_command(self, ctx: Context) -> None: 96 | """Show the bot latency. 97 | 98 | :param ctx: The context in which the command was called. 99 | :type ctx: Context 100 | """ 101 | embed = Embed( 102 | color=self.bot.default_color, 103 | description=f"pong :ping_pong: {round(self.bot.latency * 1000)}ms", 104 | ) 105 | 106 | await ctx.send(embed=embed) 107 | 108 | @hybrid_command(name='hopper', help='The legend of Grace Hopper') 109 | async def hopper_command(self, ctx: Context) -> None: 110 | """Show a link to a comic about Grace Hopper. 111 | 112 | :param ctx: The context in which the command was called. 113 | :type ctx: Context 114 | :return: None 115 | """ 116 | await ctx.send("https://www.smbc-comics.com/?id=2516") 117 | 118 | @command_config_required("github", "api_key") 119 | @hybrid_command(name="contributors", description="Show a list of Code Society Lab's contributors") 120 | @autocomplete(project=project_autocomplete) 121 | async def contributors(self, ctx: Context, project: str) -> None: 122 | """Show a list of contributors for the Code Society Lab repositories. 123 | 124 | :param ctx: The context in which the command was called. 125 | :type ctx: Context 126 | :param project: The project's name to get contributors. 127 | :type project: str 128 | """ 129 | if ctx.interaction: 130 | await ctx.interaction.response.defer() 131 | 132 | if project not in available_project_names(): 133 | return await send_error(ctx, f"Project '_{project}_' not found.") 134 | 135 | repository = GithubService().get_code_society_lab_repo(project) 136 | embeds = create_contributors_embeds(repository) 137 | view = PagedEmbedView(embeds) 138 | 139 | view.add_item(self.__CODE_SOCIETY_WEBSITE_BUTTON) 140 | view.add_item(create_repository_button(repository)) 141 | 142 | await view.send(ctx) 143 | 144 | 145 | async def setup(bot): 146 | await bot.add_cog(GraceCog(bot)) 147 | -------------------------------------------------------------------------------- /bot/extensions/language_cog.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import Cog, has_permissions, hybrid_group, Context 2 | from discord import Message, Embed 3 | from logging import warning 4 | from nltk.tokenize import TweetTokenizer 5 | from nltk.sentiment.vader import SentimentIntensityAnalyzer 6 | from bot.models.extensions.language.trigger import Trigger 7 | 8 | 9 | class LanguageCog(Cog, name="Language", description="Analyze and reacts to messages"): 10 | def __init__(self, bot): 11 | """ 12 | I know not everyone working here is familiar with NLTK, so I'll explain some terminology. 13 | Not to be confused with Auth Tokens, tokenization just means splitting the natural language 14 | into discrete meaningful chunks, usually it's words, but words like "it's" or "ain't" will be 15 | split into "it is" and "are not". 16 | We're using the casual tokenizer for now, but it can be changed down the line so long as you're 17 | aware of any new behaviors. https://www.nltk.org/api/nltk.tokenize.html 18 | """ 19 | self.bot = bot 20 | 21 | self.tokenizer = TweetTokenizer() 22 | self.sid = SentimentIntensityAnalyzer() 23 | 24 | def get_message_sentiment_polarity(self, message: Message) -> int: 25 | """ 26 | Checks sentiment of a given message 27 | :param message: A discord message to anlyze the sentiment of 28 | :type message: discord.Message 29 | :returns: 30 | -1 iff the message is more negative than positive 31 | 0 iff the message is neutral 32 | 1 iff the message is more positive than negative 33 | """ 34 | # Here we're using the VADER algorithm to determine if the message sentiment is speaking 35 | # negatively about something. We run the while message through vader and if the aggregated 36 | # score is ultimately negative, neutral, or positive 37 | sv = self.sid.polarity_scores(message.content) 38 | if sv['neu'] + sv['pos'] < sv['neg'] or sv['pos'] == 0.0: 39 | if sv['neg'] > sv['pos']: 40 | return -1 41 | return 0 42 | return 1; 43 | 44 | async def name_react(self, message: Message) -> None: 45 | """ 46 | Checks message sentiment and if the sentiment is neutral or positive, 47 | react with a positive_emoji, otherwise react with negative_emoji 48 | """ 49 | grace_trigger = Trigger.get_by(name="Grace") 50 | if grace_trigger is None: 51 | warning("Missing trigger entry for \"Grace\"") 52 | return 53 | 54 | if self.bot.user.mentioned_in(message) and not message.content.startswith('<@!'): 55 | # Note: the trigger needs to have a None-condition now that it's generic 56 | if self.get_message_sentiment_polarity(message) >= 0: 57 | await message.add_reaction(grace_trigger.positive_emoji) 58 | return 59 | await message.add_reaction(grace_trigger.negative_emoji) 60 | 61 | async def penguin_react(self, message: Message) -> None: 62 | """Checks to see if a message contains a reference to Linus (torvalds only), will be made more complicated 63 | as needed. If a linus reference is positively identified, Grace will react with a penguin emoji. 64 | I know using NLTK is kinda like bringing a tomahawk missile to a knife fight, but it may come in handy for 65 | future tasks, so the tokenizer object will be shared across all methods. 66 | 67 | :param message: A discord message to check for references to our lord and savior. 68 | :type message: discord.Message 69 | """ 70 | linus_trigger = Trigger.get_by(name="Linus") 71 | if linus_trigger is None: 72 | warning("Missing trigger entry for \"Linus\"") 73 | return 74 | 75 | message_tokens = self.tokenizer.tokenize(message.content) 76 | tokenlist = list(map(lambda s: s.lower(), message_tokens)) 77 | linustarget = [i for i, x in enumerate( 78 | tokenlist) if x in linus_trigger.words] 79 | # Get the indices of all linuses in the message 80 | 81 | if linustarget: 82 | fail = False 83 | for linusindex in linustarget: 84 | try: 85 | if tokenlist[linusindex + 1] == 'tech' and tokenlist[linusindex + 2] == 'tips': 86 | fail = True 87 | elif tokenlist[linusindex + 1] == 'and' and tokenlist[linusindex + 2] == 'lucy': 88 | fail = True 89 | except IndexError: 90 | pass 91 | 92 | determined_sentiment_polarity = self.get_message_sentiment_polarity(message) 93 | 94 | if not fail and determined_sentiment_polarity < 0: 95 | await message.add_reaction(linus_trigger.negative_emoji) 96 | return 97 | 98 | fail = (determined_sentiment_polarity < 1) 99 | 100 | if not fail: 101 | await message.add_reaction(linus_trigger.positive_emoji) 102 | 103 | @Cog.listener() 104 | async def on_message(self, message: Message) -> None: 105 | """A listener function that calls the `penguin_react`, `name_react`, and `pun_react` functions when a message 106 | is received. 107 | 108 | :param message: The message that was received. 109 | :type message: discord.Message 110 | """ 111 | await self.penguin_react(message) 112 | await self.name_react(message) 113 | 114 | @hybrid_group(name="triggers", help="Commands to manage triggers") 115 | @has_permissions(administrator=True) 116 | async def triggers_group(self, ctx) -> None: 117 | """A command group that allows administrators to manage trigger words. 118 | 119 | :param ctx: The context in which the command was called. 120 | :type ctx: discord.ext.commands.Context 121 | """ 122 | if ctx.invoked_subcommand is None: 123 | trigger = Trigger.get_by(name="Linus") 124 | if trigger is None: 125 | warning("Missing trigger entry for \"Linus\"") 126 | return 127 | 128 | embed = Embed( 129 | color=self.bot.default_color, 130 | title=f"Triggers", 131 | description="\n".join(trigger.words) 132 | ) 133 | 134 | await ctx.send(embed=embed) 135 | 136 | @triggers_group.command(name="add", help="Add a trigger word", usage="{new_word}") 137 | @has_permissions(administrator=True) 138 | async def add_trigger_word(self, ctx: Context, new_word: str) -> None: 139 | """Add a new trigger word. 140 | 141 | :param ctx: The context in which the command was called. 142 | :type ctx: discord.ext.commands.Context 143 | :param new_word: The new trigger word to be added. 144 | :type new_word: str 145 | """ 146 | trigger = Trigger.get_by(name="Linus") 147 | 148 | if trigger: 149 | if new_word in trigger.words: 150 | await ctx.send(f"**{new_word}** is already a trigger") 151 | else: 152 | trigger.add_trigger_word(new_word) 153 | 154 | await ctx.send(f"Trigger **{new_word}** added successfully") 155 | else: 156 | await ctx.send(f"Unable to add **{new_word}**") 157 | 158 | @triggers_group.command(name="remove", help="Remove a trigger word", usage="{old_word}") 159 | @has_permissions(administrator=True) 160 | async def remove_trigger_word(self, ctx: Context, old_word: str) -> None: 161 | """Remove an existing trigger word. 162 | 163 | :param ctx: The context in which the command was called. 164 | :type ctx: discord.ext.commands.Context 165 | :param old_word: The trigger word to be removed. 166 | :type old_word: str 167 | """ 168 | trigger = Trigger.get_by(name="Linus") 169 | 170 | if trigger: 171 | if old_word not in trigger.words: 172 | await ctx.send(f"**{old_word}** is not a trigger") 173 | else: 174 | trigger.remove_trigger_word(old_word) 175 | 176 | await ctx.send(f"Trigger **{old_word}** removed successfully") 177 | else: 178 | await ctx.send(f"Unable to remove **{old_word}**") 179 | 180 | 181 | async def setup(bot): 182 | await bot.add_cog(LanguageCog(bot)) 183 | -------------------------------------------------------------------------------- /bot/extensions/moderation_cog.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from bot import app 3 | from logging import info 4 | from discord import Message, Member, Reaction 5 | from discord.ext.commands import Cog, has_permissions, hybrid_command, Context 6 | from bot.helpers.log_helper import danger, notice 7 | from datetime import datetime 8 | from emoji import demojize 9 | from bot.models.channel import Channel 10 | 11 | 12 | class ModerationCog(Cog, name="Moderation", description="Collection of administrative commands."): 13 | def __init__(self, bot): 14 | self.bot = bot 15 | 16 | @property 17 | def moderation_channel(self): 18 | return self.bot.get_channel_by_name("moderation_logs") 19 | 20 | @hybrid_command(name='purge', help="Deletes n amount of messages.") 21 | @has_permissions(manage_messages=True) 22 | async def purge(self, ctx: Context, limit: int, reason: Optional[str] = "No reason given") -> None: 23 | """Purge a specified number of messages from the channel. 24 | 25 | :param ctx: The context in which the command was called. 26 | :type ctx: Context 27 | :param limit: The number of messages to be purged. 28 | :type limit: int 29 | :param reason: The reason for the purge 30 | :type reason: Optional[str] 31 | """ 32 | await ctx.defer() 33 | 34 | log = danger("PURGE", f"{limit} message(s) purged by {ctx.author.mention} in {ctx.channel.mention}") 35 | log.add_field("Reason", reason) 36 | 37 | await ctx.channel.purge(limit=int(limit) + 1, bulk=True, reason=reason) 38 | await log.send(self.moderation_channel or ctx.channel) 39 | 40 | @Cog.listener() 41 | async def on_reaction_add(self, reaction: Reaction, member: Member) -> None: 42 | message: Message = reaction.message 43 | author: Member = message.author 44 | 45 | emojis = [":SOS_button:", ":red_question_mark:"] 46 | is_already_reacted = any(filter(lambda r: r.me and demojize(r.emoji) in emojis and r.count > 0, message.reactions)) 47 | 48 | if author.bot or is_already_reacted: 49 | return None 50 | 51 | match demojize(reaction.emoji): 52 | case ":SOS_button:": 53 | await message.reply("[Don't ask to ask, just ask]()") 54 | case ":red_question_mark:": 55 | guidelines: Channel = Channel.get_by(channel_name="posting_guidelines") 56 | help: Channel = Channel.get_by(channel_name="help") 57 | 58 | if guidelines and help: 59 | await message.reply(f"If you need some help, read the <#{guidelines.channel_id}> and open a post in <#{help.channel_id}>!") 60 | case _: 61 | return None 62 | 63 | # Grace also reacts and log the reaction because some people remove their reaction afterward 64 | await message.add_reaction(reaction) 65 | 66 | log = notice("HELP REACTION", f"{member.mention} reacted to {message.jump_url} with {reaction.emoji}") 67 | await log.send(self.moderation_channel or message.channel) 68 | 69 | @Cog.listener() 70 | async def on_member_join(self, member) -> None: 71 | """A listener function that checks if a member's account age meets the minimum required age to join the server. 72 | If it doesn't, the member is kicked. 73 | 74 | :param member: The member who has just joined the server. 75 | :type member: discord.Member 76 | """ 77 | minimum_account_age = app.config.get("moderation", "minimum_account_age") 78 | account_age_in_days = (datetime.now().replace(tzinfo=None) - member.created_at.replace(tzinfo=None)).days 79 | 80 | if account_age_in_days < minimum_account_age: 81 | info(f"{member} kicked due to account age restriction!") 82 | 83 | log = danger("KICK", f"{member} has been kicked.") 84 | log.add_field("Reason: ", "Automatically kicked due to account age restriction") 85 | 86 | await member.send(f"Your account needs to be {minimum_account_age} days old or more to join the server.") 87 | await member.guild.kick(user=member, reason="Account age restriction") 88 | 89 | if self.moderation_channel: 90 | await log.send(self.moderation_channel) 91 | 92 | 93 | async def setup(bot): 94 | await bot.add_cog(ModerationCog(bot)) 95 | -------------------------------------------------------------------------------- /bot/extensions/pun_cog.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import Cog, has_permissions, hybrid_command, hybrid_group, Context 2 | from discord import Message, Embed 3 | from bot.models.bot import BotSettings 4 | from bot.models.extensions.language.pun import Pun 5 | from bot.models.extensions.language.pun_word import PunWord 6 | from nltk.tokenize import TweetTokenizer 7 | from emoji import demojize 8 | 9 | 10 | class PunCog(Cog, name="Puns", description="Automatically intrude with puns when triggered"): 11 | def __init__(self, bot): 12 | self.bot = bot 13 | 14 | self.tokenizer = TweetTokenizer() 15 | 16 | @Cog.listener() 17 | async def on_message(self, message: Message) -> None: 18 | """A listener function that calls the `pun_react` functions when a message is received. 19 | 20 | :param message: The message that was received. 21 | :type message: discord.Message 22 | """ 23 | await self.pun_react(message) 24 | 25 | async def pun_react(self, message: Message) -> None: 26 | """Add reactions and send a message in the channel if the message content contains any pun words. 27 | 28 | :param message: The message to be checked for pun words. 29 | :type message: discord.Message 30 | """ 31 | if message.author == self.bot.user: 32 | return 33 | 34 | message_tokens = self.tokenizer.tokenize(message.content) 35 | tokenlist = set(map(str.lower, message_tokens)) 36 | 37 | pun_words = PunWord.all() 38 | word_set = set(map(lambda pun_word: pun_word.word, pun_words)) 39 | 40 | matches = tokenlist.intersection(word_set) 41 | invoked_at = message.created_at.replace(tzinfo=None) 42 | 43 | if matches: 44 | matched_pun_words = set(filter(lambda pun_word: pun_word.word in matches, pun_words)) 45 | puns = map(lambda pun_word: Pun.get(pun_word.pun_id), matched_pun_words) 46 | puns = filter(lambda pun: pun.can_invoke_at_time(invoked_at), puns) 47 | puns = set(puns) # remove duplicate puns 48 | 49 | for pun_word in matched_pun_words: 50 | await message.add_reaction(pun_word.emoji()) 51 | 52 | for pun in puns: 53 | embed = Embed( 54 | color=self.bot.default_color, 55 | title=f"Gotcha", 56 | description=pun.text 57 | ) 58 | 59 | await message.channel.send(embed=embed) 60 | pun.save_last_invoked(invoked_at) 61 | 62 | @hybrid_group(name="puns", help="Commands to manage puns") 63 | @has_permissions(administrator=True) 64 | async def puns_group(self, ctx: Context) -> None: 65 | """A command group that allows administrators to manage puns words. 66 | 67 | :param ctx: The context in which the command was called. 68 | :type ctx: discord.ext.commands.Context 69 | """ 70 | 71 | @puns_group.command(name="list", help="List all puns") 72 | @has_permissions(administrator=True) 73 | async def list_puns(self, ctx: Context) -> None: 74 | if ctx.invoked_subcommand is None: 75 | pun_texts_with_ids = map(lambda pun: '{}.\t{}'.format( 76 | pun.id, pun.text), Pun.all()) 77 | 78 | embed = Embed( 79 | color=self.bot.default_color, 80 | title=f"Puns", 81 | description="\n".join(pun_texts_with_ids) 82 | ) 83 | 84 | await ctx.send(embed=embed) 85 | 86 | @puns_group.command(name="add", help="Add a pun", usage="{pun_text}") 87 | @has_permissions(administrator=True) 88 | async def add_pun(self, ctx: Context, pun_text: str) -> None: 89 | """Add a new pun word. 90 | 91 | :param ctx: The context in which the command was called. 92 | :type ctx: discord.ext.commands.Context 93 | :param pun_text: The new pun word to be added. 94 | :type pun_text: str 95 | """ 96 | Pun.create(text=pun_text) 97 | 98 | await ctx.send("Pun added.") 99 | 100 | @puns_group.command(name="remove", help="Remove a pun", usage="{pun_id}") 101 | @has_permissions(administrator=True) 102 | async def remove_pun(self, ctx: Context, pun_id: int) -> None: 103 | """Remove an old pun word. 104 | 105 | :param ctx: The context in which the command was called. 106 | :type ctx: discord.ext.commands.Context 107 | :param pun_id: The ID of the pun to which the word will be removed. 108 | :type pun_id: str 109 | """ 110 | pun = Pun.get(pun_id) 111 | 112 | if pun: 113 | await ctx.send("Pun removed.") 114 | else: 115 | await ctx.send(f"Pun with id **{pun.id}** does not exist.") 116 | 117 | @puns_group.command(name="add-word", help="Add a pun word to a pun") 118 | @has_permissions(administrator=True) 119 | async def add_pun_word(self, ctx: Context, pun_id: int, pun_word: str, emoji: str) -> None: 120 | """Add a new pun word. 121 | 122 | :param ctx: The context in which the command was called. 123 | :type ctx: discord.ext.commands.Context 124 | :param pun_id: The ID of the pun to which the word will be added. 125 | :type pun_id: int 126 | :param pun_word: The new pun word to be added. 127 | :type pun_word: str 128 | :param emoji: An emoji to be associated with the pun word. 129 | :type emoji: str 130 | """ 131 | pun = Pun.get(pun_id) 132 | 133 | if pun: 134 | if pun.has_word(pun_word): 135 | await ctx.send(f"Pun word **{pun_word}** already exists.") 136 | else: 137 | pun.add_pun_word(pun_word, demojize(emoji)) 138 | await ctx.send("Pun word added.") 139 | else: 140 | await ctx.send(f"Pun with id {pun.id} does not exist.") 141 | 142 | @puns_group.command(name="remove-word", help="Remove a pun from a pun word") 143 | @has_permissions(administrator=True) 144 | async def remove_pun_word(self, ctx: Context, id: int, pun_word: str) -> None: 145 | """Remove a new pun word. 146 | 147 | :param ctx: The context in which the command was called. 148 | :type ctx: discord.ext.commands.Context 149 | :param id: The ID of the pun to which the word will be removed. 150 | :type id: int 151 | :param pun_word: The old pun word to be removed. 152 | :type pun_word: str 153 | """ 154 | pun = Pun.get(id) 155 | 156 | if pun: 157 | if not pun.has_word(pun_word): 158 | await ctx.send(f"Pun word **{pun_word}** does not exist.") 159 | else: 160 | pun.remove_pun_word(pun_word) 161 | await ctx.send("Pun word removed.") 162 | else: 163 | await ctx.send(f"Pun with id **{pun.id}** does not exist.") 164 | 165 | @hybrid_command(name="cooldown", help="Set cooldown for puns feature in minutes.") 166 | async def set_puns_cooldown_command(self, ctx: Context, cooldown_minutes: int) -> None: 167 | settings = BotSettings.settings() 168 | settings.puns_cooldown = cooldown_minutes 169 | settings.save() 170 | 171 | await ctx.send(f"Updated cooldown to {cooldown_minutes} minutes.") 172 | 173 | 174 | async def setup(bot): 175 | await bot.add_cog(PunCog(bot)) -------------------------------------------------------------------------------- /bot/extensions/thank_cog.py: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | from discord import Member, Embed, Message 3 | from discord.ext.commands import Cog, Context, cooldown, BucketType, hybrid_group, has_permissions 4 | from bot.extensions.command_error_handler import send_command_help 5 | from bot.grace import Grace 6 | from bot.models.extensions.thank import Thank 7 | 8 | 9 | class ThankCog(Cog): 10 | """A cog containing thank you commands """ 11 | def __init__(self, bot: Grace): 12 | self.bot: Grace = bot 13 | 14 | @hybrid_group(name='thank', help='Thank commands', invoke_without_command=True) 15 | async def thank_group(self, ctx: Context) -> None: 16 | """Event listener for the `thank` command group. If no subcommand is 17 | invoked, it sends the command help to the user. 18 | 19 | :param ctx: The context of the command invocation. 20 | :type ctx: Context 21 | """ 22 | if ctx.invoked_subcommand is None: 23 | await send_command_help(ctx) 24 | 25 | @thank_group.command(name='send', description='Send a thank you to a person') 26 | @cooldown(1, 3600, BucketType.user) 27 | async def thank(self, ctx: Context, *, member: Member) -> Optional[Message]: 28 | """Send a "thank you" message to a member and increase their thank count by 1. 29 | 30 | :param ctx: The context of the command invocation. 31 | :type ctx: Context 32 | :param member: The member to thank. 33 | :type member: Member 34 | :return: Message | None 35 | """ 36 | if member.id == self.bot.user.id: 37 | return await ctx.send(f'{ctx.author.display_name}, thank you 😊', ephemeral=True) 38 | 39 | if ctx.author.id == member.id: 40 | return await ctx.send('You cannot thank yourself.', ephemeral=True) 41 | 42 | thank: Thank = Thank.get_by(member_id=member.id) 43 | 44 | if thank: 45 | thank.count += 1 46 | thank.save() 47 | else: 48 | thank = Thank.create(member_id=member.id, count=1) 49 | 50 | thank_embed: Embed = Embed( 51 | title='INFO', 52 | color=self.bot.default_color, 53 | description=f'{member.display_name}, you were thanked by **{ctx.author.display_name}**\n' 54 | f'Now, your thank count is: **{thank.count}**' 55 | ) 56 | 57 | await member.send(embed=thank_embed) 58 | await ctx.interaction.response.send_message(f'Successfully thanked **@{member.display_name}**', ephemeral=True) 59 | 60 | @thank_group.command(name='leaderboard', description='Shows top n helpers.') 61 | async def thank_leaderboard(self, ctx: Context, *, top: int = 10) -> Optional[Message]: 62 | """Display the top n helpers, sorted by their thank count. 63 | 64 | :param ctx: The context of the command invocation. 65 | :type ctx: Context 66 | :param top: The number of top helpers to display. Default is 10. 67 | :type top: int (optional) 68 | :return: Message | None 69 | """ 70 | helpers: List[Thank] = Thank.ordered() 71 | 72 | if not helpers: 73 | return await ctx.reply('No helpers found.', ephemeral=True) 74 | 75 | top = min(len(helpers), top) 76 | if top <= 0: 77 | return await ctx.reply('The top parameter must have value of at least 1.', ephemeral=True) 78 | 79 | leaderboard_embed: Embed = Embed( 80 | title=f'Helpers Leaderboard Top {top}', 81 | description='', 82 | color=self.bot.default_color 83 | ) 84 | 85 | for position in range(top): 86 | member = helpers[position] 87 | member_nickname = (await self.bot.fetch_user(member.member_id)).display_name 88 | leaderboard_embed.description += '{}. **{}**: **{}** with {} thank(s).\n'.format( 89 | position + 1, 90 | member_nickname, 91 | member.rank, 92 | member.count 93 | ) 94 | 95 | await ctx.reply(embed=leaderboard_embed, ephemeral=True) 96 | 97 | @thank_group.command(name='rank', description='Shows your current thank rank.') 98 | async def thank_rank(self, ctx: Context, *, member: Optional[Member] = None) -> None: 99 | """Show the current rank of the member who issue this command. 100 | 101 | :param ctx: The context of the command invocation. 102 | :type ctx: Context 103 | :param member: The member rank. 104 | :type member: Member 105 | """ 106 | if not member or member.id == ctx.author.id: 107 | await self.send_author_rank(ctx) 108 | elif member.id == self.bot.user.id: 109 | await self.send_bot_rank(ctx) 110 | else: 111 | await self.send_member_rank(ctx, member) 112 | 113 | async def send_bot_rank(self, ctx: Context) -> None: 114 | """Send a message showing the rank of the bot. 115 | 116 | :param ctx: The context of the command invocation. 117 | :type ctx: Context 118 | """ 119 | rank_embed: Embed = Embed(title='Grace RANK', color=self.bot.default_color) 120 | rank_embed.description = 'Grace has a range of commands that can help you greatly!\n' \ 121 | 'Rank: **Bot**' 122 | 123 | await ctx.reply(embed=rank_embed, ephemeral=True) 124 | 125 | async def send_author_rank(self, ctx: Context) -> None: 126 | """Send a message showing the rank of the user who issued the command. 127 | 128 | :param ctx: The context of the command invocation. 129 | :type ctx: Context 130 | :return: None 131 | """ 132 | rank_embed: Embed = Embed(title='YOUR RANK', color=self.bot.default_color) 133 | thank = Thank.get_by(member_id=ctx.author.id) 134 | 135 | if not thank: 136 | rank_embed.description = 'You haven\'t been thanked yet.' 137 | else: 138 | rank_embed.description = f'Your rank is: **{thank.rank}**\n' \ 139 | f'Your thank count is: {thank.count}' 140 | 141 | await ctx.reply(embed=rank_embed, ephemeral=True) 142 | 143 | async def send_member_rank(self, ctx: Context, member: Member) -> None: 144 | """Send a message showing the rank of the given member. 145 | 146 | :param ctx: The context of the command invocation. 147 | :type ctx: Context 148 | :param member: The member rank. 149 | :type member: Member 150 | """ 151 | rank_embed: Embed = Embed(title=f'{member.display_name} RANK', color=self.bot.default_color) 152 | thank = Thank.get_by(member_id=member.id) 153 | 154 | if not thank: 155 | rank_embed.description = f'User **@{member.display_name}** hasn\'t been thanked yet.' 156 | else: 157 | rank_embed.description = f'User **@{member.display_name}** has rank: **{thank.rank}**' 158 | 159 | await ctx.reply(embed=rank_embed, ephemeral=True) 160 | 161 | 162 | async def setup(bot: Grace): 163 | await bot.add_cog(ThankCog(bot)) 164 | -------------------------------------------------------------------------------- /bot/extensions/threads_cog.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from typing import Optional 3 | from logging import info 4 | from pytz import timezone 5 | from discord import Interaction, Embed, TextStyle 6 | from discord.app_commands import Choice, autocomplete 7 | from discord.ui import Modal, TextInput 8 | from discord.ext.commands import Cog, has_permissions, hybrid_command, hybrid_group, Context 9 | from bot import app, scheduler 10 | from bot.models.extensions.thread import Thread 11 | from bot.classes.recurrence import Recurrence 12 | from bot.extensions.command_error_handler import CommandErrorHandler 13 | from lib.config_required import cog_config_required 14 | 15 | 16 | class ThreadModal(Modal, title="Thread"): 17 | thread_title = TextInput( 18 | label="Title", 19 | placeholder="The title of the thread...", 20 | min_length=5, 21 | max_length=100, 22 | ) 23 | 24 | thread_content = TextInput( 25 | label="Content", 26 | placeholder="The content of the thread...", 27 | min_length=10, 28 | style=TextStyle.paragraph 29 | ) 30 | 31 | def __init__(self, recurrence: Recurrence, thread: Thread = None): 32 | super().__init__() 33 | 34 | if thread: 35 | self.thread_title.default = thread.title 36 | self.thread_content.default = thread.content 37 | 38 | self.thread = thread 39 | self.thread_recurrence = recurrence 40 | 41 | async def on_submit(self, interaction: Interaction): 42 | if self.thread: 43 | await self.update_thread(interaction) 44 | else: 45 | await self.create_thread(interaction) 46 | 47 | async def create_thread(self, interaction: Interaction): 48 | thread = Thread.create( 49 | title=self.thread_title.value, 50 | content=self.thread_content.value, 51 | recurrence=self.thread_recurrence 52 | ) 53 | await interaction.response.send_message( 54 | f'Thread __**{thread.id}**__ created!', 55 | ephemeral=True 56 | ) 57 | 58 | async def update_thread(self, interaction: Interaction): 59 | self.thread.title = self.thread_title.value, 60 | self.thread.content = self.thread_content.value, 61 | self.thread.recurrence = self.thread_recurrence 62 | 63 | self.thread.save() 64 | 65 | await interaction.response.send_message( 66 | f'Thread __**{self.thread.id}**__ updated!', 67 | ephemeral=True 68 | ) 69 | 70 | async def on_error(self, interaction: Interaction, error: Exception): 71 | await interaction.response.send_message('Oops! Something went wrong.', ephemeral=True) 72 | traceback.print_exception(type(error), error, error.__traceback__) 73 | 74 | 75 | async def thread_autocomplete(_: Interaction, current: str) -> list[Choice[str]]: 76 | return [ 77 | Choice(name=t.title, value=str(t.id)) 78 | for t in Thread.all() if current.lower() in t.title 79 | ] 80 | 81 | 82 | @cog_config_required("threads", "channel_id") 83 | class ThreadsCog(Cog, name="Threads"): 84 | def __init__(self, bot): 85 | self.bot = bot 86 | self.jobs = [] 87 | self.threads_channel_id = self.required_config 88 | self.timezone = timezone("US/Eastern") 89 | 90 | 91 | def cog_load(self): 92 | # Runs everyday at 18:30 93 | self.jobs.append(scheduler.add_job( 94 | self.daily_post, 95 | 'cron', 96 | hour=18, 97 | minute=30, 98 | timezone=self.timezone 99 | )) 100 | 101 | # Runs every monday at 18:30 102 | self.jobs.append(scheduler.add_job( 103 | self.weekly_post, 104 | 'cron', 105 | day_of_week='mon', 106 | hour=18, 107 | minute=30, 108 | timezone=self.timezone 109 | )) 110 | 111 | # Runs on the 1st of every month at 18:30 112 | self.jobs.append(scheduler.add_job( 113 | self.monthly_post, 114 | 'cron', 115 | day=1, 116 | hour=18, 117 | minute=30, 118 | timezone=self.timezone 119 | )) 120 | 121 | def cog_unload(self): 122 | for job in self.jobs: 123 | scheduler.remove_job(job.id) 124 | 125 | async def daily_post(self): 126 | info("Posting daily threads") 127 | 128 | for thread in Thread.find_by_recurrence(Recurrence.DAILY): 129 | await self.post_thread(thread) 130 | 131 | async def weekly_post(self): 132 | info("Posting weekly threads") 133 | 134 | for thread in Thread.find_by_recurrence(Recurrence.WEEKLY): 135 | await self.post_thread(thread) 136 | 137 | async def monthly_post(self): 138 | info("Posting monthly threads") 139 | 140 | for thread in Thread.find_by_recurrence(Recurrence.MONTHLY): 141 | await self.post_thread(thread) 142 | 143 | async def post_thread(self, thread: Thread): 144 | channel = self.bot.get_channel(self.threads_channel_id) 145 | role_id = app.config.get("threads", "role_id") 146 | content = f"<@&{role_id}>" if role_id else None 147 | 148 | embed = Embed( 149 | color=self.bot.default_color, 150 | title=thread.title, 151 | description=thread.content 152 | ) 153 | 154 | if channel: 155 | message = await channel.send(content=content, embed=embed) 156 | await message.create_thread(name=thread.title) 157 | 158 | @hybrid_group(name="threads", help="Commands to manage threads") 159 | @has_permissions(administrator=True) 160 | async def threads_group(self, ctx: Context): 161 | if ctx.invoked_subcommand is None: 162 | await CommandErrorHandler.send_command_help(ctx) 163 | 164 | @threads_group.command(help="List all threads") 165 | @has_permissions(administrator=True) 166 | async def list(self, ctx: Context): 167 | embed = Embed( 168 | color=self.bot.default_color, 169 | title="Threads" 170 | ) 171 | 172 | if threads := Thread.all(): 173 | for thread in threads: 174 | embed.add_field( 175 | name=f"[{thread.id}] {thread.title}", 176 | value=f"**Recurrence**: {thread.recurrence}", 177 | inline=False 178 | ) 179 | else: 180 | embed.add_field(name="No threads", value="") 181 | 182 | await ctx.send(embed=embed, ephemeral=True) 183 | 184 | @threads_group.command(help="Creates a new thread") 185 | @has_permissions(administrator=True) 186 | async def create(self, ctx: Context, recurrence: Recurrence): 187 | modal = ThreadModal(recurrence) 188 | await ctx.interaction.response.send_modal(modal) 189 | 190 | @threads_group.command(help="Deletes a given thread") 191 | @has_permissions(administrator=True) 192 | @autocomplete(thread=thread_autocomplete) 193 | async def delete(self, ctx: Context, thread: int): 194 | if thread := Thread.get(thread): 195 | thread.delete() 196 | await ctx.send("Thread successfully deleted!", ephemeral=True) 197 | else: 198 | await ctx.send("Thread not found!", ephemeral=True) 199 | 200 | @threads_group.command(help="Update a thread") 201 | @has_permissions(administrator=True) 202 | @autocomplete(thread=thread_autocomplete) 203 | async def update(self, ctx: Context, thread: int, recurrence: Recurrence): 204 | if thread := Thread.get(thread): 205 | modal = ThreadModal(recurrence, thread=thread) 206 | await ctx.interaction.response.send_modal(modal) 207 | else: 208 | await ctx.send("Thread not found!", ephemeral=True) 209 | 210 | 211 | @threads_group.command(help="Post a given thread") 212 | @has_permissions(administrator=True) 213 | @autocomplete(thread=thread_autocomplete) 214 | async def post(self, ctx: Context, thread: int): 215 | if ctx.interaction: 216 | await ctx.interaction.response.send_message( 217 | content="Opening thread!", 218 | delete_after=0, 219 | ephemeral=True 220 | ) 221 | 222 | if thread := Thread.get(thread): 223 | await self.post_thread(thread) 224 | else: 225 | await self.send("Thread not found!") 226 | 227 | 228 | async def setup(bot): 229 | await bot.add_cog(ThreadsCog(bot)) -------------------------------------------------------------------------------- /bot/extensions/translator_cog.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import Cog, hybrid_command, Context, CommandError 2 | from googletrans import Translator, LANGUAGES 3 | from discord import Embed, Interaction 4 | from discord.app_commands import Choice, autocomplete 5 | 6 | from bot.helpers.error_helper import get_original_exception 7 | 8 | 9 | async def language_autocomplete(_: Interaction, current: str) -> list[Choice[str]]: 10 | """Provide autocomplete suggestions for language names. 11 | 12 | :param _: The interaction object. 13 | :type _: Interaction 14 | :param current: The current value of the input field. 15 | :type current: str 16 | :return: A list of `Choice` objects containing language names. 17 | :rtype: list[Choice[str]] 18 | """ 19 | languages = list(LANGUAGES.values()) 20 | 21 | return [ 22 | Choice(name=language.capitalize(), value=language) 23 | for language in languages[:25] if current.lower() in language.lower() 24 | ] 25 | 26 | 27 | class TranslatorCog( 28 | Cog, 29 | name="Translator", 30 | description="Translate a sentence/word from any languages into any languages." 31 | ): 32 | @hybrid_command( 33 | name='translator', 34 | help='Translate a sentence/word from any languages into any languages', 35 | usage="sentence={sentence}" 36 | ) 37 | @autocomplete(translate_into=language_autocomplete) 38 | async def translator(self, ctx: Context, *, sentence: str, translate_into: str): 39 | """Translate a sentence or word from any language into any languages. 40 | 41 | :param ctx: The context object. 42 | :type ctx: Context 43 | :param sentence: The sentence or word to be translated. 44 | :type sentence: str 45 | :param translate_into: The language code for the target language. 46 | :type translate_into: str 47 | :return: Embed with original input and its translation 48 | """ 49 | await ctx.defer() 50 | 51 | text_translator = Translator() 52 | translated_text = text_translator.translate(sentence, dest=translate_into) 53 | 54 | embed = Embed(color=self.bot.default_color) 55 | 56 | embed.add_field( 57 | name=f"{LANGUAGES[translated_text.src].capitalize()} Original", 58 | value=sentence.capitalize(), 59 | inline=False 60 | ) 61 | embed.add_field( 62 | name=f"{translate_into} Translation", 63 | value=translated_text.text, 64 | inline=False 65 | ) 66 | 67 | await ctx.send(embed=embed) 68 | 69 | def __init__(self, bot): 70 | self.bot = bot 71 | 72 | @translator.error 73 | async def translator_error(self, ctx: Context, error: CommandError): 74 | """Error handler for the `translator` command. 75 | 76 | :param ctx: The context object. 77 | :type ctx: Context 78 | :param error: The error object. 79 | :type error: Exception 80 | :return: This function sends an embed message to the Discord channel 81 | """ 82 | original_error = get_original_exception(error) 83 | 84 | if isinstance(original_error, ValueError): 85 | await ctx.send("Please enter a valid language code.", ephemeral=True) 86 | 87 | 88 | async def setup(bot): 89 | await bot.add_cog(TranslatorCog(bot)) 90 | -------------------------------------------------------------------------------- /bot/extensions/weather_cog.py: -------------------------------------------------------------------------------- 1 | from timezonefinder import TimezoneFinder 2 | from pytz import timezone 3 | from datetime import datetime 4 | from discord.ext.commands import Cog, hybrid_command 5 | from requests import get 6 | from discord import Embed 7 | from string import capwords 8 | from lib.config_required import cog_config_required 9 | 10 | 11 | @cog_config_required("openweather", "api_key", "Generate yours [here](https://openweathermap.org/api)") 12 | class WeatherCog(Cog, name="Weather", description="get current weather information from a city"): 13 | """A cog that retrieves current weather information for a given city.""" 14 | OPENWEATHER_BASE_URL = "https://api.openweathermap.org/data/2.5/" 15 | 16 | def __init__(self, bot): 17 | self.bot = bot 18 | self.api_key = self.required_config 19 | 20 | @staticmethod 21 | def get_timezone(data: any) -> datetime: 22 | """Get the timezone for the given city. 23 | 24 | :param data: The weather data to get the timezone for. 25 | :type data: Any | None 26 | :return: The timezone based on Longitude and Latitude. 27 | :rtype: datetime.tzinfo 28 | """ 29 | longitude = float(data["coord"]['lon']) 30 | latitude = float(data["coord"]['lat']) 31 | timezone_finder = TimezoneFinder() 32 | 33 | result = timezone_finder.timezone_at( 34 | lng=longitude, 35 | lat=latitude) 36 | return datetime.now(timezone(str(result))) 37 | 38 | @staticmethod 39 | def kelvin_to_celsius(kelvin: float) -> float: 40 | """Convert a temperature in Kelvin to Celsius. 41 | 42 | :param kelvin: The temperature in Kelvin. 43 | :type kelvin: float 44 | :return: The temperature in Celsius. 45 | :rtype: float 46 | """ 47 | return kelvin - 273.15 48 | 49 | @staticmethod 50 | def kelvin_to_fahrenheit(kelvin: float) -> float: 51 | """Convert a temperature in Kelvin to fahrenheit. 52 | 53 | :param kelvin: The temperature in Kelvin. 54 | :type kelvin: float 55 | :return: The temperature in fahrenheit. 56 | :rtype: float 57 | """ 58 | return kelvin * 1.8 - 459.67 59 | 60 | async def get_weather(self, city: str): 61 | """Retrieve weather information for the specified city. 62 | 63 | :param city: The name of the city to retrieve weather information for 64 | :type city: str 65 | :return: A dictionary containing the weather information, or None if the city was not found 66 | :rtype: dict 67 | """ 68 | # complete_url to retreive weather info 69 | response = get(f"{self.OPENWEATHER_BASE_URL}/weather?appid={self.api_key}&q={city}") 70 | 71 | # code 200 means the city is found otherwise, city is not found 72 | if response.status_code == 200: 73 | return response.json() 74 | return None 75 | 76 | @hybrid_command(name='weather', help='Show weather information in your city', usage="{city}") 77 | async def weather(self, ctx, *, city_input: str): 78 | """Display weather information for the specified city. 79 | 80 | :param ctx: the Discord context for the command 81 | :type ctx: Context 82 | :param city_input: The name of the city to display weather information for 83 | :type city_input: str 84 | :return: This function sends an embed message to the Discord channel 85 | """ 86 | if ctx.interaction: 87 | await ctx.interaction.response.defer() 88 | 89 | city = capwords(city_input) 90 | data_weather = await self.get_weather(city) 91 | timezone_city = self.get_timezone(data_weather) 92 | 93 | # Now data_weather contains lists of data 94 | # from the city inputer by the user 95 | if data_weather: 96 | icon_id = data_weather["weather"][0]["icon"] 97 | main = data_weather["main"] 98 | visibility = data_weather['visibility'] 99 | current_temperature = main["temp"] 100 | 101 | fahrenheit = self.kelvin_to_fahrenheit(int(current_temperature)) 102 | celsius = self.kelvin_to_celsius(int(current_temperature)) 103 | 104 | feels_like = main["feels_like"] 105 | feels_like_fahrenheit = self.kelvin_to_fahrenheit(int(feels_like)) 106 | feels_like_celsius = self.kelvin_to_celsius(int(feels_like)) 107 | 108 | current_pressure = main["pressure"] 109 | current_humidity = main["humidity"] 110 | forcast = data_weather["weather"] 111 | weather_description = forcast[0]["description"] 112 | 113 | embed = Embed( 114 | color=self.bot.default_color, 115 | title=city, 116 | description=timezone_city.strftime('%m/%d/%Y %H:%M'), 117 | ) 118 | 119 | embed.set_image( 120 | url=f'https://openweathermap.org/img/wn/{icon_id}@2x.png' 121 | ) 122 | embed.add_field( 123 | name="Description", 124 | value=capwords(weather_description), 125 | inline=False 126 | ) 127 | embed.add_field( 128 | name="Visibility", 129 | value=f"{visibility}m | {round(visibility * 3.280839895)}ft", 130 | inline=False 131 | ) 132 | embed.add_field( 133 | name="Temperature", 134 | value=f"{round(fahrenheit, 2)}°F | {round(celsius, 2)}°C", 135 | inline=False 136 | ) 137 | embed.add_field( 138 | name="Feels Like", 139 | value=f"{round(feels_like_fahrenheit, 2)}°F | {round(feels_like_celsius, 2)}°C", 140 | inline=False 141 | ) 142 | embed.add_field( 143 | name="Atmospheric Pressure", 144 | value=f"{current_pressure} hPa", 145 | inline=False 146 | ) 147 | embed.add_field( 148 | name="Humidity", 149 | value=f"{current_humidity}%", 150 | inline=False 151 | ) 152 | else: 153 | embed = Embed( 154 | color=self.bot.default_color, 155 | description=f"{city} No Found!", 156 | ) 157 | await ctx.send(embed=embed) 158 | 159 | 160 | async def setup(bot): 161 | await bot.add_cog(WeatherCog(bot)) 162 | -------------------------------------------------------------------------------- /bot/extensions/welcome_cog.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import Cog, hybrid_command 2 | from logging import info 3 | from bot.models.channel import Channel 4 | from discord import Embed 5 | 6 | 7 | class WelcomeCog(Cog, name="Welcome", description="Welcomes new members"): 8 | """A cog that sends a welcome message to new members when they join the server.""" 9 | 10 | BASE_WELCOME_MESSAGE = "Hi {member_name}! Welcome to the **Code Society**." 11 | 12 | def __init__(self, bot): 13 | self.bot = bot 14 | 15 | @property 16 | def help_section(self): 17 | return self.__build_section( 18 | ["posting_guidelines", "help"], 19 | "If you need help, read the <#{}> and open a post in <#{}>" 20 | ) 21 | 22 | @property 23 | def info_section(self): 24 | return self.__build_section( 25 | ["info", "rules", "roles", "introductions"], 26 | "Before posting please:\n" 27 | "- Take a moment to read the <#{}> and the <#{}>.\n" 28 | "- Choose some <#{}>.\n" 29 | "- Introduce yourself in <#{}>." 30 | ) 31 | 32 | def get_welcome_message(self, member): 33 | """Return the welcome message for the given member. 34 | 35 | :param member: The member to welcome. 36 | :type member: discord.Member 37 | 38 | :return: The welcome message for the given member. 39 | :rtype: str 40 | """ 41 | return "\n\n".join(filter(None, [ 42 | self.BASE_WELCOME_MESSAGE, 43 | self.help_section, 44 | self.info_section, 45 | ])).strip().format(member_name=member.display_name) 46 | 47 | def __build_section(self, channel_names, message): 48 | """Builds a section of the welcome message by replacing placeholders with corresponding channel IDs. 49 | 50 | The message needs to contain empty ({}) or numbered ({index}) placeholders to indicate 51 | where the channel IDs will be inserted. 52 | 53 | IMPORTANT: The section will return an empty unless all the channels are found. 54 | 55 | :param channel_names: The names of the channels to include in the section. 56 | :type channel_names: List[str] 57 | 58 | :param message: A string containing placeholders ({}) or {index} 59 | indicating where the channel IDs will be inserted. 60 | :type channel_names: str 61 | 62 | :return: The constructed section of the welcome message with channel IDs inserted. 63 | :rtype: str 64 | """ 65 | channel_ids = [getattr(Channel.get_by(channel_name=n), "channel_id", "") for n in channel_names] 66 | return message.format(*channel_ids) if all(channel_ids) else "" 67 | 68 | @Cog.listener() 69 | async def on_member_update(self, before, after): 70 | """Send a welcome message to the member when their status is changed from "pending" to any other status. 71 | 72 | :param before: The member before the update. 73 | :type before: discord.Member 74 | :param after: The member after the update. 75 | :type after: discord.Member 76 | """ 77 | if not before.bot and (before.pending and not after.pending): 78 | info(f"{after.display_name} accepted the rules!") 79 | 80 | embed = Embed(color=self.bot.default_color) 81 | welcome_channel = self.bot.get_channel_by_name("welcome") 82 | 83 | if not welcome_channel: 84 | welcome_channel = before.guild.system_channel 85 | 86 | embed.add_field( 87 | name="The Code Society Server", 88 | value=self.get_welcome_message(after), 89 | inline=False 90 | ) 91 | 92 | await welcome_channel.send(f"<@{after.id}>", embed=embed) 93 | 94 | @Cog.listener() 95 | async def on_member_join(self, member): 96 | """Log a message when a member joins the server. 97 | 98 | :param member: The member who joined the server. 99 | :type member: discord.Member 100 | """ 101 | info(f"{member.display_name} joined the server!") 102 | 103 | @hybrid_command(name="welcome", description="Welcomes the person who issues the command") 104 | async def welcome_command(self, ctx): 105 | """Send a welcome message to the person who issued the command. 106 | 107 | :param ctx: The context in which the command was invoked. 108 | :type ctx: Context 109 | """ 110 | info(f"{ctx.author.display_name} asked to get welcomed!") 111 | 112 | embed = Embed( 113 | color=self.bot.default_color, 114 | title="The Code Society Server", 115 | description=self.get_welcome_message(ctx.author), 116 | ) 117 | 118 | await ctx.send(embed=embed, ephemeral=True) 119 | 120 | 121 | async def setup(bot): 122 | await bot.add_cog(WelcomeCog(bot)) 123 | -------------------------------------------------------------------------------- /bot/extensions/wikipedia_cog.py: -------------------------------------------------------------------------------- 1 | from typing import List, Any 2 | from discord.ext.commands import Cog, hybrid_command, Context 3 | from discord.ui import View 4 | from discord import ButtonStyle, ui, Embed, Interaction, Button 5 | from urllib.request import urlopen 6 | from urllib.parse import quote_plus 7 | from json import loads 8 | 9 | 10 | def search_results(search: str) -> List[Any]: 11 | """Return search results from Wikipedia for the given search query. 12 | 13 | :param search: The search query to be used to search Wikipedia. 14 | :type search: str 15 | 16 | :return: A list of search results. 17 | :rtype: list 18 | """ 19 | url_encode: str = quote_plus(search) 20 | base_url: str = f"https://en.wikipedia.org/w/api.php?action=opensearch&format=json&limit=3&namespace=0&search={url_encode}" 21 | 22 | with urlopen(base_url) as url: 23 | return loads(url.read()) 24 | 25 | 26 | class Buttons(View): 27 | def __init__(self, search: str, result: List[Any]) -> None: 28 | super().__init__() 29 | 30 | self.search: str = search 31 | self.result: List[Any] = result 32 | 33 | async def wiki_result(self, interaction: Interaction, _: Button, index: int) -> None: 34 | """Send the selected search result to the user. 35 | 36 | :param _: The Button clicked 37 | :type _: Button 38 | :param interaction: The interaction object representing the user's interaction with the bot. 39 | :type interaction: Interaction 40 | :param index: The index of the search result to be sent to the user. 41 | :type index: int 42 | """ 43 | if len(self.result[3]) >= index: 44 | await interaction.response.send_message("{mention} requested:\n {request}".format( 45 | mention=interaction.user.mention, 46 | request=self.result[3][index-1] 47 | )) 48 | self.stop() 49 | else: 50 | await interaction.response.send_message("Invalid choice.", ephemeral=True) 51 | 52 | @ui.button(label='1', style=ButtonStyle.primary) 53 | async def first_wiki_result(self, interaction: Interaction, button: Button): 54 | await self.wiki_result(interaction, button, 1) 55 | 56 | @ui.button(label='2', style=ButtonStyle.primary) 57 | async def second_wiki_result(self, interaction: Interaction, button: Button): 58 | await self.wiki_result(interaction, button, 2) 59 | 60 | @ui.button(label='3', style=ButtonStyle.primary) 61 | async def third_wiki_result(self, interaction: Interaction, button: Button): 62 | await self.wiki_result(interaction, button, 3) 63 | 64 | 65 | class Wikipedia(Cog, name="Wikipedia", description="Search on Wikipedia."): 66 | def __init__(self, bot): 67 | self.bot = bot 68 | 69 | @hybrid_command(name="wiki", description="Searches and displays the first 3 results from Wikipedia.") 70 | async def wiki(self, ctx: Context, *, search: str) -> None: 71 | """Search Wikipedia and display the first 3 search results to the user. 72 | 73 | :param ctx: The context in which the command was invoked. 74 | :type ctx: Context 75 | :param search: The search query to be used to search Wikipedia. 76 | :type search: str 77 | """ 78 | result: List[Any] = search_results(search) 79 | view: Buttons = Buttons(search, result) 80 | 81 | if len(result[1]) == 0: 82 | await ctx.interaction.response.send_message("No result found.", ephemeral=True) 83 | else: 84 | result_view = "" 85 | search_count = 1 86 | for result in result[1]: 87 | result_view += f"{str(search_count)}: {result}\n" 88 | search_count += 1 89 | 90 | embed = Embed( 91 | color=0x2376ff, 92 | title=f"Top 3 Wikipedia Search", 93 | description=result_view, 94 | ) 95 | await ctx.send(embed=embed, view=view, ephemeral=True) 96 | 97 | 98 | async def setup(bot): 99 | await bot.add_cog(Wikipedia(bot)) 100 | -------------------------------------------------------------------------------- /bot/grace.py: -------------------------------------------------------------------------------- 1 | from logging import info, warning, critical 2 | from discord import Intents, LoginFailure, ActivityType, Activity, Object as DiscordObject 3 | from discord.ext.commands import Bot, when_mentioned_or 4 | from pretty_help import PrettyHelp 5 | from bot import app 6 | from bot.helpers.bot_helper import default_color 7 | from bot.models.channel import Channel 8 | from bot.models.extension import Extension 9 | from bot import scheduler 10 | 11 | 12 | class Grace(Bot): 13 | def __init__(self): 14 | self.config = app.bot 15 | self.default_color = default_color() 16 | 17 | super().__init__( 18 | command_prefix=when_mentioned_or(self.config.get("prefix")), 19 | description=self.config.get("description"), 20 | help_command=PrettyHelp(color=self.default_color), 21 | intents=Intents.all(), 22 | activity=Activity(type=ActivityType.playing, name="::help") 23 | ) 24 | 25 | def get_channel_by_name(self, name): 26 | """Gets the channel from the database and returns the discord channel with the associated id. 27 | 28 | :param name: The name of the channel. 29 | :return: The discord channel. 30 | """ 31 | channel = Channel.get_by(channel_name=name) 32 | 33 | if channel: 34 | return self.get_channel(channel.channel_id) 35 | return None 36 | 37 | async def load_extensions(self): 38 | for module in app.extension_modules: 39 | extension = Extension.get_by(module_name=module) 40 | 41 | if not extension: 42 | warning(f"{module} is not registered. Registering the extension.") 43 | extension = Extension.create(module_name=module) 44 | 45 | if extension.is_enabled(): 46 | info(f"Loading {module}") 47 | await self.load_extension(module) 48 | else: 49 | info(f"{module} is disabled, it will not be loaded.") 50 | 51 | async def on_ready(self): 52 | info(f"{self.user.name}#{self.user.id} is online and ready to use!") 53 | scheduler.start() 54 | 55 | 56 | async def invoke(self, ctx): 57 | if ctx.command: 58 | info(f"'{ctx.command}' has been invoked by {ctx.author} ({ctx.author.display_name})") 59 | await super().invoke(ctx) 60 | 61 | async def setup_hook(self): 62 | await self.load_extensions() 63 | 64 | if app.command_sync: 65 | warning("Syncing application commands. This may take some time.") 66 | guild = DiscordObject(id=app.config.get("client", "guild_id")) 67 | 68 | self.tree.copy_global_to(guild=guild) 69 | await self.tree.sync(guild=guild) 70 | 71 | 72 | def start(): 73 | """Starts the bot""" 74 | try: 75 | if app.token: 76 | grace_bot = Grace() 77 | grace_bot.run(app.token) 78 | else: 79 | critical("Unable to find the token. Make sure your current directory contains an '.env' and that " 80 | "'DISCORD_TOKEN' is defined") 81 | except LoginFailure as e: 82 | critical(f"Authentication failed : {e}") 83 | -------------------------------------------------------------------------------- /bot/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | from bot.helpers.bot_helper import * 2 | from bot.helpers.error_helper import * 3 | from bot.helpers.log_helper import * 4 | -------------------------------------------------------------------------------- /bot/helpers/bot_helper.py: -------------------------------------------------------------------------------- 1 | from discord import Colour 2 | from bot import app 3 | 4 | 5 | def default_color() -> Colour: 6 | return Colour.from_str(app.bot.get("default_color")) 7 | -------------------------------------------------------------------------------- /bot/helpers/error_helper.py: -------------------------------------------------------------------------------- 1 | from discord import Embed, Color, DiscordException 2 | 3 | 4 | async def send_error(ctx, error_description, **kwargs): 5 | embed = Embed( 6 | title="Oops! An error occurred", 7 | color=Color.red(), 8 | description=error_description 9 | ) 10 | 11 | for key, value in kwargs.items(): 12 | embed.add_field(name=key.capitalize(), value=value) 13 | 14 | await ctx.send(embed=embed, ephemeral=True) 15 | 16 | 17 | async def send_command_error(ctx, error_description, command, argument_example=None): 18 | await send_error(ctx, error_description, example=f"```/{command} {argument_example}```") 19 | 20 | 21 | # This might be the right place for this function 22 | def get_original_exception(error: DiscordException) -> Exception: 23 | while hasattr(error, 'original'): 24 | error = error.original 25 | return error 26 | -------------------------------------------------------------------------------- /bot/helpers/github_helper.py: -------------------------------------------------------------------------------- 1 | from typing import Iterable, List 2 | from discord import Embed 3 | from discord.ui import Button 4 | from emoji import emojize 5 | from github import Repository, Organization 6 | from bot.helpers.bot_helper import default_color 7 | from bot.services.github_service import GithubService 8 | from math import ceil 9 | 10 | 11 | def available_project_names() -> Iterable[str]: 12 | organization: Organization = GithubService().get_code_society_lab() 13 | return map(lambda r: r.name, organization.get_repos()) 14 | 15 | 16 | def create_contributors_embeds(repository: Repository) -> List[Embed]: 17 | """Get an embed with a list of contributors for the Cursif repository. 18 | 19 | :return: An embed with a list of contributors. 20 | :rtype: Embed 21 | """ 22 | embeds: List[Embed] = [] 23 | 24 | contributors = repository.get_contributors() 25 | page_count: int = ceil(contributors.totalCount / 25) 26 | 27 | for i in range(page_count): 28 | embed: Embed = Embed( 29 | color=default_color(), 30 | title=f"{repository.name.capitalize()}'s Contributors", 31 | ) 32 | 33 | for contributor in contributors.get_page(i): 34 | embed.add_field( 35 | name=contributor.login, 36 | value=f"{contributor.contributions} Contributions", 37 | inline=True 38 | ) 39 | 40 | embeds.append(embed) 41 | 42 | return embeds 43 | 44 | 45 | def create_repository_button(repository: Repository) -> Button: 46 | return Button( 47 | emoji=emojize(":file_folder:"), 48 | label=f"Repository", 49 | url=repository.html_url 50 | ) 51 | -------------------------------------------------------------------------------- /bot/helpers/log_helper.py: -------------------------------------------------------------------------------- 1 | from discord import Embed, Color 2 | from datetime import datetime 3 | 4 | 5 | def info(title, description): 6 | # Will be deprected in favor of notice 7 | return LogHelper(title, description, "info") 8 | 9 | 10 | def notice(title, description): 11 | return LogHelper(title, description, "info") 12 | 13 | 14 | def warning(title, description): 15 | return LogHelper(title, description, "warning") 16 | 17 | 18 | def danger(title, description): 19 | return LogHelper(title, description, "danger") 20 | 21 | 22 | class LogHelper: 23 | __DEFAULT_COLOR = Color.from_rgb(0, 123, 255) 24 | COLORS_BY_LOG_LEVEL = { 25 | "danger": Color.from_rgb(220, 53, 69), 26 | "warning": Color.from_rgb(255, 193, 7), 27 | "info": __DEFAULT_COLOR, 28 | } 29 | 30 | def __init__(self, title, description, log_level="info"): 31 | self.embed = Embed( 32 | title=title, 33 | description=description, 34 | color=self.COLORS_BY_LOG_LEVEL.get(log_level, self.__DEFAULT_COLOR), 35 | timestamp=datetime.utcnow() 36 | ) 37 | 38 | def add_field(self, name, value): 39 | self.embed.add_field( 40 | name=name, 41 | value=value, 42 | inline=False 43 | ) 44 | 45 | async def send(self, channel): 46 | await channel.send(embed=self.embed) 47 | -------------------------------------------------------------------------------- /bot/models/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/bot/models/__init__.py -------------------------------------------------------------------------------- /bot/models/bot.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Integer, Column, BigInteger 2 | from bot import app 3 | from db.model import Model 4 | 5 | class BotSettings(app.base, Model): 6 | """Configurable settings for each server""" 7 | __tablename__ = 'bot_settings' 8 | 9 | id = Column(Integer, primary_key=True) 10 | puns_cooldown = Column(BigInteger, default=60) 11 | 12 | @classmethod 13 | def settings(self): 14 | '''Since grace runs on only one settings record per bot, 15 | this is a semantic shortcut to get the first record.''' 16 | return self.first() 17 | -------------------------------------------------------------------------------- /bot/models/channel.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import String, Column, UniqueConstraint, BigInteger 2 | from bot import app 3 | from db.model import Model 4 | 5 | 6 | class Channel(app.base, Model): 7 | __tablename__ = 'channels' 8 | 9 | channel_name = Column(String(255), primary_key=True) 10 | channel_id = Column(BigInteger, primary_key=True) 11 | 12 | UniqueConstraint("channel_name", "channel_id", name="uq_id_cn_cid") 13 | -------------------------------------------------------------------------------- /bot/models/extension.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Integer, Column, String 2 | from bot import app 3 | from bot.classes.state import State 4 | from db.model import Model 5 | 6 | 7 | class Extension(app.base, Model): 8 | """Extension model (With SQLAlchemy ORM)""" 9 | __tablename__ = "extensions" 10 | 11 | id = Column(Integer, primary_key=True) 12 | module_name = Column(String(255), nullable=False, unique=True) 13 | _state = Column("state", Integer, default=1) 14 | 15 | @classmethod 16 | def by_state(cls, state): 17 | return cls.where(_state=state.value) 18 | 19 | @property 20 | def name(self): 21 | return self.module_name.split(".")[-1].replace("_", " ").title() 22 | 23 | @property 24 | def state(self): 25 | return State(self._state) 26 | 27 | @state.setter 28 | def state(self, new_state): 29 | self._state = new_state.value 30 | 31 | @property 32 | def module(self): 33 | return app.get_extension_module(self.module_name) 34 | 35 | def is_enabled(self): 36 | return self.state == State.ENABLED 37 | 38 | def __str__(self): 39 | return f"{self.id} | {self.module} - {self.state}" 40 | -------------------------------------------------------------------------------- /bot/models/extensions/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/bot/models/extensions/__init__.py -------------------------------------------------------------------------------- /bot/models/extensions/fun/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/bot/models/extensions/fun/__init__.py -------------------------------------------------------------------------------- /bot/models/extensions/fun/answer.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | from bot import app 3 | from db.model import Model 4 | 5 | 6 | class Answer(app.base, Model): 7 | """Answer model (With SQLAlchemy ORM)""" 8 | __tablename__ = "answers" 9 | 10 | id = Column(Integer, primary_key=True) 11 | answer = Column(String(255), nullable=False) 12 | 13 | -------------------------------------------------------------------------------- /bot/models/extensions/language/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/bot/models/extensions/language/__init__.py -------------------------------------------------------------------------------- /bot/models/extensions/language/pun.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from sqlalchemy import Text, Column, Integer, DateTime 3 | from sqlalchemy.orm import relationship 4 | from bot import app 5 | from bot.models.extensions.language.pun_word import PunWord 6 | from bot.models.bot import BotSettings 7 | from db.model import Model 8 | 9 | 10 | class Pun(app.base, Model): 11 | __tablename__ = "puns" 12 | 13 | id = Column(Integer, primary_key=True) 14 | text = Column(Text(), unique=True) 15 | last_invoked = Column(DateTime) 16 | pun_words = relationship("PunWord", lazy="dynamic", cascade="all, delete-orphan") 17 | 18 | @property 19 | def words(self): 20 | for pun_word in self.pun_words: 21 | yield pun_word.word 22 | 23 | def has_word(self, word): 24 | return self.pun_words.filter(PunWord.word == word).count() > 0 25 | 26 | def add_pun_word(self, pun_word, emoji_code): 27 | PunWord(pun_id=self.id, word=pun_word, emoji_code=emoji_code).save() 28 | 29 | def remove_pun_word(self, pun_word): 30 | PunWord.where(pun_id=self.id, word=pun_word).first().delete() 31 | 32 | def can_invoke_at_time(self, time): 33 | cooldown_minutes = BotSettings.settings().puns_cooldown 34 | cooldown = timedelta(minutes=cooldown_minutes) 35 | 36 | if self.last_invoked is None: 37 | return True 38 | else: 39 | return time - self.last_invoked > cooldown 40 | 41 | def save_last_invoked(self, time): 42 | self.last_invoked = time 43 | self.save() 44 | -------------------------------------------------------------------------------- /bot/models/extensions/language/pun_word.py: -------------------------------------------------------------------------------- 1 | from emoji import emojize 2 | from sqlalchemy import Integer, String, Column, ForeignKey 3 | from bot import app 4 | from db.model import Model 5 | 6 | 7 | class PunWord(app.base, Model): 8 | __tablename__ = 'pun_words' 9 | 10 | id = Column(Integer, primary_key=True) 11 | pun_id = Column(ForeignKey("puns.id")) 12 | word = Column(String(255), nullable=False) 13 | emoji_code = Column(String(255)) 14 | 15 | def emoji(self): 16 | return emojize(self.emoji_code, language='alias') -------------------------------------------------------------------------------- /bot/models/extensions/language/trigger.py: -------------------------------------------------------------------------------- 1 | from emoji import emojize 2 | from sqlalchemy import String, Column, Integer 3 | from sqlalchemy.orm import relationship 4 | from bot import app 5 | from bot.models.extensions.language.trigger_word import TriggerWord 6 | from db.model import Model 7 | 8 | 9 | class Trigger(app.base, Model): 10 | __tablename__ = 'triggers' 11 | 12 | id = Column(Integer, primary_key=True) 13 | name = Column(String(255), unique=True) 14 | positive_emoji_code = Column(String(255), nullable=False) 15 | negative_emoji_code = Column(String(255), nullable=False) 16 | trigger_words = relationship("TriggerWord") 17 | 18 | @property 19 | def words(self): 20 | for trigger_word in self.trigger_words: 21 | yield trigger_word.word 22 | 23 | @property 24 | def positive_emoji(self): 25 | return emojize(self.positive_emoji_code, language='alias') 26 | 27 | @property 28 | def negative_emoji(self): 29 | return emojize(self.negative_emoji_code, language='alias') 30 | 31 | def add_trigger_word(self, trigger_word): 32 | TriggerWord(trigger_id=self.id, word=trigger_word).save() 33 | 34 | def remove_trigger_word(self, trigger_word): 35 | TriggerWord.where(trigger_id=self.id, word=trigger_word).first().delete() 36 | -------------------------------------------------------------------------------- /bot/models/extensions/language/trigger_word.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import String, Column, ForeignKey 2 | from bot import app 3 | from db.model import Model 4 | 5 | 6 | class TriggerWord(app.base, Model): 7 | __tablename__ = 'trigger_words' 8 | 9 | trigger_id = Column(ForeignKey("triggers.id"), primary_key=True) 10 | word = Column(String(255), primary_key=True) 11 | -------------------------------------------------------------------------------- /bot/models/extensions/thank.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, List 2 | from sqlalchemy import Column, Integer, BigInteger 3 | from bot import app 4 | from db.model import Model 5 | from sqlalchemy import desc 6 | 7 | 8 | class Thank(app.base, Model): 9 | """A class representing a Thank record in the database.""" 10 | __tablename__ = 'thanks' 11 | 12 | id = Column(Integer, primary_key=True) 13 | member_id = Column(BigInteger, nullable=False, unique=True) 14 | count = Column(Integer, default=0) 15 | 16 | @property 17 | def rank(self) -> Optional[str]: 18 | """Returns the rank of the member based on the number of times they 19 | have been thanked. 20 | 21 | :return: The rank of the member. 22 | :rtype: Optional[str] 23 | """ 24 | if self.count in range(1, 11): 25 | return 'Intern' 26 | elif self.count in range(11, 21): 27 | return 'Helper' 28 | elif self.count in range(21, 31): 29 | return 'Vetted helper' 30 | elif self.count > 30: 31 | return 'Expert' 32 | else: 33 | return None 34 | 35 | @classmethod 36 | def ordered(cls) -> List['Thank']: 37 | """Returns a list of all `Thank` objects in the database, ordered by 38 | the `count` attribute in descending order. 39 | 40 | :return: A list of `Thank` objects. 41 | :rtype: List[Thank] 42 | """ 43 | return cls.query().order_by(desc(cls.count)).all() 44 | -------------------------------------------------------------------------------- /bot/models/extensions/thread.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String, Text 2 | from bot import app 3 | from db.model import Model 4 | from bot.classes.recurrence import Recurrence 5 | 6 | 7 | class Thread(app.base, Model): 8 | __tablename__ = 'threads' 9 | 10 | id = Column(Integer, primary_key=True) 11 | title = Column(String, nullable=False,) 12 | content = Column(Text, nullable=False,) 13 | _recurrence = Column("recurrence", Integer, nullable=False, default=0) 14 | 15 | @property 16 | def recurrence(self) -> Recurrence: 17 | return Recurrence(self._recurrence) 18 | 19 | @recurrence.setter 20 | def recurrence(self, new_recurrence: Recurrence): 21 | self._recurrence = new_recurrence.value 22 | 23 | @classmethod 24 | def find_by_recurrence(cls, recurrence: Recurrence) -> 'Recurrence': 25 | return cls.where(_recurrence=recurrence.value) -------------------------------------------------------------------------------- /bot/services/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/bot/services/__init__.py -------------------------------------------------------------------------------- /bot/services/github_service.py: -------------------------------------------------------------------------------- 1 | from typing import Union, Optional 2 | from github import Github, Organization 3 | from github.Repository import Repository 4 | from bot import app 5 | 6 | 7 | class GithubService(Github): 8 | __token: Optional[Union[str, int, bool]] = app.config.get("github", "api_key") 9 | 10 | def __init__(self): 11 | if self.__token: 12 | super().__init__(self.__token, per_page=25) 13 | 14 | @classmethod 15 | def can_connect(cls): 16 | return cls.__token is not None and cls.__token != "".strip() 17 | 18 | def get_code_society_lab(self) -> Organization: 19 | return self.get_organization("code-society-lab") 20 | 21 | def get_code_society_lab_repo(self, name: str) -> Repository: 22 | return self.get_repo(f"code-society-lab/{name}", lazy=True) 23 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/config/__init__.py -------------------------------------------------------------------------------- /config/application.py: -------------------------------------------------------------------------------- 1 | from configparser import SectionProxy 2 | from importlib import import_module 3 | from logging import basicConfig, critical 4 | from logging.handlers import RotatingFileHandler 5 | from types import ModuleType 6 | from typing import Generator, Any, Union, IO, Dict 7 | from coloredlogs import install 8 | from sqlalchemy import create_engine 9 | from sqlalchemy.engine import Engine 10 | from sqlalchemy.exc import OperationalError 11 | from sqlalchemy.orm import declarative_base, sessionmaker, Session, DeclarativeMeta 12 | from sqlalchemy_utils import database_exists, create_database, drop_database 13 | from config.config import Config 14 | from pathlib import Path 15 | from config.utils import find_all_importables 16 | 17 | 18 | class Application: 19 | """This class is the core of the application In other words, this class that manage the database, the application 20 | environment and loads the configurations. 21 | 22 | Note: The database uses SQLAlchemy ORM (https://www.sqlalchemy.org/). 23 | """ 24 | 25 | __config: Union[Config, None] = None 26 | __session: Union[Session, None] = None 27 | __base: DeclarativeMeta = declarative_base() 28 | 29 | template_path: Path = Path("bin/templates/default.database.template.cfg") 30 | database_config_path: Path = Path("config/database.cfg") 31 | 32 | def __init__(self): 33 | if not self.database_config_path.exists(): 34 | self._generate_database_config() 35 | 36 | self.__token: str = self.config.get("discord", "token") 37 | self.__engine: Union[Engine, None] = None 38 | 39 | self.command_sync: bool = True 40 | 41 | @property 42 | def base(self) -> DeclarativeMeta: 43 | return self.__base 44 | 45 | @property 46 | def token(self) -> str: 47 | return self.__token 48 | 49 | @property 50 | def session(self) -> Session: 51 | """Instantiate the session for querying the database.""" 52 | 53 | if not self.__session: 54 | session: sessionmaker = sessionmaker(bind=self.__engine) 55 | self.__session = session() 56 | 57 | return self.__session 58 | 59 | @property 60 | def config(self) -> Config: 61 | if not self.__config: 62 | self.__config = Config() 63 | 64 | return self.__config 65 | 66 | @property 67 | def bot(self) -> SectionProxy: 68 | return self.config.client 69 | 70 | @property 71 | def extension_modules(self) -> Generator[str, Any, None]: 72 | """Generate the extensions modules""" 73 | from bot import extensions 74 | 75 | for module in find_all_importables(extensions): 76 | imported: ModuleType = import_module(module) 77 | 78 | if not hasattr(imported, "setup"): 79 | continue 80 | yield module 81 | 82 | @property 83 | def database_infos(self) -> Dict[str, str]: 84 | return { 85 | "dialect": self.session.bind.dialect.name, 86 | "database": self.session.bind.url.database 87 | } 88 | 89 | @property 90 | def database_exists(self): 91 | return database_exists(self.config.database_uri) 92 | 93 | def get_extension_module(self, extension_name) -> Union[str, None]: 94 | """Return the extension from the given extension name""" 95 | 96 | for extension in self.extension_modules: 97 | if extension == extension_name: 98 | return extension 99 | return None 100 | 101 | def load(self, environment: str, command_sync: bool = True): 102 | """Sets the environment and loads all the component of the application""" 103 | 104 | self.command_sync = command_sync 105 | self.config.set_environment(environment) 106 | self.load_logs() 107 | self.load_models() 108 | self.load_database() 109 | 110 | def load_models(self): 111 | """Import all models in the `bot/models` package.""" 112 | from bot import models 113 | 114 | for module in find_all_importables(models): 115 | import_module(module) 116 | 117 | def load_logs(self): 118 | file_handler: RotatingFileHandler = RotatingFileHandler( 119 | f"logs/{self.config.current_environment}.log", 120 | maxBytes=10000, 121 | backupCount=5 122 | ) 123 | 124 | basicConfig( 125 | level=self.config.environment.get("log_level"), 126 | format="[%(asctime)s] %(funcName)s %(levelname)s %(message)s", 127 | handlers=[file_handler], 128 | ) 129 | 130 | install( 131 | self.config.environment.get("log_level"), 132 | fmt="[%(asctime)s] %(programname)s %(funcName)s %(module)s %(levelname)s %(message)s", 133 | programname=self.config.current_environment, 134 | ) 135 | 136 | def load_database(self): 137 | """Loads and connects to the database using the loaded config""" 138 | 139 | self.__engine = create_engine( 140 | self.config.database_uri, 141 | echo=self.config.environment.getboolean("sqlalchemy_echo") 142 | ) 143 | 144 | if self.database_exists: 145 | try: 146 | self.__engine.connect() 147 | except OperationalError as e: 148 | critical(f"Unable to load the 'database': {e}") 149 | 150 | def unload_database(self): 151 | """Unloads the current database""" 152 | 153 | self.__engine = None 154 | self.__session = None 155 | 156 | def reload_database(self): 157 | """Reload the database. This function can be use in case there's a dynamic environment change.""" 158 | 159 | self.unload_database() 160 | self.load_database() 161 | 162 | def create_database(self): 163 | """Creates the database for the current loaded config""" 164 | 165 | self.load_database() 166 | create_database(self.config.database_uri) 167 | 168 | def drop_database(self): 169 | """Drops the database for the current loaded config""" 170 | 171 | self.load_database() 172 | drop_database(self.config.database_uri) 173 | 174 | def create_tables(self): 175 | """Creates all the tables for the current loaded database""" 176 | 177 | self.load_database() 178 | self.base.metadata.create_all(self.__engine) 179 | 180 | def drop_tables(self): 181 | """Drops all the tables for the current loaded database""" 182 | 183 | self.load_database() 184 | self.base.metadata.drop_all(self.__engine) 185 | 186 | def _generate_database_config(self): 187 | template: IO = open(self.template_path, mode='rt', encoding='utf-8') 188 | config: IO = open(self.database_config_path, mode='wt', encoding='utf-8') 189 | 190 | config.write(template.read()) 191 | 192 | template.close() 193 | config.close() -------------------------------------------------------------------------------- /config/config.py: -------------------------------------------------------------------------------- 1 | from os import path 2 | from re import match 3 | from ast import literal_eval 4 | from dotenv import load_dotenv 5 | from sqlalchemy.engine import URL 6 | from typing import MutableMapping, Mapping, Optional, Union 7 | from configparser import ConfigParser, BasicInterpolation, NoOptionError, SectionProxy 8 | 9 | 10 | class EnvironmentInterpolation(BasicInterpolation): 11 | """Interpolation which expands environment variables in values. 12 | 13 | With this literal '${NAME}', the config will process the value from the given 14 | environment variable and use it as it's value in the config. 15 | 16 | This includes exported environment variable (ex. 'export MY_VAR=...') and 17 | variable in '.env' files. 18 | 19 | Usage example. 20 | token = ${MY_SECRET_VAR} 21 | 22 | In the example above, token will take the value of the environment variable 23 | called 'MY_SECRET_VAR'. In case 'MY_SECRET_VAR' doesn't exist, the value will 24 | not be evaluated. 25 | 26 | """ 27 | 28 | def before_get( 29 | self, 30 | parser: MutableMapping[str, Mapping[str, str]], 31 | section: str, 32 | option: str, 33 | value: str, 34 | defaults: Mapping[str, str] 35 | ) -> str: 36 | value = super().before_get(parser, section, option, value, defaults) 37 | expandvars: str = path.expandvars(value) 38 | 39 | if (value.startswith("${") and value.endswith("}")) and value == expandvars: 40 | try: 41 | return str(parser.get(section, value)) 42 | except NoOptionError: 43 | return "" 44 | return expandvars 45 | 46 | 47 | class Config: 48 | """This class is the application configurations. It loads all the configuration for the given environment 49 | 50 | The config environment is chosen by checking the value of the `BOT_ENV` environment variable. If the variable 51 | is not set it will load with production by default. 52 | 53 | There can be only one config loaded at once. Which means thar if you instantiate a second or multiple Config 54 | object, they will all share the same environment. This is to say, that the config objects are identical. 55 | """ 56 | def __init__(self): 57 | load_dotenv(".env") 58 | 59 | self.__environment: Optional[str] = None 60 | self.__config: ConfigParser = ConfigParser(interpolation=EnvironmentInterpolation()) 61 | 62 | self.__config.read("config/settings.cfg") 63 | self.__config.read("config/database.cfg") 64 | self.__config.read("config/environment.cfg") 65 | 66 | @property 67 | def database_uri(self) -> Union[str, URL]: 68 | if self.database.get("url"): 69 | return self.database.get("url") 70 | 71 | return URL.create( 72 | self.database["adapter"], 73 | self.database.get("user"), 74 | self.database.get("password"), 75 | self.database.get("host"), 76 | self.database.getint("port"), 77 | self.database.get("database", self.database_name) 78 | ) 79 | 80 | @property 81 | def database(self) -> SectionProxy: 82 | return self.__config[f"database.{self.__environment}"] 83 | 84 | @property 85 | def client(self) -> SectionProxy: 86 | return self.__config["client"] 87 | 88 | @property 89 | def environment(self) -> SectionProxy: 90 | return self.__config[str(self.__environment)] 91 | 92 | @property 93 | def current_environment(self) -> Optional[str]: 94 | return self.__environment 95 | 96 | @property 97 | def database_name(self) -> str: 98 | return f"{self.client['name']}_{self.current_environment}" 99 | 100 | def get(self, section_key, value_key, fallback=None) -> Optional[Union[str, int, float, bool]]: 101 | value: str = self.__config.get(section_key, value_key, fallback=fallback) 102 | 103 | if value and match(r"^[\d.]*$|^(?:True|False)*$", value): 104 | return literal_eval(value) 105 | return value 106 | 107 | def set_environment(self, environment: str): 108 | if environment in ["production", "development", "test"]: 109 | self.__environment = environment 110 | else: 111 | raise EnvironmentError("You need to pass a valid environment. [Production, Development, Test]") 112 | -------------------------------------------------------------------------------- /config/database.template.cfg: -------------------------------------------------------------------------------- 1 | ; This file is only a template for your database configuration. It shows different type of configuration for the 2 | ; three possible environment (production, development and test). 3 | ; 4 | ; Instructions 5 | ; In the config directory, add a file called `database.cfg` and copy everything bellow `CONFIGURATIONS`. 6 | ; Edit `database.cfg` and change the values for your sql database. 7 | ; 8 | ; Configurations 9 | ; In all configuration, you need to specify the adapter. The adapter contains the sql dialect you'll use plus, 10 | ; optionally, the driver. If you want or need to include the driver, simply write dialect+driver (ex. postgresql+psycopg2) 11 | ; 12 | ; Values 13 | ; adapter (always required) : SQL dialect+driver (driver optional) 14 | ; user : The username used to connect to your sql server. 15 | ; password : The password used to connect to your sql server. 16 | ; host : The hostname of your sql database server. 17 | ; port (optional) : The port of you sql database server. 18 | ; database : database name. 19 | ; 20 | ; SQlite configuration only require the `adapter` and the ̀database`. If your database is located 21 | ; in another directory, specify it before the db file. (Ex. path/to/my/db/grace.db) 22 | ; 23 | ; You can see the list of dialects here https://docs.sqlalchemy.org/en/14/dialects/index.html 24 | ; 25 | ; For further details you can consult https://docs.sqlalchemy.org/en/14/core/engines.html 26 | 27 | ; CONFIGURATIONS 28 | [database.production] 29 | adapter = postgresql 30 | user = grace 31 | password = GraceHopper1234 32 | host = localhost 33 | port = 5432 34 | 35 | [database.development] 36 | adapter = mysql 37 | user = grace 38 | password = GraceHopper1234 39 | host = localhost 40 | port = 3306 41 | 42 | [database.test] 43 | adapter = sqlite 44 | database = grace.db -------------------------------------------------------------------------------- /config/environment.cfg: -------------------------------------------------------------------------------- 1 | [production] 2 | log_level = INFO 3 | sqlalchemy_echo = False 4 | 5 | [development] 6 | log_level = INFO 7 | sqlalchemy_echo = False 8 | 9 | [test] 10 | log_level = ERROR 11 | sqlalchemy_echo = False 12 | 13 | test = test 14 | test_int = 42 15 | test_float = 42.5 16 | test_bool = True -------------------------------------------------------------------------------- /config/settings.cfg: -------------------------------------------------------------------------------- 1 | [client] 2 | name = grace 3 | prefix = :: 4 | description = The official Code Society Discord bot 5 | default_color = 0xF4C308 6 | guild_id = ${GUILD_ID} 7 | 8 | [discord] 9 | ; Although it is possible to set directly your discord token here, we recommend, for security reasons, that you set 10 | ; your discord token as an environment variable called 'DISCORD_TOKEN'. 11 | token = ${DISCORD_TOKEN} 12 | 13 | [openweather] 14 | api_key = ${OPENWEATHER_API} 15 | 16 | [openai] 17 | api_key = ${OPENAI_API_TOKEN} 18 | 19 | [github] 20 | api_key = ${GITHUB_API} 21 | 22 | [threads] 23 | channel_id = ${THREADS_CHANNEL_ID} 24 | role_id = ${THREADS_ROLE_ID} 25 | 26 | [moderation] 27 | ; Minimum amount of days before a user can join the server 28 | minimum_account_age = 30 -------------------------------------------------------------------------------- /config/utils.py: -------------------------------------------------------------------------------- 1 | from logging import warning 2 | from os import walk 3 | from pkgutil import walk_packages 4 | from itertools import chain 5 | from pathlib import Path, PurePath 6 | from types import ModuleType 7 | from typing import Set, Any, Generator 8 | 9 | 10 | def find_all_importables(package: ModuleType) -> Set[str]: 11 | """Find all importables in the project and return them in order. 12 | 13 | This solution is based on a solution by Sviatoslav Sydorenko (webknjaz) 14 | * https://github.com/sanitizers/octomachinery/blob/2428877/tests/circular_imports_test.py 15 | """ 16 | return set( 17 | chain.from_iterable( 18 | _discover_path_importables(Path(p), package.__name__) 19 | for p in package.__path__ 20 | ) 21 | ) 22 | 23 | 24 | def _discover_path_importables(pkg_pth, pkg_name) -> Generator[Any, Any, Any]: 25 | """Yield all importables under a given path and package. 26 | 27 | This solution is based on a solution by Sviatoslav Sydorenko (webknjaz) 28 | * https://github.com/sanitizers/octomachinery/blob/2428877/tests/circular_imports_test.py 29 | """ 30 | for dir_path, _d, file_names in walk(pkg_pth): 31 | pkg_dir_path: Path = Path(dir_path) 32 | 33 | if pkg_dir_path.parts[-1] == '__pycache__': 34 | continue 35 | 36 | if all(Path(_).suffix != '.py' for _ in file_names): 37 | continue 38 | 39 | rel_pt: PurePath = pkg_dir_path.relative_to(pkg_pth) 40 | pkg_pref: str = '.'.join((pkg_name, ) + rel_pt.parts) 41 | 42 | if '__init__.py' not in file_names: 43 | warning(f"'{pkg_dir_path}' seems to be missing an '__init__.py'. This might cause issues.") 44 | 45 | yield from ( 46 | pkg_path 47 | for _, pkg_path, _ in walk_packages( 48 | (str(pkg_dir_path), ), prefix=f'{pkg_pref}.', 49 | ) 50 | ) 51 | -------------------------------------------------------------------------------- /db/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/db/__init__.py -------------------------------------------------------------------------------- /db/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /db/alembic/env.py: -------------------------------------------------------------------------------- 1 | from logging.config import fileConfig 2 | 3 | from sqlalchemy import engine_from_config 4 | from sqlalchemy import pool 5 | 6 | from alembic import context 7 | 8 | from bot import app 9 | 10 | # this is the Alembic Config object, which provides 11 | # access to the values within the .ini file in use. 12 | config = context.config 13 | 14 | # Interpret the config file for Python logging. 15 | # This line sets up loggers basically. 16 | if config.config_file_name is not None: 17 | fileConfig(config.config_file_name) 18 | 19 | # add your model's MetaData object here 20 | # for 'autogenerate' support 21 | # from myapp import mymodel 22 | # target_metadata = mymodel.Base.metadata 23 | # target_metadata = None 24 | target_metadata = app.base.metadata 25 | 26 | # other values from the config, defined by the needs of env.py, 27 | # can be acquired: 28 | # my_important_option = config.get_main_option("my_important_option") 29 | # ... etc. 30 | def get_environment_name() -> str: 31 | name = config.config_ini_section 32 | return "production" if name == "alembic" else name 33 | 34 | 35 | def run_migrations_offline() -> None: 36 | """Run migrations in 'offline' mode. 37 | 38 | This configures the context with just a URL 39 | and not an Engine, though an Engine is acceptable 40 | here as well. By skipping the Engine creation 41 | we don't even need a DBAPI to be available. 42 | 43 | Calls to context.execute() here emit the given string to the 44 | script output. 45 | 46 | """ 47 | url = config.get_main_option("sqlalchemy.url") 48 | context.configure( 49 | url=url, 50 | target_metadata=target_metadata, 51 | literal_binds=True, 52 | dialect_opts={"paramstyle": "named"}, 53 | ) 54 | 55 | with context.begin_transaction(): 56 | context.run_migrations() 57 | 58 | 59 | def run_migrations_online() -> None: 60 | """Run migrations in 'online' mode. 61 | 62 | In this scenario we need to create an Engine 63 | and associate a connection with the context. 64 | 65 | """ 66 | app.load(get_environment_name()) 67 | 68 | connectable = engine_from_config( 69 | config.get_section(config.config_ini_section), 70 | prefix="sqlalchemy.", 71 | poolclass=pool.NullPool, 72 | url=app.config.database_uri 73 | ) 74 | 75 | with connectable.connect() as connection: 76 | context.configure( 77 | connection=connection, target_metadata=target_metadata 78 | ) 79 | 80 | with context.begin_transaction(): 81 | context.run_migrations() 82 | 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /db/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = ${repr(up_revision)} 14 | down_revision = ${repr(down_revision)} 15 | branch_labels = ${repr(branch_labels)} 16 | depends_on = ${repr(depends_on)} 17 | 18 | 19 | def upgrade() -> None: 20 | ${upgrades if upgrades else "pass"} 21 | 22 | 23 | def downgrade() -> None: 24 | ${downgrades if downgrades else "pass"} 25 | -------------------------------------------------------------------------------- /db/alembic/versions/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/db/alembic/versions/.gitkeep -------------------------------------------------------------------------------- /db/alembic/versions/11f3c9cd0977_added_puns_tables.py: -------------------------------------------------------------------------------- 1 | """Added puns tables 2 | 3 | Revision ID: 11f3c9cd0977 4 | Revises: 5 | Create Date: 2022-11-08 19:39:27.524172 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '11f3c9cd0977' 14 | down_revision = None 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('puns', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('text', sa.Text(), nullable=True), 24 | sa.PrimaryKeyConstraint('id'), 25 | sa.UniqueConstraint('text'), 26 | if_not_exists=True 27 | ) 28 | op.create_table('pun_words', 29 | sa.Column('id', sa.Integer(), nullable=False), 30 | sa.Column('pun_id', sa.Integer(), nullable=True), 31 | sa.Column('word', sa.String(length=255), nullable=False), 32 | sa.Column('emoji_code', sa.String(length=255), nullable=True), 33 | sa.ForeignKeyConstraint(['pun_id'], ['puns.id'], ), 34 | sa.PrimaryKeyConstraint('id'), 35 | if_not_exists=True 36 | ) 37 | # ### end Alembic commands ### 38 | 39 | 40 | def downgrade() -> None: 41 | # ### commands auto generated by Alembic - please adjust! ### 42 | op.drop_table('pun_words') 43 | op.drop_table('puns') 44 | # ### end Alembic commands ### 45 | -------------------------------------------------------------------------------- /db/alembic/versions/381d2407fcf3_create_thanks_tables.py: -------------------------------------------------------------------------------- 1 | """Create thanks tables 2 | 3 | Revision ID: 381d2407fcf3 4 | Revises: 11f3c9cd0977 5 | Create Date: 2022-12-10 01:52:25.646625 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '381d2407fcf3' 14 | down_revision = '11f3c9cd0977' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table('thanks', 22 | sa.Column('id', sa.Integer(), nullable=False), 23 | sa.Column('member_id', sa.BigInteger(), nullable=False), 24 | sa.Column('count', sa.Integer(), nullable=True), 25 | sa.PrimaryKeyConstraint('id'), 26 | sa.UniqueConstraint('member_id'), 27 | if_not_exists=True 28 | ) 29 | # ### end Alembic commands ### 30 | 31 | 32 | def downgrade() -> None: 33 | # ### commands auto generated by Alembic - please adjust! ### 34 | op.drop_table('thanks') 35 | # ### end Alembic commands ### 36 | -------------------------------------------------------------------------------- /db/alembic/versions/614bb9e370d8_add_settings_for_puns_cooldown.py: -------------------------------------------------------------------------------- 1 | """Add settings for puns cooldown 2 | 3 | Revision ID: 614bb9e370d8 4 | Revises: 381d2407fcf3 5 | Create Date: 2023-05-29 20:55:26.456843 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = '614bb9e370d8' 14 | down_revision = '381d2407fcf3' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | # ### commands auto generated by Alembic - please adjust! ### 21 | op.create_table( 22 | 'bot_settings', 23 | sa.Column('id', sa.Integer(), nullable=False), 24 | sa.Column('puns_cooldown', sa.BigInteger(), nullable=False, server_default="60"), 25 | sa.PrimaryKeyConstraint('id'), 26 | if_not_exists=True 27 | ) 28 | op.add_column('puns', sa.Column('last_invoked', sa.DateTime(), nullable=True)) 29 | op.execute("INSERT INTO bot_settings (id) VALUES (1)") 30 | # ### end Alembic commands ### 31 | 32 | 33 | def downgrade() -> None: 34 | # ### commands auto generated by Alembic - please adjust! ### 35 | op.drop_column('puns', 'last_invoked') 36 | op.drop_table('bot_settings') 37 | # ### end Alembic commands ### 38 | -------------------------------------------------------------------------------- /db/alembic/versions/f8ac0bbc34ac_create_threads.py: -------------------------------------------------------------------------------- 1 | """Added Recurring Thread table 2 | 3 | Revision ID: f8ac0bbc34ac 4 | Revises: 614bb9e370d8 5 | Create Date: 2025-03-10 20:34:24.702582 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | 11 | 12 | # revision identifiers, used by Alembic. 13 | revision = 'f8ac0bbc34ac' 14 | down_revision = '614bb9e370d8' 15 | branch_labels = None 16 | depends_on = None 17 | 18 | 19 | def upgrade() -> None: 20 | op.create_table('threads', 21 | sa.Column('id', sa.Integer(), nullable=False), 22 | sa.Column('title', sa.String(255), nullable=False), 23 | sa.Column('content', sa.Text(), nullable=False), 24 | sa.Column('recurrence', sa.Integer(), nullable=False, default=0), 25 | sa.PrimaryKeyConstraint('id'), 26 | if_not_exists=True 27 | ) 28 | 29 | 30 | def downgrade() -> None: 31 | op.drop_table('threads') 32 | -------------------------------------------------------------------------------- /db/model.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Sized, Optional, List, Tuple, Union 2 | from sqlalchemy.orm import Query 3 | from sqlalchemy.exc import PendingRollbackError, IntegrityError 4 | from bot import app 5 | 6 | 7 | class Model: 8 | """Base class of all models containing collection of command to query records.""" 9 | 10 | def __init__(self, *args, **kwargs): 11 | super().__init__(*args, **kwargs) 12 | 13 | @classmethod 14 | def query(cls) -> Query: 15 | """Return the model query object 16 | 17 | :usage 18 | Model.query() 19 | 20 | :raises 21 | PendingRollbackError, IntegrityError: 22 | In case an exception is thrown during the query, the system will roll back 23 | """ 24 | 25 | try: 26 | return app.session.query(cls) 27 | except (PendingRollbackError, IntegrityError): 28 | app.session.rollback() 29 | raise 30 | 31 | @classmethod 32 | def get(cls, primary_key_identifier: int) -> Any: 33 | """Retrieve and returns the records with the given primary key identifier. None if none is found. 34 | 35 | :usage 36 | Model.get(5) 37 | 38 | :raises 39 | PendingRollbackError, IntegrityError: 40 | In case an exception is thrown during the query, the system will rollback 41 | """ 42 | 43 | return cls.query().get(primary_key_identifier) 44 | 45 | @classmethod 46 | def get_by(cls, **kwargs: Any): 47 | """Retrieve and returns the record with the given keyword argument. None if none is found. 48 | 49 | Only one argument should be passed. If more than one argument are supplied, 50 | a TypeError will be thrown by the function. 51 | 52 | :usage 53 | Model.get_by(name="Dr.Strange") 54 | 55 | :raises 56 | PendingRollbackError, IntegrityError, TypeError: 57 | In case an exception is thrown during the query, the system will rollback 58 | """ 59 | kwargs_count = len(kwargs) 60 | 61 | if kwargs_count > 1: 62 | raise TypeError(f"Only one argument is accepted ({kwargs_count} given)") 63 | 64 | return cls.where(**kwargs).first() 65 | 66 | @classmethod 67 | def all(cls) -> List: 68 | """Retrieve and returns all records of the model 69 | 70 | :usage 71 | Model.all() 72 | """ 73 | 74 | return cls.query().all() 75 | 76 | @classmethod 77 | def first(cls, limit: int = 1) -> Query: 78 | """Retrieve N first records 79 | 80 | :usage 81 | Model.first() 82 | Model.first(limit=100) 83 | """ 84 | 85 | if limit == 1: 86 | return cls.query().first() 87 | # noinspection PyUnresolvedReferences 88 | return cls.query().limit(limit).all() 89 | 90 | @classmethod 91 | def where(cls, **kwargs: Any) -> Query: 92 | """Retrieve and returns all records filtered by the given conditions 93 | 94 | :usage 95 | Model.where(name="some name", id=5) 96 | """ 97 | 98 | return cls.query().filter_by(**kwargs) 99 | 100 | @classmethod 101 | def filter(cls, *criterion: Tuple[Any]) -> Query: 102 | """Shorter way to call the sqlalchemy query filter method 103 | 104 | :usage 105 | Model.filter(Model.id > 5) 106 | """ 107 | 108 | return app.session.query(cls).filter(*criterion) 109 | 110 | @classmethod 111 | def count(cls) -> int: 112 | """Returns the number of records for the model 113 | 114 | :usage 115 | Model.count() 116 | """ 117 | 118 | return cls.query().count() 119 | 120 | @classmethod 121 | def create(cls, auto_save: bool = True, **kwargs: Optional[Any]) -> Any: 122 | """Creates, saves and return a new instance of the model. 123 | 124 | :usage 125 | Model.create(name="A name", color="Blue") 126 | """ 127 | model = cls(**kwargs) 128 | 129 | if auto_save: 130 | model.save() 131 | return model 132 | 133 | def save(self, commit: bool = True): 134 | """Saves the model. If commit is set to `True` it will "[f]lush pending changes and commit 135 | the current transaction.". For more information about `commit`, read sqlalchemy docs. 136 | 137 | :usage 138 | model.save() 139 | 140 | :raises 141 | PendingRollbackError, IntegrityError: 142 | In case an exception is thrown during the query, the system will rollback 143 | """ 144 | 145 | try: 146 | app.session.add(self) 147 | 148 | if commit: 149 | app.session.commit() 150 | except (PendingRollbackError, IntegrityError): 151 | app.session.rollback() 152 | raise 153 | 154 | def delete(self, commit: bool = True): 155 | """Delete the model. If commit is set to `True` it will "flush pending changes and commit 156 | the current transaction.". For more information about `commit`, read sqlalchemy docs. 157 | 158 | :usage 159 | model.delete() 160 | 161 | :raises 162 | PendingRollbackError, IntegrityError: 163 | In case an exception is thrown during the query, the system will rollback 164 | """ 165 | 166 | try: 167 | app.session.delete(self) 168 | 169 | if commit: 170 | app.session.commit() 171 | except (PendingRollbackError, IntegrityError): 172 | app.session.rollback() 173 | raise 174 | -------------------------------------------------------------------------------- /db/seed.py: -------------------------------------------------------------------------------- 1 | """Database seed modules 2 | 3 | All seed modules are located in the `db/seeds` folder. They need a ̀seed_database` function. Without this function, 4 | the modules will be skipped and thus the seeding will not work correctly. 5 | 6 | Template Ex. 7 | ``` 8 | # import your models 9 | 10 | def seed_database(): 11 | # Create an instance of the model with the desired data 12 | Model(name="a name") 13 | 14 | # Save the model 15 | model.save() 16 | ``` 17 | """ 18 | 19 | import importlib 20 | import pkgutil 21 | from db import seeds 22 | 23 | 24 | def get_seeds(): 25 | """Generate all seed modules""" 26 | 27 | for module in pkgutil.walk_packages(seeds.__path__, f"{seeds.__name__}."): 28 | if not module.ispkg: 29 | yield importlib.import_module(module.name) 30 | -------------------------------------------------------------------------------- /db/seeds/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/db/seeds/__init__.py -------------------------------------------------------------------------------- /db/seeds/answer.py: -------------------------------------------------------------------------------- 1 | from bot.models.extensions.fun.answer import Answer 2 | 3 | 4 | def seed_database(): 5 | initial_answers = [ 6 | "Hell no.", 7 | "Prolly not.", 8 | "Idk bro.", 9 | "Prolly.", 10 | "Hell yeah my dude.", 11 | "It is certain.", 12 | "It is decidedly so.", 13 | "Without a Doubt.", 14 | "Definitely.", 15 | "You may rely on it.", 16 | "As i see it, Yes.", 17 | "Most Likely.", 18 | "Outlook Good.", 19 | "Yes!", 20 | "No!", 21 | "Signs a point to Yes!", 22 | "Reply Hazy, Try again.", 23 | "IDK m8 try again.", 24 | "Better not tell you know.", 25 | "Cannot predict now.", 26 | "Concentrate and ask again.", 27 | "Don't Count on it.", 28 | "My reply is No.", 29 | "My sources say No.", 30 | "Outlook not so good.", 31 | "Very Doubtful" 32 | ] 33 | 34 | for answer in initial_answers: 35 | new_answer = Answer.create(answer=answer) 36 | -------------------------------------------------------------------------------- /db/seeds/bot.py: -------------------------------------------------------------------------------- 1 | from bot.models.bot import BotSettings 2 | 3 | 4 | def seed_database(): 5 | BotSettings.create() 6 | -------------------------------------------------------------------------------- /db/seeds/channels.py: -------------------------------------------------------------------------------- 1 | from bot.models.channel import Channel 2 | 3 | 4 | def seed_database(): 5 | """The seed function. This function is needed in order for the seed to be executed""" 6 | initial_channels = { 7 | "introductions": 916658807789199390, 8 | "roles": 823239926023192596, 9 | "info": 825404191492276225, 10 | "rules": 823183118902362132, 11 | "welcome": 823178343943897091, 12 | "moderation_logs": 876592591657918514, 13 | "help": 1019793296740073614, 14 | "posting_guidelines": 1068966762730750105 15 | } 16 | 17 | for channel_name in initial_channels: 18 | Channel.create(channel_name=channel_name, channel_id=initial_channels.get(channel_name)) 19 | 20 | -------------------------------------------------------------------------------- /db/seeds/puns.py: -------------------------------------------------------------------------------- 1 | from bot.models.extensions.language.pun import Pun 2 | 3 | 4 | def seed_database(): 5 | pun_specs = [{ 6 | 'text': "What do you call a person who hates hippos because they're so hateful? Hippo-critical.", 7 | 'pun_words': [ 8 | {'word': 'hippo', 'emoji_code': ':hippopotamus:'}, 9 | {'word': 'critical', 'emoji_code': ':thumbs_down:'} 10 | ] 11 | }, { 12 | 'text': "You call a bad discord mod an admin-is-traitor.", 13 | 'pun_words': [ 14 | {'word': 'admin', 'emoji_code': ':hammer:'}, 15 | {'word': 'traitor', 'emoji_code': ':hammer:'} 16 | ] 17 | }, { 18 | 'text': "Games like nerdlegame are a form of mathochism.", 19 | 'pun_words': [ 20 | {'word': 'math', 'emoji_code': ':1234:'}, 21 | {'word': 'masochism', 'emoji_code': ':knife:'} 22 | ] 23 | }] 24 | 25 | for pun_spec in pun_specs: 26 | pun = Pun.create(text=pun_spec['text']) 27 | 28 | for pun_word in pun_spec['pun_words']: 29 | pun.add_pun_word(pun_word['word'], pun_word['emoji_code']) 30 | -------------------------------------------------------------------------------- /db/seeds/trigger.py: -------------------------------------------------------------------------------- 1 | from bot.models.extensions.language.trigger import Trigger 2 | 3 | 4 | def seed_database(): 5 | trigger_words = [ 6 | "linus", 7 | "#linus", 8 | "#torvalds", 9 | "#linustorvalds", 10 | "torvalds" 11 | ] 12 | 13 | linus_trigger = Trigger.create( 14 | name="Linus", 15 | positive_emoji_code=":penguin:", 16 | negative_emoji_code=':pouting_face:', 17 | ) 18 | for trigger_word in trigger_words: 19 | linus_trigger.add_trigger_word(trigger_word) 20 | 21 | Trigger.create( 22 | name="Grace", 23 | positive_emoji_code=":blush:", 24 | negative_emoji_code=":cry:", 25 | ) 26 | 27 | 28 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the project 2 | We want everybody to be able to contribute to our projects whatever your level or programming experiance is. Don't hesitate to ask for help on the Code Society's [Discord server](https://discord.gg/6GEF9H9m) whatever the problem is. 3 | 4 | ## How you can contribute? 5 | You can contribute to this project in the follwing ways 6 | - [Reporting a bug](#report-a-bug) 7 | - [Proposing new features](#propose-a-new-feature) 8 | - [Adding changes/Submitting a fix](#add-changes-or-submitting-a-fix) 9 | - [Additional information](#additional-information) 10 | 11 | ## Report a bug 12 | - Open an [issue](https://github.com/Code-Society-Lab/grace/issues) follwing the **bug template**. 13 | - Communicate directly with us on the Code Society's [Discord server](https://discord.gg/6GEF9H9m). 14 | - Fix the bug directly. To do so, follow [add a changes](#add-changes). 15 | 16 | --- 17 | 18 | ## Propose a new feature. 19 | - Open an [issue](https://github.com/Code-Society-Lab/grace/issues) follwing the **feature request template**. 20 | - Communicate directly with us on the Code Society's [Discord server](https://discord.gg/6GEF9H9m). 21 | 22 | --- 23 | 24 | ## Add changes or Submitting a fix 25 | ### Setting up the bot 26 | Installing Grace is fairly simple. You can do it in three short step. 27 | 28 | 0. [Install Python and dependencies](#install-python-and-dependencies) 29 | 1. [Set up your app and token](#set-up-your-app-and-token) 30 | 2. [Configuring the database](#configuring-the-database) 31 | 32 | #### Install Python and dependencies 33 | 0. The first step is pretty simple, install [Python](https://www.python.org/downloads/). You need to install Python 3.9 or 34 | higher. 35 | 36 | 1. In the `grace` directory project, open a terminal (Linus/MacOS) or cmd (Windows) and execute `pip install -e .` 37 | (recommend for development) or `pip install .` to install all the dependencies needed in order to make the bot work. 38 | Wait until the process is finished. 39 | 40 | #### Set up your app and token 41 | First, if you didn't already do it, [register](https://discord.com/developers/docs/getting-started#creating-an-app) your 42 | bot with Discord. Then, create a file called `.env` in the project directory. Open your new `.env` file and add 43 | `DISCORD_TOKEN=` inside. (Replace by your discord token). 44 | 45 | > Do not share that file nor the information inside it to anyone. 46 | 47 | ### Database 48 | When Grace is started, a database configuration file will automatically be generated. SQLite is the default database 49 | used in development. If you wish to change it, you can follow the instruction bellow. 50 | 51 | ### Changing database 52 | In order for the bot to work, you need to connect it to a database. Supported databases are SQLite, MySQL/MariaDB, 53 | PostgresSQL, Oracle and Microsoft SQL Server. ([Supported dialects](https://docs.sqlalchemy.org/en/14/dialects/index.html)) 54 | 55 | To set up the connection to your database, create a new file in the `config` folder and call it `database.cfg`. You can 56 | have three database configurations, one for each environment (production, test and development). Each section is 57 | delimited by `[database.]`. 58 | 59 | The next step is to set up the adapter _dialect + drivers (optional)_. The rest will depend on your database. 60 | Bellow, you'll find examples of common configuration. 61 | 62 | > You can also use `config/database.template.cfg` to help you set up your `database.cfg`. 63 | 64 | #### SQLite 65 | ```ini 66 | adapter = sqlite 67 | database = grace.db 68 | ``` 69 | 70 | #### MySQL/MariaDB 71 | ```ini 72 | adapter = mysql 73 | user = grace 74 | password = GraceHopper1234 75 | host = localhost 76 | port = 3306 77 | ``` 78 | 79 | #### PostgreSQL 80 | ```ini 81 | adapter = postgresql+psycopg2 82 | user = grace 83 | password = GraceHopper1234 84 | host = localhost 85 | port = 5432 86 | ``` 87 | 88 | All those can be bypassed by directly passing an url. 89 | 90 | To create the database, tables and add the default data, execute the following commands: 91 | - `grace db create` 92 | - `grace db seed` 93 | 94 | > Don't forget to specify the environment you are using with `-e environment` 95 | --- 96 | 97 | ### Before adding your changes 98 | - Verify that there is no [issue](https://github.com/Code-Society-Lab/grace/issues) already created for the changes you want to bring. If there is and no one is assigned to the issue, assign it to yourself. 99 | - If there's no issue corresponding, [create one](https://github.com/Code-Society-Lab/grace/issues/new/choose). Don't forget to assign yourself the issue. 100 | 101 | ### Adding your changes 102 | - Start by [Forking the repository](https://docs.github.com/en/github/getting-started-with-github/quickstart/fork-a-repo). 103 | - From your forked repository, apply your changes to the code. Don't forget to [setup](#setting-up-the-bot) your bot to test it. 104 | - When you changes are done, [open a PR](#open-a-pull-request). 105 | 106 | #### Submit your PR 107 | Once your PR is submited, we (the staff) will [review](#review) it with you. The first thing you're going to want to do is a [self review](#self-review). 108 | 109 | ### Review 110 | We review every Pull Request. The purpose of reviews is to create the best code possible and ensure that the code is secured and respect the guidelines. 111 | 112 | - Reviews are always respectful, acknowledging that everyone did the best possible job with the knowledge they had at the time.
113 | - Reviews discuss content, not the person who created it.
114 | - Reviews are constructive and start conversation around feedback. 115 | 116 | #### Self review 117 | You should always review your own PR first. 118 | 119 | #### How to self review my code? 120 | - Confirm that the changes meet the user experience and goals of the bot. 121 | - Ensure that your code is **clean** and follows Python's [PEP-0008](https://www.python.org/dev/peps/pep-0008/). 122 | - Verify your code for grammar and spelling mistakes (The code and the text must be in **English**). 123 | - Test your changes to ensure there's no bugs. 124 | 125 | #### What to do after your PR is merged? 126 | Congratulate yourself, you did it! The Code Society thank you for helping us improve our community. 127 | 128 | ### Open a Pull Request 129 | - The name of the PR must be the same as the issue related to the PR. 130 | - The PR must be linked to the opened issue. 131 | - The PR must describe what change have been done. 132 | - Images or examples of the changes are more than welcome if necessary. 133 | 134 | ### Code guidelines 135 | - The code must follow Python's [PEP-0008](https://www.python.org/dev/peps/pep-0008/). 136 | - The code must be consistent and use descriptive names. 137 | - The code must try to be the most modular as possible. 138 | 139 | _In case of doubt, don't hesitate to ask for help on the [Discord server](https://discord.gg/6GEF9H9m)._ 140 | 141 | --- 142 | 143 | ## Additional information 144 | - All development has been tested on **Linux**. Running the bot on **Windows** could cause some issues or unexpected results. 145 | - Note that **we reserve the right** to close and/or refuse any issue (Don't worry we will indicate why). 146 | - Even after your PRs are merged, it may take some time to be applied on the server since the changes needs to be tested before entering in production. 147 | 148 | 149 | -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/lib/__init__.py -------------------------------------------------------------------------------- /lib/bidirectional_iterator.py: -------------------------------------------------------------------------------- 1 | from typing import List, TypeVar, Generic, Iterator, Optional 2 | 3 | T = TypeVar("T") 4 | 5 | 6 | class BidirectionalIterator(Generic[T]): 7 | """An iterator allows to go forward and backward in a list, modify the list during iteration and obtain the item 8 | at the current position in the list. 9 | 10 | :param collection: An optional collection of items, default to an empty List. 11 | :type collection: Optional[List[T]] 12 | """ 13 | def __init__(self, collection: Optional[List[T]]): 14 | self.__collection: List[T] = collection or [] 15 | self.__position: int = 0 16 | 17 | @property 18 | def current(self) -> T: 19 | """Returns the item at the current position in the list. 20 | 21 | :return: The current value 22 | :rtype: T 23 | """ 24 | return self.__collection[self.__position] 25 | 26 | @property 27 | def first(self) -> T: 28 | """Returns the first element in the list. 29 | 30 | :return: The first element 31 | :rtype: T 32 | """ 33 | return self.__collection[0] 34 | 35 | @property 36 | def last(self) -> T: 37 | """Returns the last item in the list. 38 | 39 | :return: The first element 40 | :rtype: T 41 | """ 42 | return self.__collection[-1] 43 | 44 | def add(self, item: T): 45 | """Adds and item at the end of the list. 46 | 47 | :param item: An Item 48 | :type: T 49 | """ 50 | self.__collection.append(item) 51 | 52 | def remove(self, item: T): 53 | """Removes the first occurrence of the item from a list. 54 | 55 | :param item: An item 56 | :type: T 57 | """ 58 | self.__collection.remove(item) 59 | 60 | def next(self) -> T: 61 | """Returns the next item in the list if it has any next item or return the current item. 62 | 63 | :return: The next and current item 64 | :rtype: T 65 | """ 66 | if self.has_next(): 67 | self.__position += 1 68 | return self.current 69 | 70 | def previous(self) -> T: 71 | """Returns the previous item in the list if it has any previous item or return the current item. 72 | 73 | :return: The previous or current item 74 | :rtype: T 75 | """ 76 | if self.has_previous(): 77 | self.__position -= 1 78 | return self.current 79 | 80 | def has_next(self) -> bool: 81 | """Returns true if there is any next item relative to the current position. 82 | 83 | :return: True if there is any next item or False. 84 | :rtype: bool 85 | """ 86 | return self.__position + 1 < len(self.__collection) 87 | 88 | def has_previous(self) -> bool: 89 | """Returns true if there's any previous item relative to the current position. 90 | 91 | :return: True if there is any previous item or False 92 | :rtype: bool 93 | """ 94 | return self.__position > 0 95 | 96 | def __len__(self) -> int: 97 | return len(self.__collection) 98 | 99 | def __iter__(self) -> Iterator[T]: 100 | return iter(self.__collection) 101 | -------------------------------------------------------------------------------- /lib/config_required.py: -------------------------------------------------------------------------------- 1 | from bot import app 2 | from typing import Callable, Optional 3 | from discord.ext import commands 4 | from discord.ext.commands import CogMeta, Context, DisabledCommand 5 | 6 | 7 | class ConfigRequiredError(DisabledCommand): 8 | """The base exception type for errors to required config check 9 | 10 | Inherit from `discord.ext.commands.CommandError` and can be handled like 11 | other CommandError exception in `on_command_error` 12 | """ 13 | pass 14 | 15 | 16 | class MissingRequiredConfigError(ConfigRequiredError): 17 | """Exception raised when a required configuration is missing. 18 | 19 | Inherit from `ConfigRequiredError` 20 | """ 21 | 22 | def __init__(self, section_key: str, value_key: str, message: Optional[str] = None): 23 | base_error_message = f"Missing config '{value_key}' in section '{section_key}'" 24 | super().__init__(f"{base_error_message}\n{message}" if message else base_error_message) 25 | 26 | 27 | def cog_config_required(section_key: str, value_key: str, message: Optional[str] = None) -> Callable: 28 | """Validates the presences of a given configuration before each 29 | invocation of a `discord.ext.commands.Cog` commands 30 | :param section_key: 31 | The required section key 32 | :param value_key: 33 | The required value key 34 | :param message: 35 | The optional message/instruction if missing required config 36 | :raises TypeError: 37 | If the class is not a Cog 38 | """ 39 | 40 | def wrapper(cls: CogMeta) -> CogMeta: 41 | async def _cog_before_invoke(self, _: Context): 42 | if not self.required_config: 43 | raise MissingRequiredConfigError(section_key, value_key, message) 44 | 45 | setattr(cls, "required_config", app.config.get(section_key, value_key)) 46 | setattr(cls, "cog_before_invoke", _cog_before_invoke) 47 | 48 | return cls 49 | return wrapper 50 | 51 | 52 | def command_config_required(section_key: str, value_key: str, message: Optional[str] = None) -> Callable[[Context], bool]: 53 | """Validates the presences of a given configuration before running 54 | the `discord.ext.commands.Command` 55 | 56 | :param section_key: 57 | The required section key 58 | :param value_key: 59 | The required value key 60 | :param message: 61 | The optional message/instruction if missing required config 62 | :raises TypeError: 63 | If the class is not a Cog 64 | """ 65 | 66 | async def predicate(_: Context) -> bool: 67 | if not app.config.get(section_key, value_key): 68 | raise MissingRequiredConfigError(section_key, value_key, message) 69 | return True 70 | return commands.check(predicate) 71 | -------------------------------------------------------------------------------- /lib/paged_embeds.py: -------------------------------------------------------------------------------- 1 | from typing import List, Any, Callable, Optional 2 | from discord import Embed, Interaction, Message 3 | from discord.ext.commands import Context 4 | from discord.ui import View, Button 5 | from emoji.core import emojize 6 | from lib.bidirectional_iterator import BidirectionalIterator 7 | 8 | 9 | class EmbedButton(Button): 10 | def __init__(self, embed_callback: Callable, *args, **kwargs): 11 | super().__init__(*args, **kwargs) 12 | self._embed_callback: Callable = embed_callback 13 | 14 | async def callback(self, interaction: Interaction) -> Any: 15 | embed = self._embed_callback() 16 | await self.view.after_button_callback() 17 | 18 | return await interaction.response.edit_message(embed=embed, view=self.view) 19 | 20 | 21 | class PagedEmbedView(View): 22 | def __init__(self, embeds: List[Embed]): 23 | super().__init__() 24 | 25 | self.__message: Optional[Message] = None 26 | self.__embeds: BidirectionalIterator[Embed] = BidirectionalIterator(embeds) 27 | self.__arrow_button: List[EmbedButton] = [ 28 | EmbedButton(self.__embeds.previous, emoji=emojize(":left_arrow:"), disabled=True), 29 | EmbedButton(self.__embeds.next, emoji=emojize(":right_arrow:"), disabled=True) 30 | ] 31 | 32 | self.add_item(self.previous_arrow) 33 | self.add_item(self.next_arrow) 34 | 35 | self.refresh_arrows() 36 | 37 | @property 38 | def next_arrow(self) -> EmbedButton: 39 | return self.__arrow_button[1] 40 | 41 | @property 42 | def previous_arrow(self) -> EmbedButton: 43 | return self.__arrow_button[0] 44 | 45 | def add_embed(self, embed: Embed): 46 | self.__embeds.add(embed) 47 | self.refresh_arrows() 48 | 49 | def refresh_arrows(self): 50 | self.previous_arrow.disabled = not self.__embeds.has_previous() 51 | self.next_arrow.disabled = not self.__embeds.has_next() 52 | 53 | async def after_button_callback(self): 54 | self.refresh_arrows() 55 | 56 | async def on_timeout(self): 57 | self.remove_item(self.previous_arrow) 58 | self.remove_item(self.next_arrow) 59 | 60 | await self.__message.edit(embed=self.__embeds.current, view=self) 61 | 62 | async def send(self, ctx: Context, ephemeral: bool = True): 63 | self.__message = await ctx.send(embed=self.__embeds.current, view=self, ephemeral=ephemeral) -------------------------------------------------------------------------------- /lib/timed_view.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep as async_sleep, create_task, Task 2 | from datetime import timedelta 3 | from typing import Any, Optional 4 | from discord.ui import View 5 | 6 | 7 | class TimedView(View): 8 | """A discord.ui.View class that implements a timer. 9 | 10 | The view will call an event (`on_time_update`) each seconds until the timer elapsed. Once the timer elapsed, 11 | another event (`on_timer_elapsed`) is called. 12 | 13 | :param seconds: The time in seconds to display the view, default to 900 seconds (15 minutes). 14 | :type seconds: int 15 | """ 16 | 17 | def __init__(self, seconds: int = 900): 18 | super().__init__(timeout=None) 19 | 20 | self.seconds: int = seconds 21 | self.__timer_task: Optional[Task[None]] = None 22 | 23 | @property 24 | def seconds(self) -> int: 25 | """Returns the timer's remaining seconds. 26 | 27 | :return: The remaining seconds 28 | :rtype: int 29 | """ 30 | return self.__seconds 31 | 32 | @seconds.setter 33 | def seconds(self, seconds: int): 34 | """Change the number of seconds for the timer to elapse. 35 | 36 | :param seconds: The new amount of seconds before the timer elapses 37 | :type: int 38 | :raises ValueError: Raised if the given value is lower than 1 39 | """ 40 | if seconds < 1: 41 | raise ValueError("Value cannot be lower than 1") 42 | 43 | self.__seconds = seconds 44 | 45 | @property 46 | def remaining_time(self) -> str: 47 | """Returns the timer's remaining time in HH:MM:SS. 48 | 49 | :return: The timer's remaining time. 50 | :rtype: str 51 | """ 52 | return str(timedelta(seconds=self.seconds)) 53 | 54 | def start_timer(self): 55 | """Starts the view's timer task""" 56 | self.__timer_task = create_task(self.__impl_timer_task(), name=f"grace-timed-view-timer-{self.id}") 57 | 58 | def cancel_timer(self): 59 | """Cancels the view's timer task""" 60 | self.__timer_task.cancel() 61 | 62 | async def __impl_timer_task(self): 63 | while not self.has_time_elapsed(): 64 | await self.on_timer_update() 65 | 66 | self.__seconds -= 1 67 | await async_sleep(1) 68 | await self.on_timer_elapsed() 69 | 70 | async def on_timer_update(self): 71 | """A callback that is called at each timer update. 72 | 73 | This callback does nothing by default but can be overriden to change its behaviour. 74 | """ 75 | pass 76 | 77 | async def on_timer_elapsed(self): 78 | """A callback that is called when the timer elapsed. 79 | 80 | By default, the callback calls `self.stop()` but can be overriden to change its behaviour. 81 | """ 82 | self.stop() 83 | 84 | def has_time_elapsed(self): 85 | """Returns true if the time has elapsed 86 | 87 | :returns: True if the time has elapsed or False 88 | :rtype: bool 89 | """ 90 | return self.seconds <= 0 91 | -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/logs/.gitkeep -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | warn_no_return = False 3 | namespace_packages = True 4 | ignore_missing_imports = True 5 | 6 | [mypy-discord.*] 7 | follow_imports = skip 8 | ignore_errors = true -------------------------------------------------------------------------------- /nixpacks.toml: -------------------------------------------------------------------------------- 1 | [phases.setup] 2 | aptPkgs = ["python3", "python3-pip", "python-is-python3", "libpq-dev", "python3-dev", "postgresql"] # Install the wget package with apt-get 3 | 4 | [phases.build] 5 | cmds = ["pip3 install --upgrade build setuptools", "pip3 install psycopg2-binary nltk", "pip3 install ."] 6 | 7 | [start] 8 | cmd = "python3 -m alembic upgrade head && python3 bin/grace start" 9 | -------------------------------------------------------------------------------- /nltk.txt: -------------------------------------------------------------------------------- 1 | vader_lexicon -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel", "Cython"] -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | setup( 4 | name='Grace', 5 | version='1.20.0', 6 | author='Code Society Lab', 7 | description='The Code Society community Bot', 8 | url="https://github.com/Code-Society-Lab/grace", 9 | project_urls={ 10 | "Documentation": "https://github.com/Code-Society-Lab/grace/wiki", 11 | "Issue tracker": "https://github.com/Code-Society-Lab/grace/issues", 12 | "Discord server": "https://discord.gg/code-society-823178343943897088", 13 | }, 14 | license="GNU General Public License v3.0", 15 | python_requires='>=3.10.0', 16 | packages=find_packages(), 17 | include_package_data=True, 18 | install_requires=[ 19 | 'python-dotenv', 20 | 'coloredlogs', 21 | 'logger', 22 | 'sqlalchemy', 23 | 'sqlalchemy-utils', 24 | 'discord>=2.1', 25 | 'pytest', 26 | 'emoji>=2.1.0', 27 | 'nltk', 28 | 'discord-pretty-help==2.0.4', 29 | 'requests', 30 | 'pillow', 31 | 'geopy', 32 | 'pytz', 33 | 'tzdata', 34 | 'timezonefinder', 35 | 'mypy', 36 | 'alembic==1.13.3', 37 | 'configparser', 38 | 'pygithub', 39 | 'googletrans==4.0.0-rc1', 40 | 'openai==0.26.1', 41 | 'coverage', 42 | 'apscheduler' 43 | ], 44 | scripts=['bin/grace'] 45 | ) 46 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/tests/__init__.py -------------------------------------------------------------------------------- /tests/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Society-Lab/grace/cf291199aa7f2da235fa065997c36ca156f698fc/tests/config/__init__.py -------------------------------------------------------------------------------- /tests/config/test_application.py: -------------------------------------------------------------------------------- 1 | from config.application import Application 2 | 3 | 4 | def test_environment(): 5 | app = Application() 6 | app.load(environment="test") 7 | 8 | assert app.config.current_environment == "test" 9 | 10 | 11 | def test_bot(): 12 | app = Application() 13 | app.load(environment="test") 14 | 15 | assert app.bot is not None 16 | assert app.bot.name == "client" 17 | -------------------------------------------------------------------------------- /tests/config/test_config.py: -------------------------------------------------------------------------------- 1 | from config.config import Config 2 | 3 | 4 | def test_set_environment(): 5 | config = Config() 6 | config.set_environment("test") 7 | 8 | assert config.current_environment == "test" 9 | 10 | 11 | def test_section_name(): 12 | config = Config() 13 | config.set_environment("test") 14 | 15 | assert config.environment.name == "test" 16 | 17 | 18 | def test_database(): 19 | config = Config() 20 | config.set_environment("test") 21 | 22 | assert config.database["adapter"] == "sqlite" 23 | assert config.database["database"] == "grace_test.db" 24 | 25 | 26 | def test_database_uri(): 27 | from sqlalchemy.engine import URL 28 | 29 | config = Config() 30 | config.set_environment("test") 31 | 32 | assert config.database_uri == URL.create(drivername="sqlite", database="grace_test.db") 33 | 34 | 35 | def test_get(): 36 | config = Config() 37 | config.set_environment("test") 38 | 39 | assert config.get("test", "test") == "test" 40 | assert config.get("test", "test_int") == 42 41 | assert config.get("test", "test_float") == 42.5 42 | assert config.get("test", "test_bool") is True 43 | assert config.get("test", "test_fallback") is None 44 | 45 | 46 | def test_client(): 47 | config = Config() 48 | config.set_environment("test") 49 | 50 | assert config.client is not None 51 | 52 | -------------------------------------------------------------------------------- /tests/models/__init__.py: -------------------------------------------------------------------------------- 1 | from bin.database import reset 2 | from bot import app 3 | 4 | app.load("test", command_sync=False) 5 | 6 | reset() 7 | -------------------------------------------------------------------------------- /tests/models/test_extension.py: -------------------------------------------------------------------------------- 1 | from bot.classes.state import State 2 | from bot.models.extension import Extension 3 | 4 | 5 | def test_create_extension(): 6 | """Test creating an extension""" 7 | extension = Extension.create( 8 | module_name="test_extension", 9 | state=State.ENABLED 10 | ) 11 | 12 | assert extension.module_name == "test_extension" 13 | assert extension.state == State.ENABLED 14 | 15 | 16 | def test_get_extension(): 17 | """Test getting an extension""" 18 | extension = Extension.get_by(module_name="test_extension") 19 | 20 | assert Extension.get(extension.id) == extension 21 | 22 | 23 | def test_disable_extension(): 24 | """Test disabling an extension""" 25 | extension = Extension.get_by(module_name="test_extension") 26 | extension.state = State.DISABLED 27 | 28 | assert extension.state == State.DISABLED 29 | 30 | 31 | def test_enable_extension(): 32 | """Test enabling an extension""" 33 | extension = Extension.get_by(module_name="test_extension") 34 | extension.state = State.ENABLED 35 | 36 | assert extension.state == State.ENABLED 37 | 38 | 39 | def test_get_by_state(): 40 | """Test getting extensions by state""" 41 | extensions = Extension.by_state(State.ENABLED) 42 | 43 | assert extensions.count() > 0 44 | 45 | 46 | def test_delete_extension(): 47 | """Test deleting an extension""" 48 | extension = Extension.get_by(module_name="test_extension") 49 | extension.delete() 50 | 51 | assert Extension.get(extension.id) is None 52 | --------------------------------------------------------------------------------