├── .editorconfig ├── .env_example ├── .gitattributes ├── .githooks └── pre-commit ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ └── checks.yml ├── .gitignore ├── .vscode ├── extensions.json ├── settings.json └── tasks.json ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── docker-compose-nodb.yml ├── docker-compose.yml ├── noxfile.py ├── poetry.lock ├── privacy.md ├── pyproject.toml ├── src ├── __init__.py ├── __main__.py ├── config_example.py ├── db │ ├── migrations │ │ ├── 1.sql │ │ ├── 2.sql │ │ ├── 3.sql │ │ ├── 4.sql │ │ ├── 5.sql │ │ ├── 6.sql │ │ ├── 7.py │ │ └── 8.sql │ └── schema.sql ├── etc │ ├── __init__.py │ ├── const.py │ ├── fonts │ │ ├── LICENSE.txt │ │ └── roboto-slab.ttf │ ├── perms_str.py │ ├── settings_static.py │ └── text │ │ ├── 8ball.txt │ │ ├── funfacts.txt │ │ ├── penguinfacts.txt │ │ ├── words_easy.txt │ │ ├── words_hard.txt │ │ └── words_medium.txt ├── extensions │ ├── annoverse.py │ ├── automod.py │ ├── command_handler.py │ ├── dev.py │ ├── fallingfrontier.py │ ├── fandom.py │ ├── fun.py │ ├── help.py │ ├── misc.py │ ├── moderation.py │ ├── reminders.py │ ├── reports.py │ ├── role_buttons.py │ ├── settings.py │ ├── starboard.py │ ├── tags.py │ ├── test.py │ ├── troubleshooter.py │ └── userlog.py ├── models │ ├── __init__.py │ ├── audit_log.py │ ├── bot.py │ ├── checks.py │ ├── context.py │ ├── db.py │ ├── db_user.py │ ├── errors.py │ ├── events.py │ ├── journal.py │ ├── mod_actions.py │ ├── plugin.py │ ├── rolebutton.py │ ├── settings.py │ ├── starboard.py │ ├── tag.py │ ├── timer.py │ └── views.py └── utils │ ├── __init__.py │ ├── cache.py │ ├── db_backup.py │ ├── dictionaryapi.py │ ├── helpers.py │ ├── ratelimiter.py │ ├── rpn.py │ ├── scheduler.py │ └── tasks.py └── tos.md /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.env_example: -------------------------------------------------------------------------------- 1 | TOKEN=bot_token_here 2 | 3 | POSTGRES_USER=postgres 4 | # Leave POSTGRES_HOST as sned-db to use included postgres instance, change if using docker-compose-nodb. 5 | POSTGRES_HOST=sned-db 6 | POSTGRES_DB=sned 7 | POSTGRES_PORT=5432 8 | POSTGRES_PASSWORD=db_password_here 9 | # Version only applies in the case of Docker, determines the version of the database and client utilities. 10 | POSTGRES_VERSION=14 11 | 12 | # Used for toxicity filtering 13 | PERSPECTIVE_API_KEY=perspective_api_key_here 14 | # Merriam-Webster Dictionary API key 15 | DICTIONARYAPI_API_KEY=dictionaryapi_api_key_here -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | REPO_PATH=$(/usr/bin/git rev-parse --show-toplevel) 4 | /usr/bin/python3 -m nox 5 | /usr/bin/git update-index --again :/: -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: hypergonial 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: pip 9 | directory: / 10 | schedule: 11 | interval: daily 12 | assignees: 13 | - hypergonial 14 | reviewers: 15 | - hypergonial 16 | rebase-strategy: auto 17 | target-branch: main 18 | -------------------------------------------------------------------------------- /.github/workflows/checks.yml: -------------------------------------------------------------------------------- 1 | name: Run checks 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | formatting: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | 12 | - name: Set up Python 3.11 13 | uses: actions/setup-python@v4 14 | with: 15 | python-version: "3.11" 16 | 17 | - name: check formatting via nox 18 | run: | 19 | python -m pip install nox 20 | python -m nox -s format -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # Bot configuration files 132 | config.py 133 | 134 | # DB backup files 135 | *.pgdmp 136 | db/backup/ 137 | 138 | # Dolphin directory meta files 139 | .directory 140 | 141 | # ruff 142 | .ruff_cache/ -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-python.python", 4 | "charliermarsh.ruff", 5 | "njpwerner.autodocstring", 6 | "EditorConfig.EditorConfig", 7 | "ExodiusStudios.comment-anchors", 8 | ] 9 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.autoImportCompletions": true, 3 | "editor.formatOnSave": true, 4 | "[python]": { 5 | "editor.defaultFormatter": "charliermarsh.ruff" 6 | }, 7 | "autoDocstring.docstringFormat": "numpy", 8 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | // Use Ctrl+Shift+B or the command palette to run nox. 5 | "version": "2.0.0", 6 | "tasks": [ 7 | { 8 | "label": "Run nox", 9 | "type": "shell", 10 | "command": "nox", 11 | "group": { 12 | "kind": "build", 13 | "isDefault": true 14 | } 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11 2 | 3 | ARG postgres_version=14 4 | 5 | RUN curl -sSL https://install.python-poetry.org | python3 - 6 | ENV PATH "$PATH:/root/.local/bin" 7 | 8 | RUN apt-get update && apt-get -y full-upgrade && apt-get install -y lsb-release 9 | RUN sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' 10 | RUN wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - 11 | RUN apt-get update 12 | RUN apt-get install -y postgresql-client-${postgres_version} 13 | 14 | COPY pyproject.toml poetry.lock ./ 15 | 16 | RUN poetry config virtualenvs.create false 17 | RUN poetry install -n --only main 18 | 19 | COPY . ./ 20 | CMD ["python3.11", "-O", "-m", "src"] -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | deploy: 2 | git pull 3 | docker compose up -d --build 4 | 5 | clean: 6 | docker compose down 7 | docker system prune --filter label=snedbot-sned -a -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sned 2 | 3 | > Sned is a general purpose Discord bot designed to make handling your community a breeze! 4 | 5 | ### [Add it to your server!](https://discord.com/oauth2/authorize?client_id=817730141722902548&permissions=1494984682710&scope=applications.commands%20bot) 6 | 7 | ### Main features: 8 | - Powerful moderation commands 9 | - Intuitive settings menu 10 | - AutoMod 11 | - Report system for users to anonymously report messages to your team 12 | - Customizable logging to keep moderators accountable 13 | - Toxicity filtering via [Perspective](https://www.perspectiveapi.com/) 14 | - Rolebuttons for letting users pick their roles 15 | - Starboard 16 | - Tags system 17 | - Reminders with snoozing and additional recipient support 18 | - Fun commands such as tic-tac-toe and typeracer 19 | - Much much more! 20 | 21 | ### Sned in action: 22 | 23 | | Moderation tools | Settings & configuration | Rolebuttons | Reminders | 24 | | ----------- | ----------- | ----------- | ----------- | 25 | | ![Powerful moderation commands!](https://cdn.discordapp.com/attachments/836300326172229672/952785998138466364/unknown.png) | ![Intuitive Settings and Configuration](https://cdn.discordapp.com/attachments/836300326172229672/952786784666931300/unknown.png) | ![Rolebuttons in action](https://cdn.discordapp.com/attachments/836300326172229672/952789779471294464/unknown.png) | ![Reminder snoozing in action](https://cdn.discordapp.com/attachments/836300326172229672/952790270150320228/unknown.png) | 26 | 27 | ### Configuration: 28 | 29 | To get started with setting up the bot on a server you have `Manage Server` permissions on, simply type `/settings`! 30 | 31 | ### Development: 32 | 33 | If you'd like to contribute to Sned, or host it locally, you need the following utilities: 34 | 35 | - [`make`](https://www.gnu.org/software/make/) 36 | - [`docker`](https://www.docker.com/get-started/) 37 | - [`python`](https://www.python.org/downloads/) - 3.10 or higher 38 | - [`poetry`](https://python-poetry.org/docs/) - for managing python dependencies 39 | 40 | To deploy the bot, create and fill out `.env`, you can see an example in `.env_example`, along with `config.py`, for which you can find an example in `config_example.py`. 41 | Then simply run `make deploy` to start the bot in the background along with it's database. 42 | 43 | If you'd like to contribute, please make sure to run [`nox`](https://nox.thea.codes/en/stable/index.html) in the project folder before submitting your changes. This should format all your code to match the project. 44 | -------------------------------------------------------------------------------- /docker-compose-nodb.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | sned: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | args: 8 | postgres_version: ${POSTGRES_VERSION:-14} 9 | command: ["python3.11", "-O", "main.py"] 10 | restart: always -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | sned-db: 4 | image: postgres:${POSTGRES_VERSION:-14} 5 | restart: always 6 | expose: 7 | - $POSTGRES_PORT:-5432 8 | environment: 9 | POSTGRES_USER: ${POSTGRES_USER:-postgres} 10 | POSTGRES_DB: ${POSTGRES_DB:-sned} 11 | POSTGRES_PORT: ${POSTGRES_PORT:-5432} 12 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?err} 13 | volumes: 14 | - postgres_data:/var/lib/postgresql/data 15 | networks: 16 | - private 17 | 18 | sned: 19 | build: 20 | context: . 21 | dockerfile: Dockerfile 22 | args: 23 | postgres_version: ${POSTGRES_VERSION:-14} 24 | command: ["python3.11", "-O", "-m", "src"] 25 | depends_on: 26 | - sned-db 27 | restart: always 28 | networks: 29 | - private 30 | 31 | networks: 32 | private: 33 | external: false 34 | 35 | volumes: 36 | postgres_data: -------------------------------------------------------------------------------- /noxfile.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import nox 4 | from nox import options 5 | 6 | PATH_TO_PROJECT = os.path.join(".") 7 | SCRIPT_PATHS = [ 8 | PATH_TO_PROJECT, 9 | "noxfile.py", 10 | ] 11 | 12 | options.sessions = ["format_fix"] 13 | 14 | 15 | @nox.session() 16 | def format_fix(session: nox.Session): 17 | session.install("-U", "ruff") 18 | session.run("python", "-m", "ruff", "format", *SCRIPT_PATHS) 19 | session.run("python", "-m", "ruff", *SCRIPT_PATHS, "--fix") 20 | 21 | 22 | @nox.session() 23 | def format(session: nox.Session) -> None: 24 | session.install("-U", "ruff") 25 | session.run("python", "-m", "ruff", "format", *SCRIPT_PATHS, "--check") 26 | session.run("python", "-m", "ruff", *SCRIPT_PATHS) 27 | 28 | 29 | # Copyright (C) 2022-present hypergonial 30 | 31 | # This program is free software: you can redistribute it and/or modify 32 | # it under the terms of the GNU General Public License as published by 33 | # the Free Software Foundation, either version 3 of the License, or 34 | # (at your option) any later version. 35 | 36 | # This program is distributed in the hope that it will be useful, 37 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 38 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 39 | # GNU General Public License for more details. 40 | 41 | # You should have received a copy of the GNU General Public License 42 | # along with this program. If not, see: https://www.gnu.org/licenses 43 | -------------------------------------------------------------------------------- /privacy.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy: 2 | 3 | _By using Sned, you agree to Discord's [Terms of Service](https://discord.com/terms)_ 4 | 5 | --- 6 | 7 | 8 | ## 1. What Information do we collect: 9 | 10 | Sned retains a little information about users as possible. 11 | 12 | What we do collect, and store persistently are: 13 | 14 | - Your Discord [Snowflake](https://discord.dev/reference#snowflakes) 15 | - The ID of guilds where Sned is used 16 | - Certain user-submitted content 17 | - Certain user-submitted preferences (e.g. timezone) 18 | - A list of moderation actions performed on users (journal) 19 | 20 | ## 2. Why do we store this information? 21 | 22 | Sned provides multiple services that action upon users (either first or second party individuals). 23 | 24 | Because of the asyncrhonous nature of these services, and the requirement of a context to act within, it's a requisite to know what guild, and who the actions are targeted toward. 25 | 26 | This data is only stored as long as it's required, and is immediately removed from temporary and persistent data stores when it's no longer in use. Any information that is stored persistently is done out of neccessicity. 27 | 28 | In the context of user-submitted data, we store this information because it is generally required to, as this information can be retroactively retrieved, if permitted. 29 | 30 | Examples of user-submitted content that is stored persistently include: 31 | 32 | - Reminders 33 | - Tags 34 | - Moderation Journal 35 | - Starboard entries 36 | - Rolebuttons 37 | 38 | In most instances, this information is easily user-accessible, and is removed from both temporary and persistent data stores immediately when not in use. 39 | 40 | Any information that is overridden also follows this rule, and the previous content is inaccessible from that point on. 41 | 42 | Discord Snowflakes are also used for diagnostic purposes, and help link issues with specific users or guilds. 43 | 44 | ## 3. How do we collect this information? 45 | 46 | Your discord ID is provided by Discord's [API](https://discord.dev). 47 | 48 | Under normal circumstances, this information is not stored persistently, nor for any extended period of time in temporary storage. 49 | 50 | We may collect this information temporarily or indefinitely under certain circumstances, under the restriction that you have been involved with Sned and it's services, directly or indirectly. 51 | 52 | Moderators of any server that has authorized Sned to operate on their guild may pass your user ID as an argument to a service provided by Sned, which may require storing it persistently. 53 | 54 | Alternatively some services provided by Sned accessible to non-moderators may store information about you (such as your ID). This information is as restrictive as possible, bearing only enough context to provide core functionality to the aforementioned services. 55 | 56 | 57 | ## 4. What is this information used for? 58 | 59 | Information stored by Sned is only used for the purposes stated by the related service it is used for. This information is never shared with third parties, and only leaves the confines of the service when it is required by a first-party entity. 60 | 61 | Some of Sned's services use persistently stored data to be sent back to the user at a later time. 62 | 63 | Furthermore, storing Discord identifiers (IDs, Snowflakes) allows for robust error analysis that would otherwise be inacessible or otherwise infeasible. 64 | 65 | By storing this information, it enables us to not only improve upon the product being offered to the end user posthaste, but contact users and/or server owners if necessary. 66 | 67 | It also allows users to contact us in regards to issues that may occur with Sned, so that they may be linked with a corresponding log or metric recorded by Sned for these diagnostic purposes. 68 | 69 | The use of IDs links end users to certain metadata held about them, both from Discord and tracked internally by Sned's services. Sned may, if configured, act atonomously on this metadata should configured circumstances be met. Metadata attached to users is deleted in a cascading manner, and is removed should the user's primary entity be removed from our data stores for any reason. 70 | 71 | ## 5. How to be informed when this policy updates: 72 | 73 | Should this policy be updated in the future, for any reason, an announcement will be made on Sned's [Discord Server](https://discord.gg/KNKr8FPmJa). 74 | 75 | ## 6. Contacting: 76 | 77 | Any questions or concerns can be answered via Discord, and joining Sned's [Discord Server](https://discord.gg/KNKr8FPmJa) 78 | 79 | ## 7. How is user information protected? 80 | Both temporary and persistent data stores are located on a remote server. 81 | 82 | Sensitive data can only be accessed via a single account, which requires an SSH key to log into, which is also stored in a secured location. 83 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.ruff] 2 | exclude = ["examples", "docs", "build"] 3 | select = [ 4 | "E", 5 | "F", 6 | "I", 7 | "TCH", 8 | "N", 9 | "D2", 10 | "D3", 11 | "D415", 12 | "D417", 13 | "D418", 14 | "D419", 15 | "Q", 16 | "RSE", 17 | "SIM", 18 | "RUF", 19 | ] 20 | ignore = ["F405", "F403", "E501", "D203", "D205", "D213", "RUF001"] 21 | fixable = ["I", "TCH", "D"] 22 | line-length = 120 23 | target-version = "py311" 24 | 25 | [tool.mypy] 26 | ignore_errors = true # I use pyright only because mypy dumb 27 | 28 | [tool.pyright] 29 | pythonVersion = "3.11" 30 | typeCheckingMode = "basic" 31 | 32 | [tool.poetry] 33 | name = "snedbot" 34 | version = "0.1.0" # I do not actually update this, lol 35 | description = "Your friendly neighbourhood multi-purpose Discord bot." 36 | authors = ["hypergonial <46067571+hypergonial@users.noreply.github.com>"] 37 | license = "GNU GPL-v3" 38 | 39 | [tool.poetry.dependencies] 40 | python = ">=3.11,<3.13" 41 | dateparser = "^1.1.8" 42 | psutil = "^5.9.6" 43 | Pillow = "^10.2.0" 44 | asyncpg = "^0.28.0" 45 | Levenshtein = "^0.23.0" 46 | uvloop = {version = "==0.18.0", platform="linux"} 47 | aiodns = "~=3.1.1" 48 | Brotli = "~=1.0" 49 | ciso8601 = "~=2.3" 50 | kosu = {git = "https://github.com/hypergonial/kosu.git"} 51 | hikari-lightbulb = "~=2.3.5" 52 | hikari-miru = "~=3.3.1" 53 | 54 | [tool.poetry.dev-dependencies] 55 | nox = "^2023.4.22" 56 | ruff = "^0.1.14" 57 | 58 | [build-system] 59 | requires = ["poetry-core>=1.0.0"] 60 | build-backend = "poetry.core.masonry.api" 61 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hypergonial/snedbot/c27f51756f92c485ef053d4d7b6ef4b3c6579d27/src/__init__.py -------------------------------------------------------------------------------- /src/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import logging 4 | import os 5 | import pathlib 6 | import platform 7 | import re 8 | 9 | from src.models import SnedBot 10 | 11 | DOTENV_REGEX = re.compile(r"^(?P[A-Za-z_]+[A-Za-z0-9_]*)=(?P[^#]+)(#.*)?$") 12 | BASE_DIR = str(pathlib.Path(os.path.abspath(__file__)).parents[1]) 13 | 14 | if int(platform.python_version_tuple()[1]) < 10: 15 | logging.fatal("Python version must be 3.10 or greater! Exiting...") 16 | exit(1) 17 | 18 | try: 19 | with open(os.path.join(BASE_DIR, ".env")) as env: 20 | for line in env.readlines(): 21 | match = DOTENV_REGEX.match(line) 22 | if not match: 23 | continue 24 | os.environ[match.group("identifier")] = match.group("value").strip() 25 | 26 | except FileNotFoundError: 27 | logging.info(".env file not found, using secrets from the environment instead.") 28 | 29 | try: 30 | from .config import Config 31 | except ImportError: 32 | logging.fatal( 33 | "Failed loading configuration. Please make sure 'config.py' exists in the root directory of the project and contains valid data." 34 | ) 35 | exit(1) 36 | 37 | if os.name != "nt": # Lol imagine using Windows 38 | try: 39 | import uvloop 40 | 41 | uvloop.install() 42 | except ImportError: 43 | logging.warn( 44 | "Failed to import uvloop! Make sure to install it via 'pip install uvloop' for enhanced performance!" 45 | ) 46 | 47 | bot = SnedBot(Config()) 48 | 49 | if __name__ == "__main__": 50 | bot.run() 51 | 52 | # Copyright (C) 2022-present hypergonial 53 | 54 | # This program is free software: you can redistribute it and/or modify 55 | # it under the terms of the GNU General Public License as published by 56 | # the Free Software Foundation, either version 3 of the License, or 57 | # (at your option) any later version. 58 | 59 | # This program is distributed in the hope that it will be useful, 60 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 61 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 62 | # GNU General Public License for more details. 63 | 64 | # You should have received a copy of the GNU General Public License 65 | # along with this program. If not, see: https://www.gnu.org/licenses 66 | -------------------------------------------------------------------------------- /src/config_example.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | 3 | import attr 4 | 5 | """ 6 | Configuration file example for the Discord bot Sned. 7 | The actual configuration is read from 'config.py', which must exist. 8 | All secrets are stored and read from the .env file. 9 | """ 10 | 11 | 12 | @attr.frozen(weakref_slot=False) 13 | class Config: 14 | DEV_MODE: bool = False # Control debugging mode, commands will default to DEBUG_GUILDS if True 15 | 16 | ERROR_LOGGING_CHANNEL: int = 123456789 # Error tracebacks will be sent here if specified 17 | 18 | DB_BACKUP_CHANNEL: int = 123456789 # DB backups will be sent here if specified 19 | 20 | DEBUG_GUILDS: t.Sequence[int] = (123, 456, 789) # Commands will only be registered here if DEV_MODE is on 21 | 22 | 23 | # Copyright (C) 2022-present hypergonial 24 | 25 | # This program is free software: you can redistribute it and/or modify 26 | # it under the terms of the GNU General Public License as published by 27 | # the Free Software Foundation, either version 3 of the License, or 28 | # (at your option) any later version. 29 | 30 | # This program is distributed in the hope that it will be useful, 31 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 32 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 33 | # GNU General Public License for more details. 34 | 35 | # You should have received a copy of the GNU General Public License 36 | # along with this program. If not, see: https://www.gnu.org/licenses 37 | -------------------------------------------------------------------------------- /src/db/migrations/1.sql: -------------------------------------------------------------------------------- 1 | -- Migration of schema from Sned V1 2 | 3 | 4 | -- Obsolete or removed functionality 5 | DROP TABLE IF EXISTS public.guild_blacklist; 6 | DROP TABLE IF EXISTS public.permissions; 7 | DROP TABLE IF EXISTS public.modules; 8 | DROP TABLE IF EXISTS public.priviliged; 9 | DROP TABLE IF EXISTS public.events; 10 | DROP TABLE IF EXISTS public.matchmaking_config; 11 | DROP TABLE IF EXISTS public.matchmaking_listings; 12 | DROP TABLE IF EXISTS public.ktp; 13 | 14 | -- Irrelevant for slash commands 15 | ALTER TABLE mod_config 16 | DROP clean_up_mod_commands; 17 | 18 | -- New feature 19 | ALTER TABLE mod_config 20 | ADD is_ephemeral bigint NOT NULL DEFAULT false; 21 | 22 | -- Payloads changed significantly 23 | DELETE automod_policies FROM mod_config; 24 | DELETE * FROM log_config; 25 | 26 | -- New feature 27 | ALTER TABLE log_config 28 | ADD color bool NOT NULL DEFAULT true; 29 | 30 | -- More consistent naming scheme 31 | ALTER TABLE tags 32 | RENAME COLUMN tag_name TO tagname; 33 | ALTER TABLE tags 34 | RENAME COLUMN tag_owner_id TO owner_id; 35 | ALTER TABLE tags 36 | RENAME COLUMN tag_aliases TO aliases; 37 | ALTER TABLE tags 38 | RENAME COLUMN tag_content TO content; 39 | 40 | -- Track tag creator 41 | ALTER TABLE tags 42 | ADD creator_id bigint; 43 | 44 | -- Add tag stats 45 | ALTER TABLE tags 46 | ADD uses integer NOT NULL DEFAULT 0; -------------------------------------------------------------------------------- /src/db/migrations/2.sql: -------------------------------------------------------------------------------- 1 | -- Rename buttonstyles to better match enum values 2 | UPDATE button_roles SET buttonstyle = 'PRIMARY' WHERE buttonstyle = 'Blurple'; 3 | UPDATE button_roles SET buttonstyle = 'SECONDARY' WHERE buttonstyle = 'Grey'; 4 | UPDATE button_roles SET buttonstyle = 'SUCCESS' WHERE buttonstyle = 'Green'; 5 | UPDATE button_roles SET buttonstyle = 'DANGER' WHERE buttonstyle = 'Red'; -------------------------------------------------------------------------------- /src/db/migrations/3.sql: -------------------------------------------------------------------------------- 1 | -- Add support for customizable rolebutton confirm prompts and rolebutton modes 2 | 3 | ALTER TABLE button_roles 4 | ADD add_title text; 5 | 6 | ALTER TABLE button_roles 7 | ADD add_desc text; 8 | 9 | ALTER TABLE button_roles 10 | ADD remove_title text; 11 | 12 | ALTER TABLE button_roles 13 | ADD remove_desc text; 14 | 15 | ALTER TABLE button_roles 16 | ADD mode smallint NOT NULL DEFAULT 0; 17 | 18 | ALTER TABLE button_roles 19 | RENAME COLUMN buttonlabel TO label; 20 | 21 | ALTER TABLE button_roles 22 | RENAME COLUMN buttonstyle TO style; -------------------------------------------------------------------------------- /src/db/migrations/4.sql: -------------------------------------------------------------------------------- 1 | -- Replace user flags with bitfield format. 2 | 3 | ALTER TABLE users 4 | DROP flags; 5 | 6 | ALTER TABLE users 7 | ADD flags bigint NOT NULL DEFAULT 0; 8 | 9 | -- Add field for misc. user data as json format 10 | 11 | ALTER TABLE users 12 | ADD data json NOT NULL DEFAULT '{}'; -------------------------------------------------------------------------------- /src/db/migrations/5.sql: -------------------------------------------------------------------------------- 1 | -- Convert moderation config into using bitfield 2 | 3 | ALTER TABLE mod_config 4 | DROP dm_users_on_punish; 5 | 6 | ALTER TABLE mod_config 7 | DROP is_ephemeral; 8 | 9 | ALTER TABLE mod_config 10 | ADD flags bigint NOT NULL DEFAULT 1; -------------------------------------------------------------------------------- /src/db/migrations/6.sql: -------------------------------------------------------------------------------- 1 | -- Drop prefix support 2 | 3 | ALTER TABLE global_config 4 | DROP prefix; -------------------------------------------------------------------------------- /src/db/migrations/7.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import logging 5 | import re 6 | import typing as t 7 | 8 | import hikari 9 | import lightbulb 10 | 11 | from src.models import JournalEntry, JournalEntryType 12 | 13 | if t.TYPE_CHECKING: 14 | from src.models.db import Database 15 | 16 | NOTE_REGEX = re.compile( 17 | r"\d+):\w>: (?P.+) \*\*(?P.+) (?:by|for) (?P.+):\*\* (?P.*)" 18 | ) 19 | 20 | ENTRY_TYPES = { 21 | "Banned": JournalEntryType.BAN, 22 | "Unbanned": JournalEntryType.UNBAN, 23 | "Kicked": JournalEntryType.KICK, 24 | "Timed out": JournalEntryType.TIMEOUT, 25 | "Timeout removed": JournalEntryType.TIMEOUT_REMOVE, 26 | "Muted": JournalEntryType.TIMEOUT, # Legacy 27 | "Unmuted": JournalEntryType.TIMEOUT_REMOVE, # Legacy 28 | "Warned": JournalEntryType.WARN, 29 | "1 Warning removed": JournalEntryType.WARN_REMOVE, 30 | "Warnings cleared": JournalEntryType.WARN_CLEAR, 31 | "Note": JournalEntryType.NOTE, 32 | } 33 | 34 | logger = logging.getLogger(__name__) 35 | 36 | 37 | def _parse_note(db: Database, user_id: int, guild_id: int, note: str) -> JournalEntry | None: 38 | match = NOTE_REGEX.match(note) 39 | if not match: 40 | logger.warning(f"Invalid note format:\n{note}") 41 | return 42 | 43 | users = db.app.cache.get_users_view() 44 | user: hikari.User | None = ( 45 | lightbulb.utils.find(users.values(), lambda u: str(u) == match.group("username")) 46 | if match.group("username") != "Unknown" 47 | else None 48 | ) 49 | timestamp = datetime.datetime.fromtimestamp(int(match.group("timestamp"))) 50 | content: str | None = ( 51 | match.group("content") 52 | if match.group("content") 53 | != "Error retrieving data from audit logs! Ensure the bot has permissions to view them!" 54 | else None 55 | ) 56 | entry_type = ENTRY_TYPES[match.group("verb")] 57 | 58 | return JournalEntry( 59 | user_id=hikari.Snowflake(user_id), 60 | guild_id=hikari.Snowflake(guild_id), 61 | content=content, 62 | author_id=hikari.Snowflake(user) if user else None, 63 | created_at=timestamp, 64 | entry_type=entry_type, 65 | ) 66 | 67 | 68 | async def _migrate_notes(db: Database) -> None: 69 | logger.warning("Waiting for cache availability to start journal entry migration...") 70 | await db.app.wait_until_started() 71 | 72 | logger.warning("Migrating journal entries...") 73 | records = await db.fetch("SELECT * FROM users") 74 | 75 | for record in records: 76 | notes: list[str] = record["notes"] 77 | user_id: int = record["user_id"] 78 | guild_id: int = record["guild_id"] 79 | if not notes: 80 | continue 81 | for note in notes: 82 | entry = _parse_note(db, user_id, guild_id, note) 83 | if entry: 84 | try: 85 | await entry.update() 86 | except Exception as exc: 87 | logger.error(f"Failed to migrate journal entry:\n{note}\n{exc}") 88 | 89 | await db.execute("ALTER TABLE users DROP COLUMN notes") 90 | logger.info("Journal entries migrated!") 91 | 92 | 93 | async def run(db: Database) -> None: 94 | # Defer execution to after startup, don't block 95 | db._app.create_task(_migrate_notes(db)) 96 | -------------------------------------------------------------------------------- /src/db/migrations/8.sql: -------------------------------------------------------------------------------- 1 | -- Add force_starred field to starboard entries 2 | 3 | ALTER TABLE starboard_entries 4 | ADD force_starred bool NOT NULL DEFAULT false; -------------------------------------------------------------------------------- /src/db/schema.sql: -------------------------------------------------------------------------------- 1 | -- Copyright (C) 2022-present hypergonial 2 | 3 | -- This program is free software: you can redistribute it and/or modify 4 | -- it under the terms of the GNU General Public License as published by 5 | -- the Free Software Foundation, either version 3 of the License, or 6 | -- (at your option) any later version. 7 | 8 | -- This program is distributed in the hope that it will be useful, 9 | -- but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | -- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | -- GNU General Public License for more details. 12 | 13 | -- You should have received a copy of the GNU General Public License 14 | -- along with this program. If not, see: https://www.gnu.org/licenses 15 | 16 | 17 | -- Creation of all tables necessary for the bot to function 18 | 19 | CREATE TABLE IF NOT EXISTS schema_info 20 | ( 21 | schema_version integer NOT NULL, 22 | PRIMARY KEY (schema_version) 23 | ); 24 | 25 | 26 | -- Insert schema version into schema_info table if not already present 27 | DO 28 | $do$ 29 | DECLARE _schema_version integer; 30 | BEGIN 31 | SELECT 8 INTO _schema_version; -- The current schema version, change this when creating new migrations 32 | 33 | IF NOT EXISTS (SELECT schema_version FROM schema_info) THEN 34 | INSERT INTO schema_info (schema_version) 35 | VALUES (_schema_version); 36 | END IF; 37 | END 38 | $do$; 39 | 40 | CREATE TABLE IF NOT EXISTS global_config 41 | ( 42 | guild_id bigint NOT NULL, 43 | PRIMARY KEY (guild_id) 44 | ); 45 | 46 | CREATE TABLE IF NOT EXISTS users 47 | ( 48 | user_id bigint NOT NULL, 49 | guild_id bigint NOT NULL, 50 | flags json, 51 | warns integer NOT NULL DEFAULT 0, 52 | PRIMARY KEY (user_id, guild_id), 53 | FOREIGN KEY (guild_id) 54 | REFERENCES global_config (guild_id) 55 | ON DELETE CASCADE 56 | ); 57 | 58 | CREATE TABLE IF NOT EXISTS journal 59 | ( 60 | id serial NOT NULL, 61 | user_id bigint NOT NULL, 62 | guild_id bigint NOT NULL, 63 | entry_type smallint NOT NULL, 64 | content text, 65 | author_id bigint, 66 | created_at bigint NOT NULL, 67 | PRIMARY KEY (id), 68 | FOREIGN KEY (guild_id) 69 | REFERENCES global_config (guild_id) 70 | ON DELETE CASCADE 71 | ); 72 | 73 | CREATE TABLE IF NOT EXISTS preferences 74 | ( 75 | user_id bigint NOT NULL, 76 | timezone text NOT NULL DEFAULT 'UTC', 77 | PRIMARY KEY (user_id) 78 | ); 79 | 80 | CREATE TABLE IF NOT EXISTS blacklist 81 | ( 82 | user_id bigint NOT NULL, 83 | PRIMARY KEY (user_id) 84 | ); 85 | 86 | CREATE TABLE IF NOT EXISTS mod_config 87 | ( 88 | guild_id bigint NOT NULL, 89 | dm_users_on_punish bool NOT NULL DEFAULT true, 90 | is_ephemeral bool NOT NULL DEFAULT false, 91 | automod_policies json NOT NULL DEFAULT '{}', 92 | PRIMARY KEY (guild_id), 93 | FOREIGN KEY (guild_id) 94 | REFERENCES global_config (guild_id) 95 | ON DELETE CASCADE 96 | ); 97 | 98 | CREATE TABLE IF NOT EXISTS reports 99 | ( 100 | guild_id bigint NOT NULL, 101 | is_enabled bool NOT NULL DEFAULT false, 102 | channel_id bigint, 103 | pinged_role_ids bigint[] DEFAULT '{}', 104 | PRIMARY KEY (guild_id), 105 | FOREIGN KEY (guild_id) 106 | REFERENCES global_config (guild_id) 107 | ON DELETE CASCADE 108 | ); 109 | 110 | CREATE TABLE IF NOT EXISTS timers 111 | ( 112 | id serial NOT NULL, 113 | guild_id bigint NOT NULL, 114 | user_id bigint NOT NULL, 115 | channel_id bigint, 116 | event text NOT NULL, 117 | expires bigint NOT NULL, 118 | notes text, 119 | PRIMARY KEY (id), 120 | FOREIGN KEY (guild_id) 121 | REFERENCES global_config (guild_id) 122 | ON DELETE CASCADE 123 | ); 124 | 125 | CREATE TABLE IF NOT EXISTS button_roles 126 | ( 127 | guild_id bigint NOT NULL, 128 | entry_id serial NOT NULL, 129 | channel_id bigint NOT NULL, 130 | msg_id bigint NOT NULL, 131 | emoji text NOT NULL, 132 | label text, 133 | style text, 134 | role_id bigint NOT NULL, 135 | mode smallint NOT NULL DEFAULT 0, 136 | add_title text, 137 | add_desc text, 138 | remove_title text, 139 | remove_desc text, 140 | PRIMARY KEY (guild_id, entry_id), 141 | FOREIGN KEY (guild_id) 142 | REFERENCES global_config (guild_id) 143 | ON DELETE CASCADE 144 | ); 145 | 146 | CREATE TABLE IF NOT EXISTS tags 147 | ( 148 | guild_id bigint NOT NULL, 149 | tagname text NOT NULL, 150 | owner_id bigint NOT NULL, 151 | creator_id bigint, -- This may be null for tags that were not tracked for this. 152 | uses integer NOT NULL DEFAULT 0, 153 | aliases text[], 154 | content text NOT NULL, 155 | PRIMARY KEY (guild_id, tagname), 156 | FOREIGN KEY (guild_id) 157 | REFERENCES global_config (guild_id) 158 | ON DELETE CASCADE 159 | ); 160 | 161 | CREATE TABLE IF NOT EXISTS log_config 162 | ( 163 | guild_id bigint NOT NULL, 164 | log_channels json, 165 | color bool NOT NULL DEFAULT true, 166 | PRIMARY KEY (guild_id), 167 | FOREIGN KEY (guild_id) 168 | REFERENCES global_config (guild_id) 169 | ON DELETE CASCADE 170 | ); 171 | 172 | CREATE TABLE IF NOT EXISTS starboard 173 | ( 174 | guild_id bigint NOT NULL, 175 | is_enabled bool NOT NULL DEFAULT false, 176 | star_limit smallint NOT NULL DEFAULT 5, 177 | channel_id bigint, 178 | excluded_channels bigint[] DEFAULT '{}', 179 | PRIMARY KEY (guild_id), 180 | FOREIGN KEY (guild_id) 181 | REFERENCES global_config (guild_id) 182 | ON DELETE CASCADE 183 | ); 184 | 185 | CREATE TABLE IF NOT EXISTS starboard_entries 186 | ( 187 | guild_id bigint NOT NULL, 188 | channel_id bigint NOT NULL, 189 | orig_msg_id bigint NOT NULL, 190 | entry_msg_id bigint NOT NULL, 191 | force_starred bool NOT NULL DEFAULT false, 192 | PRIMARY KEY (guild_id, channel_id, orig_msg_id), 193 | FOREIGN KEY (guild_id) 194 | REFERENCES global_config (guild_id) 195 | ON DELETE CASCADE 196 | ); -------------------------------------------------------------------------------- /src/etc/__init__.py: -------------------------------------------------------------------------------- 1 | from .perms_str import * 2 | from .settings_static import * 3 | 4 | # Copyright (C) 2022-present hypergonial 5 | 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see: https://www.gnu.org/licenses 18 | -------------------------------------------------------------------------------- /src/etc/const.py: -------------------------------------------------------------------------------- 1 | ERROR_COLOR: int = 0xFF0000 2 | WARN_COLOR: int = 0xFFCC4D 3 | EMBED_BLUE: int = 0x009DFF 4 | EMBED_GREEN: int = 0x77B255 5 | UNKNOWN_COLOR: int = 0xBE1931 6 | MISC_COLOR: int = 0xC2C2C2 7 | 8 | EMOJI_CHANNEL = "<:channel:585783907841212418>" 9 | EMOJI_MENTION = "<:mention:658538492019867683>" 10 | EMOJI_SLOWMODE = "<:slowmode:951913313577603133>" 11 | EMOJI_MOD_SHIELD = "<:mod_shield:923752735768190976>" 12 | # Badges 13 | EMOJI_BUGHUNTER = "<:bughunter:927590809241530430>" 14 | EMOJI_BUGHUNTER_GOLD = "<:bughunter_gold:927590820448710666>" 15 | EMOJI_CERT_MOD = "<:cert_mod:927582595808657449>" 16 | EMOJI_EARLY_SUPPORTER = "<:early_supporter:927582684123914301>" 17 | EMOJI_HYPESQUAD_BALANCE = "<:hypesquad_balance:927582757587136582>" 18 | EMOJI_HYPESQUAD_BRAVERY = "<:hypesquad_bravery:927582770329444434>" 19 | EMOJI_HYPESQUAD_BRILLIANCE = "<:hypesquad_brilliance:927582740977684491>" 20 | EMOJI_HYPESQUAD_EVENTS = "<:hypesquad_events:927582724523450368>" 21 | EMOJI_PARTNER = "<:partner:927591117304778772>" 22 | EMOJI_STAFF = "<:staff:927591104902201385>" 23 | EMOJI_VERIFIED_DEVELOPER = "<:verified_developer:927582706974462002>" 24 | EMOJI_FIRST = "<:first:956672908145602610>" 25 | EMOJI_PREV = "<:prev:956672875111260160>" 26 | EMOJI_NEXT = "<:next:956672907185123359>" 27 | EMOJI_LAST = "<:last:956672908082708480>" 28 | 29 | # Copyright (C) 2022-present hypergonial 30 | 31 | # This program is free software: you can redistribute it and/or modify 32 | # it under the terms of the GNU General Public License as published by 33 | # the Free Software Foundation, either version 3 of the License, or 34 | # (at your option) any later version. 35 | 36 | # This program is distributed in the hope that it will be useful, 37 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 38 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 39 | # GNU General Public License for more details. 40 | 41 | # You should have received a copy of the GNU General Public License 42 | # along with this program. If not, see: https://www.gnu.org/licenses 43 | -------------------------------------------------------------------------------- /src/etc/fonts/LICENSE.txt: -------------------------------------------------------------------------------- 1 | License for RobotoSlab-VariableFont_wght 2 | 3 | 4 | Apache License 5 | Version 2.0, January 2004 6 | http://www.apache.org/licenses/ 7 | 8 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 9 | 10 | 1. Definitions. 11 | 12 | "License" shall mean the terms and conditions for use, reproduction, 13 | and distribution as defined by Sections 1 through 9 of this document. 14 | 15 | "Licensor" shall mean the copyright owner or entity authorized by 16 | the copyright owner that is granting the License. 17 | 18 | "Legal Entity" shall mean the union of the acting entity and all 19 | other entities that control, are controlled by, or are under common 20 | control with that entity. For the purposes of this definition, 21 | "control" means (i) the power, direct or indirect, to cause the 22 | direction or management of such entity, whether by contract or 23 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 24 | outstanding shares, or (iii) beneficial ownership of such entity. 25 | 26 | "You" (or "Your") shall mean an individual or Legal Entity 27 | exercising permissions granted by this License. 28 | 29 | "Source" form shall mean the preferred form for making modifications, 30 | including but not limited to software source code, documentation 31 | source, and configuration files. 32 | 33 | "Object" form shall mean any form resulting from mechanical 34 | transformation or translation of a Source form, including but 35 | not limited to compiled object code, generated documentation, 36 | and conversions to other media types. 37 | 38 | "Work" shall mean the work of authorship, whether in Source or 39 | Object form, made available under the License, as indicated by a 40 | copyright notice that is included in or attached to the work 41 | (an example is provided in the Appendix below). 42 | 43 | "Derivative Works" shall mean any work, whether in Source or Object 44 | form, that is based on (or derived from) the Work and for which the 45 | editorial revisions, annotations, elaborations, or other modifications 46 | represent, as a whole, an original work of authorship. For the purposes 47 | of this License, Derivative Works shall not include works that remain 48 | separable from, or merely link (or bind by name) to the interfaces of, 49 | the Work and Derivative Works thereof. 50 | 51 | "Contribution" shall mean any work of authorship, including 52 | the original version of the Work and any modifications or additions 53 | to that Work or Derivative Works thereof, that is intentionally 54 | submitted to Licensor for inclusion in the Work by the copyright owner 55 | or by an individual or Legal Entity authorized to submit on behalf of 56 | the copyright owner. For the purposes of this definition, "submitted" 57 | means any form of electronic, verbal, or written communication sent 58 | to the Licensor or its representatives, including but not limited to 59 | communication on electronic mailing lists, source code control systems, 60 | and issue tracking systems that are managed by, or on behalf of, the 61 | Licensor for the purpose of discussing and improving the Work, but 62 | excluding communication that is conspicuously marked or otherwise 63 | designated in writing by the copyright owner as "Not a Contribution." 64 | 65 | "Contributor" shall mean Licensor and any individual or Legal Entity 66 | on behalf of whom a Contribution has been received by Licensor and 67 | subsequently incorporated within the Work. 68 | 69 | 2. Grant of Copyright License. Subject to the terms and conditions of 70 | this License, each Contributor hereby grants to You a perpetual, 71 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 72 | copyright license to reproduce, prepare Derivative Works of, 73 | publicly display, publicly perform, sublicense, and distribute the 74 | Work and such Derivative Works in Source or Object form. 75 | 76 | 3. Grant of Patent License. Subject to the terms and conditions of 77 | this License, each Contributor hereby grants to You a perpetual, 78 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 79 | (except as stated in this section) patent license to make, have made, 80 | use, offer to sell, sell, import, and otherwise transfer the Work, 81 | where such license applies only to those patent claims licensable 82 | by such Contributor that are necessarily infringed by their 83 | Contribution(s) alone or by combination of their Contribution(s) 84 | with the Work to which such Contribution(s) was submitted. If You 85 | institute patent litigation against any entity (including a 86 | cross-claim or counterclaim in a lawsuit) alleging that the Work 87 | or a Contribution incorporated within the Work constitutes direct 88 | or contributory patent infringement, then any patent licenses 89 | granted to You under this License for that Work shall terminate 90 | as of the date such litigation is filed. 91 | 92 | 4. Redistribution. You may reproduce and distribute copies of the 93 | Work or Derivative Works thereof in any medium, with or without 94 | modifications, and in Source or Object form, provided that You 95 | meet the following conditions: 96 | 97 | (a) You must give any other recipients of the Work or 98 | Derivative Works a copy of this License; and 99 | 100 | (b) You must cause any modified files to carry prominent notices 101 | stating that You changed the files; and 102 | 103 | (c) You must retain, in the Source form of any Derivative Works 104 | that You distribute, all copyright, patent, trademark, and 105 | attribution notices from the Source form of the Work, 106 | excluding those notices that do not pertain to any part of 107 | the Derivative Works; and 108 | 109 | (d) If the Work includes a "NOTICE" text file as part of its 110 | distribution, then any Derivative Works that You distribute must 111 | include a readable copy of the attribution notices contained 112 | within such NOTICE file, excluding those notices that do not 113 | pertain to any part of the Derivative Works, in at least one 114 | of the following places: within a NOTICE text file distributed 115 | as part of the Derivative Works; within the Source form or 116 | documentation, if provided along with the Derivative Works; or, 117 | within a display generated by the Derivative Works, if and 118 | wherever such third-party notices normally appear. The contents 119 | of the NOTICE file are for informational purposes only and 120 | do not modify the License. You may add Your own attribution 121 | notices within Derivative Works that You distribute, alongside 122 | or as an addendum to the NOTICE text from the Work, provided 123 | that such additional attribution notices cannot be construed 124 | as modifying the License. 125 | 126 | You may add Your own copyright statement to Your modifications and 127 | may provide additional or different license terms and conditions 128 | for use, reproduction, or distribution of Your modifications, or 129 | for any such Derivative Works as a whole, provided Your use, 130 | reproduction, and distribution of the Work otherwise complies with 131 | the conditions stated in this License. 132 | 133 | 5. Submission of Contributions. Unless You explicitly state otherwise, 134 | any Contribution intentionally submitted for inclusion in the Work 135 | by You to the Licensor shall be under the terms and conditions of 136 | this License, without any additional terms or conditions. 137 | Notwithstanding the above, nothing herein shall supersede or modify 138 | the terms of any separate license agreement you may have executed 139 | with Licensor regarding such Contributions. 140 | 141 | 6. Trademarks. This License does not grant permission to use the trade 142 | names, trademarks, service marks, or product names of the Licensor, 143 | except as required for reasonable and customary use in describing the 144 | origin of the Work and reproducing the content of the NOTICE file. 145 | 146 | 7. Disclaimer of Warranty. Unless required by applicable law or 147 | agreed to in writing, Licensor provides the Work (and each 148 | Contributor provides its Contributions) on an "AS IS" BASIS, 149 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 150 | implied, including, without limitation, any warranties or conditions 151 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 152 | PARTICULAR PURPOSE. You are solely responsible for determining the 153 | appropriateness of using or redistributing the Work and assume any 154 | risks associated with Your exercise of permissions under this License. 155 | 156 | 8. Limitation of Liability. In no event and under no legal theory, 157 | whether in tort (including negligence), contract, or otherwise, 158 | unless required by applicable law (such as deliberate and grossly 159 | negligent acts) or agreed to in writing, shall any Contributor be 160 | liable to You for damages, including any direct, indirect, special, 161 | incidental, or consequential damages of any character arising as a 162 | result of this License or out of the use or inability to use the 163 | Work (including but not limited to damages for loss of goodwill, 164 | work stoppage, computer failure or malfunction, or any and all 165 | other commercial damages or losses), even if such Contributor 166 | has been advised of the possibility of such damages. 167 | 168 | 9. Accepting Warranty or Additional Liability. While redistributing 169 | the Work or Derivative Works thereof, You may choose to offer, 170 | and charge a fee for, acceptance of support, warranty, indemnity, 171 | or other liability obligations and/or rights consistent with this 172 | License. However, in accepting such obligations, You may act only 173 | on Your own behalf and on Your sole responsibility, not on behalf 174 | of any other Contributor, and only if You agree to indemnify, 175 | defend, and hold each Contributor harmless for any liability 176 | incurred by, or claims asserted against, such Contributor by reason 177 | of your accepting any such warranty or additional liability. 178 | 179 | END OF TERMS AND CONDITIONS 180 | 181 | APPENDIX: How to apply the Apache License to your work. 182 | 183 | To apply the Apache License to your work, attach the following 184 | boilerplate notice, with the fields enclosed by brackets "[]" 185 | replaced with your own identifying information. (Don't include 186 | the brackets!) The text should be enclosed in the appropriate 187 | comment syntax for the file format. We also recommend that a 188 | file or class name and description of purpose be included on the 189 | same "printed page" as the copyright notice for easier 190 | identification within third-party archives. 191 | 192 | Copyright [yyyy] [name of copyright owner] 193 | 194 | Licensed under the Apache License, Version 2.0 (the "License"); 195 | you may not use this file except in compliance with the License. 196 | You may obtain a copy of the License at 197 | 198 | http://www.apache.org/licenses/LICENSE-2.0 199 | 200 | Unless required by applicable law or agreed to in writing, software 201 | distributed under the License is distributed on an "AS IS" BASIS, 202 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 203 | See the License for the specific language governing permissions and 204 | limitations under the License. 205 | -------------------------------------------------------------------------------- /src/etc/fonts/roboto-slab.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hypergonial/snedbot/c27f51756f92c485ef053d4d7b6ef4b3c6579d27/src/etc/fonts/roboto-slab.ttf -------------------------------------------------------------------------------- /src/etc/perms_str.py: -------------------------------------------------------------------------------- 1 | import hikari 2 | 3 | perms_str = { 4 | hikari.Permissions.CREATE_INSTANT_INVITE: "Create Invites", 5 | hikari.Permissions.STREAM: "Go Live", 6 | hikari.Permissions.SEND_TTS_MESSAGES: "Send TTS Messages", 7 | hikari.Permissions.MANAGE_MESSAGES: "Manage Messages", 8 | hikari.Permissions.MENTION_ROLES: "Mention @everyone and all roles", 9 | hikari.Permissions.USE_EXTERNAL_EMOJIS: "Use external emojies", 10 | hikari.Permissions.VIEW_GUILD_INSIGHTS: "View Insights", 11 | hikari.Permissions.CONNECT: "Connect to Voice", 12 | hikari.Permissions.SPEAK: "Speak in Voice", 13 | hikari.Permissions.MUTE_MEMBERS: "Mute Others in Voice", 14 | hikari.Permissions.DEAFEN_MEMBERS: "Deafen Others in Voice", 15 | hikari.Permissions.MOVE_MEMBERS: "Move Others in Voice", 16 | hikari.Permissions.REQUEST_TO_SPEAK: "Request to Speak in Stage", 17 | hikari.Permissions.START_EMBEDDED_ACTIVITIES: "Start Activities", 18 | hikari.Permissions.MODERATE_MEMBERS: "Timeout Members", 19 | } 20 | 21 | 22 | def get_perm_str(perm: hikari.Permissions) -> str: 23 | if perm_str := perms_str.get(perm): 24 | return perm_str 25 | 26 | assert perm.name is not None 27 | return perm.name.replace("_", " ").title() 28 | 29 | 30 | # Copyright (C) 2022-present hypergonial 31 | 32 | # This program is free software: you can redistribute it and/or modify 33 | # it under the terms of the GNU General Public License as published by 34 | # the Free Software Foundation, either version 3 of the License, or 35 | # (at your option) any later version. 36 | 37 | # This program is distributed in the hope that it will be useful, 38 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 39 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 40 | # GNU General Public License for more details. 41 | 42 | # You should have received a copy of the GNU General Public License 43 | # along with this program. If not, see: https://www.gnu.org/licenses 44 | -------------------------------------------------------------------------------- /src/etc/text/8ball.txt: -------------------------------------------------------------------------------- 1 | Yes. 2 | No. 3 | It will pass. 4 | Count on it. 5 | May be. 6 | You're hot. 7 | Ask again. 8 | No doubt. 9 | Absolutely. 10 | Go for it. 11 | Wait for it. 12 | Not now. 13 | Cannot tell now. 14 | Very likely. 15 | It is ok. -------------------------------------------------------------------------------- /src/etc/text/penguinfacts.txt: -------------------------------------------------------------------------------- 1 | A group of penguins in the water is called a raft but on land they're called a waddle. 2 | Yellow-eyed penguin lives near New Zealand and this is the rarest species of penguin. Only 5000 birds are left in the wild. 3 | Galapagos penguin is the only type of penguin that can be seen in the Northern hemisphere. It happens rarely and only when food in South hemisphere is scarce. 4 | Penguins' feather is dense and lubricated with oil. Besides feather, thick layer of skin and blubber provides protection from the low temperature. 5 | Penguins live between 15 and 20 years, depending on the species. 6 | Natural predators of penguins are orcas, seals, sharks, snakes, sea lions, killer whales... 7 | Penguins' eyes see better under the water than on the ground. Excellent eyesight helps them to avoid predators while they are searching for food. 8 | Penguins are social animals. They live in large communities composed of thousand to ten thousand couples. 9 | Penguins communicate by producing the sounds. They also communicate using their heads and flippers. 10 | Penguins are excellent swimmers. They can swim 15-20 miles per hour. They can keep their breath for 20 minutes and can dive deeper than any other bird. 11 | If penguin loses a chick, it can steal a chick from other family. 12 | Unique colors of their feathers provide excellent camouflage in the water. 13 | While other birds have wings for flying, penguins have adapted flippers to help them swim in the water. 14 | Most penguins live in the Southern Hemisphere, while polar bears live in the Northern Hemisphere. 15 | Large penguin populations can be found in countries such as New Zealand, Australia, Chile, Argentina and South Africa. 16 | Penguins eat a range of fish and other sealife that they catch underwater. 17 | Penguins spend around half their time in water and the other half on land. 18 | The Emperor Penguin is the tallest of all penguin species, reaching as tall as 120 cm (47 in) in height. 19 | Emperor Penguins can stay underwater for around 20 minutes at a time. 20 | Emperor Penguins often huddle together to keep warm in the cold temperatures of Antarctica. 21 | King Penguins are the second largest penguin species. They have four layers of feathers to help keep them warm on the cold subantarctic islands where they breed. 22 | Chinstrap Penguins get their name from the thin black band under their head. At times it looks like they’re wearing a black helmet. 23 | Crested penguins have yellow crests, as well as red bills and eyes. 24 | Little Blue Penguins are the smallest type of penguin, averaging around 33 cm (13 in) in height. 25 | Penguins in Antarctica have no land based predators, only water based ones. 26 | All 17 species of penguins are found exclusively in the Southern Hemisphere. 27 | The fastest species is the Gentoo Penguin, which can reach swimming speeds up to 22 mph. 28 | Fossils place the earliest penguin relative at some 60 million years ago. 29 | Penguins ingest a lot of seawater while hunting for fish, but a special gland behind their eyes filters out the saltwater from their blood stream. 30 | Scientists still don't know for sure how many kinds of penguins there are. 31 | Penguins jump into the air before diving in the water so that they swim faster. 32 | Explorers first called penguins "strange geese." That's what crew member Antonio Pigafetta wrote on Ferdinand Magellan's first circumnavigation of the globe. 33 | Penguins can swim at speeds over 10 miles per hour. Gentoos, the speediest penguins, can top 20 mph, but most species dart around at a more modest 4 to 7 mph. 34 | Penguins can dive down over 800 feet. n the deepest dive ever recorded, an emperor penguin reached an amazing 1,850 feet. 35 | Recently discovered fossils indicate that an ancient breed of penguins once stood taller than the average adult man today at 5-foot-10. 36 | Penguins don't have teeth. Fleshy spines inside their mouths help them swallow fish. 37 | Penguins go through a "catastrophic molt" once a year. They lose all of their feathers during a 2-3 week process, and can't swim/fish until the feathers grow back. 38 | Penguin couples locate each other with distinct calls. The unique sounds help them reunite on the breeding ground. 39 | Emperor penguins incubate eggs on their feet. The male penguins keep them warm under a loose fold of skin. 40 | Penguins are waterproof. Penguins spread an oil produced by the preen gland that insulates their bodies and improves their hydrodynamics. 41 | The largest penguin colonies — called rookeries or waddles when assembled on land — include hundreds of thousands of birds. 42 | Penguins are specially adapted to sink. While most birds have hollow bones to facilitate flight, penguins have dense skeletons for easier diving. 43 | Some penguins build pebble nests. Their parents do line the pile of rocks with soft moss and feathers, though. 44 | Penguins get their name from a Canadian bird. The now-extinct giant auk looked like the penguins explorers encountered in the Southern Hemisphere. 45 | Not all penguins live in the Antarctic. The Galapagos penguin stays nice and warm living near the equator. 46 | Penguins huddle for warmth. Emperor penguins have perfected their group hugs to a science, with some birds in the middle actually getting too hot. 47 | Penguins love "tobogganing." Instead of shuffling across the ice, many penguins like to lay on their stomachs and propel themselves with their feet. 48 | Penguin chicks start out as little fluffballs. Their first coat of feathers consists of a light down. The weatherproof layer grows in later. 49 | There are many movies based on penguins, like March Of The Penguins, Happy Feet, Surf's Up and many more. -------------------------------------------------------------------------------- /src/extensions/annoverse.py: -------------------------------------------------------------------------------- 1 | import hikari 2 | import lightbulb 3 | 4 | import src.etc.const as const 5 | from src.config import Config 6 | from src.models.bot import SnedBot 7 | from src.models.context import SnedContext 8 | from src.models.plugin import SnedPlugin 9 | from src.utils import helpers 10 | 11 | annoverse = SnedPlugin("Annoverse") 12 | annoverse.default_enabled_guilds = Config().DEBUG_GUILDS or (372128553031958529,) 13 | 14 | QUESTIONS_CHANNEL_ID = 955463477760229397 15 | OUTPUT_CHANNEL_ID = 955463511767654450 16 | 17 | question_counters: dict[hikari.Snowflake, int] = {} 18 | 19 | 20 | @annoverse.command 21 | @lightbulb.option("question", "The question you want to ask!") 22 | @lightbulb.command("ask", "Ask a question on the roundtable!", pass_options=True) 23 | @lightbulb.implements(lightbulb.SlashCommand) 24 | async def ask_cmd(ctx: SnedContext, question: str) -> None: 25 | assert ctx.member is not None and ctx.interaction is not None 26 | 27 | if ctx.channel_id != QUESTIONS_CHANNEL_ID: 28 | if ctx.interaction.locale == "de": 29 | embed = hikari.Embed( 30 | title="❌ Ungültiger Kanal!", 31 | description=f"Stelle deine Frage in <#{QUESTIONS_CHANNEL_ID}>", 32 | color=const.ERROR_COLOR, 33 | ) 34 | else: 35 | embed = hikari.Embed( 36 | title="❌ Invalid Channel!", 37 | description=f"You should ask your question in <#{QUESTIONS_CHANNEL_ID}>", 38 | color=const.ERROR_COLOR, 39 | ) 40 | 41 | await ctx.respond(embed=embed, flags=hikari.MessageFlag.EPHEMERAL) 42 | return 43 | 44 | if ( 45 | question_counters.get(ctx.author.id) 46 | and question_counters[ctx.author.id] >= 3 47 | and not helpers.includes_permissions( 48 | lightbulb.utils.permissions_for(ctx.member), hikari.Permissions.MANAGE_MESSAGES 49 | ) 50 | ): 51 | if ctx.interaction.locale == "de": 52 | embed = hikari.Embed( 53 | title="❌ zu viele Fragen! :)", 54 | description="Sorry, du kannst leider nur bis zu drei Fragen stellen!", 55 | color=const.ERROR_COLOR, 56 | ) 57 | else: 58 | embed = hikari.Embed( 59 | title="❌ Asking too much! :)", 60 | description="Sorry, you can only ask up to three questions!", 61 | color=const.ERROR_COLOR, 62 | ) 63 | await ctx.respond(embed=embed, flags=hikari.MessageFlag.EPHEMERAL) 64 | return 65 | 66 | await ctx.respond(hikari.ResponseType.DEFERRED_MESSAGE_CREATE) 67 | await ctx.app.rest.create_message(OUTPUT_CHANNEL_ID, f"{ctx.member.mention} **asks:** {question[:500]}") 68 | if not question_counters.get(ctx.author.id): 69 | question_counters[ctx.author.id] = 0 70 | question_counters[ctx.author.id] += 1 71 | 72 | if ctx.interaction.locale == "de": 73 | embed = hikari.Embed( 74 | title="✅ Frage eingereicht!", 75 | description="Andere können ihre Fragen über `/ask` stellen!", 76 | color=const.EMBED_GREEN, 77 | ) 78 | else: 79 | embed = hikari.Embed( 80 | title="✅ Question submitted!", 81 | description="Others can submit their question by using `/ask`!", 82 | color=const.EMBED_GREEN, 83 | ) 84 | await ctx.respond(embed=embed) 85 | 86 | 87 | def load(bot: SnedBot) -> None: 88 | bot.add_plugin(annoverse) 89 | 90 | 91 | def unload(bot: SnedBot) -> None: 92 | bot.remove_plugin(annoverse) 93 | 94 | 95 | # Copyright (C) 2022-present hypergonial 96 | 97 | # This program is free software: you can redistribute it and/or modify 98 | # it under the terms of the GNU General Public License as published by 99 | # the Free Software Foundation, either version 3 of the License, or 100 | # (at your option) any later version. 101 | 102 | # This program is distributed in the hope that it will be useful, 103 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 104 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 105 | # GNU General Public License for more details. 106 | 107 | # You should have received a copy of the GNU General Public License 108 | # along with this program. If not, see: https://www.gnu.org/licenses 109 | -------------------------------------------------------------------------------- /src/extensions/fallingfrontier.py: -------------------------------------------------------------------------------- 1 | import hikari 2 | import lightbulb 3 | import miru 4 | 5 | from src.config import Config 6 | from src.etc import const 7 | from src.models.bot import SnedBot 8 | from src.models.context import SnedSlashContext 9 | from src.models.plugin import SnedPlugin 10 | 11 | TESTER_STAGING_ROLE = 971843694896513074 12 | TESTER_STAGING_CHANNEL = 971844463884382259 13 | FF_GUILD = 684324252786360476 14 | 15 | TEST_NOTICE = """ 16 | **You are being contacted in regards to the Falling Frontier Tester Recruitment.** 17 | 18 | First of all, congratulations. 19 | As mentioned during the initial application process, we are expanding the test team which is currently focused on core gameplay mechanics and you have been selected to join the current testing group. 20 | 21 | Before you can begin testing, you will have to sign an **NDA**, the process of which will be handled by Todd of Stutter Fox Studios and Tim from Hooded Horse. 22 | 23 | If you would like to take part and agree to the requirement of signing an NDA, you will be able to coordinate with both Todd and the senior testing team in the `#tester-lounge` once you have received the appropriate roles. 24 | 25 | Once the NDA has been signed you will be be given additional permissions, allowing you to go through the setup process for **GitHub** and receive your key through **Steam**. 26 | 27 | Should you have any questions regarding the process or testing, feel free to ask in the `#tester-staging` channel after accepting this invitation. 28 | 29 | Thank you! 30 | 31 | *Notice: This is an automated message, replies will not be read.* 32 | """ 33 | 34 | ff = SnedPlugin("Falling Frontier") 35 | ff.default_enabled_guilds = Config().DEBUG_GUILDS or (FF_GUILD, 813803567445049414) 36 | 37 | 38 | @ff.listener(hikari.GuildMessageCreateEvent) 39 | async def hydrate_autoresponse(event: hikari.GuildMessageCreateEvent) -> None: 40 | if event.guild_id not in (FF_GUILD, 813803567445049414): 41 | return 42 | 43 | if event.content and event.content == "Everyone this is your daily reminder to stay hydrated!": 44 | await event.message.respond("<:FoxHydrate:851099802527072297>") 45 | 46 | 47 | @ff.listener(miru.ComponentInteractionCreateEvent) 48 | async def handle_test_invite(event: miru.ComponentInteractionCreateEvent) -> None: 49 | if not event.custom_id.startswith("FFTEST:") or event.guild_id is not None: 50 | return 51 | 52 | await event.context.defer() 53 | 54 | view = miru.View.from_message(event.context.message) 55 | for item in view.children: 56 | assert isinstance(item, miru.Button) 57 | item.disabled = True 58 | 59 | await event.context.message.edit(components=view) 60 | 61 | if event.custom_id == "FFTEST:ACCEPT": 62 | await event.app.rest.add_role_to_member(FF_GUILD, event.context.user, TESTER_STAGING_ROLE) 63 | await event.context.respond( 64 | embed=hikari.Embed( 65 | title="Tester Invite Accepted", 66 | description=f"Please see <#{TESTER_STAGING_CHANNEL}> for further instructions.\n\nThank you for participating!", 67 | color=const.EMBED_GREEN, 68 | ) 69 | ) 70 | await event.app.rest.create_message( 71 | TESTER_STAGING_CHANNEL, 72 | f"{event.user.mention} accepted the testing invitation! Welcome! <:FoxWave:851099801608388628>", 73 | user_mentions=True, 74 | ) 75 | 76 | elif event.custom_id == "FFTEST:DECLINE": 77 | await event.context.respond( 78 | embed=hikari.Embed( 79 | title="Tester Invite Declined", 80 | description="Thank you for your interest in the Falling Frontier Testing Program.", 81 | color=const.ERROR_COLOR, 82 | ) 83 | ) 84 | await event.app.rest.create_message(TESTER_STAGING_CHANNEL, f"`{event.user}` declined the testing invitation.") 85 | 86 | 87 | @ff.command 88 | @lightbulb.add_cooldown(1800, 1, lightbulb.GuildBucket) 89 | @lightbulb.app_command_permissions(hikari.Permissions.ADMINISTRATOR, dm_enabled=False) 90 | @lightbulb.option( 91 | "recipients", 92 | "A list of all users to send the notice to, one username per line, max 25 users.", 93 | type=hikari.Attachment, 94 | ) 95 | @lightbulb.command( 96 | "sendtestnotice", 97 | "Send out tester notice to new people.", 98 | guilds=Config().DEBUG_GUILDS or (FF_GUILD,), 99 | pass_options=True, 100 | ) 101 | @lightbulb.implements(lightbulb.SlashCommand) 102 | async def send_test_notice(ctx: SnedSlashContext, recipients: hikari.Attachment) -> None: 103 | await ctx.respond(hikari.ResponseType.DEFERRED_MESSAGE_CREATE) 104 | 105 | converter = lightbulb.converters.UserConverter(ctx) 106 | view = ( 107 | miru.View() 108 | .add_item(miru.Button(label="Accept", style=hikari.ButtonStyle.SUCCESS, emoji="✔️", custom_id="FFTEST:ACCEPT")) 109 | .add_item(miru.Button(label="Decline", style=hikari.ButtonStyle.DANGER, emoji="✖️", custom_id="FFTEST:DECLINE")) 110 | ) 111 | failed = [] 112 | user_str_list = (await recipients.read()).decode("utf-8").splitlines() 113 | 114 | for user_str in user_str_list[:25]: 115 | try: 116 | user = await converter.convert(user_str) 117 | await user.send(TEST_NOTICE, components=view) 118 | except (hikari.ForbiddenError, hikari.NotFoundError, ValueError, TypeError): 119 | failed.append(user_str) 120 | 121 | await ctx.respond( 122 | f"Sent testing notice to **{len(user_str_list) - len(failed)}/{len(user_str_list)}** users.\n\n**Failed to send to:** ```{' '.join(failed) if failed else 'All users were sent the notice.'}```" 123 | ) 124 | 125 | 126 | @ff.command 127 | @lightbulb.add_cooldown(1800, 1, lightbulb.GuildBucket) 128 | @lightbulb.app_command_permissions(hikari.Permissions.ADMINISTRATOR, dm_enabled=False) 129 | @lightbulb.option( 130 | "recipients", 131 | "A list of users to send keys to, one entry per line. Format: username:KEY, max 25 users.", 132 | type=hikari.Attachment, 133 | ) 134 | @lightbulb.command( 135 | "sendkeys", 136 | "Send out tester keys to new people.", 137 | guilds=Config().DEBUG_GUILDS or (FF_GUILD,), 138 | pass_options=True, 139 | ) 140 | @lightbulb.implements(lightbulb.SlashCommand) 141 | async def send_test_key(ctx: SnedSlashContext, recipients: hikari.Attachment) -> None: 142 | await ctx.respond(hikari.ResponseType.DEFERRED_MESSAGE_CREATE) 143 | 144 | converter = lightbulb.converters.UserConverter(ctx) 145 | failed = [] 146 | recipients_list = (await recipients.read()).decode("utf-8").splitlines() 147 | 148 | for line in recipients_list[:25]: 149 | try: 150 | user_str, key = line.split(":", maxsplit=1) 151 | user = await converter.convert(user_str.strip()) 152 | await user.send( 153 | f"Hello!\nYour key for the Falling Frontier Testing Program is: ```{key.strip()}```\nYou may activate it by opening **Steam**, navigating to `Games > Activate a Product on Steam...`, and entering the key." 154 | ) 155 | except (hikari.ForbiddenError, hikari.NotFoundError, ValueError, TypeError): 156 | failed.append(line.split(":", maxsplit=1)[0]) 157 | 158 | await ctx.respond( 159 | f"Sent testing keys to **{len(recipients_list) - len(failed)}/{len(recipients_list)}** users.\n\n**Failed to send to:** ```{' '.join(failed) if failed else 'All users were sent their key.'}```" 160 | ) 161 | 162 | 163 | def load(bot: SnedBot) -> None: 164 | bot.add_plugin(ff) 165 | 166 | 167 | def unload(bot: SnedBot) -> None: 168 | bot.remove_plugin(ff) 169 | 170 | 171 | # Copyright (C) 2022-present hypergonial 172 | 173 | # This program is free software: you can redistribute it and/or modify 174 | # it under the terms of the GNU General Public License as published by 175 | # the Free Software Foundation, either version 3 of the License, or 176 | # (at your option) any later version. 177 | 178 | # This program is distributed in the hope that it will be useful, 179 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 180 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 181 | # GNU General Public License for more details. 182 | 183 | # You should have received a copy of the GNU General Public License 184 | # along with this program. If not, see: https://www.gnu.org/licenses 185 | -------------------------------------------------------------------------------- /src/extensions/fandom.py: -------------------------------------------------------------------------------- 1 | import hikari 2 | import lightbulb 3 | import yarl 4 | 5 | from src.config import Config 6 | from src.etc import const 7 | from src.models.bot import SnedBot 8 | from src.models.context import SnedSlashContext 9 | from src.models.plugin import SnedPlugin 10 | 11 | fandom = SnedPlugin("Fandom") 12 | 13 | FANDOM_QUERY_URL = "https://{site}.fandom.com/api.php?action=opensearch&search={query}&limit=5" 14 | 15 | 16 | async def search_fandom(site: str, query: str) -> str | None: 17 | """Search a Fandom wiki with the specified query. 18 | 19 | Parameters 20 | ---------- 21 | site : str 22 | The subdomain of the fandom wiki. 23 | query : str 24 | The query to search for. 25 | 26 | Returns 27 | ------- 28 | Optional[str] 29 | A formatted string ready to display to the end user. `None` if no results were found. 30 | """ 31 | async with fandom.app.session.get(yarl.URL(FANDOM_QUERY_URL.format(query=query, site=site))) as response: 32 | if response.status == 200: 33 | results = await response.json() 34 | if results[1]: 35 | return "\n".join([f"[{result}]({results[3][results[1].index(result)]})" for result in results[1]]) 36 | else: 37 | raise RuntimeError(f"Failed to communicate with server. Response code: {response.status}") 38 | 39 | 40 | @fandom.command 41 | @lightbulb.app_command_permissions(None, dm_enabled=False) 42 | @lightbulb.option("query", "What are you looking for?") 43 | @lightbulb.option("wiki", "Choose the wiki to get results from. This is the 'xxxx.fandom.com' part of the URL.") 44 | @lightbulb.command("fandom", "Search a Fandom wiki for articles!", pass_options=True) 45 | @lightbulb.implements(lightbulb.SlashCommand) 46 | async def fandom_cmd(ctx: SnedSlashContext, wiki: str, query: str) -> None: 47 | await ctx.respond(hikari.ResponseType.DEFERRED_MESSAGE_CREATE) 48 | try: 49 | if results := await search_fandom(wiki, query): 50 | embed = hikari.Embed( 51 | title=f"{wiki} Wiki: {query}", 52 | description=results, 53 | color=const.EMBED_BLUE, 54 | ) 55 | else: 56 | embed = hikari.Embed( 57 | title="❌ Not found", 58 | description=f"Could not find anything for `{query}`", 59 | color=const.ERROR_COLOR, 60 | ) 61 | except RuntimeError as e: 62 | embed = hikari.Embed(title="❌ Network Error", description=f"```{e}```", color=const.ERROR_COLOR) 63 | await ctx.respond(embed=embed) 64 | 65 | 66 | @fandom.command 67 | @lightbulb.app_command_permissions(None, dm_enabled=False) 68 | @lightbulb.option( 69 | "wiki", 70 | "Choose the wiki to get results from. Defaults to 1800 if not specified.", 71 | choices=["1800", "2070", "2205", "1404"], 72 | required=False, 73 | ) 74 | @lightbulb.option("query", "What are you looking for?") 75 | @lightbulb.command( 76 | "annowiki", 77 | "Search an Anno Wiki for articles!", 78 | pass_options=True, 79 | guilds=Config().DEBUG_GUILDS or (581296099826860033, 372128553031958529), 80 | ) 81 | @lightbulb.implements(lightbulb.SlashCommand) 82 | async def annowiki(ctx: SnedSlashContext, query: str, wiki: str = "1800") -> None: 83 | wiki = wiki or "1800" 84 | 85 | await ctx.respond(hikari.ResponseType.DEFERRED_MESSAGE_CREATE) 86 | try: 87 | if results := await search_fandom(f"anno{wiki}", query): 88 | embed = hikari.Embed( 89 | title=f"Anno {wiki} Wiki: {query}", 90 | description=results, 91 | color=(218, 166, 100), 92 | ) 93 | else: 94 | embed = hikari.Embed( 95 | title="❌ Not found", 96 | description=f"Could not find anything for `{query}`", 97 | color=const.ERROR_COLOR, 98 | ) 99 | except RuntimeError as e: 100 | embed = hikari.Embed(title="❌ Network Error", description=f"```{e}```", color=const.ERROR_COLOR) 101 | await ctx.respond(embed=embed) 102 | 103 | 104 | @fandom.command 105 | @lightbulb.app_command_permissions(None, dm_enabled=False) 106 | @lightbulb.option("query", "What are you looking for?") 107 | @lightbulb.command( 108 | "ffwiki", 109 | "Search the Falling Frontier Wiki for articles!", 110 | pass_options=True, 111 | guilds=Config().DEBUG_GUILDS or (684324252786360476, 813803567445049414), 112 | ) 113 | @lightbulb.implements(lightbulb.SlashCommand) 114 | async def ffwiki(ctx: SnedSlashContext, query: str) -> None: 115 | await ctx.respond(hikari.ResponseType.DEFERRED_MESSAGE_CREATE) 116 | try: 117 | if results := await search_fandom("falling-frontier", query): 118 | embed = hikari.Embed( 119 | title=f"Falling Frontier Wiki: {query}", 120 | description=results, 121 | color=(75, 170, 147), 122 | ) 123 | else: 124 | embed = hikari.Embed( 125 | title="❌ Not found", 126 | description=f"Could not find anything for `{query}`", 127 | color=const.ERROR_COLOR, 128 | ) 129 | except RuntimeError as e: 130 | embed = hikari.Embed(title="❌ Network Error", description=f"```{e}```", color=const.ERROR_COLOR) 131 | await ctx.respond(embed=embed) 132 | 133 | 134 | def load(bot: SnedBot) -> None: 135 | bot.add_plugin(fandom) 136 | 137 | 138 | def unload(bot: SnedBot) -> None: 139 | bot.remove_plugin(fandom) 140 | 141 | 142 | # Copyright (C) 2022-present hypergonial 143 | 144 | # This program is free software: you can redistribute it and/or modify 145 | # it under the terms of the GNU General Public License as published by 146 | # the Free Software Foundation, either version 3 of the License, or 147 | # (at your option) any later version. 148 | 149 | # This program is distributed in the hope that it will be useful, 150 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 151 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 152 | # GNU General Public License for more details. 153 | 154 | # You should have received a copy of the GNU General Public License 155 | # along with this program. If not, see: https://www.gnu.org/licenses 156 | -------------------------------------------------------------------------------- /src/extensions/help.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import hikari 6 | import lightbulb 7 | 8 | import src.etc.const as const 9 | from src.models.plugin import SnedPlugin 10 | 11 | if t.TYPE_CHECKING: 12 | from src.models import SnedBot 13 | from src.models.context import SnedSlashContext 14 | 15 | 16 | help = SnedPlugin("Help") 17 | 18 | 19 | help_embeds = { 20 | # Default no topic help 21 | None: hikari.Embed( 22 | title="ℹ️ __Help__", 23 | description="""**Welcome to Sned Help!** 24 | 25 | To get started with using the bot, simply press `/` to reveal all commands! If you would like to get help about a specific topic, use `/help topic_name`. 26 | 27 | If you need assistance, found a bug, or just want to hang out, please join our [support server](https://discord.gg/KNKr8FPmJa)! 28 | 29 | Thank you for using Sned!""", 30 | color=const.EMBED_BLUE, 31 | ), 32 | # Default no topic help for people with manage guild perms 33 | "admin_home": hikari.Embed( 34 | title="ℹ️ __Help__", 35 | description="""**Welcome to Sned Help!** 36 | 37 | To get started with using the bot, simply press `/` to reveal all commands! If you would like to get help about a specific topic, use `/help topic_name`. 38 | 39 | You may begin configuring the bot via the `/settings` command, which shows all relevant settings & lets you modify them. 40 | 41 | If you need assistance, found a bug, or just want to hang out, please join our [support server](https://discord.gg/KNKr8FPmJa)! 42 | 43 | Thank you for using Sned!""", 44 | color=const.EMBED_BLUE, 45 | ), 46 | "time-formatting": hikari.Embed( 47 | title="ℹ️ __Help: Time Formatting__", 48 | description="""This help article aims to familiarize you with the various ways you can input time into bot commands. 49 | 50 | **Dates:** 51 | `2022-03-04 23:43` 52 | `04/03/2022 23:43` 53 | `2022/04/03 11:43PM` 54 | `...` 55 | 56 | **Relative:** 57 | `in 10 minutes` 58 | `tomorrow at 5AM` 59 | `next week` 60 | `2 days ago` 61 | `...` 62 | 63 | **ℹ️ Note:** 64 | Absolute time-conversion may require the bot to be aware of your timezone. You can set your timezone via the `/timezone` command, if you wish. 65 | """, 66 | color=const.EMBED_BLUE, 67 | ), 68 | "permissions": hikari.Embed( 69 | title="ℹ️ __Help: Permissions__", 70 | description="""Command permissions for the bot are managed directly through Discord. To access them, navigate to: 71 | ```Server Settings > Integrations > Sned``` 72 | Here you may configure permissions per-command or on a global basis, as you see fit.""", 73 | color=const.EMBED_BLUE, 74 | ).set_image("https://cdn.discordapp.com/attachments/836300326172229672/949047433038544896/unknown.png"), 75 | "configuration": hikari.Embed( 76 | title="ℹ️ ___Help: Configuration__", 77 | description="""To configure the bot, use the `/settings` command. This will open up an interactive menu for you to change the different properties of the bot, enable/disable features, or tailor them to your liking. 78 | If you need any assistance in configuring the bot, do not hesitate to join our [support server](https://discord.gg/KNKr8FPmJa)!""", 79 | color=const.EMBED_BLUE, 80 | ), 81 | } 82 | 83 | 84 | @help.command 85 | @lightbulb.app_command_permissions(None, dm_enabled=False) 86 | @lightbulb.option( 87 | "topic", 88 | "A specific topic to get help about.", 89 | required=False, 90 | choices=["time-formatting", "configuration", "permissions"], 91 | ) 92 | @lightbulb.command("help", "Get help regarding various subjects of the bot's functionality.", pass_options=True) 93 | @lightbulb.implements(lightbulb.SlashCommand) 94 | async def help_cmd(ctx: SnedSlashContext, topic: str | None = None) -> None: 95 | if ctx.member: 96 | topic = ( 97 | topic or "admin_home" 98 | if (lightbulb.utils.permissions_for(ctx.member) & hikari.Permissions.MANAGE_GUILD) 99 | else topic 100 | ) 101 | await ctx.respond(embed=help_embeds[topic]) 102 | 103 | 104 | def load(bot: SnedBot) -> None: 105 | bot.add_plugin(help) 106 | 107 | 108 | def unload(bot: SnedBot) -> None: 109 | bot.remove_plugin(help) 110 | 111 | 112 | # Copyright (C) 2022-present hypergonial 113 | 114 | # This program is free software: you can redistribute it and/or modify 115 | # it under the terms of the GNU General Public License as published by 116 | # the Free Software Foundation, either version 3 of the License, or 117 | # (at your option) any later version. 118 | 119 | # This program is distributed in the hope that it will be useful, 120 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 121 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 122 | # GNU General Public License for more details. 123 | 124 | # You should have received a copy of the GNU General Public License 125 | # along with this program. If not, see: https://www.gnu.org/licenses 126 | -------------------------------------------------------------------------------- /src/extensions/reports.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import hikari 4 | import lightbulb 5 | import miru 6 | 7 | from src.etc import const 8 | from src.models import SnedSlashContext 9 | from src.models.bot import SnedBot 10 | from src.models.context import SnedApplicationContext, SnedContext, SnedMessageContext, SnedUserContext 11 | from src.models.plugin import SnedPlugin 12 | from src.utils import helpers 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | reports = SnedPlugin("Reports") 17 | 18 | 19 | class ReportModal(miru.Modal): 20 | def __init__(self, member: hikari.Member) -> None: 21 | super().__init__(f"Reporting {member}") 22 | self.add_item( 23 | miru.TextInput( 24 | label="Reason for the Report", 25 | placeholder="Please enter why you believe this user should be investigated...", 26 | style=hikari.TextInputStyle.PARAGRAPH, 27 | max_length=1000, 28 | required=True, 29 | ) 30 | ) 31 | self.add_item( 32 | miru.TextInput( 33 | label="Additional Context", 34 | placeholder="If you have any additional information or proof (e.g. screenshots), please link them here.", 35 | style=hikari.TextInputStyle.PARAGRAPH, 36 | max_length=1000, 37 | ) 38 | ) 39 | self.reason: str | None = None 40 | self.info: str | None = None 41 | 42 | async def callback(self, ctx: miru.ModalContext) -> None: 43 | if not ctx.values: 44 | return 45 | 46 | for item, value in ctx.values.items(): 47 | assert isinstance(item, miru.TextInput) 48 | 49 | if item.label == "Reason for the Report": 50 | self.reason = value 51 | elif item.label == "Additional Context": 52 | self.info = value 53 | 54 | await ctx.defer(flags=hikari.MessageFlag.EPHEMERAL) 55 | 56 | 57 | async def report_error(ctx: SnedContext) -> None: 58 | guild = ctx.get_guild() 59 | assert guild is not None 60 | 61 | await ctx.respond( 62 | embed=hikari.Embed( 63 | title="❌ Oops!", 64 | description=f"It looks like the moderators of **{guild.name}** did not enable this functionality.", 65 | color=const.ERROR_COLOR, 66 | ), 67 | flags=hikari.MessageFlag.EPHEMERAL, 68 | ) 69 | 70 | 71 | async def report_perms_error(ctx: SnedApplicationContext) -> None: 72 | await ctx.respond( 73 | embed=hikari.Embed( 74 | title="❌ Oops!", 75 | description="It looks like I do not have permissions to create a message in the reports channel. Please notify an administrator!", 76 | color=const.ERROR_COLOR, 77 | ), 78 | flags=hikari.MessageFlag.EPHEMERAL, 79 | ) 80 | 81 | 82 | async def report(ctx: SnedApplicationContext, member: hikari.Member, message: hikari.Message | None = None) -> None: 83 | assert ctx.member is not None and ctx.guild_id is not None 84 | 85 | if member.id == ctx.member.id or member.is_bot: 86 | await ctx.respond( 87 | embed=hikari.Embed( 88 | title="❌ Huh?", 89 | description="I'm not sure how that would work...", 90 | color=const.ERROR_COLOR, 91 | ), 92 | flags=hikari.MessageFlag.EPHEMERAL, 93 | ) 94 | return 95 | 96 | records = await ctx.app.db_cache.get(table="reports", guild_id=ctx.guild_id) 97 | 98 | if not records or not records[0]["is_enabled"]: 99 | return await report_error(ctx) 100 | 101 | channel = ctx.app.cache.get_guild_channel(records[0]["channel_id"]) 102 | assert isinstance(channel, hikari.TextableGuildChannel) 103 | assert isinstance(channel, hikari.PermissibleGuildChannel) 104 | 105 | if not channel: 106 | await ctx.app.db.execute( 107 | """INSERT INTO reports (is_enabled, guild_id) 108 | VALUES ($1, $2) 109 | ON CONFLICT (guild_id) DO 110 | UPDATE SET is_enabled = $1""", 111 | False, 112 | ctx.guild_id, 113 | ) 114 | await ctx.app.db_cache.refresh(table="reports", guild_id=ctx.guild_id) 115 | return await report_error(ctx) 116 | 117 | me = ctx.app.cache.get_member(ctx.guild_id, ctx.app.user_id) 118 | assert me is not None 119 | perms = lightbulb.utils.permissions_in(channel, me) 120 | 121 | if not (perms & hikari.Permissions.SEND_MESSAGES): 122 | return await report_perms_error(ctx) 123 | 124 | assert ctx.interaction is not None 125 | 126 | modal = ReportModal(member) 127 | await modal.send(ctx.interaction) 128 | await modal.wait() 129 | 130 | if not modal.last_context: # Modal was closed/timed out 131 | return 132 | 133 | role_ids = records[0]["pinged_role_ids"] or [] 134 | roles = filter(lambda r: r is not None, [ctx.app.cache.get_role(role_id) for role_id in role_ids]) 135 | role_mentions = [role.mention for role in roles if role is not None] 136 | 137 | embed = hikari.Embed( 138 | title="⚠️ New Report", 139 | description=f""" 140 | **Reporter:** {ctx.member.mention} `({ctx.member.id})` 141 | **Reported User:** {member.mention} `({member.id})` 142 | **Reason:** ```{modal.reason}``` 143 | **Additional Context:** ```{modal.info or "Not provided."}```""", 144 | color=const.WARN_COLOR, 145 | ) 146 | 147 | components: hikari.UndefinedOr[miru.View] = hikari.UNDEFINED 148 | 149 | if message: 150 | components = miru.View().add_item(miru.Button(label="Associated Message", url=message.make_link(ctx.guild_id))) 151 | 152 | await channel.send( 153 | " ".join(role_mentions) or hikari.UNDEFINED, embed=embed, components=components, role_mentions=True 154 | ) 155 | 156 | await modal.last_context.respond( 157 | embed=hikari.Embed( 158 | title="✅ Report Submitted", 159 | description="A moderator will review your report shortly!", 160 | color=const.EMBED_GREEN, 161 | ), 162 | flags=hikari.MessageFlag.EPHEMERAL, 163 | ) 164 | 165 | 166 | @reports.command 167 | @lightbulb.app_command_permissions(None, dm_enabled=False) 168 | @lightbulb.option("user", "The user that is to be reported.", type=hikari.Member, required=True) 169 | @lightbulb.command("report", "Report a user to the moderation team of this server.", pass_options=True) 170 | @lightbulb.implements(lightbulb.SlashCommand) 171 | async def report_cmd(ctx: SnedSlashContext, user: hikari.Member) -> None: 172 | helpers.is_member(user) 173 | await report(ctx, user) 174 | 175 | 176 | @reports.command 177 | @lightbulb.app_command_permissions(None, dm_enabled=False) 178 | @lightbulb.command("Report User", "Report the targeted user to the moderation team of this server.", pass_options=True) 179 | @lightbulb.implements(lightbulb.UserCommand) 180 | async def report_user_cmd(ctx: SnedUserContext, target: hikari.Member) -> None: 181 | helpers.is_member(target) 182 | await report(ctx, ctx.options.target) 183 | 184 | 185 | @reports.command 186 | @lightbulb.app_command_permissions(None, dm_enabled=False) 187 | @lightbulb.command( 188 | "Report Message", "Report the targeted message to the moderation team of this server.", pass_options=True 189 | ) 190 | @lightbulb.implements(lightbulb.MessageCommand) 191 | async def report_msg_cmd(ctx: SnedMessageContext, target: hikari.Message) -> None: 192 | assert ctx.guild_id is not None 193 | member = ctx.app.cache.get_member(ctx.guild_id, target.author) 194 | if not member: 195 | await ctx.respond( 196 | embed=hikari.Embed( 197 | title="❌ Oops!", 198 | description="It looks like the author of this message already left the server!", 199 | color=const.ERROR_COLOR, 200 | ), 201 | flags=hikari.MessageFlag.EPHEMERAL, 202 | ) 203 | return 204 | 205 | await report(ctx, member, ctx.options.target) 206 | 207 | 208 | def load(bot: SnedBot) -> None: 209 | bot.add_plugin(reports) 210 | 211 | 212 | def unload(bot: SnedBot) -> None: 213 | bot.remove_plugin(reports) 214 | 215 | 216 | # Copyright (C) 2022-present hypergonial 217 | 218 | # This program is free software: you can redistribute it and/or modify 219 | # it under the terms of the GNU General Public License as published by 220 | # the Free Software Foundation, either version 3 of the License, or 221 | # (at your option) any later version. 222 | 223 | # This program is distributed in the hope that it will be useful, 224 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 225 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 226 | # GNU General Public License for more details. 227 | 228 | # You should have received a copy of the GNU General Public License 229 | # along with this program. If not, see: https://www.gnu.org/licenses 230 | -------------------------------------------------------------------------------- /src/extensions/test.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import hikari 4 | import kosu 5 | import lightbulb 6 | import miru 7 | from miru.ext import nav 8 | 9 | from src.models import SnedSlashContext 10 | from src.models.bot import SnedBot 11 | from src.models.plugin import SnedPlugin 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | test = SnedPlugin("Test") 16 | 17 | 18 | @test.listener(hikari.StartedEvent) 19 | async def start_views(event: hikari.StartedEvent) -> None: 20 | await PersistentThing().start() 21 | 22 | 23 | class PersistentThing(miru.View): 24 | def __init__(self) -> None: 25 | super().__init__(timeout=None) 26 | 27 | @miru.button(label="Foo!", style=hikari.ButtonStyle.SUCCESS, custom_id="foo") 28 | async def foo_button(self, button: miru.Button, ctx: miru.ViewContext) -> None: 29 | await ctx.respond("You clicked foo!") 30 | 31 | @miru.button(label="Bar!", style=hikari.ButtonStyle.SUCCESS, custom_id="bar") 32 | async def bar_button(self, button: miru.Button, ctx: miru.ViewContext) -> None: 33 | await ctx.respond("You clicked bar!") 34 | 35 | 36 | class BasicView(miru.View): 37 | # Define a new Select menu with two options 38 | @miru.text_select( 39 | placeholder="Select me!", 40 | options=[ 41 | miru.SelectOption(label="Option 1"), 42 | miru.SelectOption(label="Option 2"), 43 | ], 44 | ) 45 | async def basic_select(self, select: miru.TextSelect, ctx: miru.ViewContext) -> None: 46 | await ctx.respond(f"You've chosen {select.values[0]}!") 47 | 48 | # Define a new Button with the Style of success (Green) 49 | @miru.button(label="Click me!", style=hikari.ButtonStyle.SUCCESS) 50 | async def basic_button(self, button: miru.Button, ctx: miru.ViewContext) -> None: 51 | await ctx.respond("You clicked me!") 52 | 53 | # Define a new Button that when pressed will stop the view & invalidate all the buttons in this view 54 | @miru.button(label="Modal!", style=hikari.ButtonStyle.PRIMARY) 55 | async def stop_button(self, button: miru.Button, ctx: miru.ViewContext) -> None: 56 | modal = BasicModal() 57 | await ctx.respond_with_modal(modal) 58 | 59 | 60 | class BasicModal(miru.Modal): 61 | def __init__(self) -> None: 62 | super().__init__("Miru is cool!") 63 | self.add_item(miru.TextInput(label="Enter something!", placeholder="Miru is cool!")) 64 | self.add_item( 65 | miru.TextInput( 66 | label="Enter something long!", 67 | style=hikari.TextInputStyle.PARAGRAPH, 68 | min_length=200, 69 | max_length=1000, 70 | ) 71 | ) 72 | 73 | async def callback(self, ctx: miru.ModalContext) -> None: 74 | await ctx.respond(self.values) 75 | 76 | 77 | @test.command 78 | @lightbulb.command("mirupersistent", "Test miru persistent unbound") 79 | @lightbulb.implements(lightbulb.SlashCommand) 80 | async def miru_persistent(ctx: SnedSlashContext) -> None: 81 | await ctx.respond("Beep Boop!", components=PersistentThing()) 82 | 83 | 84 | @test.listener(hikari.GuildMessageCreateEvent) 85 | async def nonce_printer(event: hikari.GuildMessageCreateEvent) -> None: 86 | print(f"Nonce is: {event.message.nonce}") 87 | 88 | 89 | @test.command 90 | @lightbulb.command("mirutest", "Test miru views") 91 | @lightbulb.implements(lightbulb.SlashCommand) 92 | async def viewtest(ctx: SnedSlashContext) -> None: 93 | view = BasicView() 94 | view.add_item(miru.Button(label="Settings!", url="discord://-/settings/advanced")) 95 | resp = await ctx.respond("foo", components=view) 96 | await view.start(await resp.message()) 97 | 98 | 99 | @test.command 100 | @lightbulb.command("modaltest", "Test miru modals") 101 | @lightbulb.implements(lightbulb.SlashCommand) 102 | async def modaltest(ctx: SnedSlashContext) -> None: 103 | modal = BasicModal() 104 | await modal.send(ctx.interaction) 105 | 106 | 107 | @test.command 108 | @lightbulb.command("navtest", "Test miru nav") 109 | @lightbulb.implements(lightbulb.SlashCommand) 110 | async def navtest(ctx: SnedSlashContext) -> None: 111 | buttons = [nav.FirstButton(), nav.PrevButton(), nav.StopButton(), nav.NextButton(), nav.LastButton()] 112 | 113 | navigator = nav.NavigatorView(pages=["1", "2", "3"], buttons=buttons) 114 | await ctx.respond(hikari.ResponseType.DEFERRED_MESSAGE_CREATE) 115 | await navigator.send(ctx.interaction, responded=True) 116 | 117 | 118 | @test.command 119 | @lightbulb.option("text", "Text to analyze.") 120 | @lightbulb.command("perspectivetestmultiple", "aaa", auto_defer=True) 121 | @lightbulb.implements(lightbulb.SlashCommand) 122 | async def testmultiple_cmd(ctx: SnedSlashContext) -> None: 123 | text = ctx.options.text 124 | resps = [] 125 | for i in range(1, 80): 126 | try: 127 | print(f"REQUEST {i}") 128 | 129 | resp: kosu.AnalysisResponse = await ctx.app.perspective.analyze( 130 | text, kosu.Attribute(kosu.AttributeName.TOXICITY) 131 | ) 132 | resps.append(resp) 133 | except: 134 | raise 135 | 136 | resp_strs = [] 137 | for resp in resps: 138 | score = resp.attribute_scores[0].summary 139 | resp_strs.append(f"{score.value}") 140 | await ctx.respond("\n".join(resp_strs)) 141 | 142 | 143 | @test.command 144 | @lightbulb.option("text", "Text to analyze.") 145 | @lightbulb.command("perspectivetest", "aaa", auto_defer=True) 146 | @lightbulb.implements(lightbulb.SlashCommand) 147 | async def test_cmd(ctx: SnedSlashContext) -> None: 148 | text = ctx.options.text 149 | attribs = [ 150 | kosu.Attribute(kosu.AttributeName.TOXICITY), 151 | kosu.Attribute(kosu.AttributeName.SEVERE_TOXICITY), 152 | kosu.Attribute(kosu.AttributeName.IDENTITY_ATTACK), 153 | kosu.Attribute(kosu.AttributeName.PROFANITY), 154 | kosu.Attribute(kosu.AttributeName.INSULT), 155 | kosu.Attribute(kosu.AttributeName.THREAT), 156 | ] 157 | assert ctx.app.perspective is not None 158 | resp: kosu.AnalysisResponse = await ctx.app.perspective.analyze(text, attribs) 159 | 160 | content = "```" 161 | for score in resp.attribute_scores: 162 | content = f"{content}\n{score.name}: {score.summary.score_type}: {score.summary.value}" 163 | content = f"{content}```" 164 | await ctx.respond(content=content) 165 | 166 | 167 | def load(bot: SnedBot) -> None: 168 | # bot.add_plugin(test) 169 | pass 170 | 171 | 172 | def unload(bot: SnedBot) -> None: 173 | # bot.remove_plugin(test) 174 | pass 175 | 176 | 177 | # Copyright (C) 2022-present hypergonial 178 | 179 | # This program is free software: you can redistribute it and/or modify 180 | # it under the terms of the GNU General Public License as published by 181 | # the Free Software Foundation, either version 3 of the License, or 182 | # (at your option) any later version. 183 | 184 | # This program is distributed in the hope that it will be useful, 185 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 186 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 187 | # GNU General Public License for more details. 188 | 189 | # You should have received a copy of the GNU General Public License 190 | # along with this program. If not, see: https://www.gnu.org/licenses 191 | -------------------------------------------------------------------------------- /src/extensions/troubleshooter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import hikari 4 | import lightbulb 5 | 6 | from src.etc import const, get_perm_str 7 | from src.models import SnedSlashContext 8 | from src.models.bot import SnedBot 9 | from src.models.plugin import SnedPlugin 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | troubleshooter = SnedPlugin("Troubleshooter") 14 | 15 | # Find perms issues 16 | # Find automod config issues 17 | # Find missing channel perms issues 18 | # ... 19 | 20 | REQUIRED_PERMISSIONS = ( 21 | hikari.Permissions.VIEW_AUDIT_LOG 22 | | hikari.Permissions.MANAGE_ROLES 23 | | hikari.Permissions.KICK_MEMBERS 24 | | hikari.Permissions.BAN_MEMBERS 25 | | hikari.Permissions.MANAGE_CHANNELS 26 | | hikari.Permissions.MANAGE_THREADS 27 | | hikari.Permissions.MANAGE_NICKNAMES 28 | | hikari.Permissions.CHANGE_NICKNAME 29 | | hikari.Permissions.READ_MESSAGE_HISTORY 30 | | hikari.Permissions.VIEW_CHANNEL 31 | | hikari.Permissions.SEND_MESSAGES 32 | | hikari.Permissions.CREATE_PUBLIC_THREADS 33 | | hikari.Permissions.CREATE_PRIVATE_THREADS 34 | | hikari.Permissions.SEND_MESSAGES_IN_THREADS 35 | | hikari.Permissions.EMBED_LINKS 36 | | hikari.Permissions.ATTACH_FILES 37 | | hikari.Permissions.MENTION_ROLES 38 | | hikari.Permissions.USE_EXTERNAL_EMOJIS 39 | | hikari.Permissions.MODERATE_MEMBERS 40 | | hikari.Permissions.MANAGE_MESSAGES 41 | | hikari.Permissions.ADD_REACTIONS 42 | ) 43 | 44 | # Explain why the bot requires the perm 45 | PERM_DESCRIPTIONS = { 46 | hikari.Permissions.VIEW_AUDIT_LOG: "Required in logs to fill in details such as who the moderator in question was, or the reason of the action.", 47 | hikari.Permissions.MANAGE_ROLES: "Required to give users roles via role-buttons, and for the `/role` command to function.", 48 | hikari.Permissions.MANAGE_CHANNELS: "Used by `/slowmode` to set a custom slow mode duration for the channel.", 49 | hikari.Permissions.MANAGE_THREADS: "Used by `/slowmode` to set a custom slow mode duration for the thread.", 50 | hikari.Permissions.MANAGE_NICKNAMES: "Used by `/deobfuscate` to deobfuscate other user's nicknames.", 51 | hikari.Permissions.KICK_MEMBERS: "Required to use the `/kick` command and let auto-moderation actions kick users.", 52 | hikari.Permissions.BAN_MEMBERS: "Required to use the `/ban`, `/softban`, `/massban` command and let auto-moderation actions ban users.", 53 | hikari.Permissions.CHANGE_NICKNAME: "Required for the `/setnick` command.", 54 | hikari.Permissions.READ_MESSAGE_HISTORY: "Required for auto-moderation, starboard, `/edit`, and other commands that may require to fetch messages.", 55 | hikari.Permissions.VIEW_CHANNEL: "Required for auto-moderation, starboard, `/edit`, and other commands that may require to fetch messages.", 56 | hikari.Permissions.SEND_MESSAGES: "Required to send messages independently of commands, this includes `/echo`, `/edit`, logging, starboard, reports and auto-moderation.", 57 | hikari.Permissions.CREATE_PUBLIC_THREADS: "Required for the bot to access and manage threads.", 58 | hikari.Permissions.CREATE_PRIVATE_THREADS: "Required for the bot to access and manage threads.", 59 | hikari.Permissions.SEND_MESSAGES_IN_THREADS: "Required for the bot to access and manage threads.", 60 | hikari.Permissions.EMBED_LINKS: "Required for the bot to create embeds to display content, without this you may not see any responses from the bot, including this one :)", 61 | hikari.Permissions.ATTACH_FILES: "Required for the bot to attach files to a message, for example to send a list of users to be banned in `/massban`.", 62 | hikari.Permissions.MENTION_ROLES: "Required for the bot to always be able to mention roles, for example when reporting users. The bot will **never** mention @everyone or @here.", 63 | hikari.Permissions.USE_EXTERNAL_EMOJIS: "Required to display certain content with custom emojies, typically to better illustrate certain content.", 64 | hikari.Permissions.ADD_REACTIONS: "This permission is used for creating giveaways and adding the initial reaction to the giveaway message.", 65 | hikari.Permissions.MODERATE_MEMBERS: "Required to use the `/timeout` command and let auto-moderation actions timeout users.", 66 | hikari.Permissions.MANAGE_MESSAGES: "This permission is required to delete other user's messages, for example in the case of auto-moderation.", 67 | } 68 | 69 | 70 | @troubleshooter.command 71 | @lightbulb.app_command_permissions(hikari.Permissions.MANAGE_GUILD, dm_enabled=False) 72 | @lightbulb.command("troubleshoot", "Diagnose and locate common configuration issues.") 73 | @lightbulb.implements(lightbulb.SlashCommand) 74 | async def troubleshoot(ctx: SnedSlashContext) -> None: 75 | assert ctx.interaction.app_permissions is not None 76 | 77 | missing_perms = ~ctx.interaction.app_permissions & REQUIRED_PERMISSIONS 78 | content_list = [] 79 | 80 | if missing_perms is not hikari.Permissions.NONE: 81 | content_list.append("**Missing Permissions:**") 82 | content_list += [ 83 | f"❌ **{get_perm_str(perm)}**: {desc}" for perm, desc in PERM_DESCRIPTIONS.items() if missing_perms & perm 84 | ] 85 | 86 | if not content_list: 87 | embed = hikari.Embed( 88 | title="✅ No problems found!", 89 | description="If you believe there is an issue with Sned, found a bug, or simply have a question, please join the [support server!](https://discord.gg/KNKr8FPmJa)", 90 | color=const.EMBED_GREEN, 91 | ) 92 | else: 93 | content = "\n".join(content_list) 94 | embed = hikari.Embed( 95 | title="Uh Oh!", 96 | description=f"It looks like there may be some issues with the configuration. Please review the list below!\n\n{content}\n\nIf you need any assistance resolving these issues, please join the [support server!](https://discord.gg/KNKr8FPmJa)", 97 | color=const.ERROR_COLOR, 98 | ) 99 | 100 | await ctx.mod_respond(embed=embed) 101 | 102 | 103 | def load(bot: SnedBot) -> None: 104 | bot.add_plugin(troubleshooter) 105 | 106 | 107 | def unload(bot: SnedBot) -> None: 108 | bot.remove_plugin(troubleshooter) 109 | 110 | 111 | # Copyright (C) 2022-present hypergonial 112 | 113 | # This program is free software: you can redistribute it and/or modify 114 | # it under the terms of the GNU General Public License as published by 115 | # the Free Software Foundation, either version 3 of the License, or 116 | # (at your option) any later version. 117 | 118 | # This program is distributed in the hope that it will be useful, 119 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 120 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 121 | # GNU General Public License for more details. 122 | 123 | # You should have received a copy of the GNU General Public License 124 | # along with this program. If not, see: https://www.gnu.org/licenses 125 | -------------------------------------------------------------------------------- /src/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import * 2 | from .context import * 3 | from .db_user import * 4 | from .errors import * 5 | from .events import * 6 | from .journal import * 7 | from .plugin import * 8 | from .starboard import * 9 | from .tag import * 10 | from .timer import * 11 | from .views import * 12 | 13 | # Copyright (C) 2022-present hypergonial 14 | 15 | # This program is free software: you can redistribute it and/or modify 16 | # it under the terms of the GNU General Public License as published by 17 | # the Free Software Foundation, either version 3 of the License, or 18 | # (at your option) any later version. 19 | 20 | # This program is distributed in the hope that it will be useful, 21 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 22 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 23 | # GNU General Public License for more details. 24 | 25 | # You should have received a copy of the GNU General Public License 26 | # along with this program. If not, see: https://www.gnu.org/licenses 27 | -------------------------------------------------------------------------------- /src/models/audit_log.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import hikari 6 | 7 | if t.TYPE_CHECKING: 8 | from src.models.bot import SnedBot 9 | 10 | import logging 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class AuditLogCache: 16 | """A cache for audit log entries categorized by guild and entry type. 17 | 18 | Parameters 19 | ---------- 20 | bot: SnedBot 21 | The bot instance. 22 | capacity: int 23 | The maximum number of entries to store per guild and event type. If the number 24 | of entries exceed this number, the oldest entries will be discarded. 25 | """ 26 | 27 | def __init__(self, bot: SnedBot, capacity: int = 10) -> None: 28 | self._cache: dict[hikari.Snowflake, dict[hikari.AuditLogEventType, list[hikari.AuditLogEntry]]] = {} 29 | self._capacity = capacity 30 | self._bot = bot 31 | 32 | async def start(self) -> None: 33 | """Start the audit log cache listener.""" 34 | self._bot.event_manager.subscribe(hikari.AuditLogEntryCreateEvent, self._listen) 35 | 36 | async def stop(self) -> None: 37 | """Stop the audit log cache listener.""" 38 | self._bot.event_manager.unsubscribe(hikari.AuditLogEntryCreateEvent, self._listen) 39 | self._cache = {} 40 | 41 | async def _listen(self, event: hikari.AuditLogEntryCreateEvent) -> None: 42 | """Listen for audit log events.""" 43 | self.add(event.guild_id, event.entry) 44 | 45 | def get( 46 | self, guild: hikari.SnowflakeishOr[hikari.PartialGuild], action_type: hikari.AuditLogEventType 47 | ) -> list[hikari.AuditLogEntry]: 48 | """Get all audit log entries for a guild and event type. 49 | 50 | Parameters 51 | ---------- 52 | guild: hikari.SnowflakeishOr[hikari.PartialGuild] 53 | The guild or it's ID. 54 | action_type: hikari.AuditLogEventType 55 | The event type. 56 | 57 | Returns 58 | ------- 59 | List[hikari.AuditLogEntry] 60 | The audit log entries. 61 | """ 62 | return self._cache.get(hikari.Snowflake(guild), {}).get(action_type, []) 63 | 64 | def get_first_by( 65 | self, 66 | guild: hikari.SnowflakeishOr[hikari.PartialGuild], 67 | action_type: hikari.AuditLogEventType, 68 | predicate: t.Callable[[hikari.AuditLogEntry], bool], 69 | ) -> hikari.AuditLogEntry | None: 70 | """Get the first audit log entry that matches a predicate. 71 | 72 | Parameters 73 | ---------- 74 | guild: hikari.SnowflakeishOr[hikari.PartialGuild] 75 | The guild or it's ID. 76 | action_type: hikari.AuditLogEventType 77 | The event type. 78 | predicate: Callable[[hikari.AuditLogEntry], bool] 79 | The predicate to match. 80 | 81 | Returns 82 | ------- 83 | Optional[hikari.AuditLogEntry] 84 | The first audit log entry that matches the predicate, or None if no entry matches. 85 | """ 86 | for entry in reversed(self.get(guild, action_type)): 87 | if predicate(entry): 88 | return entry 89 | 90 | return None 91 | 92 | def add(self, guild: hikari.SnowflakeishOr[hikari.PartialGuild], entry: hikari.AuditLogEntry) -> None: 93 | """Add a new audit log entry to the cache. 94 | 95 | Parameters 96 | ---------- 97 | guild: hikari.SnowflakeishOr[hikari.PartialGuild] 98 | The guild or it's ID. 99 | entry: hikari.AuditLogEntry 100 | The audit log entry to add. 101 | """ 102 | if not isinstance(entry.action_type, hikari.AuditLogEventType): 103 | logger.warning(f"Unrecognized audit log entry type found: {entry.action_type}") 104 | return 105 | 106 | guild_id = hikari.Snowflake(guild) 107 | 108 | if guild_id not in self._cache: 109 | self._cache[guild_id] = {} 110 | 111 | if entry.action_type not in self._cache[guild_id]: 112 | self._cache[guild_id][entry.action_type] = [] 113 | 114 | # Remove the oldest entry if the cache is full 115 | if len(self._cache[guild_id][entry.action_type]) >= self._capacity: 116 | self._cache[guild_id][entry.action_type].pop(0) 117 | 118 | self._cache[guild_id][entry.action_type].append(entry) 119 | -------------------------------------------------------------------------------- /src/models/checks.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import operator 3 | 4 | import hikari 5 | import lightbulb 6 | 7 | from src.models.context import SnedApplicationContext, SnedContext 8 | from src.models.errors import BotRoleHierarchyError, RoleHierarchyError 9 | from src.utils import helpers 10 | 11 | 12 | def _guild_only(ctx: SnedContext) -> bool: 13 | if not ctx.guild_id: 14 | raise lightbulb.OnlyInGuild("This command can only be used in a guild.") 15 | return True 16 | 17 | 18 | @lightbulb.Check # type: ignore 19 | async def is_above_target(ctx: SnedContext) -> bool: 20 | """Check if the targeted user is above the bot's top role or not. 21 | Used in the moderation extension. 22 | """ 23 | if not hasattr(ctx.options, "user"): 24 | return True 25 | 26 | if not ctx.guild_id: 27 | return True 28 | 29 | guild = ctx.get_guild() 30 | if guild and guild.owner_id == ctx.options.user.id: 31 | raise BotRoleHierarchyError("Cannot execute on the owner of the guild.") 32 | 33 | me = ctx.app.cache.get_member(ctx.guild_id, ctx.app.user_id) 34 | assert me is not None 35 | 36 | if isinstance(ctx.options.user, hikari.Member): 37 | member = ctx.options.user 38 | else: 39 | member = ctx.app.cache.get_member(ctx.guild_id, ctx.options.user) 40 | 41 | if not member: 42 | return True 43 | 44 | if helpers.is_above(me, member): 45 | return True 46 | 47 | raise BotRoleHierarchyError("The targeted user's highest role is higher than the bot's highest role.") 48 | 49 | 50 | @lightbulb.Check # type: ignore 51 | async def is_invoker_above_target(ctx: SnedContext) -> bool: 52 | """Check if the targeted user is above the invoker's top role or not. 53 | Used in the moderation extension. 54 | """ 55 | if not hasattr(ctx.options, "user"): 56 | return True 57 | 58 | if not ctx.member or not ctx.guild_id: 59 | return True 60 | 61 | guild = ctx.get_guild() 62 | assert guild is not None 63 | 64 | if ctx.member.id == guild.owner_id: 65 | return True 66 | 67 | if isinstance(ctx.options.user, hikari.Member): 68 | member = ctx.options.user 69 | else: 70 | member = ctx.app.cache.get_member(ctx.guild_id, ctx.options.user) 71 | 72 | if not member: 73 | return True 74 | 75 | if helpers.is_above(ctx.member, member): 76 | return True 77 | 78 | raise RoleHierarchyError 79 | 80 | 81 | async def _has_permissions(ctx: SnedApplicationContext, *, perms: hikari.Permissions) -> bool: 82 | _guild_only(ctx) 83 | 84 | if ctx.interaction is not None and ctx.interaction.member is not None: 85 | member_perms = ctx.interaction.member.permissions 86 | else: 87 | try: 88 | channel, guild = (ctx.get_channel() or await ctx.app.rest.fetch_channel(ctx.channel_id)), ctx.get_guild() 89 | except hikari.ForbiddenError: 90 | raise lightbulb.BotMissingRequiredPermission( 91 | "Check cannot run due to missing permissions.", perms=hikari.Permissions.VIEW_CHANNEL 92 | ) 93 | 94 | if guild is None: 95 | raise lightbulb.InsufficientCache( 96 | "Some objects required for this check could not be resolved from the cache." 97 | ) 98 | if guild.owner_id == ctx.author.id: 99 | return True 100 | 101 | assert ctx.member is not None 102 | 103 | if isinstance(channel, hikari.GuildThreadChannel): 104 | channel = ctx.app.cache.get_guild_channel(channel.parent_id) 105 | 106 | assert isinstance(channel, hikari.PermissibleGuildChannel) 107 | member_perms = lightbulb.utils.permissions_in(channel, ctx.member) 108 | 109 | missing_perms = ~member_perms & perms 110 | if missing_perms is not hikari.Permissions.NONE: 111 | raise lightbulb.MissingRequiredPermission( 112 | "You are missing one or more permissions required in order to run this command", perms=missing_perms 113 | ) 114 | 115 | return True 116 | 117 | 118 | async def _bot_has_permissions(ctx: SnedContext, *, perms: hikari.Permissions) -> bool: 119 | _guild_only(ctx) 120 | 121 | if interaction := ctx.interaction: 122 | bot_perms = interaction.app_permissions 123 | assert bot_perms is not None 124 | else: 125 | try: 126 | channel, guild = (ctx.get_channel() or await ctx.app.rest.fetch_channel(ctx.channel_id)), ctx.get_guild() 127 | except hikari.ForbiddenError: 128 | raise lightbulb.BotMissingRequiredPermission( 129 | "Check cannot run due to missing permissions.", perms=hikari.Permissions.VIEW_CHANNEL 130 | ) 131 | if guild is None: 132 | raise lightbulb.InsufficientCache( 133 | "Some objects required for this check could not be resolved from the cache." 134 | ) 135 | member = guild.get_my_member() 136 | if member is None: 137 | raise lightbulb.InsufficientCache( 138 | "Some objects required for this check could not be resolved from the cache." 139 | ) 140 | 141 | if isinstance(channel, hikari.GuildThreadChannel): 142 | channel = ctx.app.cache.get_guild_channel(channel.parent_id) 143 | assert isinstance(channel, hikari.PermissibleGuildChannel) 144 | bot_perms = lightbulb.utils.permissions_in(channel, member) 145 | 146 | missing_perms = ~bot_perms & perms 147 | if missing_perms is not hikari.Permissions.NONE: 148 | raise lightbulb.BotMissingRequiredPermission( 149 | "The bot is missing one or more permissions required in order to run this command", perms=missing_perms 150 | ) 151 | 152 | return True 153 | 154 | 155 | def has_permissions(perm1: hikari.Permissions, *perms: hikari.Permissions) -> lightbulb.Check: 156 | """Just a shitty attempt at making has_guild_permissions fetch the channel if it is not present.""" 157 | reduced = functools.reduce(operator.or_, [perm1, *perms]) 158 | return lightbulb.Check(functools.partial(_has_permissions, perms=reduced)) 159 | 160 | 161 | def bot_has_permissions(perm1: hikari.Permissions, *perms: hikari.Permissions) -> lightbulb.Check: 162 | """Just a shitty attempt at making bot_has_guild_permissions fetch the channel if it is not present.""" 163 | reduced = functools.reduce(operator.or_, [perm1, *perms]) 164 | return lightbulb.Check(functools.partial(_bot_has_permissions, perms=reduced)) 165 | 166 | 167 | # Copyright (C) 2022-present hypergonial 168 | 169 | # This program is free software: you can redistribute it and/or modify 170 | # it under the terms of the GNU General Public License as published by 171 | # the Free Software Foundation, either version 3 of the License, or 172 | # (at your option) any later version. 173 | 174 | # This program is distributed in the hope that it will be useful, 175 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 176 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 177 | # GNU General Public License for more details. 178 | 179 | # You should have received a copy of the GNU General Public License 180 | # along with this program. If not, see: https://www.gnu.org/licenses 181 | -------------------------------------------------------------------------------- /src/models/context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import hikari 6 | import lightbulb 7 | import miru 8 | 9 | from src.models.mod_actions import ModerationFlags 10 | from src.models.views import AuthorOnlyView 11 | 12 | __all__ = ["SnedContext", "SnedSlashContext", "SnedMessageContext", "SnedUserContext", "SnedPrefixContext"] 13 | 14 | if t.TYPE_CHECKING: 15 | from .bot import SnedBot 16 | 17 | 18 | class ConfirmView(AuthorOnlyView): 19 | """View that drives the confirm prompt button logic.""" 20 | 21 | def __init__( 22 | self, 23 | lctx: lightbulb.Context, 24 | timeout: int, 25 | confirm_resp: dict[str, t.Any] | None = None, 26 | cancel_resp: dict[str, t.Any] | None = None, 27 | ) -> None: 28 | super().__init__(lctx, timeout=timeout) 29 | self.confirm_resp = confirm_resp 30 | self.cancel_resp = cancel_resp 31 | self.value: bool | None = None 32 | 33 | @miru.button(emoji="✖️", style=hikari.ButtonStyle.DANGER) 34 | async def cancel_button(self, button: miru.Button, ctx: miru.ViewContext) -> None: 35 | self.value = False 36 | if self.cancel_resp: 37 | await ctx.edit_response(**self.cancel_resp) 38 | self.stop() 39 | 40 | @miru.button(emoji="✔️", style=hikari.ButtonStyle.SUCCESS) 41 | async def confirm_button(self, button: miru.Button, ctx: miru.ViewContext) -> None: 42 | self.value = True 43 | if self.confirm_resp: 44 | await ctx.edit_response(**self.confirm_resp) 45 | self.stop() 46 | 47 | 48 | class SnedContext(lightbulb.Context): 49 | """Custom context for use across the bot.""" 50 | 51 | async def confirm( 52 | self, 53 | *args, 54 | confirm_payload: dict[str, t.Any] | None = None, 55 | cancel_payload: dict[str, t.Any] | None = None, 56 | timeout: int = 120, 57 | edit: bool = False, 58 | message: hikari.Message | None = None, 59 | **kwargs, 60 | ) -> bool | None: 61 | """Confirm a given action. 62 | 63 | Parameters 64 | ---------- 65 | confirm_payload : Optional[Dict[str, Any]], optional 66 | Optional keyword-only payload to send if the user confirmed, by default None 67 | cancel_payload : Optional[Dict[str, Any]], optional 68 | Optional keyword-only payload to send if the user cancelled, by default None 69 | timeout : int, optional 70 | The default timeout to use for the confirm prompt, by default 120 71 | edit : bool 72 | If True, tries editing the initial response or the provided message. 73 | message : Optional[hikari.Message], optional 74 | A message to edit & transform into the confirm prompt if provided, by default None 75 | *args : Any 76 | Arguments for the confirm prompt response. 77 | **kwargs : Any 78 | Keyword-only arguments for the confirm prompt response. 79 | 80 | Returns 81 | ------- 82 | bool 83 | Boolean determining if the user confirmed the action or not. 84 | None if no response was given before timeout. 85 | """ 86 | view = ConfirmView(self, timeout, confirm_payload, cancel_payload) 87 | 88 | kwargs.pop("components", None) 89 | kwargs.pop("component", None) 90 | 91 | if message and edit: 92 | message = await message.edit(*args, components=view, **kwargs) 93 | elif edit: 94 | message = await self.edit_last_response(*args, components=view, **kwargs) 95 | else: 96 | resp = await self.respond(*args, components=view, **kwargs) 97 | message = await resp.message() 98 | 99 | assert message is not None 100 | await view.start(message) 101 | await view.wait() 102 | return view.value 103 | 104 | @t.overload 105 | async def mod_respond( 106 | self, 107 | content: hikari.UndefinedOr[t.Any] = hikari.UNDEFINED, 108 | delete_after: t.Union[int, float, None] = None, 109 | *, 110 | attachment: hikari.UndefinedOr[hikari.Resourceish] = hikari.UNDEFINED, 111 | attachments: hikari.UndefinedOr[t.Sequence[hikari.Resourceish]] = hikari.UNDEFINED, 112 | component: hikari.UndefinedOr[hikari.api.ComponentBuilder] = hikari.UNDEFINED, 113 | components: hikari.UndefinedOr[t.Sequence[hikari.api.ComponentBuilder]] = hikari.UNDEFINED, 114 | embed: hikari.UndefinedOr[hikari.Embed] = hikari.UNDEFINED, 115 | embeds: hikari.UndefinedOr[t.Sequence[hikari.Embed]] = hikari.UNDEFINED, 116 | tts: hikari.UndefinedOr[bool] = hikari.UNDEFINED, 117 | nonce: hikari.UndefinedOr[str] = hikari.UNDEFINED, 118 | reply: hikari.UndefinedOr[hikari.SnowflakeishOr[hikari.PartialMessage]] = hikari.UNDEFINED, 119 | mentions_everyone: hikari.UndefinedOr[bool] = hikari.UNDEFINED, 120 | mentions_reply: hikari.UndefinedOr[bool] = hikari.UNDEFINED, 121 | user_mentions: hikari.UndefinedOr[ 122 | t.Union[hikari.SnowflakeishSequence[hikari.PartialUser], bool] 123 | ] = hikari.UNDEFINED, 124 | role_mentions: hikari.UndefinedOr[ 125 | t.Union[hikari.SnowflakeishSequence[hikari.PartialRole], bool] 126 | ] = hikari.UNDEFINED, 127 | ) -> lightbulb.ResponseProxy: 128 | ... 129 | 130 | @t.overload 131 | async def mod_respond( 132 | self, 133 | response_type: hikari.ResponseType, 134 | content: hikari.UndefinedOr[t.Any] = hikari.UNDEFINED, 135 | delete_after: t.Union[int, float, None] = None, 136 | *, 137 | attachment: hikari.UndefinedOr[hikari.Resourceish] = hikari.UNDEFINED, 138 | attachments: hikari.UndefinedOr[t.Sequence[hikari.Resourceish]] = hikari.UNDEFINED, 139 | component: hikari.UndefinedOr[hikari.api.ComponentBuilder] = hikari.UNDEFINED, 140 | components: hikari.UndefinedOr[t.Sequence[hikari.api.ComponentBuilder]] = hikari.UNDEFINED, 141 | embed: hikari.UndefinedOr[hikari.Embed] = hikari.UNDEFINED, 142 | embeds: hikari.UndefinedOr[t.Sequence[hikari.Embed]] = hikari.UNDEFINED, 143 | tts: hikari.UndefinedOr[bool] = hikari.UNDEFINED, 144 | nonce: hikari.UndefinedOr[str] = hikari.UNDEFINED, 145 | reply: hikari.UndefinedOr[hikari.SnowflakeishOr[hikari.PartialMessage]] = hikari.UNDEFINED, 146 | mentions_everyone: hikari.UndefinedOr[bool] = hikari.UNDEFINED, 147 | mentions_reply: hikari.UndefinedOr[bool] = hikari.UNDEFINED, 148 | user_mentions: hikari.UndefinedOr[ 149 | t.Union[hikari.SnowflakeishSequence[hikari.PartialUser], bool] 150 | ] = hikari.UNDEFINED, 151 | role_mentions: hikari.UndefinedOr[ 152 | t.Union[hikari.SnowflakeishSequence[hikari.PartialRole], bool] 153 | ] = hikari.UNDEFINED, 154 | ) -> lightbulb.ResponseProxy: 155 | ... 156 | 157 | async def mod_respond(self, *args, **kwargs) -> lightbulb.ResponseProxy: 158 | """Respond to the command while taking into consideration the current moderation command settings. 159 | This should not be used outside the moderation plugin, and may fail if it is not loaded. 160 | """ 161 | if self.guild_id: 162 | is_ephemeral = bool((await self.app.mod.get_settings(self.guild_id)).flags & ModerationFlags.IS_EPHEMERAL) 163 | flags = hikari.MessageFlag.EPHEMERAL if is_ephemeral else hikari.MessageFlag.NONE 164 | else: 165 | flags = kwargs.get("flags") or hikari.MessageFlag.NONE 166 | 167 | return await self.respond(*args, flags=flags, **kwargs) 168 | 169 | @property 170 | def app(self) -> SnedBot: 171 | return super().app # type: ignore 172 | 173 | @property 174 | def bot(self) -> SnedBot: 175 | return super().app # type: ignore 176 | 177 | 178 | class SnedApplicationContext(SnedContext, lightbulb.ApplicationContext): 179 | """Custom ApplicationContext for Sned.""" 180 | 181 | 182 | class SnedSlashContext(SnedApplicationContext, lightbulb.SlashContext): 183 | """Custom SlashContext for Sned.""" 184 | 185 | 186 | class SnedUserContext(SnedApplicationContext, lightbulb.UserContext): 187 | """Custom UserContext for Sned.""" 188 | 189 | 190 | class SnedMessageContext(SnedApplicationContext, lightbulb.MessageContext): 191 | """Custom MessageContext for Sned.""" 192 | 193 | 194 | class SnedPrefixContext(SnedContext, lightbulb.PrefixContext): 195 | """Custom PrefixContext for Sned.""" 196 | 197 | 198 | # Copyright (C) 2022-present hypergonial 199 | 200 | # This program is free software: you can redistribute it and/or modify 201 | # it under the terms of the GNU General Public License as published by 202 | # the Free Software Foundation, either version 3 of the License, or 203 | # (at your option) any later version. 204 | 205 | # This program is distributed in the hope that it will be useful, 206 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 207 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 208 | # GNU General Public License for more details. 209 | 210 | # You should have received a copy of the GNU General Public License 211 | # along with this program. If not, see: https://www.gnu.org/licenses 212 | -------------------------------------------------------------------------------- /src/models/db_user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | import json 5 | import typing as t 6 | 7 | import attr 8 | import hikari 9 | 10 | from src.models.db import DatabaseModel 11 | from src.models.journal import JournalEntry 12 | 13 | 14 | class DatabaseUserFlag(enum.Flag): 15 | """Flags stored for a user in the database.""" 16 | 17 | NONE = 0 18 | """An empty set of database user flags.""" 19 | TIMEOUT_ON_JOIN = 1 << 0 20 | """The user should be timed out when next spotted joining the guild.""" 21 | 22 | 23 | @attr.define() 24 | class DatabaseUser(DatabaseModel): 25 | """Represents user data stored inside the database.""" 26 | 27 | id: hikari.Snowflake 28 | """The ID of this user.""" 29 | 30 | guild_id: hikari.Snowflake 31 | """The guild this user is bound to.""" 32 | 33 | flags: DatabaseUserFlag 34 | """A set of flags stored for this user.""" 35 | 36 | warns: int = 0 37 | """The count of warnings stored for this user.""" 38 | 39 | data: dict[str, t.Any] = attr.field(factory=dict) 40 | """Miscellaneous data stored for this user. Must be JSON serializable.""" 41 | 42 | async def update(self) -> None: 43 | """Update or insert this user into the database.""" 44 | await self._db.execute( 45 | """ 46 | INSERT INTO users (user_id, guild_id, flags, warns,data) 47 | VALUES ($1, $2, $3, $4, $5) 48 | ON CONFLICT (user_id, guild_id) DO 49 | UPDATE SET flags = $3, warns = $4, data = $5""", 50 | self.id, 51 | self.guild_id, 52 | self.flags.value, 53 | self.warns, 54 | json.dumps(self.data), 55 | ) 56 | 57 | @classmethod 58 | async def fetch( 59 | cls, user: hikari.SnowflakeishOr[hikari.PartialUser], guild: hikari.SnowflakeishOr[hikari.PartialGuild] 60 | ) -> t.Self: 61 | """Fetch a user from the database. If not present, returns a default DatabaseUser object. 62 | 63 | Parameters 64 | ---------- 65 | user : hikari.SnowflakeishOr[hikari.PartialUser] 66 | The user to retrieve database information for. 67 | guild : hikari.SnowflakeishOr[hikari.PartialGuild] 68 | The guild the user belongs to. 69 | 70 | Returns 71 | ------- 72 | DatabaseUser 73 | An object representing stored user data. 74 | """ 75 | record = await cls._db.fetchrow( 76 | """SELECT * FROM users WHERE user_id = $1 AND guild_id = $2""", 77 | hikari.Snowflake(user), 78 | hikari.Snowflake(guild), 79 | ) 80 | 81 | if not record: 82 | return cls(hikari.Snowflake(user), hikari.Snowflake(guild), flags=DatabaseUserFlag.NONE, warns=0) 83 | 84 | return cls( 85 | id=hikari.Snowflake(record["user_id"]), 86 | guild_id=hikari.Snowflake(record["guild_id"]), 87 | flags=DatabaseUserFlag(record["flags"]), 88 | warns=record["warns"], 89 | data=json.loads(record["data"]) if record.get("data") else {}, 90 | ) 91 | 92 | @classmethod 93 | async def fetch_all(cls, guild: hikari.SnowflakeishOr[hikari.PartialGuild]) -> list[t.Self]: 94 | """Fetch all stored user data that belongs to the specified guild. 95 | 96 | Parameters 97 | ---------- 98 | guild : hikari.SnowflakeishOr[hikari.PartialGuild] 99 | The guild the users belongs to. 100 | 101 | Returns 102 | ------- 103 | List[DatabaseUser] 104 | A list of objects representing stored user data. 105 | """ 106 | records = await cls._db.fetch("""SELECT * FROM users WHERE guild_id = $1""", hikari.Snowflake(guild)) 107 | 108 | if not records: 109 | return [] 110 | 111 | return [ 112 | cls( 113 | id=hikari.Snowflake(record["user_id"]), 114 | guild_id=hikari.Snowflake(record["guild_id"]), 115 | flags=DatabaseUserFlag(record["flags"]), 116 | warns=record["warns"], 117 | data=json.loads(record["data"]) if record.get("data") else {}, 118 | ) 119 | for record in records 120 | ] 121 | 122 | async def fetch_journal(self) -> list[JournalEntry]: 123 | """Fetch all journal entries for this user. 124 | 125 | Returns 126 | ------- 127 | List[JournalEntry] 128 | A list of journal entries for this user. 129 | """ 130 | return await JournalEntry.fetch_journal(self.id, self.guild_id) 131 | 132 | 133 | # Copyright (C) 2022-present hypergonial 134 | 135 | # This program is free software: you can redistribute it and/or modify 136 | # it under the terms of the GNU General Public License as published by 137 | # the Free Software Foundation, either version 3 of the License, or 138 | # (at your option) any later version. 139 | 140 | # This program is distributed in the hope that it will be useful, 141 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 142 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 143 | # GNU General Public License for more details. 144 | 145 | # You should have received a copy of the GNU General Public License 146 | # along with this program. If not, see: https://www.gnu.org/licenses 147 | -------------------------------------------------------------------------------- /src/models/errors.py: -------------------------------------------------------------------------------- 1 | import lightbulb 2 | 3 | 4 | class TagAlreadyExistsError(Exception): 5 | """Raised when a tag is trying to get created but already exists.""" 6 | 7 | 8 | class TagNotFoundError(Exception): 9 | """Raised when a tag is not found, although most functions just return None.""" 10 | 11 | 12 | class RoleHierarchyError(lightbulb.CheckFailure): 13 | """Raised when an action fails due to role hierarchy.""" 14 | 15 | 16 | class BotRoleHierarchyError(lightbulb.CheckFailure): 17 | """Raised when an action fails due to the bot's role hierarchy.""" 18 | 19 | 20 | class MemberExpectedError(Exception): 21 | """Raised when a command expected a member and received a user instead.""" 22 | 23 | 24 | class UserBlacklistedError(Exception): 25 | """Raised when a user who is blacklisted from using the application tries to use it.""" 26 | 27 | 28 | class DMFailedError(Exception): 29 | """Raised when DMing a user fails while executing a moderation command.""" 30 | 31 | 32 | class DatabaseStateConflictError(Exception): 33 | """Raised when the database's state conflicts with the operation requested to be carried out.""" 34 | 35 | 36 | class InteractionTimeOutError(Exception): 37 | """Raised when a user interaction times out.""" 38 | 39 | 40 | # Copyright (C) 2022-present hypergonial 41 | 42 | # This program is free software: you can redistribute it and/or modify 43 | # it under the terms of the GNU General Public License as published by 44 | # the Free Software Foundation, either version 3 of the License, or 45 | # (at your option) any later version. 46 | 47 | # This program is distributed in the hope that it will be useful, 48 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 49 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 50 | # GNU General Public License for more details. 51 | 52 | # You should have received a copy of the GNU General Public License 53 | # along with this program. If not, see: https://www.gnu.org/licenses 54 | -------------------------------------------------------------------------------- /src/models/events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import attr 6 | import hikari 7 | 8 | if t.TYPE_CHECKING: 9 | from src.models.bot import SnedBot 10 | from src.models.rolebutton import RoleButton 11 | from src.models.timer import Timer 12 | 13 | 14 | class SnedEvent(hikari.Event): 15 | """Base event for any custom event implemented by this application.""" 16 | 17 | ... 18 | 19 | 20 | class SnedGuildEvent(SnedEvent): 21 | """Base event for any custom event that occurs within the context of a guild.""" 22 | 23 | app: SnedBot 24 | """The currently running application.""" 25 | _guild_id: hikari.Snowflakeish 26 | 27 | @property 28 | def guild_id(self) -> hikari.Snowflake: 29 | """The guild this event belongs to.""" 30 | return hikari.Snowflake(self._guild_id) 31 | 32 | async def fetch_guild(self) -> hikari.RESTGuild: 33 | """Perform an API call to get the guild that this event relates to. 34 | 35 | Returns 36 | ------- 37 | hikari.guilds.RESTGuild 38 | The guild this event occurred in. 39 | """ 40 | return await self.app.rest.fetch_guild(self.guild_id) 41 | 42 | async def fetch_guild_preview(self) -> hikari.GuildPreview: 43 | """Perform an API call to get the preview of the event's guild. 44 | 45 | Returns 46 | ------- 47 | hikari.guilds.GuildPreview 48 | The preview of the guild this event occurred in. 49 | """ 50 | return await self.app.rest.fetch_guild_preview(self.guild_id) 51 | 52 | def get_guild(self) -> hikari.GatewayGuild | None: 53 | """Get the cached guild that this event relates to, if known. 54 | 55 | If not known, this will return `builtins.None` instead. 56 | 57 | Returns 58 | ------- 59 | Optional[hikari.guilds.GatewayGuild] 60 | The guild this event relates to, or `builtins.None` if not known. 61 | """ 62 | if not isinstance(self.app, hikari.CacheAware): 63 | return None 64 | 65 | return self.app.cache.get_guild(self.guild_id) 66 | 67 | 68 | @attr.define() 69 | class TimerCompleteEvent(SnedGuildEvent): 70 | """Dispatched when a scheduled timer has expired.""" 71 | 72 | app: SnedBot 73 | timer: Timer 74 | """The timer that was dispatched.""" 75 | _guild_id: hikari.Snowflakeish 76 | 77 | 78 | @attr.define() 79 | class MassBanEvent(SnedGuildEvent): 80 | """Dispatched when a massban occurs.""" 81 | 82 | app: SnedBot 83 | _guild_id: hikari.Snowflakeish 84 | moderator: hikari.Member 85 | """The moderator responsible for the massban.""" 86 | total: int 87 | """The total number of users that were attempted to be banned.""" 88 | successful: int 89 | """The actual amount of users that have been banned.""" 90 | logfile: hikari.Resourceish 91 | """The massban session logfile.""" 92 | reason: str | None = None 93 | """The reason for the massban.""" 94 | 95 | 96 | @attr.define() 97 | class WarnEvent(SnedGuildEvent): 98 | """Base class for all warning events.""" 99 | 100 | app: SnedBot 101 | _guild_id: hikari.Snowflakeish 102 | member: hikari.Member 103 | """The member that was warned.""" 104 | moderator: hikari.Member 105 | """The moderator that warned the member.""" 106 | warn_count: int 107 | """The amount of warnings the member has.""" 108 | reason: str | None = None 109 | """The reason for the warning.""" 110 | 111 | 112 | @attr.define() 113 | class WarnCreateEvent(WarnEvent): 114 | """Dispatched when a user is warned.""" 115 | 116 | 117 | @attr.define() 118 | class WarnRemoveEvent(WarnEvent): 119 | """Dispatched when a warning is removed from a user.""" 120 | 121 | 122 | @attr.define() 123 | class WarnsClearEvent(WarnEvent): 124 | """Dispatched when warnings are cleared for a user.""" 125 | 126 | 127 | @attr.define() 128 | class AutoModMessageFlagEvent(SnedGuildEvent): 129 | """Dispatched when a message is flagged by auto-mod.""" 130 | 131 | app: SnedBot 132 | message: hikari.PartialMessage 133 | """The message that was flagged.""" 134 | user: hikari.PartialUser 135 | """The user that sent the message.""" 136 | _guild_id: hikari.Snowflakeish 137 | reason: str | None = None 138 | """The reason for the flag.""" 139 | 140 | 141 | @attr.define() 142 | class RoleButtonEvent(SnedGuildEvent): 143 | """Base class for all rolebutton-related events.""" 144 | 145 | app: SnedBot 146 | _guild_id: hikari.Snowflakeish 147 | rolebutton: RoleButton 148 | """The rolebutton that was altered.""" 149 | moderator: hikari.PartialUser | None = None 150 | """The moderator that altered the rolebutton.""" 151 | 152 | 153 | @attr.define() 154 | class RoleButtonCreateEvent(RoleButtonEvent): 155 | """Dispatched when a new rolebutton is created.""" 156 | 157 | 158 | @attr.define() 159 | class RoleButtonDeleteEvent(RoleButtonEvent): 160 | """Dispatched when a rolebutton is deleted.""" 161 | 162 | 163 | @attr.define() 164 | class RoleButtonUpdateEvent(RoleButtonEvent): 165 | """Dispatched when a rolebutton is updated.""" 166 | 167 | 168 | # Copyright (C) 2022-present hypergonial 169 | 170 | # This program is free software: you can redistribute it and/or modify 171 | # it under the terms of the GNU General Public License as published by 172 | # the Free Software Foundation, either version 3 of the License, or 173 | # (at your option) any later version. 174 | 175 | # This program is distributed in the hope that it will be useful, 176 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 177 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 178 | # GNU General Public License for more details. 179 | 180 | # You should have received a copy of the GNU General Public License 181 | # along with this program. If not, see: https://www.gnu.org/licenses 182 | -------------------------------------------------------------------------------- /src/models/journal.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import enum 5 | import typing as t 6 | 7 | import attr 8 | import hikari 9 | 10 | from src.models.db import DatabaseModel 11 | from src.utils import helpers 12 | 13 | if t.TYPE_CHECKING: 14 | import asyncpg 15 | 16 | 17 | class JournalEntryType(enum.IntEnum): 18 | BAN = 0 19 | UNBAN = 1 20 | KICK = 2 21 | TIMEOUT = 3 22 | TIMEOUT_REMOVE = 4 23 | NOTE = 5 24 | WARN = 6 25 | WARN_REMOVE = 7 26 | WARN_CLEAR = 8 27 | 28 | 29 | ENTRY_TYPE_VERB_MAPPING = { 30 | JournalEntryType.BAN: "🔨 Banned", 31 | JournalEntryType.UNBAN: "🔨 Unbanned", 32 | JournalEntryType.KICK: "👢 Kicked", 33 | JournalEntryType.TIMEOUT: "🔇 Timed out", 34 | JournalEntryType.TIMEOUT_REMOVE: "🔉 Timeout removed", 35 | JournalEntryType.NOTE: "💬 Note", 36 | JournalEntryType.WARN: "⚠️ Warned", 37 | JournalEntryType.WARN_REMOVE: "⚠️ 1 Warning removed", 38 | JournalEntryType.WARN_CLEAR: "⚠️ Warnings cleared", 39 | } 40 | 41 | 42 | @attr.define() 43 | class JournalEntry(DatabaseModel): 44 | """Represents a journal entry created through the /journal command.""" 45 | 46 | user_id: hikari.Snowflake 47 | """The user this journal entry belongs to.""" 48 | guild_id: hikari.Snowflake 49 | """The guild this entry belongs to.""" 50 | content: str | None 51 | """The content of the entry.""" 52 | author_id: hikari.Snowflake | None 53 | """The user who caused this entry to be created.""" 54 | created_at: datetime.datetime 55 | """UNIX timestamp of the entry's creation.""" 56 | entry_type: JournalEntryType 57 | """The type of this entry.""" 58 | id: int | None = None 59 | """The ID of the journal entry.""" 60 | 61 | @property 62 | def display_content(self) -> str: 63 | """Get the content of the entry, with a timestamp prepended.""" 64 | author_mention = f"<@{self.author_id}>" if self.author_id else "Unknown" 65 | content = self.content or "Error retrieving data from audit logs! Ensure the bot has permissions to view them!" 66 | return f"{helpers.format_dt(self.created_at, style='d')} **{ENTRY_TYPE_VERB_MAPPING.get(self.entry_type, '')} by {author_mention}**: {content}" 67 | 68 | @classmethod 69 | def from_record(cls, record: asyncpg.Record) -> t.Self: 70 | """Create a new instance of JournalEntry from an asyncpg.Record. 71 | 72 | Parameters 73 | ---------- 74 | record : asyncpg.Record 75 | The record to create the instance from. 76 | 77 | Returns 78 | ------- 79 | JournalEntry 80 | The created instance. 81 | """ 82 | return cls( 83 | id=record["id"], 84 | user_id=hikari.Snowflake(record["user_id"]), 85 | guild_id=hikari.Snowflake(record["guild_id"]), 86 | content=record.get("content"), 87 | author_id=hikari.Snowflake(record["author_id"]) if record.get("author_id") else None, 88 | created_at=datetime.datetime.fromtimestamp(record["created_at"]), 89 | entry_type=JournalEntryType(record["entry_type"]), 90 | ) 91 | 92 | @classmethod 93 | async def fetch( 94 | cls, id: int, user: hikari.SnowflakeishOr[hikari.PartialUser], guild: hikari.SnowflakeishOr[hikari.PartialGuild] 95 | ) -> t.Self | None: 96 | """Fetch a journal entry from the database. 97 | 98 | Parameters 99 | ---------- 100 | id : int 101 | The ID of the journal entry. 102 | user : hikari.SnowflakeishOr[hikari.PartialUser] 103 | The user this entry belongs to. 104 | guild : hikari.SnowflakeishOr[hikari.PartialGuild] 105 | The guild this entry belongs to. 106 | 107 | Returns 108 | ------- 109 | Optional[JournalEntry] 110 | The journal entry from the database, if found. 111 | """ 112 | record = await cls._db.fetchrow( 113 | "SELECT * FROM journal WHERE id = $1 AND user_id = $2 AND guild_id = $3", 114 | id, 115 | hikari.Snowflake(user), 116 | hikari.Snowflake(guild), 117 | ) 118 | if not record or not record.get("id"): 119 | return None 120 | return cls.from_record(record) 121 | 122 | @classmethod 123 | async def fetch_journal( 124 | cls, user: hikari.SnowflakeishOr[hikari.PartialUser], guild: hikari.SnowflakeishOr[hikari.PartialGuild] 125 | ) -> list[t.Self]: 126 | """Fetch a user's journal from the database. 127 | 128 | Parameters 129 | ---------- 130 | user : hikari.SnowflakeishOr[hikari.PartialUser] 131 | The user to fetch the journal for. 132 | guild : hikari.SnowflakeishOr[hikari.PartialGuild] 133 | The guild this entry belongs to. 134 | 135 | Returns 136 | ------- 137 | List[JournalEntry] 138 | The journal entries from the database sorted by creation date. 139 | """ 140 | records = await cls._db.fetch( 141 | "SELECT * FROM journal WHERE user_id = $1 AND guild_id = $2 ORDER BY created_at DESC", 142 | hikari.Snowflake(user), 143 | hikari.Snowflake(guild), 144 | ) 145 | return [cls.from_record(record) for record in records] 146 | 147 | async def update(self) -> None: 148 | """Update the journal entry in the database. 149 | 150 | If an entry with this ID does not yet exist, one will be created. 151 | 152 | If this entry doesn't have an ID, one will be assigned to it by the database. 153 | """ 154 | if self.id is None: # Entry doesn't yet exist, create a new one 155 | record = await self._db.fetchrow( 156 | """ 157 | INSERT INTO journal (user_id, guild_id, content, author_id, created_at, entry_type) 158 | VALUES ($1, $2, $3, $4, $5, $6) RETURNING id 159 | """, 160 | self.user_id, 161 | self.guild_id, 162 | self.content, 163 | self.author_id, 164 | self.created_at.timestamp(), 165 | self.entry_type.value, 166 | ) 167 | self.id = record.get("id") 168 | return 169 | 170 | await self._db.execute( 171 | """ 172 | INSERT INTO journal (id, user_id, guild_id, content, author_id, created_at, entry_type) 173 | VALUES ($1, $2, $3, $4, $5, $6, $7) 174 | ON CONFLICT (id) DO 175 | UPDATE SET user_id = $2, guild_id = $3, content = $4, author_id = $5, created_at = $6, 176 | entry_type = $7 177 | """, 178 | self.id, 179 | self.user_id, 180 | self.guild_id, 181 | self.content, 182 | self.author_id, 183 | self.created_at.timestamp(), 184 | self.entry_type.value, 185 | ) 186 | -------------------------------------------------------------------------------- /src/models/plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import lightbulb 6 | 7 | if t.TYPE_CHECKING: 8 | from src.models.bot import SnedBot 9 | 10 | 11 | class SnedPlugin(lightbulb.Plugin): 12 | @property 13 | def app(self) -> SnedBot: 14 | return super().app # type: ignore 15 | 16 | @app.setter 17 | def app(self, val: SnedBot) -> None: 18 | self._app = val 19 | self.create_commands() 20 | 21 | @property 22 | def bot(self) -> SnedBot: 23 | return super().bot # type: ignore 24 | 25 | 26 | # Copyright (C) 2022-present hypergonial 27 | 28 | # This program is free software: you can redistribute it and/or modify 29 | # it under the terms of the GNU General Public License as published by 30 | # the Free Software Foundation, either version 3 of the License, or 31 | # (at your option) any later version. 32 | 33 | # This program is distributed in the hope that it will be useful, 34 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 35 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 36 | # GNU General Public License for more details. 37 | 38 | # You should have received a copy of the GNU General Public License 39 | # along with this program. If not, see: https://www.gnu.org/licenses 40 | -------------------------------------------------------------------------------- /src/models/settings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import attr 6 | import hikari 7 | import miru 8 | from miru.abc import ModalItem, ViewItem 9 | 10 | if t.TYPE_CHECKING: 11 | from src.extensions.settings import SettingsView 12 | 13 | 14 | @attr.define() 15 | class SettingValue: 16 | """Monadic return value for a setting.""" 17 | 18 | is_done: bool = attr.field(default=False) 19 | """Signals that the values contained in this object are finalized in case of a 'with_done' layout.""" 20 | text: hikari.UndefinedOr[str] = attr.field(default=hikari.UNDEFINED) 21 | boolean: hikari.UndefinedOr[bool] = attr.field(default=hikari.UNDEFINED) 22 | users: hikari.UndefinedOr[t.Sequence[hikari.User]] = attr.field(default=hikari.UNDEFINED) 23 | roles: hikari.UndefinedNoneOr[t.Sequence[hikari.Role]] = attr.field(default=hikari.UNDEFINED) 24 | channels: hikari.UndefinedNoneOr[t.Sequence[hikari.InteractionChannel]] = attr.field(default=hikari.UNDEFINED) 25 | modal_values: hikari.UndefinedOr[t.Mapping[ModalItem, str]] = attr.field(default=hikari.UNDEFINED) 26 | raw_perspective_bounds: hikari.UndefinedOr[t.Mapping[str, str]] = attr.field(default=hikari.UNDEFINED) 27 | 28 | def __bool__(self) -> bool: # To make it easier to check if a value was set. 29 | return bool( 30 | self.is_done 31 | or self.text is not hikari.UNDEFINED 32 | or self.boolean is not hikari.UNDEFINED 33 | or self.users is not hikari.UNDEFINED 34 | or self.roles is not hikari.UNDEFINED 35 | or self.channels is not hikari.UNDEFINED 36 | or self.modal_values is not hikari.UNDEFINED 37 | or self.raw_perspective_bounds is not hikari.UNDEFINED 38 | ) 39 | 40 | 41 | class SettingsItem(ViewItem): 42 | @property 43 | def view(self) -> SettingsView: 44 | return super().view # type: ignore 45 | 46 | 47 | class BooleanButton(miru.Button, SettingsItem): 48 | """A boolean toggle button.""" 49 | 50 | def __init__( 51 | self, 52 | *, 53 | state: bool, 54 | label: str, 55 | disabled: bool = False, 56 | row: int | None = None, 57 | custom_id: str | None = None, 58 | ) -> None: 59 | style = hikari.ButtonStyle.SUCCESS if state else hikari.ButtonStyle.DANGER 60 | emoji = "✔️" if state else "✖️" 61 | 62 | self.state = state 63 | 64 | super().__init__(style=style, label=label, emoji=emoji, disabled=disabled, row=row, custom_id=custom_id) 65 | 66 | async def callback(self, _: miru.ViewContext) -> None: 67 | self.state = not self.state 68 | assert self.label is not None 69 | 70 | self.style = hikari.ButtonStyle.SUCCESS if self.state else hikari.ButtonStyle.DANGER 71 | self.emoji = "✔️" if self.state else "✖️" 72 | self.view.value = SettingValue(boolean=self.state, text=self.label) 73 | self.view.last_item = self 74 | 75 | 76 | class OptionButton(miru.Button, SettingsItem): 77 | """Button that sets view value to label.""" 78 | 79 | async def callback(self, _: miru.ViewContext) -> None: 80 | assert self.label is not None 81 | self.view.value = SettingValue(text=self.label) 82 | self.view.last_item = self 83 | 84 | 85 | class OptionsModal(miru.Modal): 86 | def __init__( 87 | self, 88 | view: SettingsView, 89 | title: str, 90 | *, 91 | custom_id: str | None = None, 92 | timeout: float | None = 300, 93 | ) -> None: 94 | super().__init__(title, custom_id=custom_id, timeout=timeout) 95 | self.view = view 96 | 97 | async def callback(self, context: miru.ModalContext) -> None: 98 | self.view.value = SettingValue(modal_values=context.values, is_done=True) 99 | self.view._last_context = context 100 | self.view._input_event.set() 101 | self.view._input_event.clear() 102 | self.view._done_event.set() 103 | self.view._done_event.clear() 104 | 105 | async def on_timeout(self) -> None: 106 | self.view.value = SettingValue() 107 | self.view._input_event.set() 108 | self.view._input_event.clear() 109 | self.view._done_event.set() 110 | self.view._done_event.clear() 111 | 112 | 113 | class PerspectiveBoundsModal(miru.Modal): 114 | def __init__( 115 | self, 116 | view: miru.View, 117 | values: dict[str, float], 118 | title: str, 119 | *, 120 | custom_id: str | None = None, 121 | timeout: float | None = 300, 122 | ) -> None: 123 | super().__init__(title, custom_id=custom_id, timeout=timeout) 124 | self.add_item( 125 | miru.TextInput( 126 | label="Toxicity", 127 | placeholder="Enter a floating point value...", 128 | custom_id="TOXICITY", 129 | value=str(values["TOXICITY"]), 130 | min_length=3, 131 | max_length=7, 132 | ) 133 | ) 134 | self.add_item( 135 | miru.TextInput( 136 | label="Severe Toxicity", 137 | placeholder="Enter a floating point value...", 138 | custom_id="SEVERE_TOXICITY", 139 | value=str(values["SEVERE_TOXICITY"]), 140 | min_length=3, 141 | max_length=7, 142 | ) 143 | ) 144 | self.add_item( 145 | miru.TextInput( 146 | label="Threat", 147 | placeholder="Enter a floating point value...", 148 | custom_id="THREAT", 149 | value=str(values["THREAT"]), 150 | min_length=3, 151 | max_length=7, 152 | ) 153 | ) 154 | self.add_item( 155 | miru.TextInput( 156 | label="Profanity", 157 | placeholder="Enter a floating point value...", 158 | custom_id="PROFANITY", 159 | value=str(values["PROFANITY"]), 160 | min_length=3, 161 | max_length=7, 162 | ) 163 | ) 164 | self.add_item( 165 | miru.TextInput( 166 | label="Insult", 167 | placeholder="Enter a floating point value...", 168 | custom_id="INSULT", 169 | value=str(values["INSULT"]), 170 | min_length=3, 171 | max_length=7, 172 | ) 173 | ) 174 | self.view = view 175 | 176 | async def callback(self, context: miru.ModalContext) -> None: 177 | self.view._last_context = context 178 | self.view.value = SettingValue( 179 | raw_perspective_bounds={item.custom_id: value for item, value in context.values.items()} 180 | ) # type: ignore 181 | self.view._input_event.set() 182 | self.view._input_event.clear() 183 | 184 | async def on_timeout(self) -> None: 185 | self.view.value = SettingValue() # type: ignore 186 | self.view._input_event.set() 187 | self.view._input_event.clear() 188 | 189 | 190 | class OptionsTextSelect(miru.TextSelect, SettingsItem): 191 | """Select that sets view value to first selected option's value.""" 192 | 193 | def __init__(self, with_done: bool = False, **kwargs): 194 | super().__init__(**kwargs) 195 | self.with_done = with_done 196 | 197 | async def callback(self, ctx: miru.ViewContext) -> None: 198 | self.view.value = SettingValue(text=self.values[0]) 199 | self.view.last_item = self 200 | 201 | if self.with_done: 202 | await ctx.defer() 203 | 204 | 205 | class OptionsRoleSelect(miru.RoleSelect, SettingsItem): 206 | def __init__(self, with_done: bool = False, **kwargs): 207 | super().__init__(**kwargs) 208 | self.with_done = with_done 209 | 210 | async def callback(self, ctx: miru.ViewContext) -> None: 211 | self.view.value = SettingValue(roles=self.values or None) 212 | self.view.last_item = self 213 | 214 | if self.with_done: 215 | await ctx.defer() 216 | 217 | 218 | class OptionsChannelSelect(miru.ChannelSelect, SettingsItem): 219 | def __init__(self, with_done: bool = False, **kwargs): 220 | super().__init__(**kwargs) 221 | self.with_done = with_done 222 | 223 | async def callback(self, ctx: miru.ViewContext) -> None: 224 | self.view.value = SettingValue(channels=self.values) 225 | self.view.last_item = self 226 | 227 | if self.with_done: 228 | await ctx.defer() 229 | 230 | 231 | class BackButton(OptionButton): 232 | """Go back to page that ctx.parent is set to.""" 233 | 234 | def __init__(self, parent: str, **kwargs) -> None: 235 | super().__init__(style=hikari.ButtonStyle.PRIMARY, custom_id=parent, label="Back", emoji="⬅️") 236 | self.kwargs = kwargs 237 | 238 | async def callback(self, _: miru.ViewContext) -> None: 239 | self.view.last_item = self 240 | self.view.value = SettingValue() 241 | self.view._done_event.set() # Trigger the done event in case the view is waiting for one 242 | self.view._done_event.clear() 243 | await self.view.menu_actions[self.custom_id](**self.kwargs) 244 | 245 | 246 | class DoneButton(miru.Button, SettingsItem): 247 | """Button that signals to the view the action being waited for is done.""" 248 | 249 | def __init__(self, parent: str, **kwargs) -> None: 250 | super().__init__(style=hikari.ButtonStyle.SUCCESS, custom_id=f"done:{parent}", label="Done", emoji="✔️") 251 | self.kwargs = kwargs 252 | 253 | async def callback(self, _: miru.ViewContext) -> None: 254 | self.view.last_item = self 255 | self.view.value.is_done = True # Confirm that all values are final 256 | self.view._done_event.set() 257 | self.view._done_event.clear() 258 | 259 | 260 | class QuitButton(OptionButton): 261 | """Quit settings, delete message.""" 262 | 263 | def __init__(self) -> None: 264 | super().__init__(style=hikari.ButtonStyle.DANGER, label="Quit", emoji="⬅️") 265 | 266 | async def callback(self, _: miru.ViewContext) -> None: 267 | self.view.last_item = self 268 | self.view.value = SettingValue() 269 | await self.view.menu_actions["Quit"]() 270 | 271 | 272 | # Copyright (C) 2022-present hypergonial 273 | 274 | # This program is free software: you can redistribute it and/or modify 275 | # it under the terms of the GNU General Public License as published by 276 | # the Free Software Foundation, either version 3 of the License, or 277 | # (at your option) any later version. 278 | 279 | # This program is distributed in the hope that it will be useful, 280 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 281 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 282 | # GNU General Public License for more details. 283 | 284 | # You should have received a copy of the GNU General Public License 285 | # along with this program. If not, see: https://www.gnu.org/licenses 286 | -------------------------------------------------------------------------------- /src/models/starboard.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | 5 | import attr 6 | import hikari 7 | 8 | from src.models.db import DatabaseModel 9 | 10 | if t.TYPE_CHECKING: 11 | import asyncpg 12 | 13 | 14 | @attr.define() 15 | class StarboardSettings(DatabaseModel): 16 | """Represents the starboard settings for a guild.""" 17 | 18 | guild_id: hikari.Snowflake = attr.field() 19 | """The guild this starboard settings belongs to.""" 20 | 21 | channel_id: hikari.Snowflake | None = attr.field(default=None) 22 | """The channel where the starboard messages will be sent.""" 23 | 24 | star_limit: int = attr.field(default=5) 25 | """The amount of stars needed to post a message to the starboard.""" 26 | 27 | is_enabled: bool = attr.field(default=False) 28 | """Whether the starboard is enabled or not.""" 29 | 30 | excluded_channels: t.Sequence[hikari.Snowflake] | None = attr.field(default=None) 31 | """Channels that are excluded from the starboard.""" 32 | 33 | @classmethod 34 | def from_record(cls, record: asyncpg.Record) -> StarboardSettings: 35 | """Create an instance of StarboardSettings from an asyncpg.Record.""" 36 | return cls( 37 | guild_id=record["guild_id"], 38 | channel_id=record["channel_id"], 39 | star_limit=record["star_limit"], 40 | is_enabled=record["is_enabled"], 41 | excluded_channels=record["excluded_channels"], 42 | ) 43 | 44 | @classmethod 45 | async def fetch(cls, guild: hikari.SnowflakeishOr[hikari.PartialGuild]) -> StarboardSettings: 46 | """Fetch the starboard settings for a guild from the database. If they do not exist, return default values.""" 47 | records = await cls._app.db_cache.get(table="starboard", guild_id=hikari.Snowflake(guild), limit=1) 48 | if not records: 49 | return cls(guild_id=hikari.Snowflake(guild)) 50 | return cls.from_record(records[0]) # type: ignore 51 | 52 | async def update(self) -> None: 53 | """Update the starboard settings in the database, or insert them if they do not yet exist.""" 54 | await self._db.execute( 55 | """INSERT INTO starboard 56 | (guild_id, channel_id, star_limit, is_enabled, excluded_channels) 57 | VALUES ($1, $2, $3, $4, $5) 58 | ON CONFLICT (guild_id) DO 59 | UPDATE SET channel_id = $2, star_limit = $3, is_enabled = $4, excluded_channels = $5""", 60 | self.guild_id, 61 | self.channel_id, 62 | self.star_limit, 63 | self.is_enabled, 64 | self.excluded_channels, 65 | ) 66 | await self._app.db_cache.refresh(table="starboard", guild_id=self.guild_id) 67 | 68 | 69 | @attr.define() 70 | class StarboardEntry(DatabaseModel): 71 | """Represents a starboard entry in the database.""" 72 | 73 | guild_id: hikari.Snowflake = attr.field() 74 | """The guild this starboard entry belongs to.""" 75 | 76 | channel_id: hikari.Snowflake = attr.field() 77 | """The channel the original message is in.""" 78 | 79 | original_message_id: hikari.Snowflake = attr.field() 80 | """The message that was starred.""" 81 | 82 | entry_message_id: hikari.Snowflake = attr.field() 83 | """The message that was posted to the starboard.""" 84 | 85 | force_starred: bool = attr.field(default=False) 86 | """Whether the message was force starred or not.""" 87 | 88 | @classmethod 89 | def from_record(cls, record: asyncpg.Record) -> StarboardEntry: 90 | """Create an instance of StarboardEntry from an asyncpg.Record.""" 91 | return cls( 92 | guild_id=record["guild_id"], 93 | channel_id=record["channel_id"], 94 | original_message_id=record["orig_msg_id"], 95 | entry_message_id=record["entry_msg_id"], 96 | force_starred=record["force_starred"], 97 | ) 98 | 99 | @classmethod 100 | async def fetch(cls, original_message: hikari.SnowflakeishOr[hikari.PartialMessage]) -> StarboardEntry | None: 101 | """Fetch the starboard entry for a message from the database, if one exists.""" 102 | records = await cls._app.db_cache.get( 103 | table="starboard_entries", orig_msg_id=hikari.Snowflake(original_message), limit=1 104 | ) 105 | if not records: 106 | return None 107 | return cls.from_record(records[0]) # type: ignore 108 | 109 | async def update(self) -> None: 110 | """Update the starboard entry in the database, or insert it if it does not yet exist.""" 111 | await self._db.execute( 112 | """INSERT INTO starboard_entries 113 | (guild_id, channel_id, orig_msg_id, entry_msg_id, force_starred) 114 | VALUES ($1, $2, $3, $4, $5) 115 | ON CONFLICT (guild_id, channel_id, orig_msg_id) DO 116 | UPDATE SET guild_id = $1, channel_id = $2, orig_msg_id = $3, entry_msg_id = $4, force_starred = $5""", 117 | self.guild_id, 118 | self.channel_id, 119 | self.original_message_id, 120 | self.entry_message_id, 121 | self.force_starred, 122 | ) 123 | await self._app.db_cache.refresh(table="starboard_entries", orig_msg_id=self.original_message_id) 124 | 125 | async def delete(self) -> None: 126 | """Delete the starboard entry from the database.""" 127 | await self._db.execute( 128 | "DELETE FROM starboard_entries WHERE guild_id = $1 AND orig_msg_id = $2", 129 | self.guild_id, 130 | self.original_message_id, 131 | ) 132 | await self._app.db_cache.refresh(table="starboard_entries", orig_msg_id=self.original_message_id) 133 | -------------------------------------------------------------------------------- /src/models/tag.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing as t 4 | from difflib import get_close_matches 5 | from itertools import chain 6 | 7 | import attr 8 | import hikari 9 | 10 | from src.models.db import DatabaseModel 11 | 12 | if t.TYPE_CHECKING: 13 | from src.models.context import SnedContext 14 | 15 | 16 | @attr.define() 17 | class Tag(DatabaseModel): 18 | """Represents a tag object.""" 19 | 20 | guild_id: hikari.Snowflake 21 | name: str 22 | owner_id: hikari.Snowflake 23 | aliases: list[str] | None 24 | content: str 25 | creator_id: hikari.Snowflake | None = None 26 | uses: int = 0 27 | 28 | @classmethod 29 | async def fetch( 30 | cls, name: str, guild: hikari.SnowflakeishOr[hikari.PartialGuild], add_use: bool = False 31 | ) -> t.Self | None: 32 | """Fetches a tag from the database. 33 | 34 | Parameters 35 | ---------- 36 | name : str 37 | The name of the tag to fetch. 38 | guild : hikari.SnowflakeishOr[hikari.PartialGuild] 39 | The guild the tag is located in. 40 | add_use : bool, optional 41 | If True, increments the usage counter, by default False 42 | 43 | Returns 44 | ------- 45 | Optional[Tag] 46 | The tag object, if found. 47 | """ 48 | guild_id = hikari.Snowflake(guild) 49 | 50 | if add_use: 51 | sql = "UPDATE tags SET uses = uses + 1 WHERE tagname = $1 AND guild_id = $2 OR $1 = ANY(aliases) AND guild_id = $2 RETURNING *" 52 | else: 53 | sql = "SELECT * FROM tags WHERE tagname = $1 AND guild_id = $2 OR $1 = ANY(aliases) AND guild_id = $2" 54 | 55 | record = await cls._db.fetchrow(sql, name.lower(), guild_id) 56 | 57 | if not record: 58 | return None 59 | 60 | return cls( 61 | guild_id=hikari.Snowflake(record["guild_id"]), 62 | name=record["tagname"], 63 | owner_id=hikari.Snowflake(record["owner_id"]), 64 | creator_id=hikari.Snowflake(record["creator_id"]) if record.get("creator_id") else None, 65 | aliases=record.get("aliases"), 66 | content=record["content"], 67 | uses=record["uses"], 68 | ) 69 | 70 | @classmethod 71 | async def fetch_closest_names( 72 | cls, name: str, guild: hikari.SnowflakeishOr[hikari.PartialGuild] 73 | ) -> list[str] | None: 74 | """Fetch the closest tagnames for the provided name. 75 | 76 | Parameters 77 | ---------- 78 | name : str 79 | The name to use for finding close matches. 80 | guild : hikari.SnowflakeishOr[hikari.PartialGuild] 81 | The guild the tags are located in. 82 | 83 | Returns 84 | ------- 85 | Optional[List[str]] 86 | A list of tag names and aliases. 87 | """ 88 | guild_id = hikari.Snowflake(guild) 89 | # TODO: Figure out how to fuzzymatch within arrays via SQL 90 | results = await cls._db.fetch("""SELECT tagname, aliases FROM tags WHERE guild_id = $1""", guild_id) 91 | 92 | names = [result["tagname"] for result in results] if results else [] 93 | 94 | if results is not None: 95 | names += list(chain(*[result.get("aliases") or [] for result in results])) 96 | 97 | return get_close_matches(name, names) 98 | 99 | @classmethod 100 | async def fetch_closest_owned_names( 101 | cls, 102 | name: str, 103 | guild: hikari.SnowflakeishOr[hikari.PartialGuild], 104 | owner: hikari.SnowflakeishOr[hikari.PartialUser], 105 | ) -> list[str] | None: 106 | """Fetch the closest tagnames for the provided name and owner. 107 | 108 | Parameters 109 | ---------- 110 | name : str 111 | The name to use for finding close matches. 112 | guild : hikari.SnowflakeishOr[hikari.PartialGuild] 113 | The guild the tags are located in. 114 | owner : hikari.SnowflakeishOr[hikari.PartialUser] 115 | The owner of the tags. 116 | 117 | Returns 118 | ------- 119 | Optional[List[str]] 120 | A list of tag names and aliases. 121 | """ 122 | guild_id = hikari.Snowflake(guild) 123 | owner_id = hikari.Snowflake(owner) 124 | # TODO: Figure out how to fuzzymatch within arrays via SQL 125 | results = await cls._db.fetch( 126 | """SELECT tagname, aliases FROM tags WHERE guild_id = $1 AND owner_id = $2""", guild_id, owner_id 127 | ) 128 | 129 | names = [result["tagname"] for result in results] if results else [] 130 | 131 | if results is not None: 132 | names += list(chain(*[result.get("aliases") or [] for result in results])) 133 | 134 | return get_close_matches(name, names) 135 | 136 | @classmethod 137 | async def fetch_all( 138 | cls, 139 | guild: hikari.SnowflakeishOr[hikari.PartialGuild], 140 | owner: hikari.SnowflakeishOr[hikari.PartialUser] | None = None, 141 | ) -> list[t.Self]: 142 | """Fetch all tags that belong to a guild, and optionally a user. 143 | 144 | Parameters 145 | ---------- 146 | guild : hikari.SnowflakeishOr[hikari.PartialGuild] 147 | The guild the tags belong to. 148 | owner : hikari.SnowflakeishOr[hikari.PartialUser], optional 149 | The owner the tags belong to, by default None 150 | 151 | Returns 152 | ------- 153 | List[Tag] 154 | A list of tags that match the criteria. 155 | """ 156 | guild_id = hikari.Snowflake(guild) 157 | if not owner: 158 | records = await cls._db.fetch("""SELECT * FROM tags WHERE guild_id = $1 ORDER BY uses DESC""", guild_id) 159 | else: 160 | records = await cls._db.fetch( 161 | """SELECT * FROM tags WHERE guild_id = $1 AND owner_id = $2 ORDER BY uses DESC""", 162 | guild_id, 163 | hikari.Snowflake(owner), 164 | ) 165 | 166 | if not records: 167 | return [] 168 | 169 | return [ 170 | cls( 171 | guild_id=hikari.Snowflake(record["guild_id"]), 172 | name=record["tagname"], 173 | owner_id=hikari.Snowflake(record["owner_id"]), 174 | creator_id=hikari.Snowflake(record["creator_id"]) if record.get("creator_id") else None, 175 | aliases=record.get("aliases"), 176 | content=record["content"], 177 | uses=record["uses"], 178 | ) 179 | for record in records 180 | ] 181 | 182 | @classmethod 183 | async def create( 184 | cls, 185 | name: str, 186 | guild: hikari.SnowflakeishOr[hikari.PartialGuild], 187 | creator: hikari.SnowflakeishOr[hikari.PartialUser], 188 | owner: hikari.SnowflakeishOr[hikari.PartialUser], 189 | aliases: list[str], 190 | content: str, 191 | ) -> Tag: 192 | """Create a new tag object and save it to the database. 193 | 194 | Parameters 195 | ---------- 196 | name : str 197 | The name of the tag. 198 | guild : hikari.SnowflakeishOr[hikari.PartialGuild] 199 | The guild the tag belongs to. 200 | creator : hikari.SnowflakeishOr[hikari.PartialUser] 201 | The creator of the tag. 202 | owner : hikari.SnowflakeishOr[hikari.PartialUser] 203 | The current owner of the tag. 204 | aliases : list[str] 205 | A list of all aliases the tag has. 206 | content : str 207 | The content of the tag. 208 | 209 | Returns 210 | ------- 211 | Tag 212 | The created tag object. 213 | """ 214 | await cls._db.execute( 215 | """ 216 | INSERT INTO tags (guild_id, tagname, creator_id, owner_id, aliases, content) 217 | VALUES ($1, $2, $3, $4, $5, $6)""", 218 | hikari.Snowflake(guild), 219 | name, 220 | hikari.Snowflake(creator), 221 | hikari.Snowflake(owner), 222 | aliases, 223 | content, 224 | ) 225 | return cls( 226 | guild_id=hikari.Snowflake(guild), 227 | name=name, 228 | owner_id=hikari.Snowflake(owner), 229 | creator_id=hikari.Snowflake(creator), 230 | aliases=aliases, 231 | content=content, 232 | ) 233 | 234 | async def delete(self) -> None: 235 | """Delete the tag from the database.""" 236 | await self._db.execute("""DELETE FROM tags WHERE tagname = $1 AND guild_id = $2""", self.name, self.guild_id) 237 | 238 | async def update(self) -> None: 239 | """Update the tag's attributes and sync it up to the database.""" 240 | await self._db.execute( 241 | """INSERT INTO tags (guild_id, tagname, owner_id, aliases, content) 242 | VALUES ($1, $2, $3, $4, $5) ON CONFLICT (guild_id, tagname) DO 243 | UPDATE SET owner_id = $3, aliases = $4, content = $5""", 244 | self.guild_id, 245 | self.name, 246 | self.owner_id, 247 | self.aliases, 248 | self.content, 249 | ) 250 | 251 | def parse_content(self, ctx: SnedContext) -> str: 252 | """Parse a tag's contents and substitute any variables with data. 253 | 254 | Parameters 255 | ---------- 256 | ctx : SnedContext 257 | The context to evaluate variables under. 258 | 259 | Returns 260 | ------- 261 | str 262 | The parsed tag contents. 263 | """ 264 | return self.content.replace("{user}", ctx.author.mention).replace("{channel}", f"<#{ctx.channel_id}>") 265 | 266 | 267 | # Copyright (C) 2022-present hypergonial 268 | 269 | # This program is free software: you can redistribute it and/or modify 270 | # it under the terms of the GNU General Public License as published by 271 | # the Free Software Foundation, either version 3 of the License, or 272 | # (at your option) any later version. 273 | 274 | # This program is distributed in the hope that it will be useful, 275 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 276 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 277 | # GNU General Public License for more details. 278 | 279 | # You should have received a copy of the GNU General Public License 280 | # along with this program. If not, see: https://www.gnu.org/licenses 281 | -------------------------------------------------------------------------------- /src/models/timer.py: -------------------------------------------------------------------------------- 1 | import enum 2 | 3 | import attr 4 | import hikari 5 | 6 | 7 | class TimerEvent(enum.Enum): 8 | """An enum containing all types of timer events.""" 9 | 10 | REMINDER = "reminder" 11 | """A timer dispatched when a reminder expires.""" 12 | 13 | TIMEOUT_EXTEND = "timeout_extend" 14 | """A timer dispatched when a timeout extension needs to be applied.""" 15 | 16 | TEMPBAN = "tempban" 17 | """A timer dispatched when a tempban expires.""" 18 | 19 | 20 | @attr.define() 21 | class Timer: 22 | """Represents a timer object.""" 23 | 24 | id: int 25 | """The ID of this timer.""" 26 | 27 | guild_id: hikari.Snowflake 28 | """The guild this timer is bound to.""" 29 | 30 | user_id: hikari.Snowflake 31 | """The user this timer is bound to.""" 32 | 33 | channel_id: hikari.Snowflake | None 34 | """The channel this timer is bound to.""" 35 | 36 | event: TimerEvent 37 | """The event type of this timer.""" 38 | 39 | expires: int 40 | """The expiry date of this timer as a UNIX timestamp.""" 41 | 42 | notes: str | None 43 | """Optional data for this timer. May be a JSON-serialized string depending on the event type.""" 44 | 45 | 46 | # Copyright (C) 2022-present hypergonial 47 | 48 | # This program is free software: you can redistribute it and/or modify 49 | # it under the terms of the GNU General Public License as published by 50 | # the Free Software Foundation, either version 3 of the License, or 51 | # (at your option) any later version. 52 | 53 | # This program is distributed in the hope that it will be useful, 54 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 55 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 56 | # GNU General Public License for more details. 57 | 58 | # You should have received a copy of the GNU General Public License 59 | # along with this program. If not, see: https://www.gnu.org/licenses 60 | -------------------------------------------------------------------------------- /src/models/views.py: -------------------------------------------------------------------------------- 1 | import hikari 2 | import lightbulb 3 | import miru 4 | from miru.ext import nav 5 | 6 | from src.etc import const 7 | 8 | 9 | class StopSelect(miru.TextSelect): 10 | """A select that stops the view after interaction.""" 11 | 12 | async def callback(self, context: miru.Context) -> None: 13 | self.view.stop() 14 | 15 | 16 | class AuthorOnlyView(miru.View): 17 | """A navigator that only works for the user who invoked it.""" 18 | 19 | def __init__(self, lctx: lightbulb.Context, *, timeout: float | None = 120, autodefer: bool = True) -> None: 20 | super().__init__(timeout=timeout, autodefer=autodefer) 21 | self.lctx = lctx 22 | 23 | async def view_check(self, ctx: miru.Context) -> bool: 24 | if ctx.user.id != self.lctx.author.id: 25 | await ctx.respond( 26 | embed=hikari.Embed( 27 | title="❌ Oops!", 28 | description="A magical barrier is stopping you from interacting with this component menu!", 29 | color=const.ERROR_COLOR, 30 | ), 31 | flags=hikari.MessageFlag.EPHEMERAL, 32 | ) 33 | 34 | return ctx.user.id == self.lctx.author.id 35 | 36 | 37 | class SnedNavigator(nav.NavigatorView): 38 | def __init__( 39 | self, 40 | *, 41 | pages: list[str | hikari.Embed], 42 | buttons: list[nav.NavButton] | None = None, 43 | timeout: float | None = 120.0, 44 | autodefer: bool = True, 45 | ) -> None: 46 | buttons = buttons or [ 47 | nav.FirstButton(emoji=const.EMOJI_FIRST), 48 | nav.PrevButton(emoji=const.EMOJI_PREV), 49 | nav.IndicatorButton(), 50 | nav.NextButton(emoji=const.EMOJI_NEXT), 51 | nav.LastButton(emoji=const.EMOJI_LAST), 52 | ] 53 | super().__init__(pages=pages, buttons=buttons, timeout=timeout, autodefer=autodefer) 54 | 55 | 56 | class AuthorOnlyNavigator(SnedNavigator): 57 | """A navigator that only works for the user who invoked it.""" 58 | 59 | def __init__( 60 | self, 61 | lctx: lightbulb.Context, 62 | *, 63 | pages: list[str | hikari.Embed], 64 | buttons: list[nav.NavButton] | None = None, 65 | timeout: float | None = 300.0, 66 | autodefer: bool = True, 67 | ) -> None: 68 | self.lctx = lctx 69 | 70 | super().__init__(pages=pages, buttons=buttons, timeout=timeout, autodefer=autodefer) 71 | 72 | async def view_check(self, ctx: miru.Context) -> bool: 73 | if ctx.user.id != self.lctx.author.id: 74 | await ctx.respond( 75 | embed=hikari.Embed( 76 | title="❌ Oops!", 77 | description="A magical barrier is stopping you from interacting with this navigation menu!", 78 | color=const.ERROR_COLOR, 79 | ), 80 | flags=hikari.MessageFlag.EPHEMERAL, 81 | ) 82 | 83 | return ctx.user.id == self.lctx.author.id 84 | 85 | 86 | # Copyright (C) 2022-present hypergonial 87 | 88 | # This program is free software: you can redistribute it and/or modify 89 | # it under the terms of the GNU General Public License as published by 90 | # the Free Software Foundation, either version 3 of the License, or 91 | # (at your option) any later version. 92 | 93 | # This program is distributed in the hope that it will be useful, 94 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 95 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 96 | # GNU General Public License for more details. 97 | 98 | # You should have received a copy of the GNU General Public License 99 | # along with this program. If not, see: https://www.gnu.org/licenses 100 | -------------------------------------------------------------------------------- /src/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .cache import * 2 | from .helpers import * 3 | from .ratelimiter import * 4 | from .scheduler import * 5 | from .tasks import * 6 | 7 | # Copyright (C) 2022-present hypergonial 8 | 9 | # This program is free software: you can redistribute it and/or modify 10 | # it under the terms of the GNU General Public License as published by 11 | # the Free Software Foundation, either version 3 of the License, or 12 | # (at your option) any later version. 13 | 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program. If not, see: https://www.gnu.org/licenses 21 | -------------------------------------------------------------------------------- /src/utils/cache.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import re 5 | import typing as t 6 | 7 | import hikari 8 | 9 | from src.models.db import DatabaseModel 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | if t.TYPE_CHECKING: 14 | from src.models import SnedBot 15 | 16 | 17 | class DatabaseCache: 18 | """A class aimed squarely at making caching of values easier to handle, and 19 | centralize it. It tries lazy-loading a dict whenever requesting data, 20 | or setting it. 21 | """ 22 | 23 | def __init__(self, bot: SnedBot) -> None: 24 | self.bot: SnedBot = bot 25 | self._cache: dict[str, list[dict[str, t.Any]]] = {} 26 | self.is_ready: bool = False 27 | 28 | def _clean_kwarg(self, kwarg: str) -> str: 29 | return re.sub(r"\W|^(?=\d)", "_", kwarg) 30 | 31 | async def start(self) -> None: 32 | """Initialize the database cache. This should be called after the database is set up.""" 33 | self.is_ready = False 34 | self._cache = {} 35 | DatabaseModel._db_cache = self 36 | 37 | records = await self.bot.db.fetch( 38 | """ 39 | SELECT tablename FROM pg_catalog.pg_tables 40 | WHERE schemaname='public' 41 | """ 42 | ) 43 | for record in records: 44 | self._cache[record["tablename"]] = [] 45 | logger.info("Cache initialized!") 46 | self.is_ready = True 47 | 48 | # Leaving this as async for potential future functionality 49 | async def stop(self) -> None: 50 | """Disable the cache and wipe all of it's contents.""" 51 | self.is_ready = False 52 | self._cache = {} 53 | 54 | async def get( 55 | self, table: str, *, cache_only: bool = False, limit: int | None = None, **kwargs: t.Any 56 | ) -> list[dict[str, t.Any]] | None: 57 | """Get a value from the database cache, lazily fetches from the database if the value is not cached. 58 | 59 | Parameters 60 | ---------- 61 | table : str 62 | The table to get values from 63 | cache_only : bool, optional 64 | Set to True if fetching from the database is undesirable, by default False 65 | limit : Optional[int], optional 66 | The maximum amount of rows to return, by default None 67 | **kwargs: t.Any, optional 68 | Keyword-only arguments that denote columns to filter values. 69 | 70 | Returns 71 | ------- 72 | Optional[List[Dict[str, Any]]] 73 | A list of dicts representing the rows in the specified table. 74 | """ 75 | if not self.is_ready: 76 | return None 77 | 78 | rows: list[dict[str, t.Any]] = [] 79 | 80 | for row in self._cache[table]: 81 | if limit and len(rows) >= limit: 82 | break 83 | 84 | # Check if all kwargs match what is in the row 85 | if all([row[kwarg] == value for kwarg, value in kwargs.items()]): 86 | rows.append(row) 87 | 88 | if not rows and not cache_only: 89 | await self.refresh(table, **kwargs) 90 | 91 | for row in self._cache[table]: 92 | if limit and len(rows) >= limit: 93 | break 94 | 95 | if all([row[kwarg] == value for kwarg, value in kwargs.items()]): 96 | rows.append(row) 97 | 98 | return rows if rows else None 99 | 100 | async def refresh(self, table: str, **kwargs) -> None: 101 | """Discards and reloads a specific part of the cache, should be called after modifying database values.""" 102 | if not self.is_ready: 103 | return 104 | 105 | if self._cache.get(table) is None: 106 | raise ValueError("Invalid table specified.") 107 | 108 | # Construct sql args, remove invalid python chars 109 | sql_args = [f"{self._clean_kwarg(kwarg)} = ${i+1}" for i, kwarg in enumerate(kwargs)] 110 | records = await self.bot.db.fetch(f"""SELECT * FROM {table} WHERE {' AND '.join(sql_args)}""", *kwargs.values()) 111 | 112 | for i, row in enumerate(self._cache[table]): 113 | # Pop old values that match the kwargs 114 | if all([row[kwarg] == value for kwarg, value in kwargs.items()]): 115 | self._cache[table].pop(i) 116 | 117 | for record in records: 118 | self._cache[table].append(dict(record)) 119 | 120 | async def wipe(self, guild: hikari.SnowflakeishOr[hikari.PartialGuild]) -> None: 121 | """Discards the entire cache for a guild.""" 122 | if not self.is_ready: 123 | return 124 | 125 | guild_id = hikari.Snowflake(guild) 126 | 127 | for table in self._cache: 128 | for i, row in enumerate(self._cache[table]): 129 | if row.get("guild_id") == guild_id: 130 | self._cache[table].pop(i) 131 | 132 | 133 | # Copyright (C) 2022-present hypergonial 134 | 135 | # This program is free software: you can redistribute it and/or modify 136 | # it under the terms of the GNU General Public License as published by 137 | # the Free Software Foundation, either version 3 of the License, or 138 | # (at your option) any later version. 139 | 140 | # This program is distributed in the hope that it will be useful, 141 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 142 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 143 | # GNU General Public License for more details. 144 | 145 | # You should have received a copy of the GNU General Public License 146 | # along with this program. If not, see: https://www.gnu.org/licenses 147 | -------------------------------------------------------------------------------- /src/utils/db_backup.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import os 4 | import pathlib 5 | 6 | import hikari 7 | 8 | 9 | async def backup_database() -> hikari.File: 10 | """Attempts to back up the database via pg_dump into the db_backup directory.""" 11 | logging.info("Performing daily database backup...") 12 | 13 | username: str = os.getenv("POSTGRES_USER") or "postgres" 14 | password: str = os.getenv("POSTGRES_PASSWORD") or "" 15 | hostname: str = os.getenv("POSTGRES_HOST") or "sned-db" 16 | port: str = os.getenv("POSTGRES_PORT") or "5432" 17 | db_name: str = os.getenv("POSTGRES_DB") or "sned" 18 | 19 | os.environ["PGPASSWORD"] = password 20 | 21 | filepath: str = str(pathlib.Path(os.path.abspath(__file__)).parents[1]) 22 | 23 | if not os.path.isdir(os.path.join(filepath, "db", "backup")): 24 | os.mkdir(os.path.join(filepath, "db", "backup")) 25 | 26 | now = datetime.datetime.now(datetime.timezone.utc) 27 | 28 | filename: str = f"{now.year}-{now.month}-{now.day}_{now.hour}_{now.minute}_{now.second}.pgdmp" 29 | backup_path: str = os.path.join(filepath, "db", "backup", filename) 30 | 31 | return_code = os.system( 32 | f"pg_dump -Fc -c -U {username} -d {db_name} -h {hostname} -p {port} --quote-all-identifiers -w > {backup_path}" 33 | ) 34 | os.environ["PGPASSWORD"] = "" 35 | 36 | if return_code != 0: 37 | raise RuntimeError("pg_dump failed to create a database backup file!") 38 | 39 | logging.info("Database backup complete!") 40 | return hikari.File(backup_path) 41 | 42 | 43 | # Copyright (C) 2022-present hypergonial 44 | 45 | # This program is free software: you can redistribute it and/or modify 46 | # it under the terms of the GNU General Public License as published by 47 | # the Free Software Foundation, either version 3 of the License, or 48 | # (at your option) any later version. 49 | 50 | # This program is distributed in the hope that it will be useful, 51 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 52 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 53 | # GNU General Public License for more details. 54 | 55 | # You should have received a copy of the GNU General Public License 56 | # along with this program. If not, see: https://www.gnu.org/licenses 57 | -------------------------------------------------------------------------------- /src/utils/dictionaryapi.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import re 5 | import typing as t 6 | 7 | import aiohttp 8 | import attr 9 | import yarl 10 | 11 | # The idea for creating this module, along with the URL for the M-W autocomplete endpoint was found here: 12 | # https://github.com/advaith1/dictionary 13 | 14 | URBAN_SEARCH_URL = "https://www.urbandictionary.com/define.php?term={term}" 15 | URBAN_JUMP_URL = URBAN_SEARCH_URL + "&defid={defid}" 16 | URBAN_API_SEARCH_URL = "https://api.urbandictionary.com/v0/define?term={term}" 17 | 18 | MW_AUTOCOMPLETE_URL = "https://www.merriam-webster.com/lapi/v1/mwol-search/autocomplete?search={word}" 19 | MW_SEARCH_URL = "https://www.dictionaryapi.com/api/v3/references/collegiate/json/{word}?key={key}" 20 | 21 | 22 | class UrbanError(Exception): 23 | """Base exception for Urban Dictionary API errors.""" 24 | 25 | 26 | class DictionaryError(Exception): 27 | """An exception raised if the connection to the Dictionary API fails.""" 28 | 29 | 30 | @attr.frozen(weakref_slot=False) 31 | class UrbanEntry: 32 | """A dictionary entry in the Urban Dictionary.""" 33 | 34 | word: str 35 | """The word this entry represents.""" 36 | 37 | definition: str 38 | """The definition of the word.""" 39 | 40 | defid: int 41 | """The ID of the definition.""" 42 | 43 | example: str 44 | """An example of the word.""" 45 | 46 | thumbs_up: int 47 | """The amount of thumbs up the word has.""" 48 | 49 | thumbs_down: int 50 | """The amount of thumbs down the word has.""" 51 | 52 | author: str 53 | """The author of the word.""" 54 | 55 | written_on: datetime.datetime 56 | """The date the entry was created.""" 57 | 58 | @property 59 | def jump_url(self) -> str: 60 | """The URL to jump to the entry.""" 61 | return str(yarl.URL(URBAN_JUMP_URL.format(term=self.word, defid=self.defid))) 62 | 63 | @staticmethod 64 | def parse_urban_string(string: str) -> str: 65 | """Parse a string from the Urban Dictionary API, replacing references with markdown hyperlinks.""" 66 | return re.sub( 67 | r"\[([^[\]]+)\]", 68 | lambda m: f"{m.group(0)}({yarl.URL(URBAN_SEARCH_URL.format(term=m.group(0)[1:-1]))})", 69 | string, 70 | ) 71 | 72 | @classmethod 73 | def from_dict(cls, data: dict[str, t.Any]) -> UrbanEntry: 74 | return cls( 75 | word=data["word"], 76 | definition=cls.parse_urban_string(data["definition"]), 77 | defid=data["defid"], 78 | example=cls.parse_urban_string(data["example"]), 79 | thumbs_up=data["thumbs_up"], 80 | thumbs_down=data["thumbs_down"], 81 | author=data["author"], 82 | written_on=datetime.datetime.fromisoformat(data["written_on"].replace("Z", "+00:00")), 83 | ) 84 | 85 | 86 | @attr.frozen(weakref_slot=False) 87 | class DictionaryEntry: 88 | """A dictionary entry in the Merriam-Webster Dictionary.""" 89 | 90 | id: str 91 | """The ID of this entry in the dictionary.""" 92 | 93 | word: str 94 | """The word in the dictionary entry.""" 95 | 96 | definitions: list[str] 97 | """A list of definitions for the word.""" 98 | 99 | offensive: bool 100 | """Whether the word is offensive.""" 101 | 102 | functional_label: str | None = None 103 | """The functional label of the word (e.g. noun)""" 104 | 105 | etymology: str | None = None 106 | """The etymology of the word.""" 107 | 108 | date: str | None = None 109 | """An estimated date when the word was first used.""" 110 | 111 | @classmethod 112 | def from_dict(cls, data: dict[str, t.Any]) -> DictionaryEntry: 113 | et = data.get("et", None) 114 | try: 115 | if et and et[0][0] == "text": 116 | et = re.sub(r"[{]\S+[}]", "", et[0][1]) 117 | except IndexError: 118 | et = None 119 | 120 | return cls( 121 | id=data["meta"]["id"], 122 | word=data["meta"]["id"].split(":")[0], 123 | definitions=data["shortdef"], 124 | functional_label=data.get("fl", None), 125 | offensive=data["meta"].get("offensive") or False, 126 | etymology=et, 127 | date=data.get("date", None), 128 | ) 129 | 130 | 131 | class DictionaryClient: 132 | def __init__(self, api_key: str) -> None: 133 | self._api_key = api_key 134 | self._session: aiohttp.ClientSession | None = None 135 | self._autocomplete_cache: dict[str, list[str]] = {} 136 | self._mw_entry_cache: dict[str, list[DictionaryEntry]] = {} 137 | self._urban_entry_cache: dict[str, list[UrbanEntry]] = {} 138 | 139 | @property 140 | def session(self) -> aiohttp.ClientSession: 141 | if self._session is None: 142 | self._session = aiohttp.ClientSession() 143 | return self._session 144 | 145 | async def get_urban_entries(self, word: str) -> list[UrbanEntry]: 146 | """Get entries for a word from the Urban dictionary. 147 | 148 | Parameters 149 | ---------- 150 | word : str 151 | The word to find entries for. 152 | 153 | Returns 154 | ------- 155 | List[UrbanEntry] 156 | A list of dictionary entries for the word. 157 | 158 | Raises 159 | ------ 160 | UrbanException 161 | Failed to communicate with the Urban API. 162 | """ 163 | if words := self._urban_entry_cache.get(word, None): 164 | return words 165 | 166 | async with self.session.get(URBAN_API_SEARCH_URL.format(term=word)) as resp: 167 | if resp.status != 200: 168 | raise UrbanError(f"Failed to get urban entries for {word}: {resp.status}: {resp.reason}") 169 | data = await resp.json() 170 | 171 | if data["list"]: 172 | self._urban_entry_cache[word] = [UrbanEntry.from_dict(entry) for entry in data["list"]] 173 | return self._urban_entry_cache[word] 174 | 175 | return [] 176 | 177 | async def get_mw_autocomplete(self, word: str | None = None) -> list[str]: 178 | """Get autocomplete results for a word from the Merriam-Webster dictionary. 179 | 180 | Parameters 181 | ---------- 182 | word : Optional[str] 183 | The word to get results for. 184 | 185 | Returns 186 | ------- 187 | List[str] 188 | A list of strings representing the autocomplete results. 189 | 190 | Raises 191 | ------ 192 | DictionaryException 193 | Failed to communicate with the Dictionary API. 194 | """ 195 | if not word: 196 | return ["Start typing a word to get started..."] 197 | 198 | if words := self._autocomplete_cache.get(word, None): 199 | return words 200 | 201 | async with self.session.get(MW_AUTOCOMPLETE_URL.format(word=word)) as resp: 202 | if resp.status != 200: 203 | raise DictionaryError(f"Failed to communicate with the dictionary API: {resp.status}: {resp.reason}") 204 | 205 | results: list[str] = [ 206 | doc.get("word") for doc in (await resp.json())["docs"] if doc.get("ref") == "owl-combined" 207 | ][:25] 208 | self._autocomplete_cache[word] = results 209 | 210 | return results 211 | 212 | async def get_mw_entries(self, word: str) -> list[DictionaryEntry]: 213 | """Get entries for a word from the Merriam-Webster dictionary. 214 | 215 | Parameters 216 | ---------- 217 | word : str 218 | The word to find entries for. 219 | 220 | Returns 221 | ------- 222 | List[DictionaryEntry] 223 | A list of dictionary entries for the word. 224 | 225 | Raises 226 | ------ 227 | DictionaryException 228 | Failed to communicate with the Dictionary API. 229 | """ 230 | if words := self._mw_entry_cache.get(word, None): 231 | return words 232 | 233 | async with self.session.get(MW_SEARCH_URL.format(word=word, key=self._api_key)) as resp: 234 | if resp.status != 200: 235 | raise DictionaryError(f"Failed to communicate with the dictionary API: {resp.status}: {resp.reason}") 236 | payload = await resp.json() 237 | 238 | if payload and isinstance(payload[0], dict): 239 | results: list[DictionaryEntry] = [DictionaryEntry.from_dict(data) for data in (payload)][:25] 240 | self._mw_entry_cache[word] = results 241 | return results 242 | 243 | return [] 244 | 245 | 246 | # Copyright (C) 2022-present hypergonial 247 | 248 | # This program is free software: you can redistribute it and/or modify 249 | # it under the terms of the GNU General Public License as published by 250 | # the Free Software Foundation, either version 3 of the License, or 251 | # (at your option) any later version. 252 | 253 | # This program is distributed in the hope that it will be useful, 254 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 255 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 256 | # GNU General Public License for more details. 257 | 258 | # You should have received a copy of the GNU General Public License 259 | # along with this program. If not, see: https://www.gnu.org/licenses 260 | -------------------------------------------------------------------------------- /src/utils/ratelimiter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import abc 4 | import asyncio 5 | import sys 6 | import time 7 | import traceback 8 | import typing as t 9 | from collections import deque 10 | 11 | import attr 12 | 13 | if t.TYPE_CHECKING: 14 | import hikari 15 | 16 | 17 | class ContextLike(t.Protocol): 18 | """An object that has common attributes of a context.""" 19 | 20 | @property 21 | def author(self) -> hikari.UndefinedOr[hikari.User]: 22 | ... 23 | 24 | @property 25 | def guild_id(self) -> hikari.Snowflake | None: 26 | ... 27 | 28 | @property 29 | def channel_id(self) -> hikari.Snowflake: 30 | ... 31 | 32 | 33 | @attr.define() 34 | class BucketData: 35 | """Handles the ratelimiting of a single bucket data. (E.g. a single user or a channel).""" 36 | 37 | reset_at: float 38 | """The time at which the bucket resets.""" 39 | remaining: int 40 | """The amount of requests remaining in the bucket.""" 41 | bucket: Bucket 42 | """The bucket this data belongs to.""" 43 | queue: t.Deque[asyncio.Event] = attr.field(factory=deque) 44 | """A list of events to set as the iter task proceeds.""" 45 | task: asyncio.Task[t.Any] | None = attr.field(default=None) 46 | """The task that is currently iterating over the queue.""" 47 | 48 | @classmethod 49 | def for_bucket(cls, bucket: Bucket) -> BucketData: 50 | """Create a new BucketData for a Bucket.""" 51 | return cls( 52 | bucket=bucket, 53 | reset_at=time.monotonic() + bucket.period, 54 | remaining=bucket.limit, 55 | ) 56 | 57 | def start_queue(self) -> None: 58 | """Start the queue of a BucketData. 59 | This will start setting events in the queue until the bucket is ratelimited. 60 | """ 61 | if self.task is None: 62 | self.task = asyncio.create_task(self._iter_queue()) 63 | 64 | def reset(self) -> None: 65 | """Reset the ratelimit.""" 66 | self.remaining = self.bucket.limit 67 | self.reset_at = time.monotonic() + self.bucket.period 68 | 69 | async def _iter_queue(self) -> None: 70 | """Iterate over the queue of a BucketData and set events.""" 71 | try: 72 | if self.remaining <= 0 and self.reset_at > time.monotonic(): 73 | # Sleep until ratelimit expires 74 | sleep_time = self.reset_at - time.monotonic() 75 | await asyncio.sleep(sleep_time) 76 | self.reset() 77 | elif self.reset_at <= time.monotonic(): 78 | self.reset() 79 | 80 | # Set events while not ratelimited 81 | while self.remaining > 0 and self.queue: 82 | self.remaining -= 1 83 | self.queue.popleft().set() 84 | 85 | self.task = None 86 | 87 | except Exception as e: 88 | print(f"Task Exception was never retrieved: {e}", file=sys.stderr) 89 | print(traceback.format_exc(), file=sys.stderr) 90 | 91 | 92 | class Bucket(abc.ABC): 93 | """Abstract class for ratelimiter buckets.""" 94 | 95 | def __init__(self, period: float, limit: int, wait: bool = True) -> None: 96 | """Abstract class for ratelimiter buckets. 97 | 98 | Parameters 99 | ---------- 100 | period : float 101 | The period, in seconds, after which the quota resets. 102 | limit : int 103 | The amount of requests allowed in a quota. 104 | wait : bool 105 | Determines if the ratelimiter should wait in 106 | case of hitting a ratelimit. 107 | """ 108 | self.period: float = period 109 | self.limit: int = limit 110 | self.wait: bool = wait 111 | self._bucket_data: t.Dict[str, BucketData] = {} 112 | 113 | @abc.abstractmethod 114 | def get_key(self, ctx: ContextLike) -> str: 115 | """Get key for ratelimiter bucket.""" 116 | 117 | def is_rate_limited(self, ctx: ContextLike) -> bool: 118 | """Returns a boolean determining if the ratelimiter is ratelimited or not.""" 119 | now = time.monotonic() 120 | 121 | if data := self._bucket_data.get(self.get_key(ctx)): 122 | if data.reset_at <= now: 123 | return False 124 | return data.remaining <= 0 125 | return False 126 | 127 | async def acquire(self, ctx: ContextLike) -> None: 128 | """Acquire a ratelimit, block execution if ratelimited and wait is True.""" 129 | event = asyncio.Event() 130 | 131 | # Get or insert bucket data 132 | data = self._bucket_data.setdefault(self.get_key(ctx), BucketData.for_bucket(self)) 133 | data.queue.append(event) 134 | data.start_queue() 135 | 136 | if self.wait: 137 | await event.wait() 138 | 139 | def reset(self, ctx: ContextLike) -> None: 140 | """Reset the ratelimit for a given context.""" 141 | if data := self._bucket_data.get(self.get_key(ctx)): 142 | data.reset() 143 | 144 | 145 | class GlobalBucket(Bucket): 146 | """Ratelimiter bucket for global ratelimits.""" 147 | 148 | def get_key(self, _: ContextLike) -> str: 149 | return "amongus" 150 | 151 | 152 | class GuildBucket(Bucket): 153 | """Ratelimiter bucket for guilds. 154 | 155 | Note that all ContextLike objects must have a guild_id set. 156 | """ 157 | 158 | def get_key(self, ctx: ContextLike) -> str: 159 | if not ctx.guild_id: 160 | raise KeyError("guild_id is not set.") 161 | return str(ctx.guild_id) 162 | 163 | 164 | class ChannelBucket(Bucket): 165 | """Ratelimiter bucket for channels.""" 166 | 167 | def get_key(self, ctx: ContextLike) -> str: 168 | return str(ctx.channel_id) 169 | 170 | 171 | class UserBucket(Bucket): 172 | """Ratelimiter bucket for users. 173 | 174 | Note that all ContextLike objects must have an author set. 175 | """ 176 | 177 | def get_key(self, ctx: ContextLike) -> str: 178 | if not ctx.author: 179 | raise KeyError("author is not set.") 180 | return str(ctx.author.id) 181 | 182 | 183 | class MemberBucket(Bucket): 184 | """Ratelimiter bucket for members. 185 | 186 | Note that all ContextLike objects must have an author and guild_id set. 187 | """ 188 | 189 | def get_key(self, ctx: ContextLike) -> str: 190 | if not ctx.author or not ctx.guild_id: 191 | raise KeyError("author or guild_id is not set.") 192 | return str(ctx.author.id) + str(ctx.guild_id) 193 | 194 | 195 | class RateLimiter: 196 | def __init__(self, period: float, limit: int, bucket: t.Type[Bucket], wait: bool = True) -> None: 197 | """Rate Limiter implementation for Sned. 198 | 199 | Parameters 200 | ---------- 201 | period : float 202 | The period, in seconds, after which the quota resets. 203 | limit : int 204 | The amount of requests allowed in a quota. 205 | bucket : Bucket 206 | The bucket to handle this under. 207 | wait : bool 208 | Determines if the ratelimiter should wait in 209 | case of hitting a ratelimit. 210 | """ 211 | self.bucket: Bucket = bucket(period, limit, wait) 212 | 213 | def is_rate_limited(self, ctx: ContextLike) -> bool: 214 | """Returns a boolean determining if the ratelimiter is ratelimited or not.""" 215 | return self.bucket.is_rate_limited(ctx) 216 | 217 | async def acquire(self, ctx: ContextLike) -> None: 218 | """Acquire a ratelimit, block execution if ratelimited and wait is True.""" 219 | return await self.bucket.acquire(ctx) 220 | 221 | def reset(self, ctx: ContextLike) -> None: 222 | """Reset the ratelimit for a given context.""" 223 | self.bucket.reset(ctx) 224 | 225 | 226 | # Copyright (C) 2022-present hypergonial 227 | 228 | # This program is free software: you can redistribute it and/or modify 229 | # it under the terms of the GNU General Public License as published by 230 | # the Free Software Foundation, either version 3 of the License, or 231 | # (at your option) any later version. 232 | 233 | # This program is distributed in the hope that it will be useful, 234 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 235 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 236 | # GNU General Public License for more details. 237 | 238 | # You should have received a copy of the GNU General Public License 239 | # along with this program. If not, see: https://www.gnu.org/licenses 240 | -------------------------------------------------------------------------------- /src/utils/rpn.py: -------------------------------------------------------------------------------- 1 | import typing as t 2 | from fractions import Fraction 3 | 4 | __slots__ = ("Solver", "InvalidExpressionError") 5 | 6 | CallableT = t.TypeVar("CallableT", bound=t.Callable[..., Fraction]) 7 | 8 | 9 | class InvalidExpressionError(Exception): 10 | """Raised when an expression is invalid.""" 11 | 12 | 13 | class Operator(t.Generic[CallableT]): 14 | """Represents some kind of operator.""" 15 | 16 | __slots__ = ("char", "op", "prec") 17 | 18 | def __init__(self, char: str, prec: int, op: CallableT): 19 | self.char = char 20 | self.op = op 21 | self.prec = prec 22 | 23 | 24 | class UnaryOperator(Operator[t.Callable[[Fraction], Fraction]]): 25 | """Represents a unary operator.""" 26 | 27 | def __call__(self, a: Fraction) -> Fraction: 28 | return self.op(a) 29 | 30 | 31 | class BinaryOperator(Operator[t.Callable[[Fraction, Fraction], Fraction]]): 32 | """Represents a binary operator.""" 33 | 34 | def __call__(self, a: Fraction, b: Fraction) -> Fraction: 35 | return self.op(a, b) 36 | 37 | 38 | # Workaround because of needed error handling 39 | def _div(a: Fraction, b: Fraction) -> Fraction: 40 | try: 41 | return a / b 42 | except ZeroDivisionError: 43 | raise InvalidExpressionError("Division by zero") 44 | 45 | 46 | def _pow(a: Fraction, b: Fraction) -> Fraction: 47 | if a == 0 and b < 0: 48 | raise InvalidExpressionError("The infinity stares back at you") 49 | 50 | if a < 0 and (b < 1 and b > -1): 51 | raise InvalidExpressionError("Negative base") 52 | return Fraction(a**b) 53 | 54 | 55 | OPS: dict[str, Operator] = { 56 | "+": BinaryOperator("+", 0, lambda a, b: a + b), 57 | "-": BinaryOperator("-", 0, lambda a, b: a - b), 58 | "*": BinaryOperator("*", 1, lambda a, b: a * b), 59 | "/": BinaryOperator("/", 1, _div), 60 | "~": UnaryOperator("~", 2, lambda a: -a), 61 | "^": BinaryOperator("^", 3, _pow), 62 | } 63 | """All valid operators.""" 64 | 65 | VALID_CHARS = set("0123456789.()+-*/^") 66 | """All valid characters.""" 67 | 68 | 69 | class Solver: 70 | """Solves arbitrary mathematical expressions using reverse polish notation (RPN).""" 71 | 72 | __slots__ = ("_expr", "_rpn") 73 | 74 | def __init__(self, expr: str): 75 | self._expr = expr 76 | self._rpn: list[str] = [] 77 | 78 | @property 79 | def expr(self) -> str: 80 | """The expression to evaluate.""" 81 | return self._expr 82 | 83 | def _validate(self) -> bool: 84 | """Validates the expression. 85 | This includes checking for invalid characters and parentheses. 86 | 87 | Returns 88 | ------- 89 | bool 90 | Whether the expression is valid. 91 | 92 | Raises 93 | ------ 94 | InvalidExpressionError 95 | If the expression is invalid. 96 | """ 97 | stack = [] 98 | for i, c in enumerate(self._expr): 99 | if c not in VALID_CHARS: 100 | raise InvalidExpressionError(f"Illegal character at position {i+1}: {c}") 101 | if c == "(": 102 | stack.append(c) 103 | elif c == ")": 104 | if not stack: 105 | raise InvalidExpressionError(f"Unmatched closing parenthesis at position {i+1}") 106 | stack.pop() 107 | 108 | if stack: 109 | raise InvalidExpressionError(f"Unmatched opening parenthesis at position {len(self._expr)}") 110 | 111 | return True 112 | 113 | def _preprocess(self) -> None: 114 | """Preprocesses the expression. 115 | This includes adding implicit multiplication signs 116 | and detecting negations. 117 | """ 118 | expr = self._expr 119 | new_expr: list[str] = [] 120 | for i, c in enumerate(expr): 121 | if c == "(" and i > 0 and expr[i - 1] not in OPS and expr[i - 1] != "(": 122 | new_expr.extend(("*", "(")) 123 | elif c == ")" and i + 1 < len(expr) and expr[i + 1] not in OPS and expr[i + 1] not in ("(", ")"): 124 | new_expr.extend((")", "*")) 125 | elif c == "-" and (i == 0 or expr[i - 1] in OPS or expr[i - 1] == "("): 126 | new_expr.append("~") 127 | else: 128 | new_expr.append(c) 129 | self._expr = "".join(new_expr) 130 | 131 | def _should_write_top(self, c: str, stack: list[str]) -> bool: 132 | """Determines if the top operand of the stack should be appended to the result 133 | before pushing the current operand to the stack. 134 | This is determined by the precedence of the current operand. 135 | 136 | Parameters 137 | ---------- 138 | c : str 139 | The current operand. 140 | stack : List[str] 141 | The stack of operands. 142 | 143 | Returns 144 | ------- 145 | bool 146 | Whether the top operand should be appended to the result. 147 | """ 148 | assert c in OPS 149 | 150 | if not stack: 151 | return False 152 | top = stack[-1] 153 | if top == "(": 154 | return False 155 | op = OPS[c] 156 | top_op = OPS[top] 157 | if c in ["^", "~"] and op.prec >= top_op.prec or op.prec > top_op.prec: 158 | return False 159 | return True 160 | 161 | def _to_polish_notation(self) -> None: 162 | """Convert an expression to polish notation. 163 | Note that each value must be a single character. 164 | 165 | Parameters 166 | ---------- 167 | expr : str 168 | The expression to convert. 169 | 170 | Returns 171 | ------- 172 | str 173 | The converted expression. 174 | 175 | Raises 176 | ------ 177 | InvalidExpressionError 178 | If the expression is invalid. 179 | """ 180 | result: list[str] = [""] 181 | stack = [] 182 | for c in self._expr: 183 | if c.isspace(): 184 | continue 185 | 186 | if c not in OPS and c not in ("(", ")"): 187 | result[-1] += c 188 | else: 189 | if c == "(": 190 | stack.append(c) 191 | elif c == ")": 192 | while stack: 193 | top = stack.pop() 194 | if top == "(": 195 | break 196 | result.append(top) 197 | else: 198 | while self._should_write_top(c, stack): 199 | top = stack.pop() 200 | result.append(top) 201 | stack.append(c) 202 | if not isinstance(OPS[c], UnaryOperator): 203 | result.append("") 204 | 205 | result += reversed(stack) 206 | 207 | for token in result: 208 | if token in OPS: 209 | continue 210 | if not token or token.count(".") > 1 or not token.replace(".", "").isdigit(): 211 | raise InvalidExpressionError(f"Failed parsing expression, invalid value: '{token}'") 212 | 213 | self._rpn = result 214 | 215 | def solve(self) -> Fraction: 216 | """Solves the expression. 217 | 218 | Returns 219 | ------- 220 | Fraction 221 | The result of the expression. 222 | """ 223 | self._validate() 224 | self._preprocess() 225 | self._to_polish_notation() 226 | stack = [] 227 | for c in self._rpn: 228 | if c not in OPS: 229 | stack.append(Fraction(c)) 230 | continue 231 | 232 | op = OPS[c] 233 | if isinstance(op, UnaryOperator): 234 | stack.append(op(stack.pop())) 235 | elif isinstance(op, BinaryOperator): 236 | a = stack.pop() 237 | b = stack.pop() 238 | stack.append(op(b, a)) 239 | return stack.pop() 240 | -------------------------------------------------------------------------------- /src/utils/tasks.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import inspect 3 | import logging 4 | import traceback 5 | import typing as t 6 | 7 | 8 | class IntervalLoop: 9 | """A simple interval loop that runs a coroutine at a specified interval. 10 | 11 | Parameters 12 | ---------- 13 | callback : Callable[..., Awaitable[None]] 14 | The coroutine to run at the specified interval. 15 | seconds : float, optional 16 | The number of seconds to wait before running the coroutine again. 17 | minutes : float, optional 18 | The number of minutes to wait before running the coroutine again. 19 | hours : float, optional 20 | The number of hours to wait before running the coroutine again. 21 | days : float, optional 22 | The number of days to wait before running the coroutine again. 23 | """ 24 | 25 | def __init__( 26 | self, 27 | callback: t.Callable[..., t.Awaitable[None]], 28 | seconds: float | None = None, 29 | minutes: float | None = None, 30 | hours: float | None = None, 31 | days: float | None = None, 32 | ) -> None: 33 | if not seconds and not minutes and not hours and not days: 34 | raise ValueError("Expected a loop time.") 35 | else: 36 | seconds = seconds or 0 37 | minutes = minutes or 0 38 | hours = hours or 0 39 | days = hours or 0 40 | 41 | self._coro = callback 42 | self._task: asyncio.Task | None = None 43 | self._failed: int = 0 44 | self._sleep: float = seconds + minutes * 60 + hours * 3600 + days * 24 * 3600 45 | self._stop_next: bool = False 46 | 47 | if not inspect.iscoroutinefunction(self._coro): 48 | raise TypeError("Expected a coroutine function.") 49 | 50 | async def _loopy_loop(self, *args, **kwargs) -> None: 51 | """Main loop logic.""" 52 | while not self._stop_next: 53 | try: 54 | await self._coro(*args, **kwargs) 55 | except Exception as e: 56 | logging.error(f"Task encountered exception: {e}") 57 | traceback_msg = "\n".join(traceback.format_exception(type(e), e, e.__traceback__)) 58 | logging.error(traceback_msg) 59 | 60 | if self._failed < 3: 61 | self._failed += 1 62 | await asyncio.sleep(self._sleep) 63 | else: 64 | raise RuntimeError(f"Task failed repeatedly, stopping it. Exception: {e}") 65 | else: 66 | await asyncio.sleep(self._sleep) 67 | self.cancel() 68 | 69 | def start(self, *args, **kwargs) -> None: 70 | """Start looping the task at the specified interval.""" 71 | if self._task and not self._task.done(): 72 | raise RuntimeError("Task is already running!") 73 | 74 | self._task = asyncio.create_task(self._loopy_loop(*args, **kwargs)) 75 | 76 | def cancel(self) -> None: 77 | """Cancel the looping of the task.""" 78 | if not self._task: 79 | return 80 | 81 | self._task.cancel() 82 | self._task = None 83 | 84 | def stop(self) -> None: 85 | """Gracefully stop the looping of the task.""" 86 | if self._task and not self._task.done(): 87 | self._stop_next = True 88 | 89 | 90 | # Copyright (C) 2022-present hypergonial 91 | 92 | # This program is free software: you can redistribute it and/or modify 93 | # it under the terms of the GNU General Public License as published by 94 | # the Free Software Foundation, either version 3 of the License, or 95 | # (at your option) any later version. 96 | 97 | # This program is distributed in the hope that it will be useful, 98 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 99 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 100 | # GNU General Public License for more details. 101 | 102 | # You should have received a copy of the GNU General Public License 103 | # along with this program. If not, see: https://www.gnu.org/licenses 104 | -------------------------------------------------------------------------------- /tos.md: -------------------------------------------------------------------------------- 1 | # TERMS OF SERVICE 2 | By using Sned, directly or indirectly, the user agrees to the following points. 3 | 4 | ## DEFINITIONS 5 | - "Users" refers to any individual that directly utilizes Sned. Either via command interaction with Sned through Discord, and/or any form of communication whereas the user talks/texts to the Sned bot. 6 | 7 | - "Us/We/Our" refers to the team behind Sned. The team is currently comprised of one member (hypergonial) who programs the bot, aids users with support and help in regards to Sned. 8 | 9 | - "Data" refers to any user entered information provided to Sned. This information includes data such as timezones, reminders, tags, moderation journals, Discord user information, and other public data. 10 | 11 | ## AGREEMENTS 12 | Users, whether using Sned directly or indirectly will not attempt to bypass or otherwise compromise our servers, systems, or products. Any attempt to render us undue harm can result in legal action. 13 | 14 | The user will make reasonable attempt to contact us in case of a security vulnerability, and disclose necessary information. 15 | Users are directly liable, legal or otherwise, for all content and data entered into Sned. We reserve the right to remove any user-generated content from the application without further notice. 16 | 17 | By using Sned, the user also agrees to the Discord Terms of Service. 18 | 19 | By using Sned and enabling the use of the Perspective API, the user also agrees to Perspective API Terms of Service. 20 | 21 | ## TERMINATION 22 | 23 | We reserve the right to revoke access to Sned at any time if a user attempts to break the above Terms of Service. 24 | --------------------------------------------------------------------------------