From ce1a07de3da02d3d0e8520ed061e980e26195e43 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 1 Nov 2020 22:52:53 +0100 Subject: [PATCH 1/9] Add basic commands and cog structure --- bot/cogs/reminders.py | 65 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 bot/cogs/reminders.py diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py new file mode 100644 index 0000000..ea8def3 --- /dev/null +++ b/bot/cogs/reminders.py @@ -0,0 +1,65 @@ +from collections import defaultdict + +from discord import Member +from discord.ext.commands import Cog, Context, group +from discord.ext.commands.errors import BadArgument + +from bot.core.bot import Bot +from bot.core.converters import Duration +from bot.core.timer import Timer +from bot.utils.time import stringify_duration + + +class Reminders(Cog): + def __init__(self, bot: Bot): + self.bot = bot + self.reminders = defaultdict(list) + self.timer = Timer("reminder") + + async def _remind(self, author: Member, message: str) -> None: + pass + + @group(invoke_without_command=True, alias=["reminders", "remind"]) + async def reminder(self, ctx: Context) -> None: + """Commands for configuring the reminders.""" + await ctx.send_help(ctx.command) + + @reminder.command(alias=["create", "make", "remind"]) + async def add(self, ctx: Context, duration: Duration, *, message: str) -> None: + """ + Send a reminder of given `message` after the specified `duration` expires. + """ + if duration == float("inf"): + raise BadArgument(":x: Duration can't be infinite") + + self.reminders[ctx.author].append(message) + _task_name = f"{ctx.author.id}.{len(self.reminders[ctx.author])}" + self.timer.delay(duration, _task_name, self._remind(ctx.author, message)) + + await ctx.send(f"You'll be reminded in {stringify_duration(duration)}: {message}.") + + @reminder.command(aliases=["delete"]) + async def remove(self, ctx: Context, reminder_id: int) -> None: + """ + Remove given reminder based on the `reminder_id` + + Reminder IDs are ordered numbers from 1, based on the order + you created your reminders. For example reminder id 2 will be + the second active reminder. + """ + reminder_amount = len(self.reminders[ctx.author]) + if reminder_amount < reminder_id: + if reminder_amount == 0: + await ctx.send(":x: Sorry, you don't have any active reminders.") + else: + await ctx.send(f":x: Sorry, you don't have reminder with this ID. (Maximum ID: {reminder_amount})") + return + + self.timer.abort(f"{ctx.author.id}.{reminder_id - 1}") + del self.reminders[ctx.author][reminder_id - 1] + + await ctx.send(f"Reminder {reminder_id} has been cancelled.") + + +def setup(bot: Bot) -> None: + bot.add_cog(Reminders(bot)) From 89cbaf55289ce386c1dd931f36aac4de4f493bce Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 1 Nov 2020 23:05:51 +0100 Subject: [PATCH 2/9] Add reminder send functionality --- bot/cogs/reminders.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index ea8def3..8811754 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -1,6 +1,6 @@ from collections import defaultdict -from discord import Member +from discord import Color, Embed, Member from discord.ext.commands import Cog, Context, group from discord.ext.commands.errors import BadArgument @@ -17,9 +17,14 @@ def __init__(self, bot: Bot): self.timer = Timer("reminder") async def _remind(self, author: Member, message: str) -> None: - pass + embed = Embed( + title="Your reminder has arrived", + description=message, + color=Color.blue() + ) + await author.send(embed=embed) - @group(invoke_without_command=True, alias=["reminders", "remind"]) + @group(invoke_without_command=True, aliases=["reminders", "remind"]) async def reminder(self, ctx: Context) -> None: """Commands for configuring the reminders.""" await ctx.send_help(ctx.command) From 3c67b9e10f529e5616284dcf471d9ebb18b9a409 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sun, 1 Nov 2020 23:06:04 +0100 Subject: [PATCH 3/9] Load reminder cog --- bot/__main__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/__main__.py b/bot/__main__.py index 8024bae..06f33ac 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -12,11 +12,15 @@ "bot.cogs.error_handler", "bot.cogs.help", "bot.cogs.sudo", + "bot.cogs.moderation.lock", "bot.cogs.moderation.slowmode", + "bot.cogs.setup.roles", "bot.cogs.setup.permissions", + "bot.cogs.embeds", + "bot.cogs.reminders" ] db_tables = [ "bot.database.roles", From cf9ae04489cc91b1d3c4a1e800aa45023b5f4355 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 2 Nov 2020 01:42:38 +0100 Subject: [PATCH 4/9] Fix reminder cancelling --- bot/cogs/reminders.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 8811754..8f008c3 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -17,6 +17,8 @@ def __init__(self, bot: Bot): self.timer = Timer("reminder") async def _remind(self, author: Member, message: str) -> None: + self.reminders[author].remove(message) + embed = Embed( title="Your reminder has arrived", description=message, @@ -43,7 +45,7 @@ async def add(self, ctx: Context, duration: Duration, *, message: str) -> None: await ctx.send(f"You'll be reminded in {stringify_duration(duration)}: {message}.") - @reminder.command(aliases=["delete"]) + @reminder.command(aliases=["delete", "cancel", "abort"]) async def remove(self, ctx: Context, reminder_id: int) -> None: """ Remove given reminder based on the `reminder_id` @@ -60,7 +62,7 @@ async def remove(self, ctx: Context, reminder_id: int) -> None: await ctx.send(f":x: Sorry, you don't have reminder with this ID. (Maximum ID: {reminder_amount})") return - self.timer.abort(f"{ctx.author.id}.{reminder_id - 1}") + self.timer.abort(f"{ctx.author.id}.{reminder_id}") del self.reminders[ctx.author][reminder_id - 1] await ctx.send(f"Reminder {reminder_id} has been cancelled.") From 42c334e4af2fcda4f3b0ccfcec469363207296ae Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 2 Nov 2020 02:17:56 +0100 Subject: [PATCH 5/9] Fix log message spelling --- bot/cogs/moderation/slowmode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/slowmode.py b/bot/cogs/moderation/slowmode.py index b17552b..953ec5d 100644 --- a/bot/cogs/moderation/slowmode.py +++ b/bot/cogs/moderation/slowmode.py @@ -24,7 +24,7 @@ async def slow_mode(self, ctx: Context, duration: Duration) -> None: await ctx.channel.edit(slowmode_delay=duration) if duration: - log_msg = f"ser {ctx.author} applied slowmode to #{ctx.channel} for {stringify_duration(duration)}" + log_msg = f"User {ctx.author} applied slowmode to #{ctx.channel} for {stringify_duration(duration)}" msg = f"⏱️ Applied slowmode for this channel, time delay: {stringify_duration(duration)}." else: log_msg = f"User {ctx.author} removed slowmode from #{ctx.channel}" From b7e92f885775ea8cf7becc4c1b434b5ccac65e7b Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 4 Mar 2021 15:09:59 +0100 Subject: [PATCH 6/9] Merge branch 'master' into reminders --- .dockerignore | 49 ++++ .github/workflows/lint.yaml | 111 +++++++ .github/workflows/python-package.yml | 29 -- .github/workflows/status_embed.yaml | 64 ++++ CONTRIBUTING.md | 161 +--------- Dockerfile | 32 ++ LICENSE | 360 +++++++++++++++++++++-- Pipfile | 9 +- Pipfile.lock | 422 +++++++++++++++++++-------- README.md | 41 ++- bot/__main__.py | 27 +- bot/cogs/__init__.py | 0 bot/cogs/automod/__init__.py | 0 bot/cogs/automod/filepaste.py | 87 ++++++ bot/cogs/core/__init__.py | 0 bot/cogs/{ => core}/error_handler.py | 16 +- bot/cogs/{ => core}/help.py | 0 bot/cogs/{ => core}/sudo.py | 15 +- bot/cogs/logging/__init__.py | 0 bot/cogs/logging/join_log.py | 82 ++++++ bot/cogs/logging/member_log.py | 181 ++++++++++++ bot/cogs/logging/message_log.py | 317 ++++++++++++++++++++ bot/cogs/logging/mod_log.py | 198 +++++++++++++ bot/cogs/logging/server_log.py | 243 +++++++++++++++ bot/cogs/logging/voice_log.py | 71 +++++ bot/cogs/moderation/lock.py | 130 ++++++--- bot/cogs/moderation/slowmode.py | 7 +- bot/cogs/moderation/strikes.py | 68 +++++ bot/cogs/reminders.py | 4 +- bot/cogs/setup/log_channels.py | 56 ++++ bot/cogs/setup/permissions.py | 93 +++--- bot/cogs/setup/roles.py | 84 +++--- bot/cogs/utility/__init__.py | 0 bot/cogs/{ => utility}/embeds.py | 42 ++- bot/config.py | 44 ++- bot/core/autoload.py | 50 ++++ bot/core/bot.py | 106 +++++-- bot/database/__init__.py | 422 ++++----------------------- bot/database/log_channels.py | 169 +++++------ bot/database/permissions.py | 282 ++++++++++-------- bot/database/roles.py | 151 +++++----- bot/database/strikes.py | 184 ++++++++++++ bot/utils/audit_parse.py | 106 +++++++ bot/{core => utils}/converters.py | 37 ++- bot/utils/diff.py | 237 +++++++++++++++ bot/utils/paste_upload.py | 125 ++++++++ bot/utils/time.py | 38 ++- bot/{core => utils}/timer.py | 1 + docker-compose.yml | 33 +++ 49 files changed, 3754 insertions(+), 1230 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/lint.yaml delete mode 100644 .github/workflows/python-package.yml create mode 100644 .github/workflows/status_embed.yaml create mode 100644 Dockerfile create mode 100644 bot/cogs/__init__.py create mode 100644 bot/cogs/automod/__init__.py create mode 100644 bot/cogs/automod/filepaste.py create mode 100644 bot/cogs/core/__init__.py rename bot/cogs/{ => core}/error_handler.py (93%) rename bot/cogs/{ => core}/help.py (100%) rename bot/cogs/{ => core}/sudo.py (91%) create mode 100644 bot/cogs/logging/__init__.py create mode 100644 bot/cogs/logging/join_log.py create mode 100644 bot/cogs/logging/member_log.py create mode 100644 bot/cogs/logging/message_log.py create mode 100644 bot/cogs/logging/mod_log.py create mode 100644 bot/cogs/logging/server_log.py create mode 100644 bot/cogs/logging/voice_log.py create mode 100644 bot/cogs/moderation/strikes.py create mode 100644 bot/cogs/setup/log_channels.py create mode 100644 bot/cogs/utility/__init__.py rename bot/cogs/{ => utility}/embeds.py (92%) create mode 100644 bot/core/autoload.py create mode 100644 bot/database/strikes.py create mode 100644 bot/utils/audit_parse.py rename bot/{core => utils}/converters.py (87%) create mode 100644 bot/utils/diff.py create mode 100644 bot/utils/paste_upload.py rename bot/{core => utils}/timer.py (99%) create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..122cefa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,49 @@ +# Ignore python cache +__pycache__/ + +# virtualenv +env/ +venv/ +.venv + +# Tests cache +.pytest_cache/ + +# Vscode settings +.vscode/ + +# Ignore enviromental vars in `db.env` and `.env` files (contains private information and database authentication data) +*.env + +# Ignore intellij Files (PyCharm) +.idea + +# Ignore spyder Files +.spyproject/ + +# Ignore ds store files +.DS_STORE + +# Ignore coverage reports +.coverage +.coverage.* +coverage.xml +*.cover +htmlcov/ + + +# Ignore logs +logs/* + +# Backup files +*.bak + +# Ignore docker itself +docker + +# Personal TODO files +TODO + +# Ignore SQL query files (these are usually only used for testing) +*.sql +tests_sql/ diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..8213e28 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,111 @@ +name: Lint + +on: + push: + branches: + - master + pull_request: + +env: + # Make sure pip caches dependencies and installs as user + PIP_NO_CACHE_DIR: false + PIP_USER: 1 + + # Make sure pipenv doesn't use fancy graphics + PIPENV_HIDE_EMOJIS: 1 + PIPENV_NOSPIN: 1 + + # Make sure pipenv does not try reuse an environment it's running in + PIPENV_IGNORE_VIRTUALENVS: 1 + + # Use direct paths to allow caching + PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base + PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Add custom PYTHONUSERBASE to PATH + run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH + + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.8' + + # Cache python dependencies + # the key is a composite of multiple values which + # when changed, the cache won't be restored + # in order to make updating possible + - name: Python Dependency Caching + uses: actions/cache@v2 + id: python_cache + with: + path: ${{ env.PYTHONUSERBASE }} + key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\ + ${{ steps.python.outputs.python-version }}-\ + ${{ hashFiles('./Pipfile', './Pipfile.lock') }}" + + # In case dependencies weren't restored, install them + - name: Install dependencies with pipenv + if: steps.python_cache.outputs.cache-hit != 'true' + run: | + python -m pip install --upgrade pip setuptools wheel pipenv + python -m pipenv install --dev --deploy --system + + # Cache pre-commit environment + # the key consists relevant factors to allow + # updating, when pre-commit changes + - name: Pre-commit Environment Caching + uses: actions/cache@v2 + with: + path: ${{ env.PRE_COMMIT_HOME }} + key: "precommit-0-${{ runner.os }}-${{ env.PRE_COMMIT_HOME }}-\ + ${{ steps.python.outputs.python-version }}-\ + ${{ hashFiles('./.pre-commit-config.yaml') }}" + + # Skip flake8 since it will have it's own section + # make a user install for pre-commit by using + # PIP_USER=0 + - name: Run pre-commit hooks + run: | + export PIP_USER=0; SKIP=flake8 pre-commit run --all-files + + # Run flake8 formatting checks for general code style (lint) check + # ignore `.cache` directory during this check, it is where the pipenv + # stores the venv and where other caching details live, we don't want to scan it + # Error format: + # ::error file={filename},line={line},col={col}::{message} + - name: Run flake8 + run: "flake8 \ + --format='::error file=%(path)s,line=%(row)d,col=%(col)d::\ + [flake8] %(code)s: %(text)s' \ + --extend-exclude '.cache'" + + # Prepare the Pull Request Payload artifact. If this fails, we + # we fail silently using the `continue-on-error` option. It's + # nice if this succeeds, but if it fails for any reason, it + # does not mean that our lint-test checks failed. + - name: Prepare Pull Request Payload artifact + id: prepare-artifact + if: always() && github.event_name == 'pull_request' + continue-on-error: true + run: cat $GITHUB_EVENT_PATH | jq '.pull_request' > pull_request_payload.json + + # This only makes sense if the previous step succeeded. To + # get the original outcome of the previous step before the + # `continue-on-error` conclusion is applied, we use the + # `.outcome` value. This step also fails silently. + - name: Upload a Build Artifact + if: always() && steps.prepare-artifact.outcome == 'success' + continue-on-error: true + uses: actions/upload-artifact@v2 + with: + name: pull-request-payload + path: pull_request_payload.json diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml deleted file mode 100644 index 14ec57f..0000000 --- a/.github/workflows/python-package.yml +++ /dev/null @@ -1,29 +0,0 @@ -name: Python package - -on: - push: - branches: [ master ] - pull_request: - branches: [ master ] - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.8] - - steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip setuptools wheel pipenv - python -m pipenv sync --dev - - - name: Lint with flake8 - run: | - python -m pipenv run lint diff --git a/.github/workflows/status_embed.yaml b/.github/workflows/status_embed.yaml new file mode 100644 index 0000000..ac808dc --- /dev/null +++ b/.github/workflows/status_embed.yaml @@ -0,0 +1,64 @@ +name: Status Embed + +on: + workflow_run: + workflows: + - Lint + types: + - completed + +jobs: + status_embed: + name: Send Status Embed to Discord + runs-on: ubuntu-latest + + steps: + # A workflow_run event does not contain all the information + # we need for a PR embed. That's why we upload an artifact + # with that information in the Lint workflow. + - name: Get Pull Request Information + id: pr_info + if: github.event.workflow_run.event == 'pull_request' + run: | + curl -s -H "Authorization: token $GITHUB_TOKEN" ${{ github.event.workflow_run.artifacts_url }} > artifacts.json + DOWNLOAD_URL=$(cat artifacts.json | jq -r '.artifacts[] | select(.name == "pull-request-payload") | .archive_download_url') + [ -z "$DOWNLOAD_URL" ] && exit 1 + wget --quiet --header="Authorization: token $GITHUB_TOKEN" -O pull_request_payload.zip $DOWNLOAD_URL || exit 2 + unzip -p pull_request_payload.zip > pull_request_payload.json + [ -s pull_request_payload.json ] || exit 3 + echo "::set-output name=pr_author_login::$(jq -r '.user.login // empty' pull_request_payload.json)" + echo "::set-output name=pr_number::$(jq -r '.number // empty' pull_request_payload.json)" + echo "::set-output name=pr_title::$(jq -r '.title // empty' pull_request_payload.json)" + echo "::set-output name=pr_source::$(jq -r '.head.label // empty' pull_request_payload.json)" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # Send an informational status embed to Discord instead of the + # standard embeds that Discord sends. This embed will contain + # more information and we can fine tune when we actually want + # to send an embed. + - name: GitHub Actions Status Embed for Discord + uses: SebastiaanZ/github-status-embed-for-discord@v0.2.1 + with: + # Our GitHub Actions webhook + webhook_id: '761185811609419787' + webhook_token: ${{ secrets.webhook_token }} + + # We need to provide the information of the workflow that + # triggered this workflow instead of this workflow. + workflow_name: ${{ github.event.workflow_run.name }} + run_id: ${{ github.event.workflow_run.id }} + run_number: ${{ github.event.workflow_run.run_number }} + status: ${{ github.event.workflow_run.conclusion }} + sha: ${{ github.event.workflow_run.head_sha }} + + # Now we can use the information extracted in the previous step: + pr_author_login: ${{ steps.pr_info.outputs.pr_author_login }} + pr_number: ${{ steps.pr_info.outputs.pr_number }} + pr_title: ${{ steps.pr_info.outputs.pr_title }} + pr_source: ${{ steps.pr_info.outputs.pr_source }} + + # And some additional details + actor: ${{ github.actor }} + repository: ${{ github.repository }} + ref: ${{ github.ref }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0fe6077..f3e0673 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,13 @@ -# Contributing Guide +# Contributing This project is fully open-sourced and will be automatically deployed whenever commits are pushed to `master` branch, so these are the guidelines to keep everything clean and in working order. Note that contributions may be rejected on the basis of a contributor failing to follow these guidelines +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. + ## Rules 1. **No force-pushes** or modifying the Git history in any way. @@ -121,152 +125,15 @@ def foobar(ctx: Context, value: str) -> None: Note that we end each sentence in docstrings with `.` to keep everything consistent -## Database table management - -We use a custom way to define our database tables, this was implemented in [PR #11](https://github.com/Codin-Nerds/Neutron-Bot/pull/11) and updated in [PR #14](https://github.com/Codin-Nerds/Neutron-Bot/pull/14) -You can check those pull requests as they explains in detail what was added and how to use it. - -### Making a new table - -Every database table needs to have a it's own file. This file should be named by the table name (although this isn't mandatory). -This file needs to be stored under the `bot/database` directory and it should look like this: - -```py -import asyncpg - -from bot.core.bot import Bot -from bot.database import DBTable, Database - - -class Roles(DBTable): - columns = { - "serverid": "NUMERIC(40) UNIQUE NOT NULL", - "_default": "NUMERIC(40) DEFAULT 0", - "muted": "NUMERIC(40) DEFAULT 0", - "staff": "NUMERIC(40) DEFAULT 0", - } - - def __init__(self, bot: Bot, database: Database): - super().__init__(database, "roles") - self.bot = bot - self.database = database - - async def set_staff_role(self, server_id: int, role_id: int) -> None: - await self.db_upsert( - columns=["serverid", "staff_role"], - values=[server_id, role_id], - conflict_columns=["serverid"] - ) - - async def get_staff_role(self, server_id: int) -> asyncpg.Record: - return await self.db_get( - columns="staff_role", - specification="serverid=$1", - sql_args=[guild.id] - ) - - -async def load(bot: Bot, database: Database) -> None: - await database.add_table(Roles(bot, database)) -``` - -You can see the use of a `load` function on the bottom of the file, similarly to what discord.py uses for cogs. - -You can also notice the absence of `SQL` code in the `set_staff_role` and `get_staff_role`, this is because of the custom built functions to make the process of managing the database table easier and it's also considered more readable. +## Logging levels -There are a total of 3 functions like this which provide the SQL abstraction layer: `DBTable.db_upsert`, `DBTable.db_get`, and `DBTable.db_set`. In case you'd need something more specific you will have to fall back to the SQL query, you can execute this query using `DBTable.db_execute(sql, [arg1, arg2])` or if you want to obtain data from the database, you can use the `DBTable.db_fetchone(sql, [arg1, arg2])` or `DBTable.db_fetch(sql, [arg1, arg2])`. +We define our logging levels as follows: -The `columns` class attribute on the top holds the column table structure which will be used for initial creation the table. The populate command will be executed automatically when the table loads and it will use the given sql arguments defined in the values of this dictionary. - -After you've created your table file, you'll need to reference it in the `db_tables` list defined in [`bot/__main__.py`](https://github.com/Codin-Nerds/Neutron-Bot/blob/master/bot/__main__.py). - -The table will be loaded with the bots initiation automatically. - -### Using caching - -The database system also introduces a built-in way to easily use caching for your database. This means you can use synchronous functions to read your database from cache rather than making asynchronous calls to the database itself and reading it from there. Not only does that means you don't have to use async functions, it also makes accessing the database data faster. - -Even through it's advantages, there are cases where caching isn't wanted in order to prevent using up too much memory. If this is your case, you don't have to do absolutely anything, just make the database as described in the section above, but if you do want caching, just read along. - -In order to use caching, all you need to do is specify the `caching` class parameter, similarly to the `columns` parameter, except this one is optional. By including it the lower-level methods will automatically populate a `self.cache` dictionary for you based on your caching structure in this dictionary. This class variable looks like this: - -```py -class FooTable(DBTable): - caching = { - "key": (int, "serverid"), - - "_default": int, - "muted": (int, 0), - "staff": None - } - ... -``` - -The `"key"` is used to hold the column which will be used as a unique identifier, under which the other column values will be stored, you can think of it as the primary key for caching. This is the key you'll use to access the cache itself (`self.cache[some_serverid]`). - -The rest of the values follow simple syntax guidelines: the key represents the name of that column, and the value can be one of the 3 examples shown above: - -1. **`int`**: This value definition only provides the datatype which this column should be using. This is the type that will be used to convert the `asyncpg.Record`. in this case, the following would be stored to cache `int(specific_record)` -2. **`(int, 0)`**: This acts similarly to the above except it also provides a default value of `0`. -3. **`None`**: This syntax will assume the same as the one with the pure data type, but the data type will be set to `t.Any` rather than something specific. If this type is used, you'll be storing the `asyncpg.Record` instances rather than any specified data type. - -In order to get the values from your cache you can use 2 new getter/setter methods: `DBTable.cache_get(key, column)` and `DBTable.cache_update(key, column, value)`. Usage of these methods can be seen in a full table example here: - -```py -class FooTable(DBTable): - columns = { - "serverid": "NUMERIC(40) UNIQUE NOT NULL", - "_default": "NUMERIC(40) DEFAULT 0", - "muted": "NUMERIC(40) DEFAULT 0", - "staff": "NUMERIC(40) DEFAULT 0", - } - caching = { - "key": (int, "serverid"), - - "_default": (int, 0), - "muted": (int, 0), - "staff": (int, 0) - } - - def __init__(self, bot: Bot, database: Database): - super().__init__(database, "roles") - self.bot = bot - self.database = database - - async def _set_role(self, role_name: str, guild_id: int, role_id: int) -> None: - """Set a `role_name` column to store `role_id` for the specific `guild_id`.""" - await self.db_upsert( - columns=["serverid", role_name], - values=[guild_id, role_id], - conflict_columns=["serverid"] - ) - self.cache_update(guild.id, role_name, role.id) # Cache setter function - - def _get_role(self, role_name: str, guild_id: int) -> int: - """Get a `role_name` column for specific `guild_id` from cache.""" - return self.cache_get(guild_id, role_name) # Cache getter function -``` - -### Referencing the database inside of your cogs - -After that you've set up your database table classes with your custom functions, you're ready to use them inside of a cog, doing that is pretty simple: - -```py -from discord.ext.commands import Cog - -from bot.core.bot import Bot -from bot.database.roles import Roles, Context, command - -class Foo(Cog): - def __init__(self, bot: bot): - self.bot = bot - self.roles_db: Roles = Roles.reference() - - async def set_staff(self, ctx: Context, staff_role_id: int) -> None: - await self.roles_db.set_staff_role(ctx.guild.id, staff_role_id) -``` - -Notice that the type of `self.roles_db` is hard defined. This is because the return type `Roles.reference()` is only set to an instance of `DBTable` not the specific table. That means that if you didn't hard define the type hint for it, your code editor won't be able to provide meaningful suggestions from that database table class. +* **DEBUG**: These events should add context to what's happening in a development setup to make it easier to follow what's going on while working on a project. +* **INFO**: These events are normal and don't need direct attention, but are worth keeping track of in production, like seeing which cogs were loaded during a start-up. +* **WARNING**: These events are out of the ordinary and should be fixed, but have not cause a failure. +* **ERROR**: These events have caused a failure in specific part of the application and require urgent attention. +* **CRITICAL**: These events have cause the whole application to fail and require immediate intervention. ## Work in Progress (WIP) PRs @@ -288,3 +155,7 @@ As stated earlier **ensure that "Allow edits from maintainers" is checked** This ## Changes to this Arrangement All projects evolve over time, and this contribution guide is no different. This document is open to pull requests or changes by contributors. If you believe you have something valuable to add or change, please don't hesitate to do so in a PR. + +## Footnotes + +This document was inspired by [Contributor Covenant 1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/) and [PyDis team](https://github.com/python-discord/bot/blob/master/CONTRIBUTING.md) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6edc506 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM python:3.8-slim + +# Define Git SHA build argument +ARG git_sha="development" + +# Set pip to have cleaner logs and no saved cache +ENV PIP_NO_CACHE_DIR=false \ + PIPENV_HIDE_EMOJIS=1 \ + PIPENV_IGNORE_VIRTUALENVS=1 \ + PIPENV_NOSPIN=1 \ + GIT_SHA=$git_sha + +RUN apt-get -y update \ + && apt-get install -y \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Install pipenv +RUN pip install -U pipenv + +# Create the working directory +WORKDIR /bot + +# Install project dependencies +COPY Pipfile* ./ +RUN pipenv install --system --deploy + +# Copy the source code in last to optimize rebuilding the image +COPY . . + +ENTRYPOINT ["pipenv"] +CMD ["run", "start"] diff --git a/LICENSE b/LICENSE index d2555ce..d159169 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,339 @@ -MIT License - -Copyright (c) 2020 The Codin' Hole - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/Pipfile b/Pipfile index c1c1e43..5998762 100644 --- a/Pipfile +++ b/Pipfile @@ -4,8 +4,8 @@ url = "https://pypi.org/simple" verify_ssl = true [dev-packages] -flake8 = "~=3.8.3" -flake8-annotations = "~=2.4.0" +flake8 = "~=3.8" +#flake8-annotations = "~=2.5" flake8-bugbear = "~=20.1.4" flake8-import-order = "~=0.18.1" ipython = "~=7.18.1" @@ -15,9 +15,12 @@ autopep8 = "~=1.5.4" [packages] loguru = "~=0.5.3" python-dateutil = "~=2.8.1" -"discord.py" = "~=1.5" +"discord.py" = "~=1.6" discord-ext-menus = {git="https://github.com/Rapptz/discord-ext-menus"} asyncpg = "~=0.21.0" +sqlalchemy = "==1.4.0b3" +aiohttp = "~=3.7.4" +deepdiff = "~=5.2.3" [scripts] start = "python -m bot" diff --git a/Pipfile.lock b/Pipfile.lock index b678f24..edf692b 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "8c94c12a82a668062b41b1a09c0619e69819be8903f8bb8951c212af5cc5c58b" + "sha256": "b2bc102e711d9bf2da3b2af9834d1c31e57332632ec5dfda63173ea074195548" }, "pipfile-spec": 6, "requires": { @@ -18,27 +18,53 @@ "default": { "aiohttp": { "hashes": [ - "sha256:1a4160579ffbc1b69e88cb6ca8bb0fbd4947dfcbf9fb1e2a4fc4c7a4a986c1fe", - "sha256:206c0ccfcea46e1bddc91162449c20c72f308aebdcef4977420ef329c8fcc599", - "sha256:2ad493de47a8f926386fa6d256832de3095ba285f325db917c7deae0b54a9fc8", - "sha256:319b490a5e2beaf06891f6711856ea10591cfe84fe9f3e71a721aa8f20a0872a", - "sha256:470e4c90da36b601676fe50c49a60d34eb8c6593780930b1aa4eea6f508dfa37", - "sha256:60f4caa3b7f7a477f66ccdd158e06901e1d235d572283906276e3803f6b098f5", - "sha256:66d64486172b032db19ea8522328b19cfb78a3e1e5b62ab6a0567f93f073dea0", - "sha256:687461cd974722110d1763b45c5db4d2cdee8d50f57b00c43c7590d1dd77fc5c", - "sha256:698cd7bc3c7d1b82bb728bae835724a486a8c376647aec336aa21a60113c3645", - "sha256:797456399ffeef73172945708810f3277f794965eb6ec9bd3a0c007c0476be98", - "sha256:a885432d3cabc1287bcf88ea94e1826d3aec57fd5da4a586afae4591b061d40d", - "sha256:c506853ba52e516b264b106321c424d03f3ddef2813246432fa9d1cefd361c81", - "sha256:fb83326d8295e8840e4ba774edf346e87eca78ba8a89c55d2690352842c15ba5" - ], - "version": "==3.6.3" + "sha256:119feb2bd551e58d83d1b38bfa4cb921af8ddedec9fad7183132db334c3133e0", + "sha256:16d0683ef8a6d803207f02b899c928223eb219111bd52420ef3d7a8aa76227b6", + "sha256:2eb3efe243e0f4ecbb654b08444ae6ffab37ac0ef8f69d3a2ffb958905379daf", + "sha256:2ffea7904e70350da429568113ae422c88d2234ae776519549513c8f217f58a9", + "sha256:40bd1b101b71a18a528ffce812cc14ff77d4a2a1272dfb8b11b200967489ef3e", + "sha256:418597633b5cd9639e514b1d748f358832c08cd5d9ef0870026535bd5eaefdd0", + "sha256:481d4b96969fbfdcc3ff35eea5305d8565a8300410d3d269ccac69e7256b1329", + "sha256:4c1bdbfdd231a20eee3e56bd0ac1cd88c4ff41b64ab679ed65b75c9c74b6c5c2", + "sha256:5563ad7fde451b1986d42b9bb9140e2599ecf4f8e42241f6da0d3d624b776f40", + "sha256:58c62152c4c8731a3152e7e650b29ace18304d086cb5552d317a54ff2749d32a", + "sha256:5b50e0b9460100fe05d7472264d1975f21ac007b35dcd6fd50279b72925a27f4", + "sha256:5d84ecc73141d0a0d61ece0742bb7ff5751b0657dab8405f899d3ceb104cc7de", + "sha256:5dde6d24bacac480be03f4f864e9a67faac5032e28841b00533cd168ab39cad9", + "sha256:5e91e927003d1ed9283dee9abcb989334fc8e72cf89ebe94dc3e07e3ff0b11e9", + "sha256:62bc216eafac3204877241569209d9ba6226185aa6d561c19159f2e1cbb6abfb", + "sha256:6c8200abc9dc5f27203986100579fc19ccad7a832c07d2bc151ce4ff17190076", + "sha256:6ca56bdfaf825f4439e9e3673775e1032d8b6ea63b8953d3812c71bd6a8b81de", + "sha256:71680321a8a7176a58dfbc230789790639db78dad61a6e120b39f314f43f1907", + "sha256:7c7820099e8b3171e54e7eedc33e9450afe7cd08172632d32128bd527f8cb77d", + "sha256:7dbd087ff2f4046b9b37ba28ed73f15fd0bc9f4fdc8ef6781913da7f808d9536", + "sha256:822bd4fd21abaa7b28d65fc9871ecabaddc42767884a626317ef5b75c20e8a2d", + "sha256:8ec1a38074f68d66ccb467ed9a673a726bb397142c273f90d4ba954666e87d54", + "sha256:950b7ef08b2afdab2488ee2edaff92a03ca500a48f1e1aaa5900e73d6cf992bc", + "sha256:99c5a5bf7135607959441b7d720d96c8e5c46a1f96e9d6d4c9498be8d5f24212", + "sha256:b84ad94868e1e6a5e30d30ec419956042815dfaea1b1df1cef623e4564c374d9", + "sha256:bc3d14bf71a3fb94e5acf5bbf67331ab335467129af6416a437bd6024e4f743d", + "sha256:c2a80fd9a8d7e41b4e38ea9fe149deed0d6aaede255c497e66b8213274d6d61b", + "sha256:c44d3c82a933c6cbc21039326767e778eface44fca55c65719921c4b9661a3f7", + "sha256:cc31e906be1cc121ee201adbdf844522ea3349600dd0a40366611ca18cd40e81", + "sha256:d5d102e945ecca93bcd9801a7bb2fa703e37ad188a2f81b1e65e4abe4b51b00c", + "sha256:dd7936f2a6daa861143e376b3a1fb56e9b802f4980923594edd9ca5670974895", + "sha256:dee68ec462ff10c1d836c0ea2642116aba6151c6880b688e56b4c0246770f297", + "sha256:e76e78863a4eaec3aee5722d85d04dcbd9844bc6cd3bfa6aa880ff46ad16bfcb", + "sha256:eab51036cac2da8a50d7ff0ea30be47750547c9aa1aa2cf1a1b710a1827e7dbe", + "sha256:f4496d8d04da2e98cc9133e238ccebf6a13ef39a93da2e87146c8c8ac9768242", + "sha256:fbd3b5e18d34683decc00d9a360179ac1e7a320a5fee10ab8053ffd6deab76e0", + "sha256:feb24ff1226beeb056e247cf2e24bba5232519efb5645121c4aea5b6ad74c1f2" + ], + "index": "pypi", + "version": "==3.7.4" }, "async-timeout": { "hashes": [ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" ], + "markers": "python_full_version >= '3.5.3'", "version": "==3.0.1" }, "asyncpg": { @@ -74,10 +100,11 @@ }, "attrs": { "hashes": [ - "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", - "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" + "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", + "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], - "version": "==20.2.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.3.0" }, "chardet": { "hashes": [ @@ -86,24 +113,82 @@ ], "version": "==3.0.4" }, + "deepdiff": { + "hashes": [ + "sha256:3d3da4bd7e01fb5202088658ed26427104c748dda56a0ecfac9ce9a1d2d00844", + "sha256:ae2cb98353309f93fbfdda4d77adb08fb303314d836bb6eac3d02ed71a10b40e" + ], + "index": "pypi", + "version": "==5.2.3" + }, "discord-ext-menus": { "git": "https://github.com/Rapptz/discord-ext-menus", - "ref": "84caae8038d0d3adc860957ccef05baeec2e2dd8" + "ref": "4429b564fd8f031deaccd60caa5be7320e248816" }, "discord.py": { "hashes": [ - "sha256:3acb61fde0d862ed346a191d69c46021e6063673f63963bc984ae09a685ab211", - "sha256:e71089886aa157341644bdecad63a72ff56b44406b1a6467b66db31c8e5a5a15" + "sha256:3df148daf6fbcc7ab5b11042368a3cd5f7b730b62f09fb5d3cbceff59bcfbb12", + "sha256:ba8be99ff1b8c616f7b6dcb700460d0222b29d4c11048e74366954c465fdd05f" ], "index": "pypi", - "version": "==1.5.0" + "version": "==1.6.0" + }, + "greenlet": { + "hashes": [ + "sha256:0a77691f0080c9da8dfc81e23f4e3cffa5accf0f5b56478951016d7cfead9196", + "sha256:0ddd77586553e3daf439aa88b6642c5f252f7ef79a39271c25b1d4bf1b7cbb85", + "sha256:111cfd92d78f2af0bc7317452bd93a477128af6327332ebf3c2be7df99566683", + "sha256:122c63ba795fdba4fc19c744df6277d9cfd913ed53d1a286f12189a0265316dd", + "sha256:181300f826625b7fd1182205b830642926f52bd8cdb08b34574c9d5b2b1813f7", + "sha256:1a1ada42a1fd2607d232ae11a7b3195735edaa49ea787a6d9e6a53afaf6f3476", + "sha256:1bb80c71de788b36cefb0c3bb6bfab306ba75073dbde2829c858dc3ad70f867c", + "sha256:1d1d4473ecb1c1d31ce8fd8d91e4da1b1f64d425c1dc965edc4ed2a63cfa67b2", + "sha256:292e801fcb3a0b3a12d8c603c7cf340659ea27fd73c98683e75800d9fd8f704c", + "sha256:2c65320774a8cd5fdb6e117c13afa91c4707548282464a18cf80243cf976b3e6", + "sha256:4365eccd68e72564c776418c53ce3c5af402bc526fe0653722bc89efd85bf12d", + "sha256:5352c15c1d91d22902582e891f27728d8dac3bd5e0ee565b6a9f575355e6d92f", + "sha256:58ca0f078d1c135ecf1879d50711f925ee238fe773dfe44e206d7d126f5bc664", + "sha256:5d4030b04061fdf4cbc446008e238e44936d77a04b2b32f804688ad64197953c", + "sha256:5d69bbd9547d3bc49f8a545db7a0bd69f407badd2ff0f6e1a163680b5841d2b0", + "sha256:5f297cb343114b33a13755032ecf7109b07b9a0020e841d1c3cedff6602cc139", + "sha256:62afad6e5fd70f34d773ffcbb7c22657e1d46d7fd7c95a43361de979f0a45aef", + "sha256:647ba1df86d025f5a34043451d7c4a9f05f240bee06277a524daad11f997d1e7", + "sha256:719e169c79255816cdcf6dccd9ed2d089a72a9f6c42273aae12d55e8d35bdcf8", + "sha256:7cd5a237f241f2764324396e06298b5dee0df580cf06ef4ada0ff9bff851286c", + "sha256:875d4c60a6299f55df1c3bb870ebe6dcb7db28c165ab9ea6cdc5d5af36bb33ce", + "sha256:90b6a25841488cf2cb1c8623a53e6879573010a669455046df5f029d93db51b7", + "sha256:94620ed996a7632723a424bccb84b07e7b861ab7bb06a5aeb041c111dd723d36", + "sha256:b5f1b333015d53d4b381745f5de842f19fe59728b65f0fbb662dafbe2018c3a5", + "sha256:c5b22b31c947ad8b6964d4ed66776bcae986f73669ba50620162ba7c832a6b6a", + "sha256:c93d1a71c3fe222308939b2e516c07f35a849c5047f0197442a4d6fbcb4128ee", + "sha256:cdb90267650c1edb54459cdb51dab865f6c6594c3a47ebd441bc493360c7af70", + "sha256:cfd06e0f0cc8db2a854137bd79154b61ecd940dce96fad0cba23fe31de0b793c", + "sha256:d3789c1c394944084b5e57c192889985a9f23bd985f6d15728c745d380318128", + "sha256:da7d09ad0f24270b20f77d56934e196e982af0d0a2446120cb772be4e060e1a2", + "sha256:df3e83323268594fa9755480a442cabfe8d82b21aba815a71acf1bb6c1776218", + "sha256:df8053867c831b2643b2c489fe1d62049a98566b1646b194cc815f13e27b90df", + "sha256:e1128e022d8dce375362e063754e129750323b67454cac5600008aad9f54139e", + "sha256:e6e9fdaf6c90d02b95e6b0709aeb1aba5affbbb9ccaea5502f8638e4323206be", + "sha256:eac8803c9ad1817ce3d8d15d1bb82c2da3feda6bee1153eec5c58fa6e5d3f770", + "sha256:eb333b90036358a0e2c57373f72e7648d7207b76ef0bd00a4f7daad1f79f5203", + "sha256:ed1d1351f05e795a527abc04a0d82e9aecd3bdf9f46662c36ff47b0b00ecaf06", + "sha256:f3dc68272990849132d6698f7dc6df2ab62a88b0d36e54702a8fd16c0490e44f", + "sha256:f59eded163d9752fd49978e0bab7a1ff21b1b8d25c05f0995d140cc08ac83379", + "sha256:f5e2d36c86c7b03c94b8459c3bd2c9fe2c7dab4b258b8885617d44a22e453fb7", + "sha256:f6f65bf54215e4ebf6b01e4bb94c49180a589573df643735107056f7a910275b", + "sha256:f8450d5ef759dbe59f84f2c9f77491bb3d3c44bc1a573746daf086e70b14c243", + "sha256:f97d83049715fd9dec7911860ecf0e17b48d8725de01e45de07d8ac0bd5bc378" + ], + "markers": "python_version >= '3'", + "version": "==1.0.0" }, "idna": { "hashes": [ - "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", - "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" + "sha256:5205d03e7bcbb919cc9c19885f9920d622ca52448306f2377daede5cf3faac16", + "sha256:c5b02147e01ea9920e6b0a3f1f7bb833612d507592c837a6c49552768f4054e1" ], - "version": "==2.10" + "markers": "python_version >= '3.4'", + "version": "==3.1" }, "loguru": { "hashes": [ @@ -115,25 +200,53 @@ }, "multidict": { "hashes": [ - "sha256:1ece5a3369835c20ed57adadc663400b5525904e53bae59ec854a5d36b39b21a", - "sha256:275ca32383bc5d1894b6975bb4ca6a7ff16ab76fa622967625baeebcf8079000", - "sha256:3750f2205b800aac4bb03b5ae48025a64e474d2c6cc79547988ba1d4122a09e2", - "sha256:4538273208e7294b2659b1602490f4ed3ab1c8cf9dbdd817e0e9db8e64be2507", - "sha256:5141c13374e6b25fe6bf092052ab55c0c03d21bd66c94a0e3ae371d3e4d865a5", - "sha256:51a4d210404ac61d32dada00a50ea7ba412e6ea945bbe992e4d7a595276d2ec7", - "sha256:5cf311a0f5ef80fe73e4f4c0f0998ec08f954a6ec72b746f3c179e37de1d210d", - "sha256:6513728873f4326999429a8b00fc7ceddb2509b01d5fd3f3be7881a257b8d463", - "sha256:7388d2ef3c55a8ba80da62ecfafa06a1c097c18032a501ffd4cabbc52d7f2b19", - "sha256:9456e90649005ad40558f4cf51dbb842e32807df75146c6d940b6f5abb4a78f3", - "sha256:c026fe9a05130e44157b98fea3ab12969e5b60691a276150db9eda71710cd10b", - "sha256:d14842362ed4cf63751648e7672f7174c9818459d169231d03c56e84daf90b7c", - "sha256:e0d072ae0f2a179c375f67e3da300b47e1a83293c554450b29c900e50afaae87", - "sha256:f07acae137b71af3bb548bd8da720956a3bc9f9a0b87733e0899226a2317aeb7", - "sha256:fbb77a75e529021e7c4a8d4e823d88ef4d23674a202be4f5addffc72cbb91430", - "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255", - "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d" - ], - "version": "==4.7.6" + "sha256:018132dbd8688c7a69ad89c4a3f39ea2f9f33302ebe567a879da8f4ca73f0d0a", + "sha256:051012ccee979b2b06be928a6150d237aec75dd6bf2d1eeeb190baf2b05abc93", + "sha256:05c20b68e512166fddba59a918773ba002fdd77800cad9f55b59790030bab632", + "sha256:07b42215124aedecc6083f1ce6b7e5ec5b50047afa701f3442054373a6deb656", + "sha256:0e3c84e6c67eba89c2dbcee08504ba8644ab4284863452450520dad8f1e89b79", + "sha256:0e929169f9c090dae0646a011c8b058e5e5fb391466016b39d21745b48817fd7", + "sha256:1ab820665e67373de5802acae069a6a05567ae234ddb129f31d290fc3d1aa56d", + "sha256:25b4e5f22d3a37ddf3effc0710ba692cfc792c2b9edfb9c05aefe823256e84d5", + "sha256:2e68965192c4ea61fff1b81c14ff712fc7dc15d2bd120602e4a3494ea6584224", + "sha256:2f1a132f1c88724674271d636e6b7351477c27722f2ed789f719f9e3545a3d26", + "sha256:37e5438e1c78931df5d3c0c78ae049092877e5e9c02dd1ff5abb9cf27a5914ea", + "sha256:3a041b76d13706b7fff23b9fc83117c7b8fe8d5fe9e6be45eee72b9baa75f348", + "sha256:3a4f32116f8f72ecf2a29dabfb27b23ab7cdc0ba807e8459e59a93a9be9506f6", + "sha256:46c73e09ad374a6d876c599f2328161bcd95e280f84d2060cf57991dec5cfe76", + "sha256:46dd362c2f045095c920162e9307de5ffd0a1bfbba0a6e990b344366f55a30c1", + "sha256:4b186eb7d6ae7c06eb4392411189469e6a820da81447f46c0072a41c748ab73f", + "sha256:54fd1e83a184e19c598d5e70ba508196fd0bbdd676ce159feb412a4a6664f952", + "sha256:585fd452dd7782130d112f7ddf3473ffdd521414674c33876187e101b588738a", + "sha256:5cf3443199b83ed9e955f511b5b241fd3ae004e3cb81c58ec10f4fe47c7dce37", + "sha256:6a4d5ce640e37b0efcc8441caeea8f43a06addace2335bd11151bc02d2ee31f9", + "sha256:7df80d07818b385f3129180369079bd6934cf70469f99daaebfac89dca288359", + "sha256:806068d4f86cb06af37cd65821554f98240a19ce646d3cd24e1c33587f313eb8", + "sha256:830f57206cc96ed0ccf68304141fec9481a096c4d2e2831f311bde1c404401da", + "sha256:929006d3c2d923788ba153ad0de8ed2e5ed39fdbe8e7be21e2f22ed06c6783d3", + "sha256:9436dc58c123f07b230383083855593550c4d301d2532045a17ccf6eca505f6d", + "sha256:9dd6e9b1a913d096ac95d0399bd737e00f2af1e1594a787e00f7975778c8b2bf", + "sha256:ace010325c787c378afd7f7c1ac66b26313b3344628652eacd149bdd23c68841", + "sha256:b47a43177a5e65b771b80db71e7be76c0ba23cc8aa73eeeb089ed5219cdbe27d", + "sha256:b797515be8743b771aa868f83563f789bbd4b236659ba52243b735d80b29ed93", + "sha256:b7993704f1a4b204e71debe6095150d43b2ee6150fa4f44d6d966ec356a8d61f", + "sha256:d5c65bdf4484872c4af3150aeebe101ba560dcfb34488d9a8ff8dbcd21079647", + "sha256:d81eddcb12d608cc08081fa88d046c78afb1bf8107e6feab5d43503fea74a635", + "sha256:dc862056f76443a0db4509116c5cd480fe1b6a2d45512a653f9a855cc0517456", + "sha256:ecc771ab628ea281517e24fd2c52e8f31c41e66652d07599ad8818abaad38cda", + "sha256:f200755768dc19c6f4e2b672421e0ebb3dd54c38d5a4f262b872d8cfcc9e93b5", + "sha256:f21756997ad8ef815d8ef3d34edd98804ab5ea337feedcd62fb52d22bf531281", + "sha256:fc13a9524bc18b6fb6e0dbec3533ba0496bbed167c56d0aabefd965584557d80" + ], + "markers": "python_version >= '3.6'", + "version": "==5.1.0" + }, + "ordered-set": { + "hashes": [ + "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95" + ], + "markers": "python_full_version >= '3.5.0'", + "version": "==4.0.2" }, "python-dateutil": { "hashes": [ @@ -148,29 +261,93 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, + "sqlalchemy": { + "hashes": [ + "sha256:008bd2e4691ea7a5eef674747c8a720531699f41fdd20fcf996bdaaa1f7d02e1", + "sha256:0cf76a1f78031a7e654c50e614b4244785bae04c72f181fbb609daa26f894390", + "sha256:1e9791475a2f3b50f56ed4bfca8b17c8c334f0e0f8cb5898480be994a76bd446", + "sha256:2bcac2674c8214da6ee034cf310f9c63192536b6dd42adad7972c43ae56f483e", + "sha256:3d945e870ff405856331fd7f36b43562946271a1c744d8c062597552f864e2eb", + "sha256:3f109d22bcfc06837507b04f73deb1401446cf830c213ef1d6eb00af89c5562a", + "sha256:48db9563a8a304e0177711df9abdad30aecbc13571671c9a79906d0f42c03a19", + "sha256:4db46538d8f0bb7d3d01dea1cd884204bdba3a833ec7e91379cb6ebdb9bd6fc0", + "sha256:52a908eeb27418f8b39d7b4e863b1aa455ed5c34b6fd6fefd80577e08e55c1ed", + "sha256:53988d51bafa98602394cc9a9b71400ac43ea000661387fe5446928ca50dfc2c", + "sha256:5898e0a7c838923b846ac71564ff6d6ed20baf6ad91c8d146c13ac374237574c", + "sha256:58cb52aab1325c3000a2d9eddaf71a6d91533f94eb5c855a93a35a20ce44d430", + "sha256:61c3f81d4bdf3a8cb68ba0e3e0d407448cc709e38a2d15e8087ce4a69246a0e3", + "sha256:7fdaa459c241cba353e3d95eda9403d45f633ce5a807cf4eb28c018de8b52cde", + "sha256:89b503fcc07da9fbd194746c28a9fc0fa7230f50f361ad080336ad751de6302a", + "sha256:9e12ce96e22818fda7157d2b926084178f478df7cc4d3abb8cebae031c7076ec", + "sha256:a1f4213790f5bd5c3b4dcedad248d982d540d7671a6bb454c6271cb46bb3d665", + "sha256:aafc24c63503e3df845fc4d0bbc2757a4f11c725410637bb4f8b0aced319dba7", + "sha256:b2754f5e82e7a61b3600dc1b5dc7b7a7229acc2cda3f028f29ba569f9a9b601b", + "sha256:c17f02dde5ee05956bc3fe8320f3cf5c44a11aa9ab334623729cebecdf70afef", + "sha256:d34098841a62bf679da480bce711fdebe6ebcf04e951b4d83362100ad2f7e0bf", + "sha256:defbaf1b2cbac118a88a6379d435b1d953df8af3d4bc46c1c73bb6cd81e88728", + "sha256:e2223e8ce2e079a8022915c568cfff4eb31235139db49b4eb5cb1db6b4ecf656", + "sha256:e6b43b28d5d5cbf271ea6468c387b4c0a786bfd1d5f580be387b5b3e4355a7ca", + "sha256:f2de1336b228f227e36fb9549756dbb0791beadd36fa74b8bc2aa8db8c7e36b9", + "sha256:f84b800b40e3ce612975a23385b36f9d8506fe7d511d3d4c2363e1a3304c8b07", + "sha256:fc00578bdf373b339e186fc0e9bc2692d8e03d25e785be8f24475b0833e9a055", + "sha256:fff48978d3b670b054c25ad6b7a834b3723ac055d5593552053812b74fee4377" + ], + "index": "pypi", + "version": "==1.4.0b3" + }, + "typing-extensions": { + "hashes": [ + "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918", + "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c", + "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f" + ], + "version": "==3.7.4.3" + }, "yarl": { "hashes": [ - "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409", - "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593", - "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2", - "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8", - "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d", - "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692", - "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02", - "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a", - "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8", - "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6", - "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511", - "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e", - "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a", - "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb", - "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f", - "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317", - "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6" - ], - "version": "==1.5.1" + "sha256:00d7ad91b6583602eb9c1d085a2cf281ada267e9a197e8b7cae487dadbfa293e", + "sha256:0355a701b3998dcd832d0dc47cc5dedf3874f966ac7f870e0f3a6788d802d434", + "sha256:15263c3b0b47968c1d90daa89f21fcc889bb4b1aac5555580d74565de6836366", + "sha256:2ce4c621d21326a4a5500c25031e102af589edb50c09b321049e388b3934eec3", + "sha256:31ede6e8c4329fb81c86706ba8f6bf661a924b53ba191b27aa5fcee5714d18ec", + "sha256:324ba3d3c6fee56e2e0b0d09bf5c73824b9f08234339d2b788af65e60040c959", + "sha256:329412812ecfc94a57cd37c9d547579510a9e83c516bc069470db5f75684629e", + "sha256:4736eaee5626db8d9cda9eb5282028cc834e2aeb194e0d8b50217d707e98bb5c", + "sha256:4953fb0b4fdb7e08b2f3b3be80a00d28c5c8a2056bb066169de00e6501b986b6", + "sha256:4c5bcfc3ed226bf6419f7a33982fb4b8ec2e45785a0561eb99274ebbf09fdd6a", + "sha256:547f7665ad50fa8563150ed079f8e805e63dd85def6674c97efd78eed6c224a6", + "sha256:5b883e458058f8d6099e4420f0cc2567989032b5f34b271c0827de9f1079a424", + "sha256:63f90b20ca654b3ecc7a8d62c03ffa46999595f0167d6450fa8383bab252987e", + "sha256:68dc568889b1c13f1e4745c96b931cc94fdd0defe92a72c2b8ce01091b22e35f", + "sha256:69ee97c71fee1f63d04c945f56d5d726483c4762845400a6795a3b75d56b6c50", + "sha256:6d6283d8e0631b617edf0fd726353cb76630b83a089a40933043894e7f6721e2", + "sha256:72a660bdd24497e3e84f5519e57a9ee9220b6f3ac4d45056961bf22838ce20cc", + "sha256:73494d5b71099ae8cb8754f1df131c11d433b387efab7b51849e7e1e851f07a4", + "sha256:7356644cbed76119d0b6bd32ffba704d30d747e0c217109d7979a7bc36c4d970", + "sha256:8a9066529240171b68893d60dca86a763eae2139dd42f42106b03cf4b426bf10", + "sha256:8aa3decd5e0e852dc68335abf5478a518b41bf2ab2f330fe44916399efedfae0", + "sha256:97b5bdc450d63c3ba30a127d018b866ea94e65655efaf889ebeabc20f7d12406", + "sha256:9ede61b0854e267fd565e7527e2f2eb3ef8858b301319be0604177690e1a3896", + "sha256:b2e9a456c121e26d13c29251f8267541bd75e6a1ccf9e859179701c36a078643", + "sha256:b5dfc9a40c198334f4f3f55880ecf910adebdcb2a0b9a9c23c9345faa9185721", + "sha256:bafb450deef6861815ed579c7a6113a879a6ef58aed4c3a4be54400ae8871478", + "sha256:c49ff66d479d38ab863c50f7bb27dee97c6627c5fe60697de15529da9c3de724", + "sha256:ce3beb46a72d9f2190f9e1027886bfc513702d748047b548b05dab7dfb584d2e", + "sha256:d26608cf178efb8faa5ff0f2d2e77c208f471c5a3709e577a7b3fd0445703ac8", + "sha256:d597767fcd2c3dc49d6eea360c458b65643d1e4dbed91361cf5e36e53c1f8c96", + "sha256:d5c32c82990e4ac4d8150fd7652b972216b204de4e83a122546dce571c1bdf25", + "sha256:d8d07d102f17b68966e2de0e07bfd6e139c7c02ef06d3a0f8d2f0f055e13bb76", + "sha256:e46fba844f4895b36f4c398c5af062a9808d1f26b2999c58909517384d5deda2", + "sha256:e6b5460dc5ad42ad2b36cca524491dfcaffbfd9c8df50508bddc354e787b8dc2", + "sha256:f040bcc6725c821a4c0665f3aa96a4d0805a7aaf2caf266d256b8ed71b9f041c", + "sha256:f0b059678fd549c66b89bed03efcabb009075bd131c248ecdf087bdb6faba24a", + "sha256:fcbb48a93e8699eae920f8d92f7160c03567b421bc17362a9ffbbd706a816f71" + ], + "markers": "python_version >= '3.6'", + "version": "==1.6.3" } }, "develop": { @@ -183,17 +360,19 @@ }, "attrs": { "hashes": [ - "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", - "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" + "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6", + "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700" ], - "version": "==20.2.0" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.3.0" }, "autopep8": { "hashes": [ - "sha256:d21d3901cb0da6ebd1e83fc9b0dfbde8b46afc2ede4fe32fbda0c7c6118ca094" + "sha256:9e136c472c475f4ee4978b51a88a494bfcd4e3ed17950a44a988d9e434837bea", + "sha256:cae4bc0fb616408191af41d062d7ec7ef8679c7f27b068875ca3a9e2878d5443" ], "index": "pypi", - "version": "==1.5.4" + "version": "==1.5.5" }, "backcall": { "hashes": [ @@ -207,6 +386,7 @@ "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d", "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1" ], + "markers": "python_full_version >= '3.6.1'", "version": "==3.2.0" }, "decorator": { @@ -238,14 +418,6 @@ "index": "pypi", "version": "==3.8.4" }, - "flake8-annotations": { - "hashes": [ - "sha256:0bcebb0792f1f96d617ded674dca7bf64181870bfe5dace353a1483551f8e5f1", - "sha256:bebd11a850f6987a943ce8cdff4159767e0f5f89b3c88aca64680c2175ee02df" - ], - "index": "pypi", - "version": "==2.4.1" - }, "flake8-bugbear": { "hashes": [ "sha256:a3ddc03ec28ba2296fc6f89444d1c946a6b76460f859795b35b77d4920a51b63", @@ -264,10 +436,11 @@ }, "identify": { "hashes": [ - "sha256:3139bf72d81dfd785b0a464e2776bd59bdc725b4cc10e6cf46b56a0db931c82e", - "sha256:969d844b7a85d32a5f9ac4e163df6e846d73c87c8b75847494ee8f4bd2186421" + "sha256:2179e7359471ab55729f201b3fdf7dc2778e221f868410fedcb0987b791ba552", + "sha256:2a5fdf2f5319cc357eda2550bea713a404392495961022cf2462624ce62f0f46" ], - "version": "==1.5.6" + "markers": "python_full_version >= '3.6.1'", + "version": "==2.1.0" }, "ipython": { "hashes": [ @@ -286,10 +459,11 @@ }, "jedi": { "hashes": [ - "sha256:86ed7d9b750603e4ba582ea8edc678657fb4007894a12bcf6f4bb97892f31d20", - "sha256:98cc583fa0f2f8304968199b01b6b4b94f469a1f4a74c1560506ca2a211378b5" + "sha256:18456d83f65f400ab0c2d3319e48520420ef43b23a086fdc05dff34132f0fb93", + "sha256:92550a404bad8afed881a137ec9a461fed49eca661414be45059329614ed0707" ], - "version": "==0.17.2" + "markers": "python_version >= '3.6'", + "version": "==0.18.0" }, "mccabe": { "hashes": [ @@ -307,10 +481,11 @@ }, "parso": { "hashes": [ - "sha256:97218d9159b2520ff45eb78028ba8b50d2bc61dcc062a9682666f2dc4bd331ea", - "sha256:caba44724b994a8a5e086460bb212abc5a8bc46951bf4a9a1210745953622eb9" + "sha256:15b00182f472319383252c18d5913b69269590616c947747bc50bf4ac768f410", + "sha256:8519430ad07087d4c997fda3a7918f7cfa27cb58972a8c89c2a0295a1c940e9e" ], - "version": "==0.7.1" + "markers": "python_version >= '3.6'", + "version": "==0.8.1" }, "pexpect": { "hashes": [ @@ -337,23 +512,25 @@ }, "prompt-toolkit": { "hashes": [ - "sha256:25c95d2ac813909f813c93fde734b6e44406d1477a9faef7c915ff37d39c0a8c", - "sha256:7debb9a521e0b1ee7d2fe96ee4bd60ef03c6492784de0547337ca4433e46aa63" + "sha256:0fa02fa80363844a4ab4b8d6891f62dd0645ba672723130423ca4037b80c1974", + "sha256:62c811e46bd09130fb11ab759012a4ae385ce4fb2073442d1898867a824183bd" ], - "version": "==3.0.8" + "markers": "python_full_version >= '3.6.1'", + "version": "==3.0.16" }, "ptyprocess": { "hashes": [ - "sha256:923f299cc5ad920c68f2bc0bc98b75b9f838b93b599941a6b63ddbc2476394c0", - "sha256:d7cc528d76e76342423ca640335bd3633420dc1366f258cb31d05e865ef5ca1f" + "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", + "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220" ], - "version": "==0.6.0" + "version": "==0.7.0" }, "pycodestyle": { "hashes": [ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.6.0" }, "pyflakes": { @@ -361,58 +538,75 @@ "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.2.0" }, "pygments": { "hashes": [ - "sha256:307543fe65c0947b126e83dd5a61bd8acbd84abec11f43caebaf5534cbc17998", - "sha256:926c3f319eda178d1bd90851e4317e6d8cdb5e292a3386aac9bd75eca29cf9c7" + "sha256:37a13ba168a02ac54cc5891a42b1caec333e59b66addb7fa633ea8a6d73445c0", + "sha256:b21b072d0ccdf29297a82a2363359d99623597b8a265b8081760e4d0f7153c88" ], - "version": "==2.7.1" + "markers": "python_version >= '3.5'", + "version": "==2.8.0" }, "pyyaml": { "hashes": [ - "sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97", - "sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76", - "sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2", - "sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648", - "sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf", - "sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f", - "sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2", - "sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee", - "sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d", - "sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c", - "sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a" - ], - "version": "==5.3.1" + "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf", + "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696", + "sha256:129def1b7c1bf22faffd67b8f3724645203b79d8f4cc81f674654d9902cb4393", + "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77", + "sha256:3b2b1824fe7112845700f815ff6a489360226a5609b96ec2190a45e62a9fc922", + "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5", + "sha256:4465124ef1b18d9ace298060f4eccc64b0850899ac4ac53294547536533800c8", + "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10", + "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc", + "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018", + "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e", + "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253", + "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183", + "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb", + "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185", + "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db", + "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46", + "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b", + "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63", + "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df", + "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", + "version": "==5.4.1" }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "toml": { "hashes": [ - "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f", - "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88" + "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", + "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f" ], - "version": "==0.10.1" + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==0.10.2" }, "traitlets": { "hashes": [ "sha256:178f4ce988f69189f7e523337a3e11d91c786ded9360174a3d9ca83e79bc5396", "sha256:69ff3f9d5351f31a7ad80443c2674b7099df13cc41fc5fa6e2f6d3b0330b0426" ], + "markers": "python_version >= '3.7'", "version": "==5.0.5" }, "virtualenv": { "hashes": [ - "sha256:0ebc633426d7468664067309842c81edab11ae97fcaf27e8ad7f5748c89b431b", - "sha256:2a72c80fa2ad8f4e2985c06e6fc12c3d60d060e410572f553c90619b0f6efaf3" + "sha256:147b43894e51dd6bba882cf9c282447f780e2251cd35172403745fc381a0a80d", + "sha256:2be72df684b74df0ea47679a7df93fd0e04e72520022c57b479d8f881485dbe3" ], - "version": "==20.0.35" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.4.2" }, "wcwidth": { "hashes": [ diff --git a/README.md b/README.md index 219d01f..856820d 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # Neutron Bot -[![mit](https://img.shields.io/badge/Licensed%20under-MIT-red.svg?style=flat-square)](./LICENSE) +[![mit](https://img.shields.io/badge/Licensed%20under-GPL-red.svg?style=flat-square)](./LICENSE) ![Python package](https://github.com/Codin-Nerds/Neutron-Bot/workflows/Python%20package/badge.svg) [![made-with-python](https://img.shields.io/badge/Made%20with-Python%203.8-ffe900.svg?longCache=true&style=flat-square&colorB=00a1ff&logo=python&logoColor=88889e)](https://www.python.org/) -[![Discord](https://img.shields.io/static/v1?label=The%20Codin'%20Nerds&logo=discord&message=%3E300%20members&color=%237289DA&logoColor=white)](https://discord.gg/Dhz9pM7) +[![Discord](https://img.shields.io/static/v1?label=The%20Codin'%20Nerds&logo=discord&message=%3E500%20members&color=%237289DA&logoColor=white)](https://discord.gg/Dhz9pM7) ## About the bot @@ -18,12 +18,21 @@ If you want to run this bot on your own, you can simply follow this guide: 1. Create bot on Discord's [bot portal](https://discord.com/developers/applications/) 2. Make a **New Application** 3. Go to **Bot** settings and click on **Add Bot** -4. Give **Administrator** permission to bot -5. You will find your bot **TOKEN** there, it is important that you save it -6. Go to **OAuth2** and click bot, than add **Administrator** permissions -7. You can follow the link that will appear to add the bot to your discord server +4. Make sure to give the bot indents it needs, this bot requires **server member intent** +5. Give **Administrator** permission to bot +6. You will find your bot **TOKEN** there, it is important that you save it +7. Go to **OAuth2** and click bot, than add **Administrator** permissions +8. You can follow the link that will appear to add the bot to your discord server -### Setting up postgresql database +### Docker + +You can run your application contained within a docker container, which is quite easy and very fast, this means you won't have to set up all the needed services for the bot, such as the postgresql database and the bot will run for you automatically. All you need to do is install docker. Run it as a service and use `docker-compose up` within the clonned project. You will need to have `BOT_TOKEN` environment variable set, with your bot token so that the bot can connect to your discord application. + +### Bare-bones installation + +You can also run the bot without using docker at all, even though using docker is more convenient and easier, sometimes you might want to run directly, because you might not have enough resources to spin up whole containers or you simply don't want to install docker. Even though we recommend docker installation over bare-bones one, it is important to mention it too. + +#### Setting up postgresql database In order to prevent cluttering the README too much, here's a link to official documentation regarding installation: @@ -31,15 +40,21 @@ here's a link to official documentation regarding installation: Note that the installation steps will differ depending on your operating system. Make sure to only follow the installation steps specific to your operating system. -### Running bot +After you made a database and a user with some password for it. You can tell the bot about it using environmental variabls: + +* `DATABASE_NAME="bot"` This is the name of your database +* `DATABASE_USER="bot"` This is the username (ideally this shouldn't be postgres directly, but it can be) +* `DATABASE_PASSWORD="bot"` This is the password associated with that user account +* `DATABASE_HOST="127.0.0.1"` This defaults to `127.0.0.1` (localhost), but if you are running the database remotely, you'll want to adjust this + +#### Starting the bot 1. Clone the repository (or fork it if you want to make changes) 2. Install **pipenv** `pip install pipenv` 3. Build the virtual enviroment from Pipfile.lock `pipenv sync` -4. Create **.env** file with `BOT_TOKEN=[Your bot token]` +4. Create `.env` file for your environmental variables with: + * `BOT_TOKEN=[Your bot token]`, this is tells the bot how to connect to your discord application + * `COMMAND_PREFIX=[Your prefix]`, this is optional and will default to `>>`, if you want different prefix, change this + * Rest of the postgresql config, as shown in the postgresql section 5. Configure the settings (More about this in **Settings** section) 6. Run the bot `pipenv run start` - -### Docker - -We will include the option to run the bot in a docker container soon, once we do, we'll make sure to include a step-by-step instruction on running it. diff --git a/bot/__main__.py b/bot/__main__.py index 06f33ac..5a5f3ec 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -1,6 +1,6 @@ import os -from discord import Game +from discord import Game, Intents from bot import config from bot.core.bot import Bot @@ -8,30 +8,17 @@ TOKEN = os.getenv("BOT_TOKEN") PREFIX = config.COMMAND_PREFIX -extensions = [ - "bot.cogs.error_handler", - "bot.cogs.help", - "bot.cogs.sudo", - - "bot.cogs.moderation.lock", - "bot.cogs.moderation.slowmode", - - "bot.cogs.setup.roles", - "bot.cogs.setup.permissions", - - "bot.cogs.embeds", - "bot.cogs.reminders" -] -db_tables = [ - "bot.database.roles", - "bot.database.permissions", -] +intents = Intents.default() +intents.guilds = True +intents.bans = True +intents.messages = True +intents.members = True # Requires discord app permission bot = Bot( - extensions, db_tables, command_prefix=PREFIX, activity=Game(name=f"Ping me using {PREFIX}help"), case_insensitive=True, + intents=intents ) diff --git a/bot/cogs/__init__.py b/bot/cogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/cogs/automod/__init__.py b/bot/cogs/automod/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/cogs/automod/filepaste.py b/bot/cogs/automod/filepaste.py new file mode 100644 index 0000000..b3eab66 --- /dev/null +++ b/bot/cogs/automod/filepaste.py @@ -0,0 +1,87 @@ +import textwrap + +from discord import Color, Embed, Message +from discord.ext.commands import Cog +from loguru import logger + +from bot.core.bot import Bot +from bot.utils.paste_upload import upload_attachments + + +class FilePaste(Cog): + def __init__(self, bot: Bot): + self.bot = bot + + @Cog.listener() + async def on_message(self, message: Message) -> None: + """ + Automatically remove non-whitelisted file attachments and try to upload them to hastebin. + This doesn't affect DMs or people with manage_messages permissions + + This is here for security reasons, because we usually don't want + people uploading `.exe`/`.bat` or similar executable files. + """ + if not message.attachments or not message.guild: + return + + if message.author.permissions_in(message.channel).manage_messages: + return + + # TODO: Make per-guild database for this + allowed_extensions = { + # Videos + "3gp", "avi", "mkv", "mov", "mp4", "mpeg", "gif", "wmv", + # Photos + "h264", "jpg", "jepg", "png", "svg", "psd", "bmp", + # Music + "mp3", "wav", "ogg" + } + + affected_attachments = [] + for attachment in message.attachments: + split_name = attachment.filename.lower().split(".") + extension = "txt" if len(split_name) < 2 else split_name[-1] # Default to txt if extension wasn't found + + if extension in allowed_extensions: + continue + + logger.debug(f"User <@{message.author.id}> posted a message on {message.guild.id} with protected attachments ({extension})") + affected_attachments.append(attachment) + + if len(affected_attachments) == 0: + return + + url = await upload_attachments(self.bot.http_session, affected_attachments) + + embed = Embed( + title="Your message got zapped by our spam filter.", + description=textwrap.dedent( + """ + We don't allow posting file attachments, here are some tips which might help you: + • Try shortening your message, discord limit is 2000 characters. + • Use a paste service such as `paste.gg` or similar service. + • Split your message into multiple parts (pasting is usually a better option). + """ + ), + color=Color.red() + ) + + if url is not None: + embed.add_field( + name="Automatic attachment upload", + value=textwrap.dedent( + f""" + We took care of uploading the file for you, since we know it can be tedious. + You can find your file on `paste.gg` paste service under this link: + **--> {url}** + """ + ), + inline=False + ) + + await message.channel.send(f"Hey {message.author.mention}", embed=embed) + await message.delete() + + +def setup(bot: Bot) -> None: + bot.add_cog(FilePaste(bot)) diff --git a/bot/cogs/core/__init__.py b/bot/cogs/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/cogs/error_handler.py b/bot/cogs/core/error_handler.py similarity index 93% rename from bot/cogs/error_handler.py rename to bot/cogs/core/error_handler.py index 2694382..2913fd0 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/core/error_handler.py @@ -3,10 +3,10 @@ from json import JSONDecodeError from discord import Color, Embed -from discord.ext.commands import Cog, Context, NotOwner, errors +from discord.ext.commands import Cog, Context, errors from loguru import logger -from bot.cogs.embeds import InvalidEmbed +from bot.cogs.utility.embeds import InvalidEmbed from bot.core.bot import Bot @@ -24,7 +24,7 @@ async def _send_error_embed(self, ctx: Context, title: t.Optional[str] = None, d ) await ctx.send(f"Sorry {ctx.author.mention}", embed=embed) - async def send_unhandled_embed(self, ctx: Context, exception: errors.CommandError) -> None: + async def send_unhandled_embed(self, ctx: Context, exception: BaseException) -> None: logger.warning( f"Exception {exception.__repr__()} has occurred from " f"message {ctx.message.content} invoked by {ctx.author.id} on {ctx.guild.id}" @@ -77,10 +77,10 @@ async def on_command_error(self, ctx: Context, exception: errors.CommandError) - if isinstance(original_exception, InvalidEmbed): await self.handle_invalid_embed(ctx, original_exception) return - - await self.send_unhandled_embed(ctx, original_exception) - # Raise the original exception to show the traceback - raise original_exception + if original_exception is not None: + await self.send_unhandled_embed(ctx, original_exception) + # Raise the original exception to show the traceback + raise original_exception return await self.send_unhandled_embed(ctx, exception) @@ -115,7 +115,7 @@ async def handle_user_input_error(self, ctx: Context, exception: errors.UserInpu ) async def handle_check_failure(self, ctx: Context, exception: errors.CheckFailure) -> None: - if isinstance(exception, NotOwner): + if isinstance(exception, errors.NotOwner): msg = "❌ This command is only aviable to bot owners." else: msg = "❌ You don't have permission to run this command." diff --git a/bot/cogs/help.py b/bot/cogs/core/help.py similarity index 100% rename from bot/cogs/help.py rename to bot/cogs/core/help.py diff --git a/bot/cogs/sudo.py b/bot/cogs/core/sudo.py similarity index 91% rename from bot/cogs/sudo.py rename to bot/cogs/core/sudo.py index 5966972..b6b0166 100644 --- a/bot/cogs/sudo.py +++ b/bot/cogs/core/sudo.py @@ -10,7 +10,6 @@ from discord import __version__ as discord_version from discord.ext.commands import Cog, Context, NotOwner, group -from bot import config from bot.core.bot import Bot from bot.utils.time import stringify_timedelta @@ -19,10 +18,10 @@ class Sudo(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - @group(hidden=True) + @group(invoke_without_command=True, hidden=True) async def sudo(self, ctx: Context) -> None: """Administrative information.""" - pass + await ctx.send_help(ctx.command) @sudo.command() async def shutdown(self, ctx: Context) -> None: @@ -41,11 +40,11 @@ async def restart(self, ctx: Context) -> None: async def _manage_cog(self, ctx: Context, process: str, extension: t.Optional[str] = None) -> None: if not extension: - extension = self.bot.extension_list + extensions = self.bot.extension_list else: - extension = [f"bot.cogs.{extension}"] + extensions = [f"bot.cogs.{extension}"] - for ext in extension: + for ext in extensions: try: if process == "load": self.bot.load_extension(ext) @@ -96,13 +95,13 @@ async def stats(self, ctx: Context) -> None: embed.add_field(name="**❯❯ System**", value=system, inline=True) embed.set_author(name=f"{self.bot.user.name}'s Stats", icon_url=self.bot.user.avatar_url) - embed.set_footer(text=f"Made by {config.creator}.") + embed.set_footer(text="Made by The Codin' Nerds Team.") await ctx.send(embed=embed) async def cog_check(self, ctx: Context) -> t.Optional[bool]: """Only the bot owners can use this.""" - if ctx.author.id in config.devs: + if self.bot.is_owner(ctx.author): return True raise NotOwner diff --git a/bot/cogs/logging/__init__.py b/bot/cogs/logging/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/cogs/logging/join_log.py b/bot/cogs/logging/join_log.py new file mode 100644 index 0000000..dc448c3 --- /dev/null +++ b/bot/cogs/logging/join_log.py @@ -0,0 +1,82 @@ +import datetime +import textwrap + +from discord import Color, Embed, Guild, Member +from discord.ext.commands import Cog + +from bot.config import Event +from bot.core.bot import Bot +from bot.database.log_channels import LogChannels +from bot.utils.converters import Ordinal +from bot.utils.time import time_elapsed + + +class JoinLog(Cog): + def __init__(self, bot: Bot): + self.bot = bot + + async def send_log(self, guild: Guild, *send_args, **send_kwargs) -> bool: + """ + Try to send a log message to a join_log channel for given guild, + args and kwargs to this function will be used in the actual `Channel.send` call. + + If the message was sent, return True, otherwise return False + (might happen if join_log channel isn't defined in database). + """ + join_log_id = await LogChannels.get_log_channel(self.bot.db_engine, "join_log", guild) + join_log_channel = guild.get_channel(int(join_log_id)) + if join_log_channel is None: + return False + + await join_log_channel.send(*send_args, **send_kwargs) + return True + + @Cog.listener() + async def on_member_join(self, member: Member) -> None: + if self.bot.log_is_ignored(Event.member_join, (member.guild.id, member.id)): + return + + embed = Embed( + title="Member joined", + description=textwrap.dedent( + f""" + **Mention:** {member.mention} + **Created:** {time_elapsed(member.created_at, max_units=3)} + **Members:** They are {Ordinal.make_ordinal(member.guild.member_count)} to join. + """ + ), + color=Color.green(), + ) + embed.timestamp = datetime.datetime.utcnow() + embed.set_thumbnail(url=member.avatar_url) + embed.set_footer(text=f"Member ID: {member.id}") + + await self.send_log(member.guild, embed=embed) + + @Cog.listener() + async def on_member_remove(self, member: Member) -> None: + if self.bot.log_is_ignored(Event.member_remove, (member.guild.id, member.id)): + return + + description = ( + f"**Mention:** {member.mention}\n" + f"**Joined:** {time_elapsed(member.joined_at, max_units=3)}\n" + ) + roles = ", ".join(role.mention for role in member.roles[1:]) + description += f"**Roles:** {roles}\n" if roles else '' + description += f"**Members:** Server is now at {member.guild.member_count} members." + + embed = Embed( + title="Member left", + description=description, + color=Color.dark_orange(), + ) + embed.timestamp = datetime.datetime.now() + embed.set_thumbnail(url=member.avatar_url) + embed.set_footer(text=f"Member ID: {member.id}") + + await self.send_log(member.guild, embed=embed) + + +def setup(bot: Bot) -> None: + bot.add_cog(JoinLog(bot)) diff --git a/bot/cogs/logging/member_log.py b/bot/cogs/logging/member_log.py new file mode 100644 index 0000000..5ab03af --- /dev/null +++ b/bot/cogs/logging/member_log.py @@ -0,0 +1,181 @@ +import datetime +import textwrap +import typing as t +from functools import partial + +from discord import Color, Embed, Guild, Member, User +from discord.channel import TextChannel +from discord.enums import AuditLogAction +from discord.ext.commands import Cog + +from bot.config import Event +from bot.core.bot import Bot +from bot.database.log_channels import LogChannels +from bot.utils.audit_parse import last_audit_log_with_fail_embed + + +class MemberLog(Cog): + def __init__(self, bot: Bot): + self.bot = bot + + async def get_log(self, guild: Guild) -> t.Optional[TextChannel]: + """ + Try to obtain the proper channel for given guild, if the channel is + found, return it, otherwise, return `None`. + """ + member_log_id = await LogChannels.get_log_channel(self.bot.db_engine, "member_log", guild) + return guild.get_channel(int(member_log_id)) + + async def send_log(self, guild: Guild, *send_args, **send_kwargs) -> bool: + """ + Try to send a log message to a member_log channel for given guild, + args and kwargs to this function will be used in the actual `Channel.send` call. + + If the message was sent, return True, otherwise return False + (might happen if member_log channel isn't defined in database). + """ + member_log_channel = await self.get_log(guild) + + if member_log_channel is None: + return False + + await member_log_channel.send(*send_args, **send_kwargs) + return True + + @Cog.listener() + async def on_member_update(self, member_before: Member, member_after: Member) -> None: + if self.bot.log_is_ignored(Event.member_update, (member_after.guild.id, member_after.id)): + return + + if member_before.status != member_after.status or member_before.activity != member_after.activity: + # Don't track changes of statuses and activities, they happen very often + # and it would mean spamming member_log too much, it is usually + # not worth tracking anyway. + return + elif member_before.nick != member_after.nick: + embed = Embed( + title="Nickname change", + description=textwrap.dedent( + f""" + **Mention:** {member_after.mention} + **Previous:** {member_before.nick} + **Current:** {member_after.nick} + """ + ), + color=Color.blue() + ) + elif member_before.roles != member_after.roles: + old_roles = set(member_before.roles) + new_roles = set(member_after.roles) + + # These will always only contain 1 role, but we have + # to use sets to meaningfully get it, which returns another set + removed_roles = old_roles - new_roles + added_roles = new_roles - old_roles + + action_type = "remove" if removed_roles else "add" + + description = ( + f"**Role:** {removed_roles.pop().mention if removed_roles else added_roles.pop().mention}\n" + f"**Mention:** {member_after.mention}" + ) + + last_log = await last_audit_log_with_fail_embed( + member_after.guild, + actions=[AuditLogAction.member_role_update], + target=member_after, + send_callback=partial(self.send_log, member_after.guild) + ) + + if last_log: + description += f"\n**{action_type.capitalize()}ed by:** {last_log.user.mention}" + + embed = Embed( + title=f"Role {action_type}ed", + description=description, + color=Color.green() if action_type == "add" else Color.red() + ) + elif member_before.pending != member_after.pending: + embed = Embed( + title="User verified", + description=textwrap.dedent( + f""" + User has agreed to membership screening rules. + **Mention:** {member_after.mention} + """ + ), + color=Color.blue() + ) + else: + # member_update ran without any changes + # this usually happens because on_user_update takes over + return + + embed.set_thumbnail(url=member_after.avatar_url) + embed.set_footer(text=f"Member ID: {member_after.id}") + embed.timestamp = datetime.datetime.utcnow() + + await self.send_log(member_after.guild, embed=embed) + + @Cog.listener() + async def on_user_update(self, user_before: User, user_after: User) -> None: + if user_before.avatar != user_after.avatar: + embed = Embed( + title="Avatar change", + description=textwrap.dedent( + f""" + **Previous:** [link]({user_before.avatar_url}) + **Current:** [link]({user_after.avatar_url}) + **Mention:** {user_after.mention} + """ + ), + color=Color.blue() + ) + elif user_before.name != user_after.name: + embed = Embed( + title="Username change", + description=textwrap.dedent( + f""" + **Previous:** {user_before.name} + **Current:** {user_after.name} + **Mention:** {user_after.mention} + """ + ), + color=Color.blue() + ) + elif user_before.discriminator != user_after.discriminator: + embed = Embed( + title="Discriminator change", + description=textwrap.dedent( + f""" + **Previous:** {user_before.discriminator} + **Current:** {user_after.discriminator} + **Mention:** {user_after.mention} + """ + ), + color=Color.blue() + ) + else: + return + + embed.set_thumbnail(url=user_after.avatar_url) + embed.set_footer(text=f"User ID: {user_after.id}") + embed.timestamp = datetime.datetime.utcnow() + + member_log_channels = [] + for guild in self.bot.guilds: + if guild.get_member(user_after.id) is None: + continue + + member_log_channel = self.get_log(guild) + if member_log_channel: + member_log_channels.append(member_log_channel) + + for member_log_channel in member_log_channels: + if self.bot.log_is_ignored(Event.user_update, (member_log_channel.guild.id, user_after.id)): + continue + await member_log_channel.send(embed=embed) + + +def setup(bot: Bot) -> None: + bot.add_cog(MemberLog(bot)) diff --git a/bot/cogs/logging/message_log.py b/bot/cogs/logging/message_log.py new file mode 100644 index 0000000..b80529b --- /dev/null +++ b/bot/cogs/logging/message_log.py @@ -0,0 +1,317 @@ +import asyncio +import datetime +import textwrap +import typing as t + +from discord import Color, Embed, Guild, Message +from discord.errors import NotFound +from discord.ext.commands import Cog +from discord.raw_models import RawMessageDeleteEvent, RawMessageUpdateEvent + +from bot.config import Event +from bot.core.bot import Bot +from bot.database.log_channels import LogChannels +from bot.utils.paste_upload import upload_files, upload_text +from bot.utils.time import time_elapsed + +# We should limit the maximum message size allowed for +# the embeds, to avoid huge embeds cluttering the message log +# for no reason, if they are over this size, we upload these +# messages to a paste service instead. +MAX_LOGGED_MESSAGE_SIZE = 800 + + +class MessageLog(Cog): + def __init__(self, bot: Bot): + self.bot = bot + self._handled_cached = set() + + def is_ignored(self, message: Message, event: t.Optional[Event] = None) -> bool: + """ + Determine if the listener should proceed, or if given message should be ignored. + + Ignored circumstances: + * Message is a DM + * Message was sent by a bot + * Message was found in ignored list for given `event` (skipped if event isn't provided) + + If `True` is returned given message should be ignored, otherwise return `False`. + """ + if not message.guild or message.author.bot: + return True + + if event and self.bot.log_is_ignored(event, (message.guild.id, message.id)): + return True + + return False + + async def send_log(self, guild: Guild, *send_args, **send_kwargs) -> bool: + """ + Try to send a log message to a message_log channel for given guild, + args and kwargs to this function will be used in the actual `Channel.send` call. + + If the message was sent, return True, otherwise return False + (might happen if message_log channel isn't defined in database). + """ + message_log_id = await LogChannels.get_log_channel(self.bot.db_engine, "message_log", guild) + message_log_channel = guild.get_channel(int(message_log_id)) + if message_log_channel is None: + return False + + await message_log_channel.send(*send_args, **send_kwargs) + return True + + @Cog.listener() + async def on_message_edit(self, before: Message, after: Message) -> None: + """ + Send a log message whenever any sent message is edited. + + This is useful for moderation purposes, because users often try to hide what + they send, to avoid getting caught breaking some rules, logging the content of + these messages will prevent that. + + Messages can sometimes get quite long, and we don't want to clutter the log with them, + if this is the case, we upload this message to a paste service and only provide a link. + """ + # Add this message to set of ignored messages for raw events, these trigger even if + # the message was cached, and to prevent double logging, we need to ignore them + self._handled_cached.add((after.guild.id, after.id)) + + if self.is_ignored(message=after, event=Event.message_edit): + return + + response = ( + f"**Author:** {after.author.mention}\n" + f"**Channel:** {after.channel.mention}" + ) + if before.edited_at: + response += f"\n**Last edited:** {time_elapsed(before.edited_at, after.edited_at, max_units=3)}" + else: + response += f"\n**Initially created:** {time_elapsed(before.created_at, after.edited_at, max_units=3)}" + + # Limit log embed to avoid huge messages cluttering the log, + # if message is longer, upload it instead. + if len(before.clean_content + after.clean_content) > MAX_LOGGED_MESSAGE_SIZE: + # NOTE; Text uploading might be happening too often, and might have to be removed + # in the future + payload = [ + { + "name": "before", + "content": { + "format": "text", + "value": before.content + } + }, + { + "name": "after", + "content": { + "format": "text", + "value": after.content + } + }, + ] + + url = await upload_files( + self.bot.http_session, payload, + paste_name="Automatic message upload.", + paste_description="This paste was automatically generated from edited discord message." + ) + if url: + response += f"\n**Changes:** Message too long, check [message upload]({url})" + else: + response += "\n**Changes:** Message too long (WARNING: Automatic upload failed)" + else: + response += f"\n**Before:** {before.content}" + response += f"\n**After:** {after.content}" + + response += f"\n[Jump link]({after.jump_url})" + + embed = Embed( + title="Message edited", + description=response, + color=Color.dark_orange() + ) + embed.timestamp = after.edited_at + embed.set_footer(text=f"Message ID: {after.id}") + + await self.send_log(after.guild, embed=embed) + + @Cog.listener() + async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: + """ + Send an embed whenever uncached message got edited, we do not have the + previous contents of this message, so we can only send the current (edited) + content, and the fact that it was actually edited. + + This listener trigers whenever a message is edited, this includes the messages + that are cached and trigger `on_message_edit` directly, which means we have to + ignore these. + + Disocrd's API also triggers this listener whenever an embed is sent. This is quite + unexpected behavior for this event, and we should ignore it as it isn't an actual + message edit event. + + In case neither of these cases are true, we may log the embed (with some further + checks which are also present in `on_message_edit`, such as DM check). + """ + # Try to fetch the message before it may get removed, if we don't manage that + # we can ignore this event + try: + channel = self.bot.get_channel(int(payload.channel_id)) + message = await channel.fetch_message(payload.message_id) + except NotFound: + return + + # As described in docstring, this even also triggers on embed sending, if that's + # the case, we can simply ignore that + if "embeds" in payload.data and len(payload.data["embeds"]) > 0: + return + + # Sleep for a while to leave enough time for normal event to execute, which will add this + # channel into the handled cached set, to avoid double logging + await asyncio.sleep(1) + if (message.guild.id, message.id) in self._handled_cached: + return + + if self.is_ignored(message, Event.message_edit): + return + + response = ( + f"**Author:** {message.author.mention}\n" + f"**Channel:** {message.channel.mention}\n" + f"**Before:** This message is an uncached message, content can't be displayed" + ) + + if len(message.clean_content) > MAX_LOGGED_MESSAGE_SIZE: + url = await upload_text( + self.bot.http_session, message.content, + file_name="message.md", paste_name="Automatic message upload.", + paste_description="This paste was automatically generated from edited discrod message." + ) + if url: + response += f"\n**After:** Message too long, check [message upload]({url})" + else: + response += "\n**After:** Message too long (WARNING: Automatic upload failed" + else: + response += f"\n**After:** {message.content}" + + response += f"\n[Jump url]({message.jump_url})" + + embed = Embed( + title="Uncached message edited", + description=response, + color=Color.dark_orange() + ) + embed.timestamp = datetime.datetime.utcnow() + embed.set_footer(text=f"Message ID: {message.id}\n") + + await self.send_log(message.guild, embed=embed) + + @Cog.listener() + async def on_message_delete(self, message: Message) -> None: + """ + Send a log message whenever any sent message is removed. + + This is useful for moderation purposes, because users often try to hide what + they send, to avoid getting caught breaking some rules, logging the content of + these messages will prevent that. + + Messages can sometimes get quite long, and we don't want to clutter the log with them, + if this is the case, we upload this message to a paste service and only provide a link. + """ + # Add this message to set of ignored messages for raw events, these trigger even if + # the message was cached, and to prevent double logging, we need to ignore them + self._handled_cached.add((message.guild.id, message.id)) + + if self.is_ignored(message, Event.message_delete): + return + + # Add message to ignore list, to prevent raw event on executing + self.bot.log_ignore(Event.message_delete, (message.guild.id, message.id)) + + response = ( + f"**Author:** {message.author.mention}\n" + f"**Channel:** {message.channel.mention}\n" + f"**Initially created: ** {time_elapsed(message.created_at, max_units=3)}" + ) + + if message.attachments: + readable_attachments = ', '.join(attachment.filename for attachment in message.attachments) + response += f"\n**Attachment file-name{'s' if len(message.attachments) > 1 else ''}:** {readable_attachments}" + + # Limit log embed to avoid huge messages cluttering the log, + # if message is longer, upload it instead. + if len(message.clean_content) > MAX_LOGGED_MESSAGE_SIZE: + # NOTE; Text uploading might be happening too often, and might have to be removed + # in the future + url = await upload_text( + self.bot.http_session, message.content, + file_name="message.md", paste_name="Automatic message upload.", + paste_description="This paste was automatically generated from removed discrod message." + ) + if url: + response += f"\n**Content:** Message too long, check [message upload]({url})" + else: + response += "\n**Contet:** Message too long (WARNING: Automatic upload failed)" + else: + response += f"\n**Content:** {message.content}" + + response += f"\n[Jump link]({message.jump_url})" + + embed = Embed( + title="Message deleted", + description=response, + color=Color.dark_red() + ) + embed.timestamp = datetime.datetime.utcnow() + embed.set_footer(text=f"Message ID: {message.id}") + + await self.send_log(message.guild, embed=embed) + + @Cog.listener() + async def on_raw_message_delete(self, payload: RawMessageDeleteEvent) -> None: + """ + Send an embed whenever uncached message got deleted, we do not have the + previous contents of this message, so we can only send the the fact that + it was actually deleted, and channel it happened in. + + This listener trigers whenever a message is deleted, this includes the messages + that are cached and trigger `on_message_delete` directly, which means we have to + ignore these. + + In case this wasn't the case, we may log the embed (with some further + checks which are also present in `on_message_edit`, such as DM check). + """ + guild = self.bot.get_guild(payload.guild_id) + + if not guild: + return + + # Sleep for a while to leave enough time for normal event to execute, which will add this + # channel into the handled cached set, to avoid double logging + await asyncio.sleep(1) + if (payload.guild_id, payload.message_id) in self._handled_cached: + return + + if self.bot.log_is_ignored(Event.message_delete, (guild.id, payload.message_id)): + return + + channel = self.bot.get_channel(payload.channel_id) + embed = Embed( + title="Uncached message deleted", + description=textwrap.dedent( + f""" + **Channel:** {channel.mention if channel else 'Unable to get channel'} + **Content:** This message is an uncached message, content can't be displayed + """ + ), + color=Color.dark_red() + ) + embed.timestamp = datetime.datetime.utcnow() + embed.set_footer(text=f"Message ID: {payload.message_id}") + + await self.send_log(guild, embed=embed) + + +def setup(bot: Bot) -> None: + bot.add_cog(MessageLog(bot)) diff --git a/bot/cogs/logging/mod_log.py b/bot/cogs/logging/mod_log.py new file mode 100644 index 0000000..05769fe --- /dev/null +++ b/bot/cogs/logging/mod_log.py @@ -0,0 +1,198 @@ +import datetime +import textwrap +import typing as t +from functools import partial + +from discord import Color, Embed, Guild, Member, User +from discord.enums import AuditLogAction +from discord.ext.commands import Cog + +from bot.config import Event +from bot.core.bot import Bot +from bot.database.log_channels import LogChannels +from bot.database.roles import Roles +from bot.utils.audit_parse import last_audit_log_with_fail_embed + + +class ModLog(Cog): + def __init__(self, bot: Bot): + self.bot = bot + self.audit_cache = set() + + async def send_log(self, guild: Guild, *send_args, **send_kwargs) -> bool: + """ + Try to send a log message to a mod_log channel for given guild, + args and kwargs to this function will be used in the actual `Channel.send` call. + + If the message was sent, return True, otherwise return False + (might happen if mod_log channel isn't defined in database). + """ + mod_log_id = await LogChannels.get_log_channel(self.bot.db_engine, "mod_log", guild) + mod_log_channel = guild.get_channel(int(mod_log_id)) + if mod_log_channel is None: + return False + + await mod_log_channel.send(*send_args, **send_kwargs) + return True + + @Cog.listener() + async def on_member_ban(self, guild: Guild, user: t.Union[User, Member]) -> None: + if self.bot.log_is_ignored(Event.member_ban, (guild.id, user.id)): + return + + unban_log_entry = await last_audit_log_with_fail_embed( + guild, + actions=[AuditLogAction.ban], + send_callback=partial(self.send_log, guild), + target=user + ) + if unban_log_entry is None: + return + + embed = Embed( + title="User banned", + description=textwrap.dedent( + f""" + **User:** {user.mention} + **Author:** {unban_log_entry.user.mention} + **Reason:** {unban_log_entry.reason if unban_log_entry.reason else "N/A"} + """ + ), + color=Color.dark_red() + ) + embed.set_thumbnail(url=user.avatar_url) + embed.set_footer(text=f"User ID: {user.id}") + embed.timestamp = unban_log_entry.created_at + + await self.send_log(guild, embed=embed) + + @Cog.listener() + async def on_member_unban(self, guild: Guild, user: Member) -> None: + if self.bot.log_is_ignored(Event.member_unban, (guild.id, user.id)): + return + + ban_log_entry = await last_audit_log_with_fail_embed( + guild, + actions=[AuditLogAction.unban], + send_callback=partial(self.send_log, guild), + target=user + ) + if ban_log_entry is None: + return + + embed = Embed( + title="User unbanned", + description=textwrap.dedent( + f""" + **User:** {user.mention} + **Author:** {ban_log_entry.user.mention} + **Reason:** {ban_log_entry.reason if ban_log_entry.reason else "N/A"} + """ + ), + color=Color.dark_green(), + ) + embed.set_thumbnail(url=user.avatar_url) + embed.set_footer(text=f"User ID: {user.id}") + embed.timestamp = ban_log_entry.created_at + + await self.send_log(guild, embed=embed) + + @Cog.listener() + async def on_member_remove(self, member: Member) -> None: + """ + This is a handler which checks if there is a kick entry in audit log, + when the member leaves, if there is, this wasn't a normal leave, but + rather a kick. In which case, kick log is sent. + """ + if self.bot.log_is_ignored(Event.member_kick, (member.guild.id, member.id)): + return + + kick_log_entry = await last_audit_log_with_fail_embed( + member.guild, + actions=[AuditLogAction.kick], + send_callback=partial(self.send_log, member.guild), + target=member, + audit_cache=self.audit_cache + ) + if kick_log_entry is None: + return + + embed = Embed( + title="User kicked", + description=textwrap.dedent( + f""" + **User:** {member.mention} + **Author:** {kick_log_entry.user.mention} + **Reason:** {kick_log_entry.reason if kick_log_entry.reason else "N/A"} + """ + ), + color=Color.red() + ) + embed.set_thumbnail(url=member.avatar_url) + embed.set_footer(text=f"User ID: {member.id}") + embed.timestamp = kick_log_entry.created_at + + await self.send_log(member.guild, embed=embed) + + @Cog.listener() + async def on_member_update(self, member_before: Member, member_after: Member) -> None: + """ + This is a handler which checks if muted role was added to given member, + if it was, a log message is sent, describing this mute action. + """ + if self.bot.log_is_ignored(Event.member_mute, (member_after.guild.id, member_after.id)): + return + + # Only continue if there was a role update. This listener does capture + # more things, but we aren't interested in them here. + if member_before.roles == member_after.roles: + return + + old_roles = set(member_before.roles) + new_roles = set(member_after.roles) + + # These will always only contain 1 role, but we have + # to use sets to meaningfully get it, which returns another set + removed_roles = old_roles - new_roles + added_roles = new_roles - old_roles + + muted_role_id = await Roles.get_role(self.bot.db_engine, "muted", member_after.guild) + muted_role = member_after.guild.get_role(muted_role_id) + if muted_role is None: + return + + # Don't proceed if muted role wasn't added or removed + if muted_role not in added_roles.union(removed_roles): + return + + # TODO: Add mute strike + + action_type = "unmute" if removed_roles else "mute" + description = f"**User:** {member_after.mention}" + + audit_entry = await last_audit_log_with_fail_embed( + member_after.guild, + actions=[AuditLogAction.member_role_update], + send_callback=partial(self.send_log, member_after.guild), + target=member_before + ) + if audit_entry is not None: + description += ( + f"\n**{action_type.capitalize()}d By:** {audit_entry.user.mention}" + f"\n**Reason:** {audit_entry.reason if audit_entry.reason else 'N/A'}" + ) + + embed = Embed( + title=f"User {action_type}d", + description=description, + color=Color.red() if action_type == "mute" else Color.green() + ) + embed.set_thumbnail(url=member_after.avatar_url) + embed.set_footer(text=f"User ID: {member_after.id}") + embed.timestamp = audit_entry.created_at if audit_entry is not None else datetime.datetime.now() + + await self.send_log(member_after.guild, embed=embed) + + +def setup(bot: Bot) -> None: + bot.add_cog(ModLog(bot)) diff --git a/bot/cogs/logging/server_log.py b/bot/cogs/logging/server_log.py new file mode 100644 index 0000000..c779983 --- /dev/null +++ b/bot/cogs/logging/server_log.py @@ -0,0 +1,243 @@ +import datetime +from functools import partial + +from discord import Color, Embed, Guild, Role +from discord.abc import GuildChannel +from discord.channel import CategoryChannel, VoiceChannel +from discord.enums import AuditLogAction +from discord.ext.commands import Cog + +from bot.core.bot import Bot +from bot.database.log_channels import LogChannels +from bot.utils.audit_parse import last_audit_log_with_fail_embed +from bot.utils.diff import add_change_field, add_channel_perms_field, add_permissions_field + + +class ServerLog(Cog): + def __init__(self, bot: Bot): + self.bot = bot + + async def send_log(self, guild: Guild, *send_args, **send_kwargs) -> bool: + """ + Try to send a log message to a server_log channel for given guild, + args and kwargs to this function will be used in the actual `Channel.send` call. + + If the message was sent, return True, otherwise return False + (might happen if server_log channel isn't defined in database). + """ + server_log_id = await LogChannels.get_log_channel(self.bot.db_engine, "server_log", guild) + server_log_channel = guild.get_channel(int(server_log_id)) + if server_log_channel is None: + return False + + await server_log_channel.send(*send_args, **send_kwargs) + return True + + # region: Channels + + @staticmethod + def channel_path(channel: GuildChannel) -> str: + """ + Format path to given channel without direct mentioning, + if this channel will be removed afterwards, we can know which + category it belonged to. + """ + if channel.category: + return f"{channel.category}/#{channel.name}" + return f"#{channel.name}" + + def channel_type(self, channel: GuildChannel) -> str: + """Classify given channel and return it's category name (str).""" + if isinstance(channel, CategoryChannel): + return "Category" + elif isinstance(channel, VoiceChannel): + return "Voice channel" + return "Text channel" + + @Cog.listener() + async def on_guild_channel_update(self, channel_before: GuildChannel, channel_after: GuildChannel) -> None: + if channel_before.overwrites != channel_after.overwrites: + description = f"**Channel:** {channel_after.mention}\n" + + last_log = await last_audit_log_with_fail_embed( + channel_after.guild, + actions=[AuditLogAction.overwrite_create, AuditLogAction.overwrite_delete, AuditLogAction.overwrite_update], + send_callback=partial(self.send_log, channel_after.guild) + ) + + if last_log: + description += f"**Updated by:** {last_log.user.mention}\n\n" + + embed = Embed( + title=f"{self.channel_type(channel_after)} permissions updated", + description=description, + color=Color.dark_blue() + ) + + embed = add_channel_perms_field(embed, channel_before, channel_after) + else: + description = f"**Channel:** {channel_after.mention}" + + last_log = await last_audit_log_with_fail_embed( + channel_after.guild, + actions=[AuditLogAction.channel_update], + send_callback=partial(self.send_log, channel_after.guild) + ) + + if last_log: + description += f"\n**Updated by:** {last_log.user.mention}" + + embed = Embed( + title=f"{self.channel_type(channel_after)} updated", + description=description, + color=Color.dark_blue() + ) + embed = add_change_field(embed, channel_before, channel_after) + + embed.timestamp = datetime.datetime.now() + embed.set_footer(text=f"Channel ID: {channel_after.id}") + + await self.send_log(channel_after.guild, embed=embed) + + @Cog.listener() + async def on_guild_channel_delete(self, channel: GuildChannel) -> None: + last_log = await last_audit_log_with_fail_embed( + channel.guild, + actions=[AuditLogAction.channel_delete], + send_callback=partial(self.send_log, channel.guild) + ) + description = f"**Channel path:** {self.channel_path(channel)}" + if last_log: + description += f"\n**Removed by:** {last_log.user.mention}" + + embed = Embed( + title=f"{self.channel_type(channel)} removed", + description=description, + color=Color.red() + ) + embed.set_footer(text=f"Channel ID: {channel.id}") + embed.timestamp = datetime.datetime.utcnow() + await self.send_log(channel.guild, embed=embed) + + @Cog.listener() + async def on_guild_channel_create(self, channel: GuildChannel) -> None: + last_log = await last_audit_log_with_fail_embed( + channel.guild, + actions=[AuditLogAction.channel_create], + send_callback=partial(self.send_log, channel.guild) + ) + description = f"**Channel path:** {self.channel_path(channel)}" + if last_log: + description += f"\n**Created by:** {last_log.user.mention}" + + embed = Embed( + title=f"{self.channel_type(channel)} created", + description=description, + color=Color.green() + ) + embed.set_footer(text=f"Channel ID: {channel.id}") + embed.timestamp = datetime.datetime.utcnow() + await self.send_log(channel.guild, embed=embed) + + # endregion + # region: Roles + + @Cog.listener() + async def on_guild_role_update(self, before: Role, after: Role) -> None: + description = f"**Role:** {after.mention}" + + last_log = await last_audit_log_with_fail_embed( + after.guild, + actions=[AuditLogAction.role_update], + send_callback=partial(self.send_log, after.guild) + ) + + embed = Embed( + title="Role updated", + description=description, + color=Color.dark_gold() + ) + + if last_log: + description += f"\n**Updated by:** {last_log.user.mention}" + + if before.permissions != after.permissions: + embed = add_permissions_field(embed, before.permissions, after.permissions) + else: + embed = add_change_field(embed, before, after) + + embed.timestamp = datetime.datetime.now() + + await self.send_log(after.guild, embed=embed) + + @Cog.listener() + async def on_guild_role_create(self, role: Role) -> None: + last_log = await last_audit_log_with_fail_embed( + role.guild, + actions=[AuditLogAction.role_create], + send_callback=partial(self.send_log, role.guild) + ) + description = f"**Role:** {role.mention}" + if last_log: + description += f"\n**Created by:** {last_log.user.mention}" + + embed = Embed( + title="Role created", + description=description, + color=Color.green() + ) + embed.set_footer(text=f"Role ID: {role.id}") + embed.timestamp = datetime.datetime.utcnow() + await self.send_log(role.guild, embed=embed) + + @Cog.listener() + async def on_guild_role_delete(self, role: Role) -> None: + last_log = await last_audit_log_with_fail_embed( + role.guild, + actions=[AuditLogAction.role_delete], + send_callback=partial(self.send_log, role.guild) + ) + + description = f"**Role:** @{role.name}" + + if last_log: + description += f"\n**Removed by:** {last_log.user.mention}" + + embed = Embed( + title="Role deleted", + description=description, + color=Color.red() + ) + embed.set_footer(text=f"Role ID: {role.id}") + embed.timestamp = datetime.datetime.utcnow() + await self.send_log(role.guild, embed=embed) + + # endregion + + @Cog.listener() + async def on_guild_update(self, before: Guild, after: Guild) -> None: + description = "Guild configuration has changed." + + last_log = await last_audit_log_with_fail_embed( + after, + actions=[AuditLogAction.guild_update], + send_callback=partial(self.send_log, after) + ) + + if last_log: + description += f"\n**Updated by:** {last_log.user.mention}" + + embed = Embed( + title="Guild updated", + description=description, + color=Color.dark_gold() + ) + embed = add_change_field(embed, before, after) + + embed.timestamp = datetime.datetime.now() + + await self.send_log(after, embed=embed) + + +def setup(bot: Bot) -> None: + bot.add_cog(ServerLog(bot)) diff --git a/bot/cogs/logging/voice_log.py b/bot/cogs/logging/voice_log.py new file mode 100644 index 0000000..2881b67 --- /dev/null +++ b/bot/cogs/logging/voice_log.py @@ -0,0 +1,71 @@ +from discord import Color, Embed, Guild, Member, VoiceState +from discord.ext.commands import Cog + +from bot.core.bot import Bot +from bot.database.log_channels import LogChannels + + +class MessageLog(Cog): + def __init__(self, bot: Bot): + self.bot = bot + + async def send_log(self, guild: Guild, *send_args, **send_kwargs) -> bool: + """ + Try to send a log message to a voice_log channel for given guild, + args and kwargs to this function will be used in the actual `Channel.send` call. + + If the message was sent, return True, otherwise return False + (might happen if voice_log channel isn't defined in database). + """ + voice_log_id = await LogChannels.get_log_channel(self.bot.db_engine, "voice_log", guild) + voice_log_channel = guild.get_channel(int(voice_log_id)) + if voice_log_channel is None: + return False + + await voice_log_channel.send(*send_args, **send_kwargs) + return True + + @Cog.listener() + async def on_voice_state_update(self, member: Member, before: VoiceState, after: VoiceState) -> None: + if before.afk != after.afk: + embed = Embed( + title="User went AFK" if after.afk else "User is no longer AFK", + description=f"**User:** {member.mention}", + color=Color.blue() + ) + elif before.channel != after.channel: + description_lines = [f"**User:** {member.mention}"] + if before.channel: + description_lines.append(f"**Previous Channel:** {before.channel}") + if after.channel: + description_lines.append(f"**New Channel:** {after.channel}") + + embed = Embed( + title="User changed channels" if after.channel else "User left voice channel", + description="\n".join(description_lines), + color=Color.blue() + ) + elif before.deaf != after.deaf: + embed = Embed( + title="User deafened" if after.deaf else "User undeafened", + description=f"**User:** {member.mention}", + color=Color.dark_orange() + ) + elif before.mute != after.mute: + embed = Embed( + title="User silenced" if after.mute else "User unsilenced", + description=f"**User:** {member.mention}", + color=Color.dark_orange() + ) + # Client actions cal also happen here, i.e. self mute/deaf/stream/video + # we don't need to log those, so we can return early in that case + else: + return + + embed.set_thumbnail(url=member.avatar_url) + + await self.send_log(member.guild, embed=embed) + + +def setup(bot: Bot) -> None: + bot.add_cog(MessageLog(bot)) diff --git a/bot/cogs/moderation/lock.py b/bot/cogs/moderation/lock.py index 222f784..57e758a 100644 --- a/bot/cogs/moderation/lock.py +++ b/bot/cogs/moderation/lock.py @@ -3,61 +3,93 @@ from collections import defaultdict from discord import TextChannel -from discord.ext.commands import Cog, Context, MissingPermissions, command +from discord.ext.commands import Cog, Context, command +from discord.ext.commands.errors import MissingPermissions from loguru import logger from bot.core.bot import Bot -from bot.core.converters import Duration -from bot.core.timer import Timer from bot.database.permissions import Permissions from bot.database.roles import Roles +from bot.utils.converters import Duration from bot.utils.time import stringify_duration +from bot.utils.timer import Timer class Lock(Cog): def __init__(self, bot: Bot): self.bot = bot - self.locked_channels = defaultdict(set) + self.previous_permissions = defaultdict(lambda: defaultdict(None)) + # provide synchronous cache for `cog_unload` method + self.staff_roles = defaultdict(lambda: None) self.timer = Timer("channel_lock") - self.roles_db: Roles = Roles.reference() - self.permissions_db: Permissions = Permissions.reference() - async def _lock(self, channel: TextChannel) -> bool: + async def _lock(self, channel: TextChannel) -> t.Literal[-1, 0, 1]: """ Disable the permission to `send_messages` in `channel` for the `default_role` on that server. - Return `False` in case the channel was already silenced, - otherwise return `True` + Return codes: + - 1: Channel locked successfully + - 0: Channel was already silenced + - -1: Channel was silenced manually """ - default_role_id = self.roles_db.get_default_role(channel.guild) + default_role_id = await Roles.get_role(self.bot.db_engine, "default", channel.guild) + if default_role_id is None: + default_role_id = channel.guild.default_role.id default_role = channel.guild.get_role(default_role_id) current_permissions = channel.overwrites_for(default_role) + last_permissions = self.previous_permissions[channel.guild].get(channel) - if current_permissions.send_messages is False: + if last_permissions and current_permissions.send_messages is False: logger.warning(f"Tried to silence already silenced channel #{channel} ({channel.id}).") - return False + return 0 + elif current_permissions.send_messages is False: + logger.warning(f"Tried to silence manually silenced channel #{channel} ({channel.id}).") + return -1 + + self.previous_permissions[channel.guild][channel] = current_permissions + # Store staff role into dict in order to have it accessable for the synchronous `cog_unload` function + self.staff_roles[channel.guild] = await Roles.get_role(self.bot.db_engine, "staff", channel.guild) await channel.set_permissions(default_role, **dict(current_permissions, send_messages=False)) - self.locked_channels[channel.guild].add(channel) - return True - async def _unlock(self, channel: TextChannel) -> bool: + return 1 + + async def _unlock(self, channel: TextChannel) -> t.Literal[-2, -1, 0, 1]: """ Reset the permission to `send_messages` in `channel` for the `default_role` on that server. + + Return codes: + 1: Channel successfully unlocked + 0: Channel wasn't silenced + -1: Channel was already unsilenced manually + -2: Channel was silenced manually """ - default_role_id = self.roles_db.get_default_role(channel.guild) + default_role_id = await Roles.get_role(self.bot.db_engine, "default", channel.guild) + if default_role_id is None: + default_role_id = channel.guild.default_role.id default_role = channel.guild.get_role(default_role_id) current_permissions = channel.overwrites_for(default_role) + last_permissions = self.previous_permissions[channel.guild].get(channel) + + if last_permissions and current_permissions.send_messages is not False: + logger.warning(f"Tried to unsilence already manually unsilenced channel #{channel} ({channel.id}).") + del self.previous_permissions[channel.guild][channel] + return -1 - if current_permissions.send_messages is not False: - logger.warning(f"Tried to unsilence already unsilenced channel #{channel} ({channel.id}).") - return False + elif current_permissions.send_messages is not False: + logger.warning(f"Tried to unsilence non-silenced channel #{channel} ({channel.id}).") + return 0 - await channel.set_permissions(default_role, **dict(current_permissions, send_messages=None)) - self.locked_channels[channel.guild].discard(channel) - return True + elif last_permissions is None: + logger.warning(f"Tried to unsilence manually silenced channel #{channel} ({channel.id}).") + return -2 + + await channel.set_permissions(default_role, **dict(last_permissions)) + del self.previous_permissions[channel.guild][channel] + + return 1 @command(aliases=["silence"]) async def lock(self, ctx: Context, duration: t.Optional[Duration] = None, *, reason: t.Optional[str]) -> None: @@ -65,26 +97,26 @@ async def lock(self, ctx: Context, duration: t.Optional[Duration] = None, *, rea Disallow everyones permission to talk in this channel for given `duration` or indefinitely. """ - if duration == float("inf"): - duration = None + if duration is None: + duration = float("inf") - max_duration = await self.permissions_db.get_locktime(ctx.guild, ctx.author) - - if any([ - not duration and max_duration != -1, - duration and max_duration != -1 and duration > max_duration - ]): + max_duration = await Permissions.get_permission_from_member(self.bot.db_engine, self.bot, "lock", ctx.guild, ctx.author) + if duration > max_duration: raise MissingPermissions(["sufficient_locktime"]) logger.debug(f"Channel #{ctx.channel} was silenced by {ctx.author}.") - if not await self._lock(ctx.channel): + status = await self._lock(ctx.channel) + if status == 0: await ctx.send(":x: This channel is already locked.") return + elif status == -1: + await ctx.send(":x: This channel was already locked manually using channel permissions.") + return reason = "No reason specified" if not reason else reason - if not duration: + if duration == float("inf"): await ctx.send(f"🔒 Channel locked indefinitely: {reason}.") return @@ -96,24 +128,36 @@ async def unlock(self, ctx: Context) -> None: """Unsilence current channel.""" logger.debug(f"Channel #{ctx.channel} was unsilenced.") - if await self._unlock(ctx.channel): + status = await self._unlock(ctx.channel) + if status == 1: self.timer.abort(ctx.channel.id) await ctx.send("🔓 Channel unlocked.") - else: - await ctx.send(":x: This channel isn't silenced.") + elif status == 0: + await ctx.send(":x: This channel isn't locked.") + elif status == -1: + self.timer.abort(ctx.channel.id) + await ctx.send(":x: This channel was already unsilenced manually, no action taken.") + elif status == -2: + await ctx.send(":x: This channel is silenced manually using channel permissions, you'll need to unsilence it manually.") def cog_unload(self) -> None: """Send a modlog message about the channels which were left unsilenced""" self.timer.abort_all() - for guild, channels in self.locked_channels.items(): - moderator_role_id = self.roles_db.get_staff_role(guild.id) - if moderator_role_id: - message = f"<@&{moderator_role_id}> " + + for guild, channels in self.previous_permissions.items(): + # Access staff role, to provide the ability to get it synchronously + # database read would be better, but that's an async operaion + staff_role_id = self.staff_roles[guild] + if staff_role_id: + message = f"⚠️ <@&{staff_role_id}> " else: - message = "" - message += "This channel was left locked after lock cog unloaded" - for channel in channels: + message = "⚠️ " + message += "This channel was left locked after lock cog unloaded, performing automatic unlock." + + for channel in channels.keys(): + logger.debug(f"Channel #{channel} ({channel.id}) was left locked after lock cog unloaded, performing automatic unlock.") asyncio.create_task(channel.send(message)) + asyncio.create_task(self._unlock(channel)) async def cog_check(self, ctx: Context) -> bool: """ @@ -125,7 +169,7 @@ async def cog_check(self, ctx: Context) -> bool: if ctx.author.permissions_in(ctx.channel).manage_messages: return True - return False + return MissingPermissions("Only members with manage messages rights can use this command.") def setup(bot: Bot) -> None: diff --git a/bot/cogs/moderation/slowmode.py b/bot/cogs/moderation/slowmode.py index 953ec5d..7820b32 100644 --- a/bot/cogs/moderation/slowmode.py +++ b/bot/cogs/moderation/slowmode.py @@ -1,8 +1,9 @@ -from discord.ext.commands import BadArgument, Cog, Context, command +from discord.ext.commands import Cog, Context, command +from discord.ext.commands.errors import BadArgument, MissingPermissions from loguru import logger from bot.core.bot import Bot -from bot.core.converters import Duration +from bot.utils.converters import Duration from bot.utils.time import stringify_duration @@ -43,7 +44,7 @@ async def cog_check(self, ctx: Context) -> bool: if ctx.author.permissions_in(ctx.channel).manage_channels: return True - return False + raise MissingPermissions("Only members with manage messages rights can use this command.") def setup(bot: Bot) -> None: diff --git a/bot/cogs/moderation/strikes.py b/bot/cogs/moderation/strikes.py new file mode 100644 index 0000000..dad5bec --- /dev/null +++ b/bot/cogs/moderation/strikes.py @@ -0,0 +1,68 @@ +import typing as t + +from discord.ext.commands import Cog, Context, group +from discord.ext.commands.errors import BadArgument, MissingPermissions +from sqlalchemy.exc import NoResultFound + +from bot.config import STRIKE_TYPES +from bot.core.bot import Bot +from bot.database.strikes import Strikes as StrikesDB +from bot.utils.converters import ProcessedUser + + +class Strikes(Cog): + """ + Since this cog interracts with strikes database entries directly, + it is limited to administrators. This doesn't mean others won't + have permissions to add strikes, but it will be done with other + commands, such as `ban`, `kick`, etc., these commands will be used + in different cogs, and the strikes will be added automatically. + This is manual way for direct adjustment, creation or deletion of strikes. + """ + def __init__(self, bot: Bot): + self.bot = bot + + @group(invoke_without_command=True, name="strike", aliases=["strikes", "infraction", "infractions"]) + async def strike_group(self, ctx: Context) -> None: + """ + Commands for configuring the strike messages. + + Note: Strike manipulation is usually a bad idea, if you want to + add a strike to the user, you can use commands made for it. + Using this will manually add the strike, without invoking the action + which naturally comes with it (such as ban, kick, etc.). + """ + await ctx.send_help(ctx.command) + + @strike_group.command(aliases=["create"]) + async def add(self, ctx: Context, user: ProcessedUser, strike_type: str, *, reason: t.Optional[str] = None) -> None: + """Add a new strike to given `user`""" + if strike_type not in STRIKE_TYPES: + raise BadArgument(f"Invalid strike type, possible types are: `{', '.join(STRIKE_TYPES)}`") + + strike_id = await StrikesDB.set_strike(self.bot.db_engine, ctx.guild, user, ctx.author, strike_type, reason) + await ctx.send(f"✅ {strike_type} strike (ID: `{strike_id}`) applied to {user.mention}, reason: `{reason}`") + + @strike_group.command(aliases=["del"]) + async def remove(self, ctx: Context, strike_id: int) -> None: + try: + await StrikesDB.remove_strike(self.bot.db_engine, ctx.guild, strike_id) + except NoResultFound: + await ctx.send(f"❌ Strike with ID `{strike_id}` does not exist.") + else: + await ctx.send(f"✅ Strike with ID `{strike_id}` has been removed.") + + async def cog_check(self, ctx: Context) -> bool: + """ + Only allow users with administrator permission to use these function. + + For details about why is this cog limited for admins only, check the cog description. + """ + if ctx.author.permissions_in(ctx.channel).administrator: + return True + + raise MissingPermissions("Only members with administrator rights can use this command.") + + +def setup(bot: Bot) -> None: + bot.add_cog(Strikes(bot)) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 8f008c3..7e63c46 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -5,9 +5,9 @@ from discord.ext.commands.errors import BadArgument from bot.core.bot import Bot -from bot.core.converters import Duration -from bot.core.timer import Timer +from bot.utils.converters import Duration from bot.utils.time import stringify_duration +from bot.utils.timer import Timer class Reminders(Cog): diff --git a/bot/cogs/setup/log_channels.py b/bot/cogs/setup/log_channels.py new file mode 100644 index 0000000..2e7202f --- /dev/null +++ b/bot/cogs/setup/log_channels.py @@ -0,0 +1,56 @@ +import typing as t + +from discord import Color, Embed +from discord.ext.commands import Cog, Context, group +from discord.ext.commands.converter import TextChannelConverter +from discord.ext.commands.errors import MissingPermissions + +from bot.core.bot import Bot +from bot.database.log_channels import LogChannels + + +class LogChannelsSetup(Cog): + def __init__(self, bot: Bot): + self.bot = bot + + @group(invoke_without_command=True, aliases=["logging", "log", "logs", "logchannel"]) + async def logging_group(self, ctx: Context, log_type: str, channel: TextChannelConverter) -> None: + """Commands for configuring the log channels.""" + try: + await LogChannels.set_log_channel(self.bot.db_engine, log_type, ctx.guild, channel) + except ValueError: + await ctx.send(f":x: Invalid logging type, types: `{', '.join(LogChannels.valid_log_types)}`") + return + await ctx.send(":white_check_mark: Log channel updated") + + @logging_group.command(aliases=["info", "status"]) + async def show(self, ctx: Context) -> None: + """Show configured log channels.""" + obtained_channels = await LogChannels.get_log_channels(self.bot.db_engine, ctx.guild) + + description_lines = [] + for log_type in LogChannels.valid_log_types: + channel_id = obtained_channels.get(log_type, None) + channel = ctx.guild.get_channel(int(channel_id)) + + readable_log_type = log_type.replace("_log", "").capitalize() + description_lines.append(f"{readable_log_type} level logs: {channel.mention if channel else ''}") + + embed = Embed( + title="Log channels configuration", + description="\n".join(description_lines), + color=Color.blue() + ) + + await ctx.send(embed=embed) + + async def cog_check(self, ctx: Context) -> t.Optional[bool]: + """Only allow users with administrator permission to use these functions.""" + if ctx.author.guild_permissions.administrator: + return True + + raise MissingPermissions("Only members with administrator rights can use this command.") + + +def setup(bot: Bot) -> None: + bot.add_cog(LogChannelsSetup(bot)) diff --git a/bot/cogs/setup/permissions.py b/bot/cogs/setup/permissions.py index 3e618ff..825c399 100644 --- a/bot/cogs/setup/permissions.py +++ b/bot/cogs/setup/permissions.py @@ -1,80 +1,63 @@ -import textwrap +import typing as t -from discord import Embed -from discord.ext.commands import Cog, Context, RoleConverter, command +from discord import Color, Embed +from discord.ext.commands import Cog, Context, group +from discord.ext.commands.converter import RoleConverter +from discord.ext.commands.errors import MissingPermissions from bot.core.bot import Bot -from bot.core.converters import Duration from bot.database.permissions import Permissions +from bot.utils.converters import Duration from bot.utils.time import stringify_duration class PermissionsSetup(Cog): + """ + This cog is here to provide initial setup of log channels for given guild. + After that, there usually isn't a use for it anymore, unless that channel is changed. + """ def __init__(self, bot: Bot): self.bot = bot - self.permissions_db: Permissions = Permissions.reference() - @command(aliases=["bantime"]) - async def ban_time(self, ctx: Context, role: RoleConverter, duration: Duration) -> None: - """ - Set maximum time users with specified role can ban users for. - - If user have multiple roles with different ban times, - the ban time for role higher in the hierarchy will be preferred. - """ - await self.permissions_db.set_bantime(ctx.guild, role, duration) - await ctx.send(":white_check_mark: Permissions updated.") - - @command(aliases=["mutetime"]) - async def mute_time(self, ctx: Context, role: RoleConverter, duration: Duration) -> None: - """ - Set maximum time users with specified role can mute users for. - - If user have multiple roles with different mute times, - the mute time for role higher in the hierarchy will be preferred. - """ - await self.permissions_db.set_mutetime(ctx.guild, role, duration) + @group(invoke_without_command=True, name="permissions", aliases=["perm", "perms"]) + async def permissions_group(self, ctx: Context, permission_type: str, role: RoleConverter, duration: Duration) -> None: + """Commands for configuring the role permissions.""" + try: + await Permissions.set_role_permission(self.bot.db_engine, permission_type, ctx.guild, role, duration) + except ValueError: + await ctx.send(f":x: Invalid logging type, types: `{', '.join(Permissions.valid_time_types)}`") + return await ctx.send(":white_check_mark: Permissions updated.") - @command(aliases=["locktime"]) - async def lock_time(self, ctx: Context, role: RoleConverter, duration: Duration) -> None: - """ - Set maximum time users with specified role can lock channels for. + @permissions_group.command(aliases=["info", "status"]) + async def show(self, ctx: Context, role: RoleConverter) -> None: + """Show configured log channels.""" + obtained_times = await Permissions.get_permissions(self.bot.db_engine, ctx.guild, role) - If user have multiple roles with different lock times, - the lock time for role higher in the hierarchy will be preferred. - """ - await self.permissions_db.set_locktime(ctx.guild, role, duration) - await ctx.send(":white_check_mark: Permissions updated.") + description_lines = [] + for time_type in Permissions.valid_time_types: - @command(aliases=["showpermissions"]) - async def show_permissions(self, ctx: Context, role: RoleConverter) -> None: - """Show configured role permissions for the given `role`""" - ban_time = await self.permissions_db.get_bantime(ctx.guild, role) - mute_time = await self.permissions_db.get_mutetime(ctx.guild, role) - lock_time = await self.permissions_db.get_locktime(ctx.guild, role) + time = obtained_times.get(time_type, None) + readable_time = stringify_duration(time) if time is not None else '' - if ban_time: - ban_time = stringify_duration(ban_time) - if mute_time: - mute_time = stringify_duration(mute_time) - if lock_time: - lock_time = stringify_duration(lock_time) + readable_time_type = time_type.replace("_time", "") + description_lines.append(f"Maximum {readable_time_type} time: {readable_time}") embed = Embed( - description=textwrap.dedent( - f""" - **Permissions for {role.mention} role** - - Maximum ban time: `{ban_time}` - Maximum mute time: `{mute_time}` - Maximum Lock time: `{lock_time}` - """ - ) + title=f"Permissions for {role} role", + description="\n".join(description_lines), + color=Color.blue() ) await ctx.send(embed=embed) + async def cog_check(self, ctx: Context) -> t.Optional[bool]: + """Only allow users with administrator permission to use these functions.""" + if ctx.author.guild_permissions.administrator: + return True + + raise MissingPermissions("Only members with administrator rights can use this command.") + def setup(bot: Bot) -> None: bot.add_cog(PermissionsSetup(bot)) diff --git a/bot/cogs/setup/roles.py b/bot/cogs/setup/roles.py index 602f2d0..3f768f2 100644 --- a/bot/cogs/setup/roles.py +++ b/bot/cogs/setup/roles.py @@ -1,7 +1,9 @@ -import textwrap +import typing as t -from discord import Embed, Role -from discord.ext.commands import Cog, Context, RoleConverter, command +from discord import Color, Embed +from discord.ext.commands import Cog, Context, group +from discord.ext.commands.converter import RoleConverter +from discord.ext.commands.errors import MissingPermissions from bot.core.bot import Bot from bot.database.roles import Roles @@ -10,53 +12,47 @@ class RolesSetup(Cog): def __init__(self, bot: Bot): self.bot = bot - self.roles_db: Roles = Roles.reference() - - @command(aliases=["defaultrole"]) - async def default_role(self, ctx: Context, role: RoleConverter) -> None: - """Setup default role.""" - await self.roles_db.set_default_role(ctx.guild, role) - await ctx.send(":white_check_mark: Role updated.") - - @command(aliases=["staffrole"]) - async def staff_role(self, ctx: Context, role: RoleConverter) -> None: - """Setup the staff role.""" - await self.roles_db.set_staff_role(ctx.guild, role) - await ctx.send(":white_check_mark: Role updated.") - - @command(aliases=["mutedrole"]) - async def muted_role(self, ctx: Context, role: RoleConverter) -> None: - """Setup the muted role.""" - await self.roles_db.set_muted_role(ctx.guild, role) - await ctx.send(":white_check_mark: Role updated.") - - @command(aliases=["showroles"]) - async def show_roles(self, ctx: Context) -> None: - """Show configured roles in the server.""" - default = ctx.guild.get_role(self.roles_db.get_default_role(ctx.guild)) - staff = ctx.guild.get_role(self.roles_db.get_staff_role(ctx.guild)) - muted = ctx.guild.get_role(self.roles_db.get_muted_role(ctx.guild)) - - if isinstance(default, Role): - default = default.mention - if isinstance(staff, Role): - staff = staff.mention - if isinstance(muted, Role): - muted = muted.mention + + @group(invoke_without_command=True, name="roles", aliases=["role"]) + async def roles_group(self, ctx: Context, role_type: str, role: RoleConverter) -> None: + """Commands for configuring the server roles.""" + try: + await Roles.set_role(self.bot.db_engine, role_type, ctx.guild, role) + except ValueError: + await ctx.send(f":x: Invalid role type, types: `{', '.join(Roles.valid_role_types)}`") + return + await ctx.send(":white_check_mark: Permissions updated.") + + @roles_group.command(aliases=["info", "status"]) + async def show(self, ctx: Context) -> None: + """Show configured log channels.""" + obtained_roles = await Roles.get_roles(self.bot.db_engine, ctx.guild) + + description_lines = [] + for role_type in Roles.valid_role_types: + + role_id = obtained_roles.get(role_type, None) + role = ctx.guild.get_role(role_id) + readable_role = role.mention if role is not None else '' + + readable_role_type = role_type.replace("_role", "").capitalize() + description_lines.append(f"{readable_role_type} role: {readable_role}") embed = Embed( - title="Configured role settings", - description=textwrap.dedent( - f""" - Default role: {default} - Staff role: {staff} - Muted role: {muted} - """ - ) + title="Server role setup", + description="\n".join(description_lines), + color=Color.blue() ) await ctx.send(embed=embed) + async def cog_check(self, ctx: Context) -> t.Optional[bool]: + """Only allow users with administrator permission to use these functions.""" + if ctx.author.guild_permissions.administrator: + return True + + raise MissingPermissions("Only members with administrator rights can use this command.") + def setup(bot: Bot) -> None: bot.add_cog(RolesSetup(bot)) diff --git a/bot/cogs/utility/__init__.py b/bot/cogs/utility/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/cogs/embeds.py b/bot/cogs/utility/embeds.py similarity index 92% rename from bot/cogs/embeds.py rename to bot/cogs/utility/embeds.py index c84e77a..58f1a7a 100644 --- a/bot/cogs/embeds.py +++ b/bot/cogs/utility/embeds.py @@ -1,14 +1,15 @@ import json import typing as t from collections import defaultdict -from contextlib import suppress -from discord import Embed, Forbidden, Member, TextChannel +from discord import Embed, Member, TextChannel from discord.errors import HTTPException -from discord.ext.commands import Cog, ColourConverter, Context, MessageConverter, group +from discord.ext.commands import Cog, Context, group +from discord.ext.commands.converter import ColourConverter, MessageConverter +from discord.ext.commands.errors import CheckFailure, MissingPermissions from bot.core.bot import Bot -from bot.core.converters import Unicode +from bot.utils.converters import Unicode class InvalidEmbed(Exception): @@ -41,19 +42,17 @@ def __init__(self, ctx: Context, json_dict: dict) -> None: self.json = JsonEmbedParser.process_dict(json_dict) @classmethod - async def from_str(cls: "JsonEmbedParser", ctx: Context, json_string: str) -> t.Union["JsonEmbedParser", bool]: + async def from_str(cls, ctx: Context, json_string: str) -> "JsonEmbedParser": """Return class instance from json string. This will return either class instance (on correct json string), or False on incorrect json string. """ json_dict = await cls.parse_json(ctx, json_string) - if json_dict is False: - return False return cls(ctx, json_dict) @classmethod - def from_embed(cls: "JsonEmbedParser", ctx: Context, embed: t.Union[Embed, EmbedData]) -> "JsonEmbedParser": + def from_embed(cls, ctx: Context, embed: t.Union[Embed, EmbedData]) -> "JsonEmbedParser": """Return class instance from embed.""" if isinstance(embed, EmbedData): embed_dict = embed.embed.to_dict() @@ -63,7 +62,7 @@ def from_embed(cls: "JsonEmbedParser", ctx: Context, embed: t.Union[Embed, Embed return cls(ctx, json_dict) @staticmethod - async def parse_json(ctx: Context, json_code: str) -> t.Union[dict, bool]: + async def parse_json(ctx: Context, json_code: str) -> dict: """Parse given json code.""" # Sanitize code (remove codeblocks if any) if "```" in json_code: @@ -312,11 +311,8 @@ async def from_message(self, ctx: Context, message: MessageConverter) -> None: async def load(self, ctx: Context, *, json_code: str) -> None: """Generate Embed from given JSON code.""" embed_parser = await JsonEmbedParser.from_str(ctx, json_code) - if embed_parser is not False: - self.embeds[ctx.author] = embed_parser.make_embed() - await ctx.send("Embed updated accordingly to provided JSON") - else: - await ctx.send("Invalid embed JSON") + self.embeds[ctx.author] = embed_parser.make_embed() + await ctx.send("Embed updated accordingly to provided JSON") @embed_group.command(aliases=["json_dump", "to_json", "get_json", "export"]) async def dump(self, ctx: Context) -> None: @@ -327,16 +323,13 @@ async def dump(self, ctx: Context) -> None: @embed_group.command() async def message_dump(self, ctx: Context, channel: TextChannel, message_id: int) -> None: - """Dump an embed with it's ID.""" + """Dump JSON of embed in message (by ID).""" member = channel.server and channel.server.get_member(ctx.message.author.id) if channel != ctx.message.channel and not member: - await ctx.send("Private Channel, or Invalid Server.") - return - - with suppress(Forbidden): - msg = await self.bot.get_message(channel, str(message_id)) + raise CheckFailure("Channel you're trying to access is private or invalid.") + msg = await self.bot.get_message(channel, str(message_id)) if msg.author.id != self.bot.user.id: await ctx.send("Invalid User's Message.") return @@ -391,13 +384,16 @@ async def reset(self, ctx: Context) -> None: # endregion def cog_check(self, ctx: Context) -> bool: - """Only allow users with manage messages permission to invoke commands in this cog. + """ + Only allow users with manage messages permission to invoke commands in this cog. This is needed because Embeds can be much longer in comparison to regular messages, therefore it would be very easy to spam things and clutter the chat. """ - perms = ctx.author.permissions_in(ctx.channel) - return perms.manage_messages + if ctx.author.permissions_in(ctx.channel).manage_messages: + return True + + return MissingPermissions("Only members with manage messages rights can use this command.") def setup(bot: Bot) -> Bot: diff --git a/bot/config.py b/bot/config.py index c9b26e5..a3c5902 100644 --- a/bot/config.py +++ b/bot/config.py @@ -1,22 +1,42 @@ -# Imports import os +from enum import Enum + + +COMMAND_PREFIX = os.getenv("COMMAND_PREFIX", ">>") -# Developer Mode Settings: DEV_MODE = True -# Ownership settings: -creator = "The Codin Nerds Team" -devs = [711194921683648523, 306876636526280705] +# Aviable types of strikes +STRIKE_TYPES = [ + "ban", "kick", "mute", "note", "custom", + "automod-ban", "automod-kick", "automod-mute", "automod-note", +] # Database DATABASE = { "host": os.getenv("DATABASE_HOST", "127.0.0.1"), - "database": os.getenv("DATABASE_NAME"), - "user": os.getenv("DATABASE_USER"), - "password": os.getenv("DATABASE_PASSWORD"), - "min_size": int(os.getenv("POOL_MIN", "20")), - "max_size": int(os.getenv("POOL_MAX", "100")), + "database": os.getenv("DATABASE_NAME", "bot"), + "user": os.getenv("DATABASE_USER", "bot"), + "password": os.getenv("DATABASE_PASSWORD", "bot"), } +DATABASE_ENGINE_STRING = f"postgresql+asyncpg://{DATABASE['user']}:{DATABASE['password']}@{DATABASE['host']}/{DATABASE['database']}" -# Prefix Settings -COMMAND_PREFIX = os.getenv("COMMAND_PREFIX", ">>") + +class Event(Enum): + """ + Used to identify specific event for bot.cogs.logging.mod_log. + This isn't in sync with all discord.py real events, it is here + to hold a unique identifier for each event + """ + member_ban = "member_ban" + member_unban = "member_unban" + member_kick = "member_kick" + member_mute = "member_mute" + + member_join = "member_join" + member_remove = "member_remove" + member_update = "member_update" + user_update = "user_update" + + message_edit = "message_edit" + message_delete = "message_delete" diff --git a/bot/core/autoload.py b/bot/core/autoload.py new file mode 100644 index 0000000..9a54234 --- /dev/null +++ b/bot/core/autoload.py @@ -0,0 +1,50 @@ +import importlib +import inspect +import pkgutil +import typing as t +from types import FunctionType, ModuleType + +import bot.cogs +import bot.database + + +def bare_name(name: str) -> str: + """Return a bare (unqualified) name given a qualified module/package `name`.""" + return name.rsplit(".", maxsplit=1)[-1] + + +def readable_name(name: str) -> str: + """ + Return uncluttered name by removing first 2 directories + (without `bot.X.`, which is in every extension/database anyway). + """ + return name.split(".", maxsplit=2)[-1] + + +def walk_modules(package: ModuleType, check: t.Optional[FunctionType] = None) -> t.Iterator[str]: + """Yield extension names from the bot.cogs subpackage.""" + + def on_error(name: str) -> t.NoReturn: + raise ImportError(name=name) # pragma: no cover + + for module in pkgutil.walk_packages(package.__path__, f"{package.__name__}.", onerror=on_error): + if bare_name(module.name).startswith("_"): + # Ignore module/package names starting with an underscore. + continue + + if check and not check(module): + continue + + yield module.name + + +def extension_check(module: ModuleType) -> bool: + if module.ispkg: + imported = importlib.import_module(module.name) + # If it lacks a setup function, it's not an extension. + return inspect.isfunction(getattr(imported, "setup", None)) + return True + + +EXTENSIONS = frozenset(walk_modules(bot.cogs, extension_check)) +DATABASES = frozenset(walk_modules(bot.database)) diff --git a/bot/core/bot.py b/bot/core/bot.py index 105c4ec..3a29985 100644 --- a/bot/core/bot.py +++ b/bot/core/bot.py @@ -1,47 +1,69 @@ import time +import typing as t +from collections import defaultdict from datetime import datetime import aiohttp +from asyncpg.exceptions import InvalidPasswordError from discord.ext.commands import AutoShardedBot as Base_Bot from loguru import logger +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine from bot import config -from bot.database import Database +from bot.config import Event +from bot.core.autoload import EXTENSIONS, readable_name +from bot.database import Base as DbBase +from bot.database import load_tables class Bot(Base_Bot): """Subclassed Neutron bot.""" - def __init__(self, extensions: list, db_tables: list, *args, **kwargs) -> None: + def __init__(self, *args, **kwargs) -> None: """Initialize the subclass.""" super().__init__(*args, **kwargs) self.start_time = datetime.utcnow() - - self.extension_list = extensions - self.db_table_list = db_tables self.initial_call = True + self._ignored_logs = defaultdict(set) async def load_extensions(self) -> None: """Load all listed cogs.""" - for extension in self.extension_list: + for extension in EXTENSIONS: try: self.load_extension(extension) - logger.debug(f"Cog {extension} loaded.") + logger.debug(f"Cog {readable_name(extension)} loaded.") except Exception as e: - logger.error(f"Cog {extension} failed to load with {type(e)}: {e}") - - async def db_connect(self) -> None: - """Estabolish connection with the database.""" - self.database = Database(config.DATABASE) - connected = await self.database.connect() - while not connected: - logger.warning("Retrying to connect to database in 5s") - # Synchronous sleep function to stop everything until db is connecting + logger.error(f"Cog {readable_name(extension)} failed to load with {type(e)}: {e}") + + async def db_connect(self) -> AsyncEngine: + """ + Estabolish connection with the database and return the asynchronous engine. + + Function which interract with database will then be able to use this engine to + create their `AsyncSession` instances, which will then be used to perform any + operations on the database. + + We retrun `AsyncEngine` instead of directly using `AsyncSession`, because reusing + the same session isn't thread-safe and when multiple calls happen concurrently, + it causes issues that asyncpg can't handle. + """ + load_tables() # Load all DB Tables, in order to bring them into the metadata of DbBase + + engine = create_async_engine(config.DATABASE_ENGINE_STRING) + try: + async with engine.begin() as conn: + await conn.run_sync(DbBase.metadata.create_all) # Create all database tables from found models + except ConnectionRefusedError: + # Keep recursively trying to connect to the database + logger.error("Unable to connect to database, retrying in 5s") time.sleep(5) - connected = await self.database.connect() + return await self.db_connect() + except InvalidPasswordError as exc: + logger.critical("Invalid database password.") + raise exc - await self.database.load_tables(self.db_table_list, self) + return engine async def on_ready(self) -> None: if self.initial_call: @@ -53,16 +75,16 @@ async def on_ready(self) -> None: else: logger.info("Bot connection reinitialized") - def run(self, token: str) -> None: + def run(self, token: t.Optional[str]) -> None: """Override the default `run` method and add a missing token check""" if not token: - logger.error("Missing Bot Token!") + logger.critical("Missing Bot Token!") else: super().run(token) async def start(self, *args, **kwargs) -> None: """ - Estabolish a connection to asyncpg database and aiohttp session. + Estabolish a session for sqlalchemy database and aiohttp. Overwriting `start` method is needed in order to only make a connection after the bot itself has been initiated. @@ -70,15 +92,45 @@ async def start(self, *args, **kwargs) -> None: Setting these on `__init__` directly would mean in case the bot fails to run it won't be easy to close the connection. """ - self.session = aiohttp.ClientSession() - await self.db_connect() + self.http_session = aiohttp.ClientSession() + self.db_engine = await self.db_connect() await super().start(*args, **kwargs) async def close(self) -> None: """Close the bot and do some cleanup.""" logger.info("Closing bot connection") - if hasattr(self, "session"): - await self.session.close() - if hasattr(self, "database") and hasattr(self.database, "pool"): - await self.database.disconnect() + if hasattr(self, "http_session"): + await self.http_session.close() await super().close() + + def log_ignore(self, event: Event, *items: t.Any) -> None: + """ + Add event to the set of ignored events to abort log sending. + + This function is meant for other cogs, to use and add ignored events, + which is useful, because if we trigger an action like banning with a command, + we may have more information about that ban, than we would get from the listener. + The cog that ignored some event can then send a log message directly, with this + additional info. + + `items` can contain multiple uniquely identifiable keys for given events to be + ignored. This unique key will then be used for checking if given even it ignored. + """ + for item in items: + if item not in self._ignored_logs[event]: + self._ignored_logs[event].add(item) + + def log_is_ignored(self, event: Event, key: t.Any, remove: bool = True) -> bool: + """ + Check if given event with uniquely identifiable `key` is present in + the ignore set, if it is, return `True`, otherwise return `False`. + + By default, after this function is executed, the ignored entry will get removed, + because we already applied ignore as this check was used. If this isn't the case, + `remove` kwarg can be set to `False`, to prevent this automatic deletion. + """ + found = key in self._ignored_logs[event] + if found and remove: + self._ignored_logs[event].remove(key) + + return found diff --git a/bot/database/__init__.py b/bot/database/__init__.py index 4870e6e..02d7864 100644 --- a/bot/database/__init__.py +++ b/bot/database/__init__.py @@ -1,386 +1,92 @@ import typing as t -from abc import abstractmethod -from collections import defaultdict -from contextlib import suppress -from dataclasses import field, make_dataclass -from importlib import import_module -import asyncpg +from discord import Guild, Member, Role, TextChannel, User from loguru import logger +from sqlalchemy.dialects import postgresql +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.ext.declarative import declarative_base -if t.TYPE_CHECKING: - from bot.core.bot import Bot +Base = declarative_base() -class Singleton(type): +def load_tables() -> t.List[Base]: """ - This is Singleton Design Pattern. - - It makes sure that classes with this metaclass - will only ever have one single instance, when they're - initiated for the first time this instance is created, - every next initiation will simply result in returning - the stored single instace. - """ - _instance = None - - def __call__(cls, *args, **kwargs): - """If instance already exists, return it.""" - if not cls._instance: - cls._instance = super(Singleton, cls).__call__(*args, **kwargs) - - return cls._instance - - -class DBTable(metaclass=Singleton): - """ - This is a basic database table structure model. - - This class automatically creates the initial database - tables accordingly to `columns` dict which is a mandantory - class parameter defined in the top-level class, it should - look like this: - columns = { - "column_name": "SQL creation syntax", - "example": "NUMERIC(40) UNIQUE NOT NULL" - ... - } - - After the table is populated, caching will be automatically - set up based on the `caching` dict which is an optional class - parameter defined in the top-level class, if this parameter isn't - defined, caching will be skipped. Example for caching: - caching = { - "key": "table_name", # This will be the key for the stored `cache` dict - - # These will be the entries for the cache - "column_name": (python datatype, default_value), - "column_name2": python datatype # default_value is optional - } - - There are also multiple methods which serves as an abstraction - layer for for executing raw SQL code. - - There is also a special `reference` classmethod which will - return the running instance (from the singleton model). + Import all database tables in order to load them + into the `Base` metadata, so that they can be initialized. """ - def __init__(self, db: "Database", table_name: str): - self.database = db - self.table = table_name - self.pool = self.database.pool - self.timeout = self.database.timeout - self.cache = {} - - @abstractmethod - async def __async_init__(self) -> None: - """ - This is asynchronous initialization function which - will get automatically called by `Database` when - the table is added. (Calling this method is handeled - by the `_populate` function). - """ - raise NotImplementedError - - async def _init(self) -> None: - """ - This method calls `_populate` and `_make_cache` - to make all the db tables and create the table cache. - After that, `__async_init__` method is called which - refers to top-level async initialization, if this - method isn't defined, nothing will happen. - - This also makes sure that `columns` dictionary is - defined properly in the top-level class.. - """ - if not hasattr(self, "columns") or not isinstance(self.columns, dict): - raise RuntimeError(f"Table {self.__class__} doesn't have a `columns` dict defined properly.") - - await self._populate() - await self._make_cache() - with suppress(NotImplementedError): - await self.__async_init__() - - async def _populate(self) -> None: - """ - This method is used to create the initial table structure - and define it's structure and columns. - - This method also calls `__async_init__` method on top level table - (if there is one). - """ - table_structure = ",\n".join(f"{column} {sql_details}" for column, sql_details in self.columns.items()) - populate_command = f"CREATE TABLE IF NOT EXISTS {self.table} (\n{table_structure}\n)" - - logger.trace(f"Populating {self.__class__}") - async with self.pool.acquire(timeout=self.timeout) as db: - await db.execute(populate_command) - - async def _make_cache(self) -> None: - """ - Crate and populate basic caching model from top-level `self.caching`. - - This function creates `self.cache_columns` which stores the cached columns - and their type together with `self.cache` which stores the actual cache. - """ - if not hasattr(self, "caching") or not isinstance(self.caching, dict): - logger.trace(f"Skipping defining cache for {self.__class__}, `caching` dict wasn't specified") - return - - self.cache_columns = {} - cache_key_type, self._cache_key = self.caching.pop("key") - self.cache_columns[self._cache_key] = cache_key_type - - # Create cache model - field_list = [] - for column, specification in self.caching.items(): - if isinstance(specification, tuple): - val = (column, specification[0], field(default=specification[1])) - _type = specification[0] - elif specification is None: - val = column - _type = None - else: - val = (column, specification) - _type = specification - - field_list.append(val) - self.cache_columns[column] = _type + loaded_modules = [] - self._cache_model = make_dataclass("Entry", field_list) + # Import here, to avoid circular imports (autoload requires `bot.database`) + from bot.core import autoload - # Create and populate the cache - self.cache = defaultdict(self._cache_model) - columns = list(self.columns.keys()) - - entries = await self.db_get(columns) # Get db entries to store - for entry in entries: - db_entry = {} - for col_name, record in zip(columns, *iter(entry)): - # Convert to specified type - with suppress(IndexError, TypeError): - _type = self.cache_columns[col_name] - record = _type(record) - db_entry[col_name] = record - # Store the cache model into the cache - key = db_entry.pop(self._cache_key) - cache_entry = self._cache_model(**db_entry) - self.cache[key] = cache_entry - - def cache_update(self, key: str, column: str, value: t.Any) -> None: - """ - Update the stored cache value for `update_key` on `primary_value` to given `update_value`. - """ - setattr(self.cache[key], column, value) - - def cache_get(self, key: str, column: str) -> t.Any: - """ - Obtain the value of `attribute` stored in cache for `primary_value` - """ - return getattr(self.cache[key], column) - - @classmethod - def reference(cls) -> "DBTable": - """ - This is a method which returns the running instance of given class. - - This works based on the singleton single instance model and it was - added as a substitution for calling __init__ from the top level class - directly, since that requires passing arguments which won't be used - due to the single instance model, using the `reference` function - allows you to retrieve this instance without the need of passing - any additional arguments. - - It should be noted that using this will return the instance of the - top-level class, but the editor will only see it as an instance of - this class (`DBTable`) due to the return type being set to it. - To circumvent this you should statically define the type of the - variable which will be used to store this instance. - """ - return cls._instance - - async def db_execute(self, sql: str, sql_args: t.Optional[list] = None) -> None: - """ - This method serves as an abstraction layer - from using context manager and executing the - sql command directly from there. - """ - if not sql_args: - sql_args = [] - - async with self.pool.acquire(timeout=self.timeout) as db: - await db.execute(sql, *sql_args) - - async def db_fetchone(self, sql: str, sql_args: t.Optional[list] = None) -> asyncpg.Record: - """ - This method serves as an abstraction layer - from using context manager and fetching the - sql query directly from there. - """ - if not sql_args: - sql_args = [] - - async with self.pool.acquire(timeout=self.timeout) as db: - return await db.fetchrow(sql, *sql_args) - - async def db_fetch(self, sql: str, sql_args: t.Optional[list] = None) -> t.List[asyncpg.Record]: - """ - This method serves as an abstraction layer - from using context manager and fetching the - sql query directly from there. - """ - if not sql_args: - sql_args = [] - - async with self.pool.acquire(timeout=self.timeout) as db: - return await db.fetch(sql, *sql_args) - - async def db_get( - self, columns: t.List[str], specification: t.Optional[str] = None, sql_args: t.Optional[list] = None - ) -> t.Union[asyncpg.Record, t.List[asyncpg.Record]]: - """ - This method serves as an abstraction layer - from using SQL syntax in the top-level database - table class, it runs the basic selection (get) - query without needing to use SQL syntax at all. - """ - sql = f"SELECT ({', '.join(columns)}) FROM {self.table}" - if specification: - sql += f" WHERE ({specification})" - - if len(columns) == 1: - return await self.db_fetchone(sql, sql_args) - return await self.db_fetch(sql, sql_args) - - async def db_set(self, columns: t.List[str], values: t.List[str]) -> None: - """ - This method serves as an abstraction layer - from using SQL syntax in the top-level database - table class, it runs the basic insertion (set) - command without needing to use SQL syntax at all. - """ - sql_columns = ", ".join(columns) - sql_values = ", ".join(f"${n + 1}" for n in range(len(values))) - - sql = f""" - INSERT INTO {self.table} ({sql_columns}) - VALUES ({sql_values}) - """ - - await self.db_execute(sql, values) - - async def db_upsert(self, columns: t.List[str], values: t.List[str], conflict_columns: t.List[str]) -> None: - """ - This method serves as an abstraction layer - from using SQL syntax in the top-level database - table class, it runs the basic insert/update (upsert) - command without needing to use SQL syntax at all. - """ - sql_columns = ", ".join(columns) - sql_values = ", ".join(f"${n + 1}" for n in range(len(values))) - sql_conflict_columns = ", ".join(conflict_columns) - - sql_update = "" - for index, column in enumerate(columns): - if column not in conflict_columns: - sql_update += f"{column}=${index + 1}" - - sql = f""" - INSERT INTO {self.table} ({sql_columns}) - VALUES ({sql_values}) - ON CONFLICT ({sql_conflict_columns}) DO - UPDATE SET {sql_update} - """ + # Load found modules + for db_module_import_path in autoload.DATABASES: + try: + loaded_modules.append(__import__(db_module_import_path)) + except ImportError as e: + logger.error(f"Unable to load database: {autoload.readable_name(db_module_import_path)} --> {e}") - await self.db_execute(sql, values) + return loaded_modules -class Database(metaclass=Singleton): +async def upsert(session: AsyncSession, model: Base, conflict_columns: list, values: dict) -> None: """ - This is the main connection class with the postgres database. - - This class is here to ensure the ease of connecting and - disconnecting from the database and loading the top-level - database table classes. + SQLAlchemy lacks postgres specific upsert function, this is + it's implementation to avoid code repetition in the database models. """ - def __init__(self, db_parameters: dict, timeout: int = 5): - required_parameters = set(["host", "database", "user", "password"]) - # Make sure db_parameters contains all required keys by checking - # if it's a subset of `required_parameters` - if required_parameters > set(db_parameters.keys()): - raise RuntimeError( - "The `db_parameters` dict doesn't contain one or more" - f" of the required parameters: {required_parameters}" - ) - - self.db_parameters = db_parameters - self.timeout = 5 + table = model.__table__ + stmt = postgresql.insert(table) + affected_columns = { + col.name: col for col in stmt.excluded + if col.name in values and col.name not in conflict_columns + } - self.tables = set() + if not affected_columns: + raise ValueError("Couldn't find any columns to update.") - async def connect(self) -> bool: - """ - Connect to the database using the `self.db_parameters` - provided in the `__init__` method. + stmt = stmt.on_conflict_do_update( + index_elements=conflict_columns, + set_=affected_columns + ) - Store this connection in `self.pool` attribute - """ - logger.debug("Connecting to the database") - try: - self.pool = await asyncpg.create_pool(**self.db_parameters) - except asyncpg.exceptions.PostgresError: - logger.error("Unable to connect to the database") - return False + await session.execute(stmt, values) - return True - async def disconnect(self) -> None: - """Close the database pool connection.""" - logger.debug("Closing connection to the database") - await self.pool.close() +# region: Common getters for tables - async def load_tables(self, tables: t.List[str], bot: "Bot") -> None: - """ - Load on all given `tables`. +def get_str_guild(guild: t.Union[str, int, Guild]) -> str: + """Make sure `guild` parameter is string.""" + if isinstance(guild, Guild): + guild = str(guild.id) + if isinstance(guild, int): + guild = str(guild) + return guild - This function imports every table in `tables` and awaits - the `load` coroutine which initiates the top-level - database table class and calls `self.load_table`. - """ - for table in tables: - logger.trace(f"Adding {table} table") - module = import_module(table) - if not hasattr(module, "load"): - logger.error(f"Unable to load table: {table} (this: {__name__} module: {module}), it doesn't have the async `load` function set up") - return - await module.load(bot, self) - async def add_table(self, table: DBTable) -> None: - """ - Add the `table` into the `self.tables` set and - execute it's `_populate` function. +def get_str_role(role: t.Union[str, int, Role]) -> str: + """Make sure `role` parameter is string.""" + if isinstance(role, Role): + role = str(role.id) + if isinstance(role, int): + role = str(role) + return role - In case the `table` is already added, log a warning - and don't add it into the table. The `_populate` function - won't be called either. - """ - if table in self.tables: - logger.warning(f"Tried to add already added table ({table.__class__}), skipping.") - return - if not isinstance(table, DBTable): - raise TypeError("`table` argument must be an instance of `DBTable`") - self.tables.add(table) - await table._init() +def get_str_channel(channel: t.Union[str, int, TextChannel]) -> str: + """Make sure `channel` parameter is string.""" + if isinstance(channel, TextChannel): + channel = str(channel.id) + if isinstance(channel, int): + channel = str(channel) + return channel - async def remove_table(self, table: "DBTable") -> None: - """ - Remove the table from `self.tables`. - This also reset's the `table`s unique singleton instance. - """ - if table not in self.tables: - logger.warning(f"Tried to remove unknown table ({table.__class__})") +def get_str_user(member: t.Union[str, int, Member, User]) -> str: + """Make sure `member` parameter is string.""" + if isinstance(member, (Member, User)): + member = str(member.id) + if isinstance(member, int): + member = str(member) + return member - logger.trace(f"Removing {table.__class__}") - self.tables.remove(table) - table._instance = None +# endregion diff --git a/bot/database/log_channels.py b/bot/database/log_channels.py index d6a06c9..0cd0ad2 100644 --- a/bot/database/log_channels.py +++ b/bot/database/log_channels.py @@ -1,110 +1,93 @@ import typing as t -from dataclasses import dataclass from discord import Guild, TextChannel from loguru import logger +from sqlalchemy import Column, String +from sqlalchemy.exc import NoResultFound +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession -from bot.core.bot import Bot -from bot.database import DBTable, Database - - -@dataclass -class Entry: - """Class for storing the database rows of log_channels table.""" - server: int = 0 - mod: int = 0 - message: int = 0 - member: int = 0 - join: int = 0 - - -class LogChannels(DBTable): - """ - This table stores all guild-specific roles: - * `server` log channel - * `mod` log channel - * `message` log channel - * `member` log channel - * `join` log channel - Under the single `serverid` column - """ - - columns = { - "serverid": "NUMERIC(40) UNIQUE NOT NULL", - "server": "NUMERIC(40) DEFAULT 0", - "mod": "NUMERIC(40) DEFAULT 0", - "message": "NUMERIC(40) DEFAULT 0", - "member": "NUMERIC(40) DEFAULT 0", - "join": "NUMERIC(40) DEFAULT 0" - } - - caching = { - "key": (int, "serverid"), - - "server": (int, 0), - "mod": (int, 0), - "message": (int, 0), - "member": (int, 0), - "join": (int, 0) - } - - def __init__(self, bot: Bot, database: Database): - super().__init__(database, "log_channels") - self.bot = bot - self.database = database - self.cache: t.Dict[int, Entry] = {} - - async def _set_channel(self, channel_name: str, guild: t.Union[Guild, int], channel: t.Union[TextChannel, int]) -> None: - """Set a `channel_name` column to store `channel.id` for the specific `guild.id`.""" - if isinstance(channel, TextChannel): - channel = channel.id - if isinstance(guild, Guild): - guild = guild.id - - logger.debug(f"Setting {channel_name}-log channel on {guild} to <#{channel}>") - await self.db_upsert( - columns=["serverid", channel_name], - values=[guild, channel], - conflict_columns=["serverid"] - ) - self.cache_update(guild, channel_name, channel) - - def _get_channel(self, channel_name: str, guild: t.Union[Guild, int]) -> int: - """Get a `role_name` column for specific `guild` from cache.""" - if isinstance(guild, Guild): - guild = guild.id - return self.cache_get(guild, channel_name) +from bot.database import Base, get_str_channel, get_str_guild, upsert - async def set_server_log(self, guild: t.Union[Guild, int], channel: t.Union[TextChannel, int]) -> None: - await self._set_channel("server", guild, channel) - async def set_mod_log(self, guild: t.Union[Guild, int], channel: t.Union[TextChannel, int]) -> None: - await self._set_channel("mod", guild, channel) +class LogChannels(Base): + __tablename__ = "log_channels" - async def set_message_log(self, guild: t.Union[Guild, int], channel: t.Union[TextChannel, int]) -> None: - await self._set_channel("message", guild, channel) + guild = Column(String, primary_key=True, nullable=False) - async def set_member_log(self, guild: t.Union[Guild, int], channel: t.Union[TextChannel, int]) -> None: - await self._set_channel("member", guild, channel) + server_log = Column(String, nullable=True) + mod_log = Column(String, nullable=True) + message_log = Column(String, nullable=True) + member_log = Column(String, nullable=True) + join_log = Column(String, nullable=True) + voice_log = Column(String, nullable=True) - async def set_join_log(self, guild: t.Union[Guild, int], channel: t.Union[TextChannel, int]) -> None: - await self._set_channel("join", guild, channel) + valid_log_types = ["server_log", "mod_log", "message_log", "member_log", "join_log", "voice_log"] - def get_server_log(self, guild: t.Union[Guild, int]) -> int: - return self._get_channel("server", guild) + @classmethod + def _get_normalized_log_type(cls, log_type: str) -> str: + """Make sure `log_type` is in proper format and is valid.""" + log_type = log_type if log_type.endswith("_log") else log_type + "_log" - def get_mod_log(self, guild: t.Union[Guild, int]) -> int: - return self._get_channel("mod", guild) + if log_type not in cls.valid_log_types: + raise ValueError(f"`log_type` received invalid type: {log_type}, valid types: {', '.join(cls.valid_log_types)}") - def get_message_log(self, guild: t.Union[Guild, int]) -> int: - return self._get_channel("message", guild) + return log_type - def get_member_log(self, guild: t.Union[Guild, int]) -> int: - return self._get_channel("member", guild) + @classmethod + async def set_log_channel( + cls, + engine: AsyncEngine, + log_type: str, + guild: t.Union[str, int, Guild], + channel: t.Union[str, int, TextChannel] + ) -> None: + """Store given `channel` as `log_type` log channel for `guild` into the database.""" + session = AsyncSession(engine) - def get_join_log(self, guild: t.Union[Guild, int]) -> int: - return self._get_channel("join", guild) + guild = get_str_guild(guild) + channel = get_str_channel(channel) + log_type = cls._get_normalized_log_type(log_type) + logger.debug(f"Setting {log_type} channel on {guild} to <#{channel}>") -async def load(bot: Bot, database: Database) -> None: - await database.add_table(LogChannels(bot, database)) + await upsert( + session, cls, + conflict_columns=["guild"], + values={"guild": guild, log_type: channel} + ) + await session.commit() + await session.close() + + @classmethod + async def get_log_channels(cls, engine: AsyncEngine, guild: t.Union[str, int, Guild]) -> dict: + """Obtain defined log channels for given `guild` from the database.""" + session = AsyncSession(engine) + + guild = get_str_guild(guild) + + try: + row = await session.run_sync(lambda session: session.query(cls).filter_by(guild=guild).one()) + except NoResultFound: + dct = {col: None for col in cls.__table__.columns.keys()} + dct.update({'guild': guild}) + return dct + else: + return row.to_dict() + finally: + await session.close() + + @classmethod + async def get_log_channel(cls, engine: AsyncEngine, log_type: str, guild: t.Union[str, int, Guild]) -> dict: + log_type = cls._get_normalized_log_type(log_type) + + log_channels = await cls.get_log_channels(engine, guild) + return log_channels[log_type] + + def to_dict(self) -> dict: + dct = {} + for col in self.__table__.columns.keys(): + val = getattr(self, col) + if col.endswith("_log"): + val = int(val) if val is not None else None + dct[col] = val + return dct diff --git a/bot/database/permissions.py b/bot/database/permissions.py index 3154388..0533a9c 100644 --- a/bot/database/permissions.py +++ b/bot/database/permissions.py @@ -1,132 +1,176 @@ import typing as t -from dataclasses import dataclass from discord import Guild, Member, Role from loguru import logger +from sqlalchemy import Column, Integer, String +from sqlalchemy.exc import NoResultFound +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from bot.core.bot import Bot -from bot.database import DBTable, Database - - -@dataclass -class Entry: - """Class for storing the database rows of roles table.""" - _default: int - muted: int - staff: int - - -class Permissions(DBTable): - """ - This table stores these permissions: - * `bantime` maximum amount of time (in seconds) for temp-ban - * `mutetime` maximum amount of time (in seconds) for temp-mute - * `locktime` maximum amount of time (in seconds) for channel lock - For given `role` in given `serverid` - """ - columns = { - "serverid": "NUMERIC(40) NOT NULL", - "role": "NUMERIC(40) DEFAULT 0", - "bantime": "INTEGER DEFAULT 0", - "mutetime": "INTEGER DEFAULT 0", - "locktime": "INTEGER DEFAULT 0", - "UNIQUE": "(serverid, role)" - } - - def __init__(self, bot: Bot, database: Database): - super().__init__(database, "permissions") - self.bot = bot - self.database = database - - async def _set_permission(self, permission_name: str, guild: t.Union[Guild, int], role: t.Union[Role, int], value: t.Any) -> None: - """Set a `role_name` column to store `role` for the specific `guild`.""" - if isinstance(guild, Guild): - guild = guild.id - if isinstance(role, Role): - role = role.id - - logger.debug(f"Setting {permission_name} on {guild} for <@&{role}> to {value}") - await self.db_upsert( - columns=["serverid", "role", permission_name], - values=[guild, role, value], - conflict_columns=["serverid", "role"] +from bot.database import Base, get_str_guild, get_str_role, upsert + + +class Permissions(Base): + __tablename__ = "permissions" + + guild = Column(String, primary_key=True, nullable=False) + role = Column(String, primary_key=True, nullable=False) + + ban_time = Column(Integer, nullable=True) + mute_time = Column(Integer, nullable=True) + lock_time = Column(Integer, nullable=True) + + valid_time_types = ["ban_time", "mute_time", "lock_time"] + + @staticmethod + def _get_int_time(time: t.Union[int, float]) -> int: + """Make sure to return time as int (seconds), or -1 for infinity.""" + if time == float("inf"): + return -1 + if isinstance(time, float): + return round(time) + return time + + @staticmethod + def _return_time(time: t.Optional[int]) -> t.Optional[t.Union[int, float]]: + """Return infinity if number was -1, otherwise, return given number.""" + if time == -1: + return float("inf") + return time + + @classmethod + def _get_normalized_time_type(cls, time_type: str) -> str: + """Make sure `time_type` is in proper format and is valid.""" + time_type = time_type if time_type.endswith("_time") else time_type + "_time" + + if time_type not in cls.valid_time_types: + raise ValueError(f"`time_type` received invalid type: {time_type}, valid types: {', '.join(cls.valid_time_types)}") + + return time_type + + @classmethod + async def set_role_permission( + cls, + engine: AsyncEngine, + time_type: str, + guild: t.Union[str, int, Guild], + role: t.Union[str, int, Role], + time: t.Union[int, float] + ) -> None: + """Store given `time` as `time_type` permission for `role` on `guild` into the database.""" + session = AsyncSession(bind=engine) + + guild = get_str_guild(guild) + role = get_str_role(role) + time_type = cls._get_normalized_time_type(time_type) + time = cls._get_int_time(time) + + logger.debug(f"Setting {time_type} for {role} on {guild} to {time}") + + await upsert( + session, cls, + conflict_columns=["role", "guild"], + values={"guild": guild, "role": role, time_type: time} ) + await session.commit() + await session.close() - async def _get_permission(self, permission_name: str, guild: t.Union[Guild, int], role: t.Union[Role, int]) -> t.Any: - """Get a `role_name` column for specific `guild` from cache.""" - if isinstance(guild, Guild): - guild = guild.id - if isinstance(role, Role): - role = role.id - - record = await self.db_get( - columns=[permission_name], - specification="serverid=$1 AND role=$2", - sql_args=[guild, role] - ) + @classmethod + async def get_permissions(cls, engine: AsyncEngine, guild: t.Union[str, int, Guild], role: t.Union[str, int, Role]) -> dict: + """Obtain permissions for `role` on `guild` from the database.""" + session = AsyncSession(bind=engine) - try: - return record[0] - except TypeError: - return None + guild = get_str_guild(guild) + role = get_str_role(role) - async def _get_time(self, time_permission: str, guild: t.Union[Guild, int], identifier: t.Union[Member, Role, int]) -> t.Optional[int]: - if isinstance(identifier, int): - user = self.bot.get_user(identifier) + try: + row = await session.run_sync(lambda session: session.query(cls).filter_by(guild=guild, role=role).one()) + except NoResultFound: + dct = {col: None for col in cls.__table__.columns.keys()} + dct.update({'guild': guild, 'role': role}) + return dct + else: + return row.to_dict() + finally: + await session.close() + + @classmethod + async def get_permission( + cls, + engine: AsyncEngine, + time_type: str, + guild: t.Union[str, int, Guild], + role: t.Union[str, int, Role] + ) -> t.Optional[int]: + """Obtain`time_type` permissions for `role` on `guild` from the database.""" + time_type = cls._get_normalized_time_type(time_type) + + permissions = await cls.get_permissions(engine, guild, role) + return permissions[time_type] + + @classmethod + async def get_permissions_from_member( + cls, + engine: AsyncEngine, + bot: Bot, + guild: t.Union[str, int, Guild], + member: t.Union[str, int, Member] + ) -> dict: + if isinstance(guild, str): + guild = int(guild) + if isinstance(member, str): + member = int(member) + + if isinstance(member, int): + user = bot.get_user(member) if not user: - return await self._get_permission(time_permission, guild, identifier) - + raise ValueError(f"Unable to find valid user by: {member}") if isinstance(guild, int): - true_guild = self.bot.get_guild(guild) + true_guild = bot.get_guild(guild) if not true_guild: - raise RuntimeError(f"Unable to find a guild with id: {guild}") - guild = true_guild - - identifier = guild.get_member(user.id) - - if isinstance(identifier, Member): - if identifier.guild_permissions.administrator: - return -1 - - # Follow role hierarchy from most important role to everyone - # and use the first found time, if non is found, return `None` - for role in identifier.roles[::-1]: - time = await self._get_permission(time_permission, guild, role) - if time is not None and time != 0: - return time - else: - return None - - if isinstance(identifier, Role): - return await self._get_permission(time_permission, guild, identifier) - - async def _set_time(self, time_permission: str, guild: t.Union[Guild, int], role: t.Union[Role, int], value: t.Union[int, float]) -> None: - if value == float("inf"): - value = -1 - - if not isinstance(value, int): - return RuntimeError(f"value must be an integer, got {type(value)}: {value}") - - await self._set_permission(time_permission, guild, role, value) - - async def set_bantime(self, guild: t.Union[Guild, int], role: t.Union[Role, int], value: t.Union[int, float]) -> None: - await self._set_time("bantime", guild, role, value) - - async def set_mutetime(self, guild: t.Union[Guild, int], role: t.Union[Role, int], value: t.Union[int, float]) -> None: - await self._set_time("mutetime", guild, role, value) - - async def set_locktime(self, guild: t.Union[Guild, int], role: t.Union[Role, int], value: t.Union[int, float]) -> None: - await self._set_time("locktime", guild, role, value) - - async def get_bantime(self, guild: t.Union[Guild, int], identifier: t.Union[Member, Role, int]) -> t.Optional[int]: - return await self._get_time("bantime", guild, identifier) - - async def get_mutetime(self, guild: t.Union[Guild, int], identifier: t.Union[Member, Role, int]) -> t.Optional[int]: - return await self._get_time("mutetime", guild, identifier) - - async def get_locktime(self, guild: t.Union[Guild, int], identifier: t.Union[Member, Role, int]) -> t.Optional[int]: - return await self._get_time("locktime", guild, identifier) - - -async def load(bot: Bot, database: Database) -> None: - await database.add_table(Permissions(bot, database)) + raise ValueError(f"Unable to find a guild with id: {guild}") + + member = guild.get_member(user) + + if isinstance(member, Member): + # Administrators doesn't have limited permissions, makes sure + # to handle for that + if member.guild_permissions.administrator: + dct = {col: float("inf") for col in cls.__table__.columns.keys()} + dct.update({'guild': guild}) + return dct + + # Follow the hierarchy from most important role to everyone + # and use the first found time in each time type, + # if none found, return empty permissions + dct = {col: None for col in cls.__table__.columns.keys() if col.endswith("_time")} + for role in member.roles[::-1]: + perms = await cls.get_permissions(engine, guild, role) + for key, value in dct: + if value is not None: + dct[key] = perms[key] + dct.update({'guild': guild}) + return dct + + @classmethod + async def get_permission_from_member( + cls, + engine: AsyncEngine, + bot: Bot, + time_type: str, + guild: t.Union[str, int, Guild], + member: t.Union[str, int, Member] + ) -> t.Optional[int]: + time_type = cls._get_normalized_time_type(time_type) + + permissions = await cls.get_permissions_from_member(engine, bot, guild, member) + return permissions[time_type] + + def to_dict(self) -> dict: + dct = {} + for col in self.__table__.columns.keys(): + val = getattr(self, col) + if col.endswith("_time"): + val = self._return_time(val) + dct[col] = val + return dct diff --git a/bot/database/roles.py b/bot/database/roles.py index cf9b61f..f409293 100644 --- a/bot/database/roles.py +++ b/bot/database/roles.py @@ -1,91 +1,90 @@ import typing as t -from dataclasses import dataclass from discord import Guild, Role from loguru import logger +from sqlalchemy import Column, String +from sqlalchemy.exc import NoResultFound +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from bot.database import Base, get_str_guild, get_str_role, upsert -from bot.core.bot import Bot -from bot.database import DBTable, Database - - -@dataclass -class Entry: - """Class for storing the database rows of roles table.""" - _default: int - muted: int - staff: int - - -class Roles(DBTable): - """ - This table stores all guild-specific roles: - * `default` role (column is named `_default` to avoid SQL confusion) - * `muted` role - * `staff` role - Under the single `serverid` column - """ - columns = { - "serverid": "NUMERIC(40) UNIQUE NOT NULL", - "_default": "NUMERIC(40) DEFAULT 0", - "muted": "NUMERIC(40) DEFAULT 0", - "staff": "NUMERIC(40) DEFAULT 0", - } - caching = { - "key": (int, "serverid"), - - "_default": (int, 0), - "muted": (int, 0), - "staff": (int, 0) - } - - def __init__(self, bot: Bot, database: Database): - super().__init__(database, "roles") - self.bot = bot - self.database = database - - async def _set_role(self, role_name: str, guild: t.Union[Guild, int], role: t.Union[Role, int]) -> None: - """Set a `role_name` column to store `role` for the specific `guild`.""" - if isinstance(guild, Guild): - guild = guild.id - if isinstance(role, Role): - role = role.id - - logger.debug(f"Setting {role_name} role on {guild} to <@&{role}>") - await self.db_upsert( - columns=["serverid", role_name], - values=[guild, role], - conflict_columns=["serverid"] - ) - self.cache_update(guild, role_name, role) - def _get_role(self, role_name: str, guild: t.Union[Guild, int]) -> int: - """Get a `role_name` column for specific `guild` from cache.""" - if isinstance(guild, Guild): - guild = guild.id - return self.cache_get(guild, role_name) +class Roles(Base): + __tablename__ = "roles" - async def set_default_role(self, guild: t.Union[Guild, int], role: t.Union[Role, int]) -> None: - await self._set_role("_default", guild, role) + guild = Column(String, primary_key=True, nullable=False) - async def set_muted_role(self, guild: t.Union[Guild, int], role: t.Union[Role, int]) -> None: - await self._set_role("muted", guild, role) + default_role = Column(String, nullable=True) + muted_role = Column(String, nullable=True) + staff_role = Column(String, nullable=True) - async def set_staff_role(self, guild: t.Union[Guild, int], role: t.Union[Role, int]) -> None: - await self._set_role("staff", guild, role) + valid_role_types = ["default_role", "muted_role", "staff_role"] - def get_default_role(self, guild: t.Union[Guild, int]) -> int: - role = self._get_role("_default", guild) - if role == 0: - role = guild.default_role.id + @classmethod + def _get_normalized_role_type(cls, role_type: str) -> str: + """Make sure `role_type` is in proper format and is valid.""" + role_type = role_type if role_type.endswith("_role") else role_type + "_role" - return role + if role_type not in cls.valid_role_types: + raise ValueError(f"`role_type` received invalid type: {role_type}, valid types: {', '.join(cls.valid_role_types)}") - def get_muted_role(self, guild: t.Union[Guild, int]) -> int: - return self._get_role("muted", guild) + return role_type - def get_staff_role(self, guild: t.Union[Guild, int]) -> int: - return self._get_role("staff", guild) + @classmethod + async def set_role( + cls, + engine: AsyncEngine, + role_type: str, + guild: t.Union[str, int, Guild], + role: t.Union[str, int, Role], + ) -> None: + """Store given `role` as `role_type` role for on `guild` into the database.""" + session = AsyncSession(bind=engine) + role_type = cls._get_normalized_role_type(role_type) + guild = get_str_guild(guild) + role = get_str_role(role) -async def load(bot: Bot, database: Database) -> None: - await database.add_table(Roles(bot, database)) + logger.debug(f"Setting {role_type} on {guild} to {role}") + + await upsert( + session, cls, + conflict_columns=["guild"], + values={"guild": guild, role_type: role} + ) + await session.commit() + await session.close() + + @classmethod + async def get_roles(cls, engine: AsyncEngine, guild: t.Union[str, int, Guild]) -> dict: + """Obtain roles on `guild` from the database.""" + session = AsyncSession(bind=engine) + + guild = get_str_guild(guild) + try: + row = await session.run_sync(lambda session: session.query(cls).filter_by(guild=guild).one()) + except NoResultFound: + dct = {col: None for col in cls.__table__.columns.keys()} + dct.update({'guild': guild}) + return dct + else: + return row.to_dict() + finally: + await session.close() + + @classmethod + async def get_role(cls, engine: AsyncEngine, role_type: str, guild: t.Union[str, int, Guild]) -> str: + """Obtain`time_type` permissions for `role` on `guild` from the database.""" + role_type = cls._get_normalized_role_type(role_type) + + roles = await cls.get_roles(engine, guild) + return roles[role_type] + + def to_dict(self) -> dict: + dct = {} + for col in self.__table__.columns.keys(): + val = getattr(self, col) + if col.endswith("_role"): + val = int(val) if val is not None else None + dct[col] = val + return dct diff --git a/bot/database/strikes.py b/bot/database/strikes.py new file mode 100644 index 0000000..aa856d7 --- /dev/null +++ b/bot/database/strikes.py @@ -0,0 +1,184 @@ +import typing as t + +from discord import Guild, Member, User +from loguru import logger +from sqlalchemy import Column, Integer, String +from sqlalchemy.exc import NoResultFound +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from bot.database import Base, get_str_guild, get_str_user, upsert + + +class StrikeIndex(Base): + __tablename__ = "strike_index" + + guild = Column(String, primary_key=True, nullable=False) + next_id = Column(Integer, nullable=False, default=0) + + @classmethod + async def get_id(cls, engine: AsyncEngine, guild: t.Union[str, int, Guild]) -> int: + session = AsyncSession(bind=engine) + guild = get_str_guild(guild) + + # Logic for increasing strike ID if it was already found + # but using the default if the entry is new + row = await session.run_sync(lambda session: session.query(cls).filter_by(guild=guild).one()) + next_id = row.next_id + 1 + row.next_id = next_id + await session.commit() + await session.close() + return next_id + + +class Strikes(Base): + __tablename__ = "strikes" + + guild = Column(String, primary_key=True, nullable=False) + id = Column(Integer, primary_key=True, nullable=False) + + author = Column(String, nullable=False) + user = Column(String, nullable=False) + type = Column(String, nullable=False) + reason = Column(String, nullable=True) + + @classmethod + async def set_strike( + cls, + engine: AsyncEngine, + guild: t.Union[str, int, Guild], + author: t.Union[str, int, Member], + user: t.Union[str, int, Member, User], + strike_type: str, + reason: t.Optional[str], + strike_id: t.Optional[int] = None + ) -> int: + session = AsyncSession(bind=engine) + + guild = get_str_guild(guild) + author = get_str_user(author) + user = get_str_user(user) + + # We don't usually expect we have passed id, instead we're determining + # which ID should be used from the index table to keep the strikes serial + # with their specific guild, if strike is specified, it means we're updating + if not strike_id: + strike_id = await StrikeIndex.get_id(session, guild) + + logger.debug(f"Adding {strike_type} strike to {user} from {author} for {reason}: id: {strike_id}") + + await upsert( + session, cls, + conflict_columns=["guild", "id"], + values={ + "guild": guild, + "id": strike_id, + "author": author, + "user": user, + "type": strike_type, + "reason": reason + } + ) + await session.commit() + await session.close() + return strike_id + + @classmethod + async def remove_strike(cls, engine: AsyncEngine, guild: t.Union[str, int, Guild], strike_id: int) -> dict: + session = AsyncSession(bind=engine) + + guild = get_str_guild(guild) + + row = await session.run_sync(lambda session: session.query(cls).filter_by(guild=guild, id=strike_id).one()) + dct = row.to_dict() + await session.run_sync(lambda session: session.delete(row)) + await session.close() + + logger.debug(f"Strike {strike_id} has been removed") + + return dct + + @classmethod + async def get_user_strikes(cls, engine: AsyncEngine, guild: t.Union[str, int, Guild], user: t.Union[str, int, Member, User]) -> list: + """Obtain all strikes on `guild` for `user` from the database.""" + session = AsyncSession(bind=engine) + + guild = get_str_guild(guild) + user = get_str_user(user) + + try: + rows = await session.run_sync(lambda session: session.query(cls).filter_by(guild=guild, user=user).all()) + except NoResultFound: + return [] + else: + strikes = [] + for row in rows: + strikes.append(row.to_dict()) + return strikes + finally: + await session.close() + + @classmethod + async def get_author_strikes(cls, engine: AsyncEngine, guild: t.Union[str, int, Guild], author: t.Union[str, int, Member, User]) -> list: + """Obtain all strikes on `guild` by `author` from the database.""" + session = AsyncSession(bind=engine) + + guild = get_str_guild(guild) + author = get_str_user(author) + + try: + rows = await session.run_sync(lambda session: session.query(cls).filter_by(guild=guild, author=author).all()) + except NoResultFound: + return [] + else: + strikes = [] + for row in rows: + strikes.append(row.to_dict()) + return strikes + finally: + await session.close() + + @classmethod + async def get_strike_by_id(cls, engine: AsyncEngine, guild: t.Union[str, int, Guild], strike_id: int) -> dict: + """Obtain specific strike in `guild` with id of `strike_id` from the database.""" + session = AsyncSession(bind=engine) + + guild = get_str_guild(guild) + + try: + row = await session.run_sync(lambda session: session.query(cls).filter_by(guild=guild, id=strike_id).one()) + except NoResultFound: + dct = {col: None for col in cls.__table__.columns.keys()} + dct.update({'guild': guild, 'id': strike_id}) + return dct + else: + return row.to_dict() + finally: + await session.close() + + @classmethod + async def get_guild_strikes(cls, engine: AsyncEngine, guild: t.Union[str, int, Guild]) -> list: + """Obtain all strikes belonging to `guild` from the database.""" + session = AsyncSession(bind=engine) + + guild = get_str_guild(guild) + + try: + rows = await session.run_sync(lambda session: session.query(cls).filter_by(guild=guild).all()) + except NoResultFound: + return [] + else: + strikes = [] + for row in rows: + strikes.append(row.to_dict()) + return strikes + finally: + session.close() + + def to_dict(self) -> dict: + dct = {} + for col in self.__table__.columns.keys(): + val = getattr(self, col) + if col in ("user", "author"): + val = int(val) if val is not None else None + dct[col] = val + return dct diff --git a/bot/utils/audit_parse.py b/bot/utils/audit_parse.py new file mode 100644 index 0000000..d7db0d4 --- /dev/null +++ b/bot/utils/audit_parse.py @@ -0,0 +1,106 @@ +import datetime +import typing as t + +from discord import AuditLogEntry, Color, Embed, Guild +from discord.enums import AuditLogAction +from discord.errors import Forbidden + + +async def last_audit_log( + guild: Guild, + actions: t.Iterable[AuditLogAction], + target: t.Any = None, + max_time: int = 5, + audit_cache: t.Optional[t.Set[int]] = None, +) -> t.Optional[AuditLogEntry]: + """ + This function can be used, to obtain last audit entry for given `actions` + with given `target` (for example banned user) in given `max_time` (in seconds). + + Many listeners often doesn't contain all the things which we could need + to construct a meaningful and descriptive log message. Audit entries + can help with this, because they contain useful information, such as + responsible moderator for given action, action reason, etc. + + In case `audit_cache` is provided, we will use it to check, if given audit entry + was already checked. This is useful because there are certain things, where we don't + want to parse the same entry of the audit log twice, even though it would match the + search parameters. + + If an entry was found, `AuditLogEntry` is returned, otherwise, we return `None`. + If bot doesn't have permission to access audit log, `Forbidden` exception is raised. + + NOTE: Currently `audit_cache` is a set of AuditLogEntry IDs, because entries aren't hashable + This will be changing in discord.py 1.7, and this should be updated once 1.7 is released. + """ + found_logs = [] + for action in actions: + try: + audit_logs = await guild.audit_logs(limit=1, action=action).flatten() + found_logs.extend(audit_logs) + except Forbidden as exc: # Bot can't access audit logs + raise exc + + # We haven't found any valid logs + if len(found_logs) == 0: + return + + # Get latest log from extracted ones + last_log = max(found_logs, key=lambda log_entry: log_entry.created_at) + + # Make sure to only go through audit logs within 5 seconds, + # if this log is older, ignore it + time_after = datetime.datetime.utcnow() - datetime.timedelta(seconds=max_time) + if last_log.created_at < time_after: + return + + # This entry was pointed at a different target + if target is not None and last_log.target != target: + return + + # Sometimes, we might not want to retreive the same audit log entry twice, + # for example in the case with kicks, if we already found an audit entry + # for a valid kick, user rejoined and left on his own, within our `max_time`, + # we would mark that leaving as a kick action, because we will scan the same + # audit log entry twice, to prevent this, we keep a cache of times audit + # log entries were created, and if they match, they're the same entry + if audit_cache is not None: + if last_log.id not in audit_cache: + return + + # if this wasn't the case, the entry is valid, and we should update the cache + # with the new processed entry time + audit_cache.add(last_log.id) + + return last_log + + +async def last_audit_log_with_fail_embed( + guild: Guild, + actions: t.Iterable[AuditLogAction], + send_callback: t.Awaitable, + target: t.Any = None, + max_time: int = 5, + audit_cache: t.Set[int] = None, +) -> t.Optional[AuditLogEntry]: + """ + This functions extends functionality of `last_audit_log` from `bot.utils.audit_parse`. + + We often need to send a failing message whenever parsing audit log fails, this function takes + care of this, with accepting `send_callback` argument, that will be called with `embed` kwarg, + to send this failing embed. Once this happens, we return `None`, instead of raising exception. + """ + try: + last_log = await last_audit_log(guild, actions, target, max_time, audit_cache) + except Forbidden: + embed = Embed( + title="Error parsing audit log", + description="Parsing audit log for kick actions failed, " + "make sure to give the bot right to read audit log.", + color=Color.red() + ) + embed.timestamp = datetime.datetime.utcnow() + await send_callback(embed=embed) + return + + return last_log diff --git a/bot/core/converters.py b/bot/utils/converters.py similarity index 87% rename from bot/core/converters.py rename to bot/utils/converters.py index 45c3695..0763ae0 100644 --- a/bot/core/converters.py +++ b/bot/utils/converters.py @@ -7,11 +7,9 @@ from dateutil.relativedelta import relativedelta from discord import Member, User from discord.errors import NotFound -from discord.ext.commands import ( - BadArgument, Context, Converter, - MemberConverter, MemberNotFound, - UserConverter, UserNotFound -) +from discord.ext.commands import Context +from discord.ext.commands.converter import Converter, MemberConverter, UserConverter +from discord.ext.commands.errors import BadArgument, ConversionError, MemberNotFound, UserNotFound from loguru import logger @@ -156,6 +154,33 @@ async def convert(self, ctx: Context, duration: str) -> t.Union[int, float]: return diff.total_seconds() +class Ordinal(Converter): + """Convert integers to ordinal string representation""" + @staticmethod + def make_ordinal(n: int) -> str: + """ + Convert an integer into its ordinal representation: + * make_ordinal(0) => "0th" + * make_ordinal(3) => "3rd" + * make_ordinal(122) => "122nd" + * make_ordinal(213) => "213th" + """ + suffix = ["th", "st", "nd", "rd", "th"][min(n % 10, 4)] + if 11 <= (n % 100) <= 13: + suffix = "th" + return str(n) + suffix + + async def convert(self, ctx: Context, number: str) -> str: + if number.isdecimal(): + return self.make_ordinal(int(number)) + if number.endswith(("th", "st", "nd", "rd")): + # Run conversion here, to prevent user inputted + # invalid ordinals, i.e.: 3st + if number[:-2].isdecimal(): + return self.make_ordinal(int(number[:-2])) + raise ConversionError(f"{number} is not an ordinal number (`1st`, `2nd`, ...)") + + class CodeBlock(Converter): """ Convert given wrapped string in codeblock into a tuple of language and the wrapped string @@ -189,7 +214,7 @@ async def convert(self, ctx: Context, codeblock: str) -> t.Tuple[t.Optional[str] if inline_match: return (None, inline_match.group(1)) - return codeblock + return (None, codeblock) class ProcessedUser(UserConverter): diff --git a/bot/utils/diff.py b/bot/utils/diff.py new file mode 100644 index 0000000..42a3342 --- /dev/null +++ b/bot/utils/diff.py @@ -0,0 +1,237 @@ +import dataclasses +import typing as t +from collections import namedtuple + +from deepdiff import DeepDiff +from discord import Embed, Guild, Role +from discord.abc import GuildChannel +from discord.channel import TextChannel, VoiceChannel +from discord.permissions import Permissions + +from bot.utils.time import stringify_duration + + +format_mapping = { + TextChannel: { + "slowmode_delay": lambda time: stringify_duration(time) if time != 0 else 'Off' + }, + VoiceChannel: { + "bitrate": lambda bps: f"{round(bps/1000)}kbps" + }, + Guild: { + "afk_timeout": lambda time: stringify_duration(time), + "_large": None + }, + Role: { + "_colour": lambda colour: hex(colour).replace("0x", "#").upper() + } +} + + +ValueUpdate = namedtuple("ValueUpdate", ("attr_name", "old_value", "new_value")) +PermissionFlags = dataclasses.make_dataclass(cls_name="PermissionsHolder", fields=Permissions.VALID_FLAGS) + + +def _get_format_mapping_for(obj: t.Any, mapping_override: t.Optional[dict] = None) -> t.Dict[str, t.Optional[t.Callable]]: + """ + Perform a linear search on `format_mapping` to check, which type matches + given `object`, we can't perform a normal dict key lookup, because this + might be used with inheritance, which would result in mapping not being + detected for given superclass, even though the parent class was listed. + + By default, this will only search for entries in `format_mapping`, but if + `mapping_override` is set, we will extend the found rules with this override, + making it possible to override default values in `format_mapping` and apply + custom formatting rules on top of the default ones. + Example of this override mapping: + ```py + mapping_override = { + "overridden attribute": lambda x: f"{x} seconds", + "not shown attribute": None + } + ``` + """ + found_format_rules = {} + + for formatting_for, format_rules in format_mapping.items(): + if isinstance(obj, formatting_for): + found_format_rules.update(format_rules) + break + + if mapping_override is not None: + found_format_rules.update(mapping_override) + + return found_format_rules + + +def compare_objects( + obj_before: t.Any, + obj_after: t.Any, + *, + use_format_mapping: bool = True, + mapping_override: t.Optional[dict] = None +) -> t.List[ValueUpdate]: + """ + Compare passed objects `obj_before` and `obj_after`. + Return list of (named)tuples describing each found value update: + `(attribute name, old value, new value)` + + By default, `format_mapping` dict will be followed and the values + will bre reformatted accordingly, if this isn't desired, you can + set `use_format_mapping` to `False`. You can also set `mapping_override` + which will act on top of `format_mapping`. This mapping looks like this: + ```py + mapping_override = { + "overridden attribute": lambda x: f"{x} seconds", + "not shown attribute": None + } + ``` + """ + diff = DeepDiff(obj_before, obj_after) + diff_values = diff.get("values_changed", {}) + diff_values.update(diff.get("type_changes", {})) + + if use_format_mapping: + format_rules = _get_format_mapping_for(obj_before, mapping_override) + else: + format_rules = {} + + changes = [] + for attr_name, value in diff_values.items(): + attr_name = attr_name.replace("root.", "") + + new = value["new_value"] + old = value["old_value"] + + formatting = format_rules.get(attr_name, lambda x: x) + # Setting formatting to `None` should skip the variable + if formatting is None: + continue + + new = formatting(new) + old = formatting(old) + + changes.append(ValueUpdate(attr_name=attr_name, new_value=new, old_value=old)) + + return changes + + +def add_change_field( + embed: Embed, + obj_before: t.Any, + obj_after: t.Any, + *, + mapping_override: t.Optional[dict] = None +) -> Embed: + """ + Compare passed objects `obj_before` and `obj_after`. + Return the passed embed with 2 new fields, containing formatted differences between + these 2 objects. Returned object is a new Embed, to avoid mutating original. + + `mapping_override` can be set, which provides an easy way of ignoring or editing the + values comming from the diff. This mapping looks like this: + { + "overridden attribute": lambda x: f"{x} seconds", + "not shown attribute": None + } + """ + if mapping_override is None: + mapping_override = {} + + # Preserve original objects and work on copies + embed = embed.copy() + + field_before_lines = [] + field_after_lines = [] + + for attr_name, old, new in compare_objects(obj_before, obj_after, mapping_override=mapping_override): + attr_name = attr_name.replace("_", " ").replace(".", " ").capitalize() + new = str(new).replace("_", " ") + old = str(old).replace("_", " ") + + field_before_lines.append(f"**{attr_name}:** {old}") + field_after_lines.append(f"**{attr_name}:** {new}") + + embed.add_field( + name="Before", + value="\n".join(field_before_lines), + inline=True + ) + embed.add_field( + name="After", + value="\n".join(field_after_lines), + inline=True + ) + + return embed + + +def add_channel_perms_field( + embed: t.Optional[Embed], + channel_before: GuildChannel, + channel_after: GuildChannel, +) -> Embed: + """ + Compare overwrites fo passed channels `channel_before` and `channel_after`. + Return the passed embed with a new field, containing formatted differences between + channel permission overrides. Returned object is a new Embed, to avoid mutating original. + """ + embed_lines = [] + all_overwrites = set(channel_before.overwrites.keys()).union(set(channel_after.overwrites.keys())) + + for overwrite_for in all_overwrites: + before_overwrites = channel_before.overwrites_for(overwrite_for) + after_overwrites = channel_after.overwrites_for(overwrite_for) + + if before_overwrites == after_overwrites: + continue + + embed_lines.append(f"**Overwrite changes for {overwrite_for.mention}:**") + + for before_perm, after_perm in zip(before_overwrites, after_overwrites): + if before_perm[1] != after_perm[1]: + perm_name = before_perm[0].replace("_", " ").replace(".", " ").capitalize() + + if before_perm[1] is True: + before_emoji = "✅" + elif before_perm[1] is False: + before_emoji = "❌" + else: + before_emoji = "⬜" + + if after_perm[1] is True: + after_emoji = "✅" + elif after_perm[1] is False: + after_emoji = "❌" + else: + after_emoji = "⬜" + + embed_lines.append(f"**`{perm_name}:`** {before_emoji} ➜ {after_emoji}") + + embed = embed.copy() + embed.add_field( + name="Details", + value="\n".join(embed_lines), + inline=False + ) + + return embed + + +def add_permissions_field( + embed: t.Optional[Embed], + permissions_before: Permissions, + permissions_after: Permissions, +) -> Embed: + """" + Compare permissions fo passed channels `channel_before` and `channel_after`. + Return the passed embed with a new field, containing formatted differences between + permission flags. Returned object is a new Embed, to avoid mutating original. + """ + before_flag_dict = {flag: getattr(permissions_before, flag, None) for flag in Permissions.VALID_FLAGS} + after_flag_dict = {flag: getattr(permissions_after, flag, None) for flag in Permissions.VALID_FLAGS} + + before_flags = PermissionFlags(**before_flag_dict) + after_flags = PermissionFlags(**after_flag_dict) + + return add_change_field(embed, before_flags, after_flags) diff --git a/bot/utils/paste_upload.py b/bot/utils/paste_upload.py new file mode 100644 index 0000000..8588153 --- /dev/null +++ b/bot/utils/paste_upload.py @@ -0,0 +1,125 @@ +import json +import sys +import typing as t + +from aiohttp import ClientSession +from discord import Attachment +from discord.errors import NotFound +from loguru import logger + + +async def upload_files( + http_session: ClientSession, + files: t.Sequence[dict], + paste_name: str = "Automatic paste.", + paste_description: str = "This paste was automatically generated by NeutronBot.", +) -> t.Optional[str]: + """ + Try to upload given `files` to paste.gg service with `http_session`. + + `files` is a list of json dicts containing all of the files intended to be uploaded. + This should only be a partial payload to paste.gg, describing the files themselves, + not the whole payload. Example formatting of this list of dictionaries is described in the + API documentation for paste.gg: https://github.com/ascclemens/paste/blob/master/api.md + + You can also specify `name` and `description`, of given paste. + This function will return the URL to pasted content, or `None` if pasting failed. + """ + # Make sure to convert files sequence to list + # payload must be using list for json.dumps to work properly + if not isinstance(files, list): + files = list(files) + + payload = { + "name": paste_name, + "description": paste_description, + "files": files + } + + logger.debug(f"Uploading {len(files)} file{'s' if len(files) > 1 else ''} to paste.gg, size: {sys.getsizeof(files)}B") + try: + response = await http_session.post( + "https://api.paste.gg/v1/pastes", + headers={"Content-Type": "application/json"}, + data=json.dumps(payload) + ) + except ConnectionError: + logger.warning("Failed to paste content to paste.gg, Ended with ConnectionError.") + return + + if response.status != 201: + logger.warning(f"Failed to paste content to paste.gg, ended with {response.status}.") + return + json_response = await response.json() + paste_id = json_response["result"]["id"] + return f"https://www.paste.gg/{paste_id}" + + +async def upload_text( + http_session: ClientSession, + text: str, + file_name: str = "text.txt", + paste_name: str = "Automatic text paste.", + paste_description: str = "This paste was automatically generated from given text.", +) -> t.Optional[str]: + """ + Try to upload given `text` to paste.gg service. + + You can also specify `file_name`, `paste_name` and `paste_description`, of given paste. + This function will return the URL to pasted content, or `None` if pasting failed. + """ + payload = { + "name": file_name, + "content": { + "format": "text", + "value": text + } + } + + return await upload_files( + http_session, + files=[payload], + paste_name=paste_name, + paste_description=paste_description + ) + + +async def upload_attachments(http_session: ClientSession, attachments: t.List[Attachment], max_file_size: int = 500_000) -> t.Optional[str]: + """ + Try to upload given `attachments` to paste.gg service. + + Attachments which doesn't follow UTF-8 encoding will be ignored. + Attachments which weren't found (were already removed) will be ignored. + Attachments over `max_file_size` (defaults to 500KB) will be ignored. + If there aren't any applicable attachments to be uploaded, return None. + Otherwise return URL to the uploaded content of given attachments. + """ + files = [] + for attachment in attachments: + # Don't try loading files over maximum size + if attachment.size > max_file_size: + logger.debug(f"Attachment {attachment.filename} skipped, maximum size surpassed ({attachment.size} > {max_file_size})") + continue + + try: + content = await attachment.read() + value = content.decode("utf-8") + except (NotFound, UnicodeDecodeError): + continue + else: + files.append({ + "name": attachment.filename, + "content": { + "format": "text", + "value": value + } + }) + + if len(files) == 0: + return + + return await upload_files( + http_session, files, + paste_name="Automatic attachment paste.", + paste_description="This paste was automatically generated from a discord message attachment." + ) diff --git a/bot/utils/time.py b/bot/utils/time.py index d1ef645..1fda7af 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -4,7 +4,7 @@ from dateutil.relativedelta import relativedelta -def stringify_reldelta(rel_delta: relativedelta, min_unit: str = "seconds") -> str: +def stringify_reldelta(rel_delta: relativedelta, min_unit: str = "seconds", max_units: int = 8) -> str: """ Convert `dateutil.relativedelta.relativedelta` into a readable string @@ -23,6 +23,10 @@ def stringify_reldelta(rel_delta: relativedelta, min_unit: str = "seconds") -> s `1 year 2 months 2 weeks 5 days 4 hours 2 minutes and 1 second` you'd get: `1 year 2 months 2 weeks and 5 days` + + `max_units` is the maximum amount of units to be used. + If the produced string would go over this amount, smaller + units will be cut to fit into this number. """ rel_delta = rel_delta.normalized() time_dict = { @@ -40,9 +44,15 @@ def stringify_reldelta(rel_delta: relativedelta, min_unit: str = "seconds") -> s time_list = [] for unit, value in time_dict.items(): + # Stop early and don't parse smaller units + # if we already hit the max allowed amount of units + if len(time_list) == max_units: + break + if value: time_list.append(f"{int(value)} {unit if value != 1 else unit[:-1]}") + # Stop if we hit the minimal unit if unit == min_unit: break @@ -57,7 +67,7 @@ def stringify_reldelta(rel_delta: relativedelta, min_unit: str = "seconds") -> s return stringified_time -def stringify_timedelta(time_delta: timedelta, min_unit: str = "seconds") -> str: +def stringify_timedelta(time_delta: timedelta, min_unit: str = "seconds", max_units: int = 8) -> str: """ Convert `datetime.timedelta` into a readable string @@ -76,13 +86,17 @@ def stringify_timedelta(time_delta: timedelta, min_unit: str = "seconds") -> str `1 year 2 months 2 weeks 5 days 4 hours 2 minutes and 1 second` you'd get: `1 year 2 months 2 weeks and 5 days` + + `max_units` is the maximum amount of units to be used. + If the produced string would go over this amount, smaller + units will be cut to fit into this number. """ now = datetime.now() rel_delta = relativedelta(now + time_delta, now) - return stringify_reldelta(rel_delta, min_unit=min_unit) + return stringify_reldelta(rel_delta, min_unit=min_unit, max_units=max_units) -def stringify_duration(duration: t.Union[int, float], min_unit: str = "seconds") -> str: +def stringify_duration(duration: t.Union[int, float], min_unit: str = "seconds", max_units: int = 8) -> str: """ Convert `duration` in seconds into a readable time string @@ -101,6 +115,10 @@ def stringify_duration(duration: t.Union[int, float], min_unit: str = "seconds") `1 year 2 months 2 weeks 5 days 4 hours 2 minutes and 1 second` you'd get: `1 year 2 months 2 weeks and 5 days` + + `max_units` is the maximum amount of units to be used. + If the produced string would go over this amount, smaller + units will be cut to fit into this number. """ if isinstance(duration, float): if duration == float("inf"): @@ -108,10 +126,10 @@ def stringify_duration(duration: t.Union[int, float], min_unit: str = "seconds") now = datetime.now() rel_delta = relativedelta(now + timedelta(seconds=duration), now) - return stringify_reldelta(rel_delta, min_unit=min_unit) + return stringify_reldelta(rel_delta, min_unit=min_unit, max_units=max_units) -def time_elapsed(_from: datetime, to: t.Optional[datetime] = None, min_unit: str = "seconds") -> str: +def time_elapsed(_from: datetime, to: t.Optional[datetime] = None, min_unit: str = "seconds", max_units: int = 8) -> str: """ Returns how much time has elapsed in a readable string when no `to` value is specified, current time is assumed @@ -131,10 +149,14 @@ def time_elapsed(_from: datetime, to: t.Optional[datetime] = None, min_unit: str `1 year 2 months 2 weeks 5 days 4 hours 2 minutes and 1 second ago` you'd get: `1 year 2 months 2 weeks and 5 days ago` + + `max_units` is the maximum amount of units to be used. + If the produced string would go over this amount, smaller + units will be cut to fit into this number. """ if not to: - to = datetime.datetime.utcnow() + to = datetime.utcnow() rel_delta = relativedelta(to, _from) - stringified_time = stringify_reldelta(rel_delta, min_unit=min_unit) + stringified_time = stringify_reldelta(rel_delta, min_unit=min_unit, max_units=max_units) return f"{stringified_time} ago." diff --git a/bot/core/timer.py b/bot/utils/timer.py similarity index 99% rename from bot/core/timer.py rename to bot/utils/timer.py index 085f985..e03034e 100644 --- a/bot/core/timer.py +++ b/bot/utils/timer.py @@ -125,3 +125,4 @@ def _task_executed(self, task_name: t.Hashable, executed_task: t.Coroutine) -> N exception = executed_task.exception() if exception: logger.error(f"Exception ocurred while executing {self.id}:{task_name} ({id(executed_task)})") + raise exception diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f33f256 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,33 @@ +# This docker compose is used for quick setups of database which +# the bot project relies on for testing. Use it if you haven't got a +# ready-to-use site environment already setup. + +version: "3.7" + +services: + postgres: + image: postgres:12-alpine + ports: + - 5432:5432 + environment: + POSTGRES_DB: bot + POSTGRES_USER: bot + POSTGRES_PASSWORD: bot + + bot: + build: + context: . + dockerfile: Dockerfile + volumes: + - ./logs:/bot/logs + - .:/bot:ro + tty: true + depends_on: + - postgres + environment: + BOT_TOKEN: ${BOT_TOKEN} + COMMAND_PREFIX: ${COMMAND_PREFIX} + DATABASE_HOST: postgres:5432 + DATABASE_NAME: bot + DATABASE_USER: bot + DATABASE_PASSWORD: bot From fb57778865036b3b06814386fa4b50fa1595b06c Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 4 Mar 2021 15:10:32 +0100 Subject: [PATCH 7/9] Move reminders to utilities --- bot/cogs/{ => utility}/reminders.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename bot/cogs/{ => utility}/reminders.py (100%) diff --git a/bot/cogs/reminders.py b/bot/cogs/utility/reminders.py similarity index 100% rename from bot/cogs/reminders.py rename to bot/cogs/utility/reminders.py From d86b1e22b93669e90bad32d17a094ad453a8a447 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 4 Mar 2021 15:16:12 +0100 Subject: [PATCH 8/9] Show reminder ID in message --- bot/cogs/utility/reminders.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/utility/reminders.py b/bot/cogs/utility/reminders.py index 7e63c46..7f38c99 100644 --- a/bot/cogs/utility/reminders.py +++ b/bot/cogs/utility/reminders.py @@ -43,7 +43,10 @@ async def add(self, ctx: Context, duration: Duration, *, message: str) -> None: _task_name = f"{ctx.author.id}.{len(self.reminders[ctx.author])}" self.timer.delay(duration, _task_name, self._remind(ctx.author, message)) - await ctx.send(f"You'll be reminded in {stringify_duration(duration)}: {message}.") + await ctx.send( + f"You'll be reminded in {stringify_duration(duration)}: {message} " + f"(Reminder ID: {len(self.reminders[ctx.author])})." + ) @reminder.command(aliases=["delete", "cancel", "abort"]) async def remove(self, ctx: Context, reminder_id: int) -> None: From 0b5b0bca09e224fbbaec94ea03cb534a159cafaf Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 4 Mar 2021 15:23:15 +0100 Subject: [PATCH 9/9] Add message to cache only if not ignored --- bot/cogs/logging/message_log.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/logging/message_log.py b/bot/cogs/logging/message_log.py index b80529b..86d01a3 100644 --- a/bot/cogs/logging/message_log.py +++ b/bot/cogs/logging/message_log.py @@ -75,11 +75,12 @@ async def on_message_edit(self, before: Message, after: Message) -> None: """ # Add this message to set of ignored messages for raw events, these trigger even if # the message was cached, and to prevent double logging, we need to ignore them - self._handled_cached.add((after.guild.id, after.id)) if self.is_ignored(message=after, event=Event.message_edit): return + self._handled_cached.add((after.guild.id, after.id)) + response = ( f"**Author:** {after.author.mention}\n" f"**Channel:** {after.channel.mention}"