├── .env ├── .github └── workflows │ └── pre-commit.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── ballsdex ├── __init__.py ├── __main__.py ├── core │ ├── __init__.py │ ├── admin │ │ ├── __init__.py │ │ ├── resources.py │ │ └── routes.py │ ├── bot.py │ ├── commands.py │ ├── dev.py │ ├── image_generator │ │ ├── __init__.py │ │ ├── image_gen.py │ │ └── src │ │ │ ├── ArsenicaTrial-Extrabold.ttf │ │ │ ├── Bobby Jones Soft.otf │ │ │ ├── OpenSans-Bold.ttf │ │ │ ├── OpenSans-Semibold.ttf │ │ │ ├── arial.ttf │ │ │ ├── capitalist.png │ │ │ ├── communist.png │ │ │ ├── democracy.png │ │ │ ├── dictatorship.png │ │ │ ├── fr_test.png │ │ │ ├── shiny.png │ │ │ └── union.png │ ├── metrics.py │ ├── models.py │ └── utils │ │ ├── __init__.py │ │ ├── buttons.py │ │ ├── discord-ext-menus.LIENSE │ │ ├── logging.py │ │ ├── menus.py │ │ ├── paginator.py │ │ ├── tortoise.py │ │ └── transformers.py ├── logging.py ├── packages │ ├── admin │ │ ├── __init__.py │ │ └── cog.py │ ├── balls │ │ ├── __init__.py │ │ ├── cog.py │ │ └── countryballs_paginator.py │ ├── config │ │ ├── __init__.py │ │ ├── cog.py │ │ └── components.py │ ├── countryballs │ │ ├── __init__.py │ │ ├── cog.py │ │ ├── components.py │ │ ├── countryball.py │ │ └── spawn.py │ ├── info │ │ ├── __init__.py │ │ └── cog.py │ ├── players │ │ ├── __init__.py │ │ └── cog.py │ └── trade │ │ ├── __init__.py │ │ ├── cog.py │ │ ├── display.py │ │ ├── menu.py │ │ └── trade_user.py ├── settings.py └── templates │ ├── base.html │ ├── dashboard.html │ ├── password.html │ └── providers │ └── login │ └── password.html ├── docker-compose.yml ├── gatewayproxy └── config.json.example ├── json-config-ref.json ├── migrations └── models │ ├── 0_20220909204856_init.sql │ ├── 10_20221022184330_update.sql │ ├── 11_20230127140747_update.sql │ ├── 12_20230224112031_update.sql │ ├── 13_20230301173448_update.sql │ ├── 14_20230326131235_update.sql │ ├── 15_20230404133020_update.sql │ ├── 16_20230410144614_update.sql │ ├── 17_20230420000356_update.sql │ ├── 19_20230522100203_update.sql │ ├── 1_20220909223048_update.sql │ ├── 20_20230725165036_update.sql │ ├── 21_20231113175415_update.sql │ ├── 22_20231115171322_update.sql │ ├── 23_20231205113247_update.sql │ ├── 24_20231219112238_update.sql │ ├── 25_20240109160009_update.sql │ ├── 26_20240121004430_update.sql │ ├── 27_20240123140205_update.sql │ ├── 28_20240225164026_update.sql │ ├── 29_20240320142916_update.sql │ ├── 2_20220910020800_update.sql │ ├── 3_20220913220923_update.sql │ ├── 4_20220915164836_update.sql │ ├── 5_20220924005726_update.sql │ ├── 6_20220924014717_update.sql │ ├── 7_20220926000044_update.sql │ ├── 8_20220926000601_update.sql │ └── 9_20221007231725_update.sql ├── poetry.lock ├── pyproject.toml └── static └── uploads └── .gitkeep /.env: -------------------------------------------------------------------------------- 1 | POSTGRES_PASSWORD=defaultballsdexpassword -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: Pre-commit 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | pre-commit: 9 | runs-on: ubuntu-latest 10 | name: pre-commit 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v2 14 | - name: Setup Python 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: 3.11 18 | - uses: actions/cache@v3 19 | with: 20 | path: ~/.cache/pre-commit 21 | key: pre-commit|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }} 22 | - uses: actions/cache@v3 23 | with: 24 | path: ~/.cache/pypoetry 25 | key: poetry-${{ hashFiles('poetry.lock') }} 26 | - name: Install Poetry 27 | run: | 28 | curl -sSL https://install.python-poetry.org | python3 - 29 | echo "$HOME/.poetry/bin" >> $GITHUB_PATH 30 | - name: Install dependencies 31 | run: poetry install --with=dev --no-interaction 32 | - name: Run black 33 | run: poetry run black --check --diff $(git ls-files "*.py") 34 | - name: Run pre-commit checks 35 | run: poetry run pre-commit run -a 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python stuff 2 | *.pyc 3 | __pycache__ 4 | 5 | # macOS 6 | .DS_Store 7 | 8 | # Python binaries 9 | *.egg-info 10 | dist 11 | build 12 | 13 | # log files 14 | *.log* 15 | 16 | # Virtual environment 17 | venv 18 | .venv 19 | .python-version 20 | 21 | # Visual Studio code settings 22 | .vscode 23 | 24 | # database 25 | *sqlite3* 26 | *.rdb 27 | 28 | # static 29 | static 30 | 31 | # cache 32 | .pytest_cache 33 | 34 | # settings 35 | config.yml 36 | docker-compose.override.yml 37 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | 3 | files: .*\.py$ 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.5.0 8 | hooks: 9 | - id: trailing-whitespace 10 | exclude: settings\.py 11 | - id: end-of-file-fixer 12 | - id: check-added-large-files 13 | files: "" 14 | - id: debug-statements 15 | files: .*\.py$ 16 | - repo: https://github.com/psf/black-pre-commit-mirror 17 | rev: 23.11.0 18 | hooks: 19 | - id: black 20 | - repo: https://github.com/pycqa/isort 21 | rev: 5.12.0 22 | hooks: 23 | - id: isort 24 | args: 25 | - --profile 26 | - black 27 | - --filter-files 28 | - repo: https://github.com/RobertCraigie/pyright-python 29 | rev: v1.1.335 30 | hooks: 31 | - id: pyright 32 | additional_dependencies: 33 | - discord 34 | - cachetools 35 | - rich 36 | - Pillow 37 | - prometheus_client 38 | - tortoise-orm 39 | - git+https://github.com/fastapi-admin/fastapi-admin.git 40 | - aerich==0.6.3 41 | - redis 42 | - repo: https://github.com/csachs/pyproject-flake8 43 | rev: v6.1.0 44 | hooks: 45 | - id: pyproject-flake8 46 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for contributing to this repo! This is a short guide to set you up for running Ballsdex in 4 | a development environment, with some tips on the code structure. 5 | 6 | ## Setting up the environment 7 | 8 | ### PostgreSQL and Redis 9 | 10 | Using Docker: 11 | 12 | 1. Install Docker. 13 | 2. Run `docker compose build` at the root of this repository. 14 | 3. Create an `.env` file like this: 15 | 16 | ```env 17 | BALLSDEXBOT_TOKEN=your discord token 18 | POSTGRES_PASSWORD=a random string 19 | ``` 20 | 21 | 4. Run `docker compose up -d postgres-db redis-cache`. This will not start the bot. 22 | 23 | ---- 24 | 25 | Without docker, check how to install and setup PostgreSQL and Redis-server on your OS. 26 | Export the appropriate environment variables as described in the 27 | [README](README.md#without-docker). 28 | 29 | ### Installing the dependencies 30 | 31 | 1. Get Python 3.10 and pip 32 | 2. Install poetry with `pip install poetry` 33 | 3. Run `poetry install` 34 | 4. You may run commands inside the virtualenv with `poetry run ...`, or use `poetry shell` 35 | 5. Set up your IDE Python version to the one from Poetry. The path to the virtualenv can 36 | be obtained with `poetry show -v`. 37 | 38 | ## Running the code 39 | 40 | ### Starting the bot 41 | 42 | - `poetry shell` 43 | - ```bash 44 | BALLSDEXBOT_DB_URL="postgres://ballsdex:password@localhost:5432/ballsdex" \ 45 | python3 -m ballsdex --dev --debug 46 | ``` 47 | 48 | Replace `password` with the same value as the one in the `.env` file. 49 | If appropriate, you may also replace `localhost` and `5432` for the host and the port. 50 | 51 | ### Starting the admin panel 52 | 53 | **Warning: You need to run migrations from the bot at least once before starting the admin 54 | panel without the other components.** 55 | 56 | If you're not actively working on the admin panel, you can just do `docker compose up admin-panel`. 57 | Otherwise, follow these instructions to directly have the process without rebuilding. 58 | 59 | - `poetry shell` 60 | - ```bash 61 | BALLSDEXBOT_DB_URL="postgres://ballsdex:password@localhost:5432/ballsdex" \ 62 | BALLSDEXBOT_REDIS_URL="redis://127.0.0.1" \ 63 | python3 -m ballsdex --dev --debug 64 | ``` 65 | 66 | Once again, replace `password` with the same value as the one in the `.env` file. 67 | If appropriate, you may also replace `localhost` and `5432` for the host and the port. 68 | 69 | ## Migrations 70 | 71 | When modifying the Tortoise models, you need to create a migration file to reflect the changes 72 | everywhere. For this, we're using [aerich](https://github.com/tortoise/aerich). 73 | 74 | ### Applying the changes from remote 75 | 76 | When new migrations are available, you can either start the bot to run them automatically, or 77 | execute the following command: 78 | 79 | ```sh 80 | BALLSDEXBOT_DB_URL="postgres://ballsdex:password@localhost:5432/ballsdex" \ 81 | aerich upgrade 82 | ``` 83 | 84 | Once again, replace `password` with the same value as the one in the `.env` file. 85 | If appropriate, you may also replace `localhost` and `5432` for the host and the port. 86 | 87 | ### Creating new migrations 88 | 89 | If you modified the models, `aerich` can automatically generate a migration file. 90 | 91 | **You need to make sure you have already ran previous migrations, and that your database 92 | is not messy!** Aerich's behaviour can be odd if not in ideal conditions. 93 | 94 | Execute the following command to generate migrations, and push the created files: 95 | 96 | ```sh 97 | BALLSDEXBOT_DB_URL="postgres://ballsdex:password@localhost:5432/ballsdex" \ 98 | aerich migrate 99 | ``` 100 | 101 | ## Coding style 102 | 103 | The repo is validating code with `flake8` and formatting with `black`. They can be setup as a 104 | pre-commit hook to make them run before committing files: 105 | 106 | ```sh 107 | pre-commit install 108 | ``` 109 | 110 | You can also run them manually: 111 | 112 | ```sh 113 | pre-commit run -a 114 | ``` 115 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | FROM python:3.11-buster 4 | 5 | ENV PYTHONFAULTHANDLER=1 \ 6 | PYTHONUNBUFFERED=1 \ 7 | PYTHONHASHSEED=random \ 8 | PIP_NO_CACHE_DIR=off \ 9 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 10 | PIP_DEFAULT_TIMEOUT=100 11 | 12 | RUN pip install poetry==1.4.1 13 | 14 | WORKDIR /code 15 | COPY poetry.lock pyproject.toml /code/ 16 | 17 | RUN poetry config virtualenvs.create false && poetry install --no-interaction --no-ansi 18 | 19 | COPY . /code 20 | RUN mkdir -p /code/static 21 | RUN mkdir -p /code/static/uploads 22 | 23 | # wait for postgres to be ready 24 | CMD sleep 2 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Auguste Charpentier 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | This project vendors discord.ext.menus package 24 | (https://github.com/Rapptz/discord-ext-menus) made by Danny Y. (Rapptz) which 25 | is distributed under MIT License. 26 | Copy of this license can be found in discord-ext-menus.LICENSE file in 27 | ballsdex/core/utils folder of this repository. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BallsDex Discord Bot 2 | 3 | [](https://discord.gg/Qn2Rkdkxwc) 4 | [](https://github.com/laggron42/BallsDex-DiscordBot/actions/workflows/pre-commit.yml) 5 | [](https://github.com/laggron42/BallsDex-DiscordBot/issues) 6 | [](https://github.com/Rapptz/discord.py) 7 | [](https://github.com/ambv/black) 8 | [](https://patreon.com/retke) 9 | 10 | BallsDex is a bot for collecting countryballs on Discord and exchange them with your friends! 11 | 12 | You can invite the bot [here](https://discord.com/api/oauth2/authorize?client_id=999736048596816014&permissions=537193536&scope=bot%20applications.commands). 13 | 14 | [](https://discord.gg/Qn2Rkdkxwc) 15 | 16 | ## Suggestions, questions and bug reports 17 | 18 | Our self host support has ended and we no longer maintain a support server. Any bugs or issues can be raised by creating an issue on this repo, support issues will be closed. 19 | 20 | ## Documentation 21 | 22 | You can learn how to setup Ballsdex and use all of its tools on the 23 | [wiki](https://github.com/laggron42/BallsDex-Discordbot/wiki/)! 24 | More sections are added progressively. 25 | 26 | ## Supporting 27 | 28 | If you appreciate my work, you can support me on my [Patreon](https://patreon.com/retke)! 29 | 30 | ## Contributing 31 | 32 | Take a look at [the contribution guide](CONTRIBUTING.md) for setting up your environment! 33 | 34 | ## License 35 | 36 | This repository is released under the [MIT license](https://opensource.org/licenses/MIT). 37 | 38 | If distributing this bot, credits to the original authors must not be removed. 39 | -------------------------------------------------------------------------------- /ballsdex/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "2.16.0" 2 | -------------------------------------------------------------------------------- /ballsdex/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import functools 4 | import logging 5 | import logging.handlers 6 | import os 7 | import sys 8 | import time 9 | from pathlib import Path 10 | from signal import SIGTERM 11 | 12 | import discord 13 | import yarl 14 | from aerich import Command 15 | from discord.ext.commands import when_mentioned_or 16 | from rich import print 17 | from tortoise import Tortoise 18 | 19 | from ballsdex import __version__ as bot_version 20 | from ballsdex.core.bot import BallsDexBot 21 | from ballsdex.logging import init_logger 22 | from ballsdex.settings import read_settings, settings, update_settings, write_default_settings 23 | 24 | discord.voice_client.VoiceClient.warn_nacl = False # disable PyNACL warning 25 | log = logging.getLogger("ballsdex") 26 | 27 | TORTOISE_ORM = { 28 | "connections": {"default": os.environ.get("BALLSDEXBOT_DB_URL")}, 29 | "apps": { 30 | "models": { 31 | "models": ["ballsdex.core.models", "aerich.models"], 32 | "default_connection": "default", 33 | }, 34 | }, 35 | } 36 | 37 | 38 | class CLIFlags(argparse.Namespace): 39 | version: bool 40 | config_file: Path 41 | reset_settings: bool 42 | disable_rich: bool 43 | debug: bool 44 | dev: bool 45 | 46 | 47 | def parse_cli_flags(arguments: list[str]) -> CLIFlags: 48 | parser = argparse.ArgumentParser( 49 | prog="BallsDex bot", description="Collect and exchange countryballs on Discord" 50 | ) 51 | parser.add_argument("--version", "-V", action="store_true", help="Display the bot's version") 52 | parser.add_argument( 53 | "--config-file", type=Path, help="Set the path to config.yml", default=Path("./config.yml") 54 | ) 55 | parser.add_argument( 56 | "--reset-settings", 57 | action="store_true", 58 | help="Reset the config file with the latest default configuration", 59 | ) 60 | parser.add_argument("--disable-rich", action="store_true", help="Disable rich log format") 61 | parser.add_argument("--debug", action="store_true", help="Enable debug logs") 62 | parser.add_argument("--dev", action="store_true", help="Enable developer mode") 63 | args = parser.parse_args(arguments, namespace=CLIFlags()) 64 | return args 65 | 66 | 67 | def reset_settings(path: Path): 68 | write_default_settings(path) 69 | print(f"[green]A new settings file has been written at [blue]{path}[/blue].[/green]") 70 | print("[yellow]Configure the [bold]discord-token[/bold] value and restart the bot.[/yellow]") 71 | sys.exit(0) 72 | 73 | 74 | def print_welcome(): 75 | print("[green]{0:-^50}[/green]".format(f" {settings.bot_name} bot ")) 76 | print("[green]{0: ^50}[/green]".format(f" Collect {settings.collectible_name}s ")) 77 | print("[blue]{0:^50}[/blue]".format("Discord bot made by El Laggron")) 78 | print("") 79 | print(" [red]{0:<20}[/red] [yellow]{1:>10}[/yellow]".format("Bot version:", bot_version)) 80 | print( 81 | " [red]{0:<20}[/red] [yellow]{1:>10}[/yellow]".format( 82 | "Discord.py version:", discord.__version__ 83 | ) 84 | ) 85 | print("") 86 | 87 | 88 | def patch_gateway(proxy_url: str): 89 | """This monkeypatches discord.py in order to be able to use a custom gateway URL. 90 | 91 | Parameters 92 | ---------- 93 | proxy_url : str 94 | The URL of the gateway proxy to use. 95 | """ 96 | 97 | class ProductionHTTPClient(discord.http.HTTPClient): # type: ignore 98 | async def get_gateway(self, **_): 99 | return f"{proxy_url}?encoding=json&v=10" 100 | 101 | async def get_bot_gateway(self, **_): 102 | try: 103 | data = await self.request( 104 | discord.http.Route("GET", "/gateway/bot") # type: ignore 105 | ) 106 | except discord.HTTPException as exc: 107 | raise discord.GatewayNotFound() from exc 108 | return data["shards"], f"{proxy_url}?encoding=json&v=10" 109 | 110 | class ProductionDiscordWebSocket(discord.gateway.DiscordWebSocket): # type: ignore 111 | def is_ratelimited(self): 112 | return False 113 | 114 | async def debug_send(self, data, /): 115 | self._dispatch("socket_raw_send", data) 116 | await self.socket.send_str(data) 117 | 118 | async def send(self, data, /): 119 | await self.socket.send_str(data) 120 | 121 | class ProductionReconnectWebSocket(Exception): 122 | def __init__(self, shard_id: int | None, *, resume: bool = False): 123 | self.shard_id: int | None = shard_id 124 | self.resume: bool = False 125 | self.op: str = "IDENTIFY" 126 | 127 | def is_ws_ratelimited(self): 128 | return False 129 | 130 | async def before_identify_hook(self, shard_id: int | None, *, initial: bool = False): 131 | pass 132 | 133 | discord.http.HTTPClient.get_gateway = ProductionHTTPClient.get_gateway # type: ignore 134 | discord.http.HTTPClient.get_bot_gateway = ProductionHTTPClient.get_bot_gateway # type: ignore 135 | discord.gateway.DiscordWebSocket._keep_alive = None # type: ignore 136 | discord.gateway.DiscordWebSocket.is_ratelimited = ( # type: ignore 137 | ProductionDiscordWebSocket.is_ratelimited 138 | ) 139 | discord.gateway.DiscordWebSocket.debug_send = ( # type: ignore 140 | ProductionDiscordWebSocket.debug_send 141 | ) 142 | discord.gateway.DiscordWebSocket.send = ProductionDiscordWebSocket.send # type: ignore 143 | discord.gateway.DiscordWebSocket.DEFAULT_GATEWAY = yarl.URL(proxy_url) # type: ignore 144 | discord.gateway.ReconnectWebSocket.__init__ = ( # type: ignore 145 | ProductionReconnectWebSocket.__init__ 146 | ) 147 | BallsDexBot.is_ws_ratelimited = is_ws_ratelimited 148 | BallsDexBot.before_identify_hook = before_identify_hook 149 | 150 | 151 | async def shutdown_handler(bot: BallsDexBot, signal_type: str | None = None): 152 | if signal_type: 153 | log.info(f"Received {signal_type}, stopping the bot...") 154 | else: 155 | log.info("Shutting down the bot...") 156 | try: 157 | await asyncio.wait_for(bot.close(), timeout=10) 158 | finally: 159 | pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] 160 | [task.cancel() for task in pending] 161 | try: 162 | await asyncio.wait_for(asyncio.gather(*pending, return_exceptions=True), timeout=5) 163 | except asyncio.TimeoutError: 164 | log.error( 165 | f"Timed out cancelling tasks. {len([t for t in pending if not t.cancelled])}/" 166 | f"{len(pending)} tasks are still pending!" 167 | ) 168 | sys.exit(0 if signal_type else 1) 169 | 170 | 171 | def global_exception_handler(bot: BallsDexBot, loop: asyncio.AbstractEventLoop, context: dict): 172 | """ 173 | Logs unhandled exceptions in other tasks 174 | """ 175 | exc = context.get("exception") 176 | # These will get handled later when it *also* kills loop.run_forever 177 | if exc is not None and isinstance(exc, (KeyboardInterrupt, SystemExit)): 178 | return 179 | log.critical( 180 | "Caught unhandled exception in %s:\n%s", 181 | context.get("future", "event loop"), 182 | context["message"], 183 | exc_info=exc, 184 | ) 185 | 186 | 187 | def bot_exception_handler(bot: BallsDexBot, bot_task: asyncio.Future): 188 | """ 189 | This is set as a done callback for the bot 190 | 191 | Must be used with functools.partial 192 | 193 | If the main bot.run dies for some reason, 194 | we don't want to swallow the exception and hang. 195 | """ 196 | try: 197 | bot_task.result() 198 | except (SystemExit, KeyboardInterrupt, asyncio.CancelledError): 199 | pass # Handled by the global_exception_handler, or cancellation 200 | except Exception as exc: 201 | log.critical("The main bot task didn't handle an exception and has crashed", exc_info=exc) 202 | log.warning("Attempting to die as gracefully as possible...") 203 | asyncio.create_task(shutdown_handler(bot)) 204 | 205 | 206 | class RemoveWSBehindMsg(logging.Filter): 207 | """Filter used when gateway proxy is set, the "behind" message is meaningless in this case.""" 208 | 209 | def __init__(self): 210 | super().__init__(name="discord.gateway") 211 | 212 | def filter(self, record): 213 | if record.levelname == "WARNING" and "Can't keep up" in record.msg: 214 | return False 215 | 216 | return True 217 | 218 | 219 | async def init_tortoise(db_url: str): 220 | log.debug(f"Database URL: {db_url}") 221 | await Tortoise.init(config=TORTOISE_ORM) 222 | 223 | # migrations 224 | command = Command(TORTOISE_ORM, app="models") 225 | await command.init() 226 | migrations = await command.upgrade() 227 | if migrations: 228 | log.info(f"Ran {len(migrations)} migrations: {', '.join(migrations)}") 229 | 230 | 231 | def main(): 232 | bot = None 233 | server = None 234 | cli_flags = parse_cli_flags(sys.argv[1:]) 235 | if cli_flags.version: 236 | print(f"BallsDex Discord bot - {bot_version}") 237 | sys.exit(0) 238 | if cli_flags.reset_settings: 239 | print("[yellow]Resetting configuration file.[/yellow]") 240 | reset_settings(cli_flags.config_file) 241 | 242 | try: 243 | read_settings(cli_flags.config_file) 244 | except FileNotFoundError: 245 | print("[yellow]The config file could not be found, generating a default one.[/yellow]") 246 | reset_settings(cli_flags.config_file) 247 | else: 248 | update_settings(cli_flags.config_file) 249 | 250 | print_welcome() 251 | queue_listener: logging.handlers.QueueListener | None = None 252 | 253 | loop = asyncio.new_event_loop() 254 | asyncio.set_event_loop(loop) 255 | 256 | try: 257 | queue_listener = init_logger(cli_flags.disable_rich, cli_flags.debug) 258 | 259 | token = settings.bot_token 260 | if not token: 261 | log.error("Token not found!") 262 | print("[red]You must provide a token inside the config.yml file.[/red]") 263 | time.sleep(1) 264 | sys.exit(0) 265 | 266 | db_url = os.environ.get("BALLSDEXBOT_DB_URL", None) 267 | if not db_url: 268 | log.error("Database URL not found!") 269 | print("[red]You must provide a DB URL with the BALLSDEXBOT_DB_URL env var.[/red]") 270 | time.sleep(1) 271 | sys.exit(0) 272 | 273 | if settings.gateway_url is not None: 274 | log.info("Using custom gateway URL: %s", settings.gateway_url) 275 | patch_gateway(settings.gateway_url) 276 | logging.getLogger("discord.gateway").addFilter(RemoveWSBehindMsg()) 277 | 278 | prefix = settings.prefix 279 | 280 | try: 281 | loop.run_until_complete(init_tortoise(db_url)) 282 | except Exception: 283 | log.exception("Failed to connect to database.") 284 | return # will exit with code 1 285 | log.info("Tortoise ORM and database ready.") 286 | 287 | bot = BallsDexBot( 288 | command_prefix=when_mentioned_or(prefix), 289 | dev=cli_flags.dev, # type: ignore 290 | shard_count=settings.shard_count, 291 | ) 292 | 293 | exc_handler = functools.partial(global_exception_handler, bot) 294 | loop.set_exception_handler(exc_handler) 295 | loop.add_signal_handler( 296 | SIGTERM, lambda: loop.create_task(shutdown_handler(bot, "SIGTERM")) 297 | ) 298 | 299 | log.info("Initialized bot, connecting to Discord...") 300 | future = loop.create_task(bot.start(token)) 301 | bot_exc_handler = functools.partial(bot_exception_handler, bot) 302 | future.add_done_callback(bot_exc_handler) 303 | 304 | loop.run_forever() 305 | except KeyboardInterrupt: 306 | if bot is not None: 307 | loop.run_until_complete(shutdown_handler(bot, "Ctrl+C")) 308 | except Exception: 309 | log.critical("Unhandled exception.", exc_info=True) 310 | if bot is not None: 311 | loop.run_until_complete(shutdown_handler(bot)) 312 | finally: 313 | if queue_listener: 314 | queue_listener.stop() 315 | loop.run_until_complete(loop.shutdown_asyncgens()) 316 | if server is not None: 317 | loop.run_until_complete(server.stop()) 318 | if Tortoise._inited: 319 | loop.run_until_complete(Tortoise.close_connections()) 320 | asyncio.set_event_loop(None) 321 | loop.stop() 322 | loop.close() 323 | sys.exit(bot._shutdown if bot else 1) 324 | 325 | 326 | if __name__ == "__main__": 327 | main() 328 | -------------------------------------------------------------------------------- /ballsdex/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/__init__.py -------------------------------------------------------------------------------- /ballsdex/core/admin/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import pathlib 3 | 4 | from fastapi import FastAPI 5 | from fastapi_admin.app import app as admin_app 6 | from fastapi_admin.exceptions import ( 7 | forbidden_error_exception, 8 | not_found_error_exception, 9 | server_error_exception, 10 | unauthorized_error_exception, 11 | ) 12 | from fastapi_admin.providers.login import UsernamePasswordProvider 13 | from redis import asyncio as aioredis 14 | from starlette.middleware.cors import CORSMiddleware 15 | from starlette.responses import RedirectResponse 16 | from starlette.staticfiles import StaticFiles 17 | from starlette.status import ( 18 | HTTP_401_UNAUTHORIZED, 19 | HTTP_403_FORBIDDEN, 20 | HTTP_404_NOT_FOUND, 21 | HTTP_500_INTERNAL_SERVER_ERROR, 22 | ) 23 | from tortoise.contrib.fastapi import register_tortoise 24 | 25 | from ballsdex.__main__ import TORTOISE_ORM 26 | from ballsdex.core.admin import resources, routes # noqa: F401 27 | from ballsdex.core.admin.resources import User 28 | 29 | BASE_DIR = pathlib.Path(".") 30 | 31 | 32 | def init_fastapi_app() -> FastAPI: 33 | app = FastAPI() 34 | app.mount( 35 | "/static", 36 | StaticFiles(directory=BASE_DIR / "static"), 37 | name="static", 38 | ) 39 | app.mount( 40 | "/ballsdex/core/image_generator/src", 41 | StaticFiles(directory=BASE_DIR / "ballsdex/core/image_generator/src"), 42 | name="image_gen", 43 | ) 44 | 45 | @app.get("/") 46 | async def index(): 47 | return RedirectResponse(url="/admin") 48 | 49 | admin_app.add_exception_handler( 50 | HTTP_500_INTERNAL_SERVER_ERROR, server_error_exception # type: ignore 51 | ) 52 | admin_app.add_exception_handler(HTTP_404_NOT_FOUND, not_found_error_exception) # type: ignore 53 | admin_app.add_exception_handler(HTTP_403_FORBIDDEN, forbidden_error_exception) # type: ignore 54 | admin_app.add_exception_handler( 55 | HTTP_401_UNAUTHORIZED, unauthorized_error_exception # type: ignore 56 | ) 57 | 58 | @app.on_event("startup") 59 | async def startup(): 60 | redis = aioredis.from_url( 61 | os.environ["BALLSDEXBOT_REDIS_URL"], decode_responses=True, encoding="utf8" 62 | ) 63 | await admin_app.configure( 64 | logo_url="https://i.imgur.com/HwNKi5a.png", 65 | template_folders=[os.path.join(BASE_DIR, "ballsdex", "templates")], 66 | favicon_url="https://raw.githubusercontent.com/fastapi-admin/" # type: ignore 67 | "fastapi-admin/dev/images/favicon.png", 68 | providers=[ 69 | UsernamePasswordProvider( 70 | login_logo_url="https://preview.tabler.io/static/logo.svg", 71 | admin_model=User, 72 | ) 73 | ], 74 | redis=redis, 75 | ) 76 | 77 | app.mount("/admin", admin_app) 78 | app.add_middleware( 79 | CORSMiddleware, 80 | allow_origins=["*"], 81 | allow_credentials=True, 82 | allow_methods=["*"], 83 | allow_headers=["*"], 84 | expose_headers=["*"], 85 | ) 86 | register_tortoise(app, config=TORTOISE_ORM) 87 | 88 | return app 89 | 90 | 91 | _app = init_fastapi_app() 92 | -------------------------------------------------------------------------------- /ballsdex/core/admin/resources.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List 3 | 4 | from fastapi_admin.app import app 5 | from fastapi_admin.enums import Method 6 | from fastapi_admin.file_upload import FileUpload 7 | from fastapi_admin.resources import Action, Field, Link, Model 8 | from fastapi_admin.widgets import displays, filters, inputs 9 | from starlette.requests import Request 10 | 11 | from ballsdex.core.models import ( 12 | Ball, 13 | BallInstance, 14 | BlacklistedGuild, 15 | BlacklistedID, 16 | Economy, 17 | GuildConfig, 18 | Player, 19 | Regime, 20 | Special, 21 | User, 22 | ) 23 | 24 | 25 | @app.register 26 | class Home(Link): 27 | label = "Home" 28 | icon = "fas fa-home" 29 | url = "/admin" 30 | 31 | 32 | upload = FileUpload(uploads_dir=os.path.join(".", "static", "uploads")) 33 | 34 | 35 | @app.register 36 | class AdminResource(Model): 37 | label = "Admin" 38 | model = User 39 | icon = "fas fa-user" 40 | page_pre_title = "admin list" 41 | page_title = "Admins" 42 | filters = [ 43 | filters.Search( 44 | name="username", 45 | label="Name", 46 | search_mode="contains", 47 | placeholder="Search for username", 48 | ), 49 | ] 50 | fields = [ 51 | "id", 52 | "username", 53 | Field( 54 | name="password", 55 | label="Password", 56 | display=displays.InputOnly(), 57 | input_=inputs.Password(), 58 | ), 59 | Field( 60 | name="avatar", 61 | label="Avatar", 62 | display=displays.Image(width="40"), 63 | input_=inputs.Image(null=True, upload=upload), 64 | ), 65 | "created_at", 66 | ] 67 | 68 | async def cell_attributes(self, request: Request, obj: dict, field: Field) -> dict: 69 | if field.name == "id": 70 | return {"class": "bg-danger text-white"} 71 | return await super().cell_attributes(request, obj, field) 72 | 73 | 74 | @app.register 75 | class SpecialResource(Model): 76 | label = "Special events" 77 | model = Special 78 | icon = "fas fa-star" 79 | page_pre_title = "special list" 80 | page_title = "Special events list" 81 | filters = [ 82 | filters.Search( 83 | name="name", label="Name", search_mode="icontains", placeholder="Search for events" 84 | ) 85 | ] 86 | fields = [ 87 | "name", 88 | "catch_phrase", 89 | Field( 90 | name="start_date", 91 | label="Start date of the event", 92 | display=displays.DateDisplay(), 93 | input_=inputs.Date(help_text="Date when special balls will start spawning"), 94 | ), 95 | Field( 96 | name="end_date", 97 | label="End date of the event", 98 | display=displays.DateDisplay(), 99 | input_=inputs.Date(help_text="Date when special balls will stop spawning"), 100 | ), 101 | "rarity", 102 | Field( 103 | name="background", 104 | label="Special background", 105 | display=displays.Image(width="40"), 106 | input_=inputs.Image(upload=upload, null=True), 107 | ), 108 | "emoji", 109 | "tradeable", 110 | ] 111 | 112 | async def get_actions(self, request: Request) -> List[Action]: 113 | actions = await super().get_actions(request) 114 | actions.append( 115 | Action( 116 | icon="fas fa-upload", 117 | label="Generate card", 118 | name="generate", 119 | method=Method.GET, 120 | ajax=False, 121 | ) 122 | ) 123 | return actions 124 | 125 | 126 | @app.register 127 | class RegimeResource(Model): 128 | label = "Regime" 129 | model = Regime 130 | icon = "fas fa-flag" 131 | page_pre_title = "regime list" 132 | page_title = "Regimes" 133 | fields = [ 134 | "name", 135 | Field( 136 | name="background", 137 | label="Background (1428x2000)", 138 | display=displays.Image(width="40"), 139 | input_=inputs.Image(upload=upload, null=True), 140 | ), 141 | ] 142 | 143 | 144 | @app.register 145 | class EconomyResource(Model): 146 | label = "Economy" 147 | model = Economy 148 | icon = "fas fa-coins" 149 | page_pre_title = "economy list" 150 | page_title = "Economies" 151 | fields = [ 152 | "name", 153 | Field( 154 | name="icon", 155 | label="Icon (512x512)", 156 | display=displays.Image(width="40"), 157 | input_=inputs.Image(upload=upload, null=True), 158 | ), 159 | ] 160 | 161 | 162 | @app.register 163 | class BallResource(Model): 164 | label = "Ball" 165 | model = Ball 166 | page_size = 50 167 | icon = "fas fa-globe" 168 | page_pre_title = "ball list" 169 | page_title = "Balls" 170 | filters = [ 171 | filters.Search( 172 | name="country", 173 | label="Country", 174 | search_mode="icontains", 175 | placeholder="Search for balls", 176 | ), 177 | filters.ForeignKey(model=Regime, name="regime", label="Regime"), 178 | filters.ForeignKey(model=Economy, name="economy", label="Economy"), 179 | filters.Boolean(name="enabled", label="Enabled"), 180 | filters.Boolean(name="tradeable", label="Tradeable"), 181 | ] 182 | fields = [ 183 | "country", 184 | "short_name", 185 | "catch_names", 186 | "created_at", 187 | "regime", 188 | "economy", 189 | "health", 190 | "attack", 191 | "rarity", 192 | "enabled", 193 | "tradeable", 194 | Field( 195 | name="emoji_id", 196 | label="Emoji ID", 197 | ), 198 | Field( 199 | name="wild_card", 200 | label="Wild card", 201 | display=displays.Image(width="40"), 202 | input_=inputs.Image(upload=upload, null=True), 203 | ), 204 | Field( 205 | name="collection_card", 206 | label="Collection card", 207 | display=displays.Image(width="40"), 208 | input_=inputs.Image(upload=upload, null=True), 209 | ), 210 | Field( 211 | name="credits", 212 | label="Image credits", 213 | ), 214 | Field( 215 | name="capacity_name", 216 | label="Capacity name", 217 | ), 218 | Field( 219 | name="capacity_description", 220 | label="Capacity description", 221 | ), 222 | ] 223 | 224 | async def get_actions(self, request: Request) -> List[Action]: 225 | actions = await super().get_actions(request) 226 | actions.append( 227 | Action( 228 | icon="fas fa-upload", 229 | label="Generate card", 230 | name="generate", 231 | method=Method.GET, 232 | ajax=False, 233 | ) 234 | ) 235 | return actions 236 | 237 | 238 | @app.register 239 | class BallInstanceResource(Model): 240 | label = "Ball instance" 241 | model = BallInstance 242 | icon = "fas fa-atlas" 243 | page_pre_title = "ball instances list" 244 | page_title = "Ball instances" 245 | filters = [ 246 | filters.Search( 247 | name="id", 248 | label="Ball Instance ID", 249 | placeholder="Search for ball IDs", 250 | ), 251 | filters.ForeignKey(model=Ball, name="ball", label="Ball"), 252 | filters.ForeignKey(model=Special, name="special", label="Special"), 253 | filters.Date(name="catch_date", label="Catch date"), 254 | filters.Boolean(name="shiny", label="Shiny"), 255 | filters.Boolean(name="favorite", label="Favorite"), 256 | filters.Search( 257 | name="player__discord_id", 258 | label="User ID", 259 | placeholder="Search for Discord user ID", 260 | ), 261 | filters.Search( 262 | name="server_id", 263 | label="Server ID", 264 | placeholder="Search for Discord server ID", 265 | ), 266 | filters.Boolean(name="tradeable", label="Tradeable"), 267 | ] 268 | fields = [ 269 | "id", 270 | "ball", 271 | "player", 272 | "catch_date", 273 | "server_id", 274 | "shiny", 275 | "special", 276 | "favorite", 277 | "health_bonus", 278 | "attack_bonus", 279 | "tradeable", 280 | ] 281 | 282 | 283 | @app.register 284 | class PlayerResource(Model): 285 | label = "Player" 286 | model = Player 287 | icon = "fas fa-user" 288 | page_pre_title = "player list" 289 | page_title = "Players" 290 | filters = [ 291 | filters.Search( 292 | name="discord_id", 293 | label="ID", 294 | search_mode="icontains", 295 | placeholder="Filter by ID", 296 | ), 297 | ] 298 | fields = [ 299 | "discord_id", 300 | "balls", 301 | ] 302 | 303 | 304 | @app.register 305 | class GuildConfigResource(Model): 306 | label = "Guild config" 307 | model = GuildConfig 308 | icon = "fas fa-cog" 309 | page_title = "Guild configs" 310 | filters = [ 311 | filters.Search( 312 | name="guild_id", 313 | label="ID", 314 | search_mode="icontains", 315 | placeholder="Filter by ID", 316 | ), 317 | ] 318 | fields = ["guild_id", "spawn_channel", "enabled"] 319 | 320 | 321 | @app.register 322 | class BlacklistedIDResource(Model): 323 | label = "Blacklisted user ID" 324 | model = BlacklistedID 325 | icon = "fas fa-user-lock" 326 | page_title = "Blacklisted user IDs" 327 | filters = [ 328 | filters.Search( 329 | name="discord_id", 330 | label="ID", 331 | search_mode="icontains", 332 | placeholder="Filter by ID", 333 | ), 334 | filters.Search( 335 | name="reason", 336 | label="Reason", 337 | search_mode="search", 338 | placeholder="Search by reason", 339 | ), 340 | ] 341 | fields = [ 342 | "discord_id", 343 | "reason", 344 | ] 345 | 346 | 347 | @app.register 348 | class BlacklistedGuildIDResource(Model): 349 | label = "Blacklisted Guild ID" 350 | model = BlacklistedGuild 351 | icon = "fas fa-lock" 352 | page_title = "Blacklisted Guild IDs" 353 | filters = [ 354 | filters.Search( 355 | name="guild_id", 356 | label="ID", 357 | search_mode="icontains", 358 | placeholder="Filter by Guild ID", 359 | ), 360 | filters.Search( 361 | name="reason", 362 | label="Reason", 363 | search_mode="search", 364 | placeholder="Search by reason", 365 | ), 366 | ] 367 | fields = [ 368 | "discord_id", 369 | "reason", 370 | ] 371 | -------------------------------------------------------------------------------- /ballsdex/core/admin/routes.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, Path 2 | from fastapi_admin.app import app 3 | from fastapi_admin.depends import get_resources 4 | from fastapi_admin.template import templates 5 | from starlette.requests import Request 6 | from starlette.responses import Response 7 | from tortoise.exceptions import DoesNotExist 8 | 9 | from ballsdex.core.models import Ball, BallInstance, GuildConfig, Player, Special 10 | 11 | 12 | @app.get("/") 13 | async def home( 14 | request: Request, 15 | resources=Depends(get_resources), 16 | ): 17 | return templates.TemplateResponse( 18 | "dashboard.html", 19 | context={ 20 | "request": request, 21 | "resources": resources, 22 | "ball_count": await Ball.all().count(), 23 | "player_count": await Player.all().count(), 24 | "guild_count": await GuildConfig.all().count(), 25 | "resource_label": "Dashboard", 26 | "page_pre_title": "overview", 27 | "page_title": "Dashboard", 28 | }, 29 | ) 30 | 31 | 32 | @app.get("/ball/generate/{pk}") 33 | async def generate_card( 34 | request: Request, 35 | pk: str = Path(...), 36 | ): 37 | ball = await Ball.get(pk=pk).prefetch_related("regime", "economy") 38 | temp_instance = BallInstance(ball=ball, player=await Player.first(), count=1) 39 | buffer = temp_instance.draw_card() 40 | return Response(content=buffer.read(), media_type="image/png") 41 | 42 | 43 | @app.get("/special/generate/{pk}") 44 | async def generate_special_card( 45 | request: Request, 46 | pk: str = Path(...), 47 | ): 48 | special = await Special.get(pk=pk) 49 | try: 50 | ball = await Ball.first().prefetch_related("regime", "economy") 51 | except DoesNotExist: 52 | return Response( 53 | content="At least one ball must exist", status_code=422, media_type="text/html" 54 | ) 55 | temp_instance = BallInstance(ball=ball, special=special, player=await Player.first(), count=1) 56 | buffer = temp_instance.draw_card() 57 | return Response(content=buffer.read(), media_type="image/png") 58 | -------------------------------------------------------------------------------- /ballsdex/core/commands.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from typing import TYPE_CHECKING 4 | 5 | from discord.ext import commands 6 | from tortoise import Tortoise 7 | 8 | log = logging.getLogger("ballsdex.core.commands") 9 | 10 | if TYPE_CHECKING: 11 | from .bot import BallsDexBot 12 | 13 | 14 | class Core(commands.Cog): 15 | """ 16 | Core commands of BallsDex bot 17 | """ 18 | 19 | def __init__(self, bot: "BallsDexBot"): 20 | self.bot = bot 21 | 22 | @commands.command() 23 | async def ping(self, ctx: commands.Context): 24 | """ 25 | Ping! 26 | """ 27 | await ctx.send("Pong") 28 | 29 | @commands.command() 30 | @commands.is_owner() 31 | async def reloadtree(self, ctx: commands.Context): 32 | """ 33 | Sync the application commands with Discord 34 | """ 35 | await self.bot.tree.sync() 36 | await ctx.send("Application commands tree reloaded.") 37 | 38 | @commands.command() 39 | @commands.is_owner() 40 | async def reload(self, ctx: commands.Context, package: str): 41 | """ 42 | Reload an extension 43 | """ 44 | package = "ballsdex.packages." + package 45 | try: 46 | try: 47 | await self.bot.reload_extension(package) 48 | except commands.ExtensionNotLoaded: 49 | await self.bot.load_extension(package) 50 | except commands.ExtensionNotFound: 51 | await ctx.send("Extension not found") 52 | except Exception: 53 | await ctx.send("Failed to reload extension.") 54 | log.error(f"Failed to reload extension {package}", exc_info=True) 55 | else: 56 | await ctx.send("Extension reloaded.") 57 | 58 | @commands.command() 59 | @commands.is_owner() 60 | async def reloadcache(self, ctx: commands.Context): 61 | """ 62 | Reload the cache of database models. 63 | 64 | This is needed each time the database is updated, otherwise changes won't reflect until 65 | next start. 66 | """ 67 | await self.bot.load_cache() 68 | await ctx.message.add_reaction("✅") 69 | 70 | @commands.command() 71 | @commands.is_owner() 72 | async def analyzedb(self, ctx: commands.Context): 73 | """ 74 | Analyze the database. This refreshes the counts displayed by the `/about` command. 75 | """ 76 | connection = Tortoise.get_connection("default") 77 | t1 = time.time() 78 | await connection.execute_query("ANALYZE") 79 | t2 = time.time() 80 | await ctx.send(f"Analyzed database in {round((t2 - t1) * 1000)}ms.") 81 | -------------------------------------------------------------------------------- /ballsdex/core/image_generator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/__init__.py -------------------------------------------------------------------------------- /ballsdex/core/image_generator/image_gen.py: -------------------------------------------------------------------------------- 1 | import os 2 | import textwrap 3 | from pathlib import Path 4 | from typing import TYPE_CHECKING 5 | 6 | from PIL import Image, ImageDraw, ImageFont, ImageOps 7 | 8 | if TYPE_CHECKING: 9 | from ballsdex.core.models import BallInstance 10 | 11 | 12 | SOURCES_PATH = Path(os.path.dirname(os.path.abspath(__file__)), "./src") 13 | WIDTH = 1500 14 | HEIGHT = 2000 15 | 16 | RECTANGLE_WIDTH = WIDTH - 40 17 | RECTANGLE_HEIGHT = (HEIGHT // 5) * 2 18 | 19 | CORNERS = ((34, 261), (1393, 992)) 20 | artwork_size = [b - a for a, b in zip(*CORNERS)] 21 | 22 | title_font = ImageFont.truetype(str(SOURCES_PATH / "ArsenicaTrial-Extrabold.ttf"), 170) 23 | capacity_name_font = ImageFont.truetype(str(SOURCES_PATH / "Bobby Jones Soft.otf"), 110) 24 | capacity_description_font = ImageFont.truetype(str(SOURCES_PATH / "OpenSans-Semibold.ttf"), 75) 25 | stats_font = ImageFont.truetype(str(SOURCES_PATH / "Bobby Jones Soft.otf"), 130) 26 | credits_font = ImageFont.truetype(str(SOURCES_PATH / "arial.ttf"), 40) 27 | 28 | 29 | def draw_card(ball_instance: "BallInstance"): 30 | ball = ball_instance.countryball 31 | ball_health = (237, 115, 101, 255) 32 | 33 | if ball_instance.shiny: 34 | image = Image.open(str(SOURCES_PATH / "shiny.png")) 35 | ball_health = (255, 255, 255, 255) 36 | elif special_image := ball_instance.special_card: 37 | image = Image.open("." + special_image) 38 | else: 39 | image = Image.open("." + ball.cached_regime.background) 40 | icon = Image.open("." + ball.cached_economy.icon) if ball.cached_economy else None 41 | 42 | draw = ImageDraw.Draw(image) 43 | draw.text((50, 20), ball.short_name or ball.country, font=title_font) 44 | for i, line in enumerate(textwrap.wrap(f"Ability: {ball.capacity_name}", width=26)): 45 | draw.text( 46 | (100, 1050 + 100 * i), 47 | line, 48 | font=capacity_name_font, 49 | fill=(230, 230, 230, 255), 50 | stroke_width=2, 51 | stroke_fill=(0, 0, 0, 255), 52 | ) 53 | for i, line in enumerate(textwrap.wrap(ball.capacity_description, width=32)): 54 | draw.text( 55 | (60, 1300 + 80 * i), 56 | line, 57 | font=capacity_description_font, 58 | stroke_width=1, 59 | stroke_fill=(0, 0, 0, 255), 60 | ) 61 | draw.text( 62 | (320, 1670), 63 | str(ball_instance.health), 64 | font=stats_font, 65 | fill=ball_health, 66 | stroke_width=1, 67 | stroke_fill=(0, 0, 0, 255), 68 | ) 69 | draw.text( 70 | (1120, 1670), 71 | str(ball_instance.attack), 72 | font=stats_font, 73 | fill=(252, 194, 76, 255), 74 | stroke_width=1, 75 | stroke_fill=(0, 0, 0, 255), 76 | anchor="ra", 77 | ) 78 | draw.text( 79 | (30, 1870), 80 | # Modifying the line below is breaking the licence as you are removing credits 81 | # If you don't want to receive a DMCA, just don't 82 | "Created by El Laggron\n" f"Artwork author: {ball.credits}", 83 | font=credits_font, 84 | fill=(0, 0, 0, 255), 85 | stroke_width=0, 86 | stroke_fill=(255, 255, 255, 255), 87 | ) 88 | 89 | artwork = Image.open("." + ball.collection_card) 90 | image.paste(ImageOps.fit(artwork, artwork_size), CORNERS[0]) # type: ignore 91 | 92 | if icon: 93 | icon = ImageOps.fit(icon, (192, 192)) 94 | image.paste(icon, (1200, 30), mask=icon) 95 | icon.close() 96 | artwork.close() 97 | 98 | return image 99 | -------------------------------------------------------------------------------- /ballsdex/core/image_generator/src/ArsenicaTrial-Extrabold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/src/ArsenicaTrial-Extrabold.ttf -------------------------------------------------------------------------------- /ballsdex/core/image_generator/src/Bobby Jones Soft.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/src/Bobby Jones Soft.otf -------------------------------------------------------------------------------- /ballsdex/core/image_generator/src/OpenSans-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/src/OpenSans-Bold.ttf -------------------------------------------------------------------------------- /ballsdex/core/image_generator/src/OpenSans-Semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/src/OpenSans-Semibold.ttf -------------------------------------------------------------------------------- /ballsdex/core/image_generator/src/arial.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/src/arial.ttf -------------------------------------------------------------------------------- /ballsdex/core/image_generator/src/capitalist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/src/capitalist.png -------------------------------------------------------------------------------- /ballsdex/core/image_generator/src/communist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/src/communist.png -------------------------------------------------------------------------------- /ballsdex/core/image_generator/src/democracy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/src/democracy.png -------------------------------------------------------------------------------- /ballsdex/core/image_generator/src/dictatorship.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/src/dictatorship.png -------------------------------------------------------------------------------- /ballsdex/core/image_generator/src/fr_test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/src/fr_test.png -------------------------------------------------------------------------------- /ballsdex/core/image_generator/src/shiny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/src/shiny.png -------------------------------------------------------------------------------- /ballsdex/core/image_generator/src/union.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/image_generator/src/union.png -------------------------------------------------------------------------------- /ballsdex/core/metrics.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import math 4 | from collections import defaultdict 5 | from datetime import datetime 6 | from typing import TYPE_CHECKING 7 | 8 | from aiohttp import web 9 | from prometheus_client import CONTENT_TYPE_LATEST, Gauge, Histogram, generate_latest 10 | 11 | if TYPE_CHECKING: 12 | from ballsdex.core.bot import BallsDexBot 13 | 14 | log = logging.getLogger("ballsdex.core.metrics") 15 | 16 | 17 | class PrometheusServer: 18 | """ 19 | Host an HTTP server for metrics collection by Prometheus. 20 | """ 21 | 22 | def __init__(self, bot: "BallsDexBot", host: str = "localhost", port: int = 15260): 23 | self.bot = bot 24 | self.host = host 25 | self.port = port 26 | 27 | self.app = web.Application(logger=log) 28 | self.runner: web.AppRunner 29 | self.site: web.TCPSite 30 | self._inited = False 31 | 32 | self.app.add_routes((web.get("/metrics", self.get),)) 33 | 34 | self.guild_count = Gauge("guilds", "Number of guilds the server is in", ["size"]) 35 | self.shards_latecy = Histogram( 36 | "gateway_latency", "Shard latency with the Discord gateway", ["shard_id"] 37 | ) 38 | self.asyncio_delay = Histogram( 39 | "asyncio_delay", 40 | "How much time asyncio takes to give back control", 41 | buckets=( 42 | 0.001, 43 | 0.0025, 44 | 0.005, 45 | 0.0075, 46 | 0.01, 47 | 0.025, 48 | 0.05, 49 | 0.075, 50 | 0.1, 51 | 0.25, 52 | 0.5, 53 | 0.75, 54 | 1.0, 55 | 2.5, 56 | 5.0, 57 | 7.5, 58 | 10.0, 59 | float("inf"), 60 | ), 61 | ) 62 | 63 | async def collect_metrics(self): 64 | guilds: dict[int, int] = defaultdict(int) 65 | for guild in self.bot.guilds: 66 | if not guild.member_count: 67 | continue 68 | guilds[10 ** math.ceil(math.log(max(guild.member_count - 1, 1), 10))] += 1 69 | for size, count in guilds.items(): 70 | self.guild_count.labels(size=size).set(count) 71 | 72 | for shard_id, latency in self.bot.latencies: 73 | self.shards_latecy.labels(shard_id=shard_id).observe(latency) 74 | 75 | t1 = datetime.now() 76 | await asyncio.sleep(1) 77 | t2 = datetime.now() 78 | self.asyncio_delay.observe((t2 - t1).total_seconds() - 1) 79 | 80 | async def get(self, request: web.Request) -> web.Response: 81 | log.debug("Request received") 82 | await self.collect_metrics() 83 | response = web.Response(body=generate_latest()) 84 | response.content_type = CONTENT_TYPE_LATEST 85 | return response 86 | 87 | async def setup(self): 88 | self.runner = web.AppRunner(self.app) 89 | await self.runner.setup() 90 | self.site = web.TCPSite(self.runner, host=self.host, port=self.port) 91 | self._inited = True 92 | 93 | async def run(self): 94 | await self.setup() 95 | await self.site.start() # this call isn't blocking 96 | log.info(f"Prometheus server started on http://{self.site._host}:{self.site._port}/") 97 | 98 | async def stop(self): 99 | if self._inited: 100 | await self.site.stop() 101 | await self.runner.cleanup() 102 | log.info("Prometheus server stopped") 103 | -------------------------------------------------------------------------------- /ballsdex/core/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/super-dev03/discbot-python/1d635109c6dcd1088bbdebecc03f73cad7ac2f70/ballsdex/core/utils/__init__.py -------------------------------------------------------------------------------- /ballsdex/core/utils/buttons.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ui import Button, View 3 | 4 | 5 | class ConfirmChoiceView(View): 6 | def __init__(self, interaction: discord.Interaction): 7 | super().__init__(timeout=90) 8 | self.value = None 9 | self.interaction = interaction 10 | 11 | async def interaction_check(self, interaction: discord.Interaction) -> bool: 12 | if interaction.user != self.interaction.user: 13 | await interaction.response.send_message( 14 | "Only the original author can use this.", ephemeral=True 15 | ) 16 | return False 17 | if self.value is not None: 18 | await interaction.response.send_message( 19 | "You've already made a choice.", ephemeral=True 20 | ) 21 | return False 22 | return True 23 | 24 | async def on_timeout(self): 25 | for item in self.children: 26 | item.disabled = True # type: ignore 27 | try: 28 | await self.interaction.followup.edit_message("@original", view=self) # type: ignore 29 | except discord.NotFound: 30 | pass 31 | 32 | @discord.ui.button( 33 | style=discord.ButtonStyle.success, emoji="\N{HEAVY CHECK MARK}\N{VARIATION SELECTOR-16}" 34 | ) 35 | async def confirm_button(self, interaction: discord.Interaction, button: Button): 36 | for item in self.children: 37 | item.disabled = True # type: ignore 38 | await interaction.response.edit_message( 39 | content=interaction.message.content + "\nConfirmed", view=self # type: ignore 40 | ) 41 | self.value = True 42 | self.stop() 43 | 44 | @discord.ui.button( 45 | style=discord.ButtonStyle.danger, 46 | emoji="\N{HEAVY MULTIPLICATION X}\N{VARIATION SELECTOR-16}", 47 | ) 48 | async def cancel_button(self, interaction: discord.Interaction, button: Button): 49 | for item in self.children: 50 | item.disabled = True # type: ignore 51 | await interaction.response.edit_message( 52 | content=interaction.message.content + "\nCancelled", view=self # type: ignore 53 | ) 54 | self.value = False 55 | self.stop() 56 | -------------------------------------------------------------------------------- /ballsdex/core/utils/discord-ext-menus.LIENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2020 Danny Y. (Rapptz) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a 6 | copy of this software and associated documentation files (the "Software"), 7 | to deal in the Software without restriction, including without limitation 8 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | and/or sell copies of the Software, and to permit persons to whom the 10 | Software is furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 16 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /ballsdex/core/utils/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import discord 4 | 5 | from ballsdex.core.bot import BallsDexBot 6 | from ballsdex.settings import settings 7 | 8 | log = logging.getLogger("ballsdex.packages.admin.cog") 9 | 10 | 11 | async def log_action(message: str, bot: BallsDexBot, console_log: bool = False): 12 | if settings.log_channel: 13 | channel = bot.get_channel(settings.log_channel) 14 | if not channel: 15 | log.warning(f"Channel {settings.log_channel} not found") 16 | return 17 | if not isinstance(channel, discord.TextChannel): 18 | log.warning(f"Channel {channel.name} is not a text channel") # type: ignore 19 | return 20 | await channel.send(message) 21 | if console_log: 22 | log.info(message) 23 | -------------------------------------------------------------------------------- /ballsdex/core/utils/paginator.py: -------------------------------------------------------------------------------- 1 | # TODO: credits 2 | 3 | from __future__ import annotations 4 | 5 | import logging 6 | from typing import TYPE_CHECKING, Any, Dict, Optional 7 | 8 | import discord 9 | from discord.ext.commands import Paginator as CommandPaginator 10 | 11 | from ballsdex.core.utils import menus 12 | 13 | if TYPE_CHECKING: 14 | from ballsdex.core.bot import BallsDexBot 15 | 16 | log = logging.getLogger("ballsdex.core.utils.paginator") 17 | 18 | 19 | class NumberedPageModal(discord.ui.Modal, title="Go to page"): 20 | page = discord.ui.TextInput(label="Page", placeholder="Enter a number", min_length=1) 21 | 22 | def __init__(self, max_pages: Optional[int]) -> None: 23 | super().__init__() 24 | if max_pages is not None: 25 | as_string = str(max_pages) 26 | self.page.placeholder = f"Enter a number between 1 and {as_string}" 27 | self.page.max_length = len(as_string) 28 | 29 | async def on_submit(self, interaction: discord.Interaction) -> None: 30 | self.interaction = interaction 31 | self.stop() 32 | 33 | 34 | class Pages(discord.ui.View): 35 | def __init__( 36 | self, 37 | source: menus.PageSource, 38 | *, 39 | interaction: discord.Interaction["BallsDexBot"], 40 | check_embeds: bool = False, 41 | compact: bool = False, 42 | ): 43 | super().__init__() 44 | self.source: menus.PageSource = source 45 | self.check_embeds: bool = check_embeds 46 | self.original_interaction = interaction 47 | self.bot = self.original_interaction.client 48 | self.current_page: int = 0 49 | self.compact: bool = compact 50 | self.clear_items() 51 | self.fill_items() 52 | 53 | async def send(self, *args, **kwargs): 54 | if self.original_interaction.response.is_done(): 55 | await self.original_interaction.followup.send(*args, **kwargs) 56 | else: 57 | await self.original_interaction.response.send_message(*args, **kwargs) 58 | 59 | def fill_items(self) -> None: 60 | if not self.compact: 61 | self.numbered_page.row = 1 62 | self.stop_pages.row = 1 63 | 64 | if self.source.is_paginating(): 65 | max_pages = self.source.get_max_pages() 66 | use_last_and_first = max_pages is not None and max_pages >= 2 67 | if use_last_and_first: 68 | self.add_item(self.go_to_first_page) 69 | self.add_item(self.go_to_previous_page) 70 | if not self.compact: 71 | self.add_item(self.go_to_current_page) 72 | self.add_item(self.go_to_next_page) 73 | if use_last_and_first: 74 | self.add_item(self.go_to_last_page) 75 | if not self.compact: 76 | self.add_item(self.numbered_page) 77 | self.add_item(self.stop_pages) 78 | 79 | async def _get_kwargs_from_page(self, page: int) -> Dict[str, Any]: 80 | value = await discord.utils.maybe_coroutine(self.source.format_page, self, page) 81 | if isinstance(value, dict): 82 | return value 83 | elif isinstance(value, str): 84 | return {"content": value, "embed": None} 85 | elif isinstance(value, discord.Embed): 86 | return {"embed": value, "content": None} 87 | elif value is True: 88 | return {} 89 | else: 90 | raise TypeError("Wrong page type returned") 91 | 92 | async def show_page(self, interaction: discord.Interaction, page_number: int) -> None: 93 | page = await self.source.get_page(page_number) 94 | self.current_page = page_number 95 | kwargs = await self._get_kwargs_from_page(page) 96 | self._update_labels(page_number) 97 | if kwargs is not None: 98 | if interaction.response.is_done(): 99 | await interaction.followup.edit_message( 100 | "@original", **kwargs, view=self # type: ignore 101 | ) 102 | else: 103 | await interaction.response.edit_message(**kwargs, view=self) 104 | 105 | def _update_labels(self, page_number: int) -> None: 106 | self.go_to_first_page.disabled = page_number == 0 107 | if self.compact: 108 | max_pages = self.source.get_max_pages() 109 | self.go_to_last_page.disabled = max_pages is None or (page_number + 1) >= max_pages 110 | self.go_to_next_page.disabled = ( 111 | max_pages is not None and (page_number + 1) >= max_pages 112 | ) 113 | self.go_to_previous_page.disabled = page_number == 0 114 | return 115 | 116 | self.go_to_current_page.label = str(page_number + 1) 117 | self.go_to_previous_page.label = str(page_number) 118 | self.go_to_next_page.label = str(page_number + 2) 119 | self.go_to_next_page.disabled = False 120 | self.go_to_previous_page.disabled = False 121 | self.go_to_first_page.disabled = False 122 | 123 | max_pages = self.source.get_max_pages() 124 | if max_pages is not None: 125 | self.go_to_last_page.disabled = (page_number + 1) >= max_pages 126 | if (page_number + 1) >= max_pages: 127 | self.go_to_next_page.disabled = True 128 | self.go_to_next_page.label = "…" 129 | if page_number == 0: 130 | self.go_to_previous_page.disabled = True 131 | self.go_to_previous_page.label = "…" 132 | 133 | async def show_checked_page(self, interaction: discord.Interaction, page_number: int) -> None: 134 | max_pages = self.source.get_max_pages() 135 | try: 136 | if max_pages is None: 137 | # If it doesn't give maximum pages, it cannot be checked 138 | await self.show_page(interaction, page_number) 139 | elif max_pages > page_number >= 0: 140 | await self.show_page(interaction, page_number) 141 | except IndexError: 142 | # An error happened that can be handled, so ignore it. 143 | pass 144 | 145 | async def interaction_check(self, interaction: discord.Interaction[BallsDexBot]) -> bool: 146 | if not await interaction.client.blacklist_check(interaction): 147 | return False 148 | if interaction.user and interaction.user.id in ( 149 | self.bot.owner_id, 150 | self.original_interaction.user.id, 151 | ): 152 | return True 153 | await interaction.response.send_message( 154 | "This pagination menu cannot be controlled by you, sorry!", ephemeral=True 155 | ) 156 | return False 157 | 158 | async def on_timeout(self) -> None: 159 | self.stop() 160 | for item in self.children: 161 | item.disabled = True # type: ignore 162 | try: 163 | await self.original_interaction.followup.edit_message( 164 | "@original", view=self # type: ignore 165 | ) 166 | except discord.HTTPException: 167 | pass 168 | 169 | async def on_error( 170 | self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item 171 | ) -> None: 172 | log.error("Error on pagination", exc_info=error) 173 | if interaction.response.is_done(): 174 | await interaction.followup.send("An unknown error occurred, sorry", ephemeral=True) 175 | else: 176 | await interaction.response.send_message( 177 | "An unknown error occurred, sorry", ephemeral=True 178 | ) 179 | 180 | async def start(self, *, content: Optional[str] = None, ephemeral: bool = False) -> None: 181 | if ( 182 | self.check_embeds 183 | and not self.original_interaction.channel.permissions_for( # type: ignore 184 | self.original_interaction.guild.me # type: ignore 185 | ).embed_links 186 | ): 187 | await self.send( 188 | "Bot does not have embed links permission in this channel.", ephemeral=True 189 | ) 190 | return 191 | 192 | await self.source._prepare_once() 193 | page = await self.source.get_page(0) 194 | kwargs = await self._get_kwargs_from_page(page) 195 | if content: 196 | kwargs.setdefault("content", content) 197 | 198 | self._update_labels(0) 199 | await self.send(**kwargs, view=self, ephemeral=ephemeral) 200 | 201 | @discord.ui.button(label="≪", style=discord.ButtonStyle.grey) 202 | async def go_to_first_page(self, interaction: discord.Interaction, button: discord.ui.Button): 203 | """go to the first page""" 204 | await self.show_page(interaction, 0) 205 | 206 | @discord.ui.button(label="Back", style=discord.ButtonStyle.blurple) 207 | async def go_to_previous_page( 208 | self, interaction: discord.Interaction, button: discord.ui.Button 209 | ): 210 | """go to the previous page""" 211 | await self.show_checked_page(interaction, self.current_page - 1) 212 | 213 | @discord.ui.button(label="Current", style=discord.ButtonStyle.grey, disabled=True) 214 | async def go_to_current_page( 215 | self, interaction: discord.Interaction, button: discord.ui.Button 216 | ): 217 | pass 218 | 219 | @discord.ui.button(label="Next", style=discord.ButtonStyle.blurple) 220 | async def go_to_next_page(self, interaction: discord.Interaction, button: discord.ui.Button): 221 | """go to the next page""" 222 | await self.show_checked_page(interaction, self.current_page + 1) 223 | 224 | @discord.ui.button(label="≫", style=discord.ButtonStyle.grey) 225 | async def go_to_last_page(self, interaction: discord.Interaction, button: discord.ui.Button): 226 | """go to the last page""" 227 | # The call here is safe because it's guarded by skip_if 228 | await self.show_page(interaction, self.source.get_max_pages() - 1) # type: ignore 229 | 230 | @discord.ui.button(label="Skip to page...", style=discord.ButtonStyle.grey) 231 | async def numbered_page(self, interaction: discord.Interaction, button: discord.ui.Button): 232 | """lets you type a page number to go to""" 233 | 234 | modal = NumberedPageModal(self.source.get_max_pages()) 235 | await interaction.response.send_modal(modal) 236 | timed_out = await modal.wait() 237 | 238 | if timed_out: 239 | await interaction.followup.send("Took too long", ephemeral=True) 240 | return 241 | elif self.is_finished(): 242 | await modal.interaction.response.send_message("Took too long", ephemeral=True) 243 | return 244 | 245 | value = str(modal.page.value) 246 | if not value.isdigit(): 247 | await modal.interaction.response.send_message( 248 | f"Expected a number not {value!r}", ephemeral=True 249 | ) 250 | return 251 | 252 | value = int(value) 253 | await self.show_checked_page(modal.interaction, value - 1) 254 | if not modal.interaction.response.is_done(): 255 | error = modal.page.placeholder.replace("Enter", "Expected") # type: ignore 256 | await modal.interaction.response.send_message(error, ephemeral=True) 257 | 258 | @discord.ui.button(label="Quit", style=discord.ButtonStyle.red) 259 | async def stop_pages(self, interaction: discord.Interaction, button: discord.ui.Button): 260 | """stops the pagination session.""" 261 | for item in self.children: 262 | item.disabled = True # type: ignore 263 | await interaction.response.edit_message(view=self) 264 | self.stop() 265 | 266 | 267 | class FieldPageSource(menus.ListPageSource): 268 | """A page source that requires (field_name, field_value) tuple items.""" 269 | 270 | def __init__( 271 | self, 272 | entries: list[tuple[Any, Any]], 273 | *, 274 | per_page: int = 12, 275 | inline: bool = False, 276 | clear_description: bool = True, 277 | ) -> None: 278 | super().__init__(entries, per_page=per_page) 279 | self.embed: discord.Embed = discord.Embed(colour=discord.Colour.blurple()) 280 | self.clear_description: bool = clear_description 281 | self.inline: bool = inline 282 | 283 | async def format_page(self, menu: Pages, entries: list[tuple[Any, Any]]) -> discord.Embed: 284 | self.embed.clear_fields() 285 | if self.clear_description: 286 | self.embed.description = None 287 | 288 | for key, value in entries: 289 | self.embed.add_field(name=key, value=value, inline=self.inline) 290 | 291 | maximum = self.get_max_pages() 292 | if maximum > 1: 293 | text = f"Page {menu.current_page + 1}/{maximum} ({len(self.entries)} entries)" 294 | self.embed.set_footer(text=text) 295 | 296 | return self.embed 297 | 298 | 299 | class TextPageSource(menus.ListPageSource): 300 | def __init__(self, text, *, prefix="```", suffix="```", max_size=2000): 301 | pages = CommandPaginator(prefix=prefix, suffix=suffix, max_size=max_size - 200) 302 | for line in text.split("\n"): 303 | pages.add_line(line) 304 | 305 | super().__init__(entries=pages.pages, per_page=1) 306 | 307 | async def format_page(self, menu: Pages, content): 308 | maximum = self.get_max_pages() 309 | if maximum > 1: 310 | return f"{content}\nPage {menu.current_page + 1}/{maximum}" 311 | return content 312 | 313 | 314 | class SimplePageSource(menus.ListPageSource): 315 | async def format_page(self, menu: SimplePages, entries): 316 | pages = [] 317 | for index, entry in enumerate(entries, start=menu.current_page * self.per_page): 318 | pages.append(f"{index + 1}. {entry}") 319 | 320 | maximum = self.get_max_pages() 321 | if maximum > 1: 322 | footer = f"Page {menu.current_page + 1}/{maximum} ({len(self.entries)} entries)" 323 | menu.embed.set_footer(text=footer) 324 | 325 | menu.embed.description = "\n".join(pages) 326 | return menu.embed 327 | 328 | 329 | class SimplePages(Pages): 330 | """A simple pagination session reminiscent of the old Pages interface. 331 | 332 | Basically an embed with some normal formatting. 333 | """ 334 | 335 | def __init__( 336 | self, entries, *, interaction: discord.Interaction["BallsDexBot"], per_page: int = 12 337 | ): 338 | super().__init__(SimplePageSource(entries, per_page=per_page), interaction=interaction) 339 | self.embed = discord.Embed(colour=discord.Colour.blurple()) 340 | -------------------------------------------------------------------------------- /ballsdex/core/utils/tortoise.py: -------------------------------------------------------------------------------- 1 | from tortoise import Tortoise 2 | 3 | 4 | async def row_count_estimate(table_name: str, *, analyze: bool = True) -> int: 5 | """ 6 | Estimate the number of rows in a table. This is *insanely* faster than querying all rows, 7 | but the number given is an estimation, not the real value. 8 | 9 | Source: https://stackoverflow.com/a/7945274 10 | 11 | Parameters 12 | ---------- 13 | table_name: str 14 | Name of the table which you want to get the row count of. 15 | analyze: bool = True 16 | If the returned number is wrong (`-1`), Postgres hasn't built a cache yet. When this 17 | happens, an `ANALYSE` query is sent to rebuild the cache. Set this parameter to `False` 18 | to prevent this and get a potential invalid result. 19 | 20 | Returns 21 | ------- 22 | int 23 | Estimated number of rows 24 | """ 25 | connection = Tortoise.get_connection("default") 26 | 27 | # returns as a tuple the number of rows affected (always 1) and the result as a list 28 | _, rows = await connection.execute_query( 29 | f"SELECT reltuples AS estimate FROM pg_class where relname = '{table_name}';" 30 | ) 31 | # Record type: https://magicstack.github.io/asyncpg/current/api/index.html#record-objects 32 | record = rows[0] 33 | result = int(record["estimate"]) 34 | if result == -1 and analyze is True: 35 | # the cache wasn't built yet, let's ask for an analyze query 36 | await connection.execute_query(f"ANALYZE {table_name}") 37 | return await row_count_estimate(table_name, analyze=False) # prevent recursion error 38 | 39 | return result 40 | -------------------------------------------------------------------------------- /ballsdex/core/utils/transformers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import time 3 | from datetime import timedelta 4 | from enum import Enum 5 | from typing import TYPE_CHECKING, Generic, Iterable, TypeVar 6 | 7 | import discord 8 | from discord import app_commands 9 | from discord.interactions import Interaction 10 | from tortoise.exceptions import DoesNotExist 11 | from tortoise.expressions import Q, RawSQL 12 | from tortoise.models import Model 13 | from tortoise.timezone import now as tortoise_now 14 | 15 | from ballsdex.core.models import ( 16 | Ball, 17 | BallInstance, 18 | Economy, 19 | Regime, 20 | Special, 21 | balls, 22 | economies, 23 | regimes, 24 | ) 25 | from ballsdex.settings import settings 26 | 27 | if TYPE_CHECKING: 28 | from ballsdex.core.bot import BallsDexBot 29 | 30 | log = logging.getLogger("ballsdex.core.utils.transformers") 31 | T = TypeVar("T", bound=Model) 32 | 33 | __all__ = ( 34 | "BallTransform", 35 | "BallInstanceTransform", 36 | "SpecialTransform", 37 | "RegimeTransform", 38 | "EconomyTransform", 39 | ) 40 | 41 | 42 | class TradeCommandType(Enum): 43 | """ 44 | If a command is using `BallInstanceTransformer` for trading purposes, it should define this 45 | enum to filter out values. 46 | """ 47 | 48 | PICK = 0 49 | REMOVE = 1 50 | 51 | 52 | class ValidationError(Exception): 53 | """ 54 | Raised when an autocomplete result is forbidden and should raise a user message. 55 | """ 56 | 57 | def __init__(self, message: str): 58 | self.message = message 59 | 60 | 61 | class ModelTransformer(app_commands.Transformer, Generic[T]): 62 | """ 63 | Base abstract class for autocompletion from on Tortoise models 64 | 65 | Attributes 66 | ---------- 67 | name: str 68 | Name to qualify the object being listed 69 | model: T 70 | The Tortoise model associated to the class derivation 71 | """ 72 | 73 | name: str 74 | model: T 75 | 76 | def key(self, model: T) -> str: 77 | """ 78 | Return a string used for searching while sending autocompletion suggestions. 79 | """ 80 | raise NotImplementedError() 81 | 82 | async def validate(self, interaction: discord.Interaction["BallsDexBot"], item: T): 83 | """ 84 | A function to validate the fetched item before calling back the command. 85 | 86 | Raises 87 | ------ 88 | ValidationError 89 | Raised if the item does not pass validation with the message to be displayed 90 | """ 91 | pass 92 | 93 | async def get_from_pk(self, value: int) -> T: 94 | """ 95 | Return a Tortoise model instance from a primary key. 96 | 97 | Raises 98 | ------ 99 | KeyError | tortoise.exceptions.DoesNotExist 100 | Entry does not exist 101 | """ 102 | return await self.model.get(pk=value) 103 | 104 | async def get_options( 105 | self, interaction: discord.Interaction["BallsDexBot"], value: str 106 | ) -> list[app_commands.Choice[int]]: 107 | """ 108 | Generate the list of options for autocompletion 109 | """ 110 | raise NotImplementedError() 111 | 112 | async def autocomplete( 113 | self, interaction: Interaction["BallsDexBot"], value: str 114 | ) -> list[app_commands.Choice[int]]: 115 | t1 = time.time() 116 | choices: list[app_commands.Choice[int]] = [] 117 | for option in await self.get_options(interaction, value): 118 | choices.append(option) 119 | t2 = time.time() 120 | log.debug( 121 | f"{self.name.title()} autocompletion took " 122 | f"{round((t2-t1)*1000)}ms, {len(choices)} results" 123 | ) 124 | return choices 125 | 126 | async def transform(self, interaction: Interaction["BallsDexBot"], value: str) -> T | None: 127 | if not value: 128 | await interaction.response.send_message( 129 | "You need to use the autocomplete function for the economy selection." 130 | ) 131 | return None 132 | try: 133 | instance = await self.get_from_pk(int(value)) 134 | await self.validate(interaction, instance) 135 | except (DoesNotExist, KeyError, ValueError): 136 | await interaction.response.send_message( 137 | f"The {self.name} could not be found. Make sure to use the autocomplete " 138 | "function on this command.", 139 | ephemeral=True, 140 | ) 141 | return None 142 | except ValidationError as e: 143 | await interaction.response.send_message(e.message, ephemeral=True) 144 | return None 145 | else: 146 | return instance 147 | 148 | 149 | class BallInstanceTransformer(ModelTransformer[BallInstance]): 150 | name = settings.collectible_name 151 | model = BallInstance # type: ignore 152 | 153 | async def get_from_pk(self, value: int) -> BallInstance: 154 | return await self.model.get(pk=value).prefetch_related("player") 155 | 156 | async def validate(self, interaction: discord.Interaction["BallsDexBot"], item: BallInstance): 157 | # checking if the ball does belong to user, and a custom ID wasn't forced 158 | if item.player.discord_id != interaction.user.id: 159 | raise ValidationError(f"That {settings.collectible_name} doesn't belong to you.") 160 | 161 | async def get_options( 162 | self, interaction: Interaction["BallsDexBot"], value: str 163 | ) -> list[app_commands.Choice[int]]: 164 | balls_queryset = BallInstance.filter(player__discord_id=interaction.user.id) 165 | 166 | if (special := getattr(interaction.namespace, "special", None)) and special.isdigit(): 167 | balls_queryset = balls_queryset.filter(special_id=int(special)) 168 | if (shiny := getattr(interaction.namespace, "shiny", None)) and shiny is not None: 169 | balls_queryset = balls_queryset.filter(shiny=shiny) 170 | 171 | if interaction.command and (trade_type := interaction.command.extras.get("trade", None)): 172 | if trade_type == TradeCommandType.PICK: 173 | balls_queryset = balls_queryset.filter( 174 | Q( 175 | Q(locked__isnull=True) 176 | | Q(locked__lt=tortoise_now() - timedelta(minutes=30)) 177 | ) 178 | ) 179 | else: 180 | balls_queryset = balls_queryset.filter( 181 | locked__isnull=False, locked__gt=tortoise_now() - timedelta(minutes=30) 182 | ) 183 | balls_queryset = ( 184 | balls_queryset.select_related("ball") 185 | .annotate( 186 | searchable=RawSQL( 187 | "to_hex(ballinstance.id) || ' ' || ballinstance__ball.country || " 188 | "' ' || ballinstance__ball.catch_names" 189 | ) 190 | ) 191 | .filter(searchable__icontains=value) 192 | .limit(25) 193 | ) 194 | 195 | choices: list[app_commands.Choice] = [ 196 | app_commands.Choice(name=x.description(bot=interaction.client), value=str(x.pk)) 197 | for x in await balls_queryset 198 | ] 199 | return choices 200 | 201 | 202 | class TTLModelTransformer(ModelTransformer[T]): 203 | """ 204 | Base class for simple Tortoise model autocompletion with TTL cache. 205 | 206 | This is used in most cases except for BallInstance which requires special handling depending 207 | on the interaction passed. 208 | 209 | Attributes 210 | ---------- 211 | ttl: float 212 | Delay in seconds for `items` to live until refreshed with `load_items`, defaults to 300 213 | """ 214 | 215 | ttl: float = 300 216 | 217 | def __init__(self): 218 | self.items: dict[int, T] = {} 219 | self.search_map: dict[T, str] = {} 220 | self.last_refresh: float = 0 221 | log.debug(f"Inited transformer for {self.name}") 222 | 223 | async def load_items(self) -> Iterable[T]: 224 | """ 225 | Query values to fill `items` with. 226 | """ 227 | return await self.model.all() 228 | 229 | async def maybe_refresh(self): 230 | t = time.time() 231 | if t - self.last_refresh > self.ttl: 232 | self.items = {x.pk: x for x in await self.load_items()} 233 | self.last_refresh = t 234 | self.search_map = {x: self.key(x).lower() for x in self.items.values()} 235 | 236 | async def get_options( 237 | self, interaction: Interaction["BallsDexBot"], value: str 238 | ) -> list[app_commands.Choice[str]]: 239 | await self.maybe_refresh() 240 | 241 | i = 0 242 | choices: list[app_commands.Choice] = [] 243 | for item in self.items.values(): 244 | if value.lower() in self.search_map[item]: 245 | choices.append(app_commands.Choice(name=self.key(item), value=str(item.pk))) 246 | i += 1 247 | if i == 25: 248 | break 249 | return choices 250 | 251 | 252 | class BallTransformer(TTLModelTransformer[Ball]): 253 | name = settings.collectible_name 254 | model = Ball() 255 | 256 | def key(self, model: Ball) -> str: 257 | return model.country 258 | 259 | async def load_items(self) -> Iterable[Ball]: 260 | return balls.values() 261 | 262 | 263 | class BallEnabledTransformer(BallTransformer): 264 | async def load_items(self) -> Iterable[Ball]: 265 | return {k: v for k, v in balls.items() if v.enabled}.values() 266 | 267 | 268 | class SpecialTransformer(TTLModelTransformer[Special]): 269 | name = "special event" 270 | model = Special() 271 | 272 | def key(self, model: Special) -> str: 273 | return model.name 274 | 275 | 276 | class SpecialEnabledTransformer(SpecialTransformer): 277 | async def load_items(self) -> Iterable[Special]: 278 | return await Special.filter(hidden=False).all() 279 | 280 | 281 | class RegimeTransformer(TTLModelTransformer[Regime]): 282 | name = "regime" 283 | model = Regime() 284 | 285 | def key(self, model: Regime) -> str: 286 | return model.name 287 | 288 | async def load_items(self) -> Iterable[Regime]: 289 | return regimes.values() 290 | 291 | 292 | class EconomyTransformer(TTLModelTransformer[Economy]): 293 | name = "economy" 294 | model = Economy() 295 | 296 | def key(self, model: Economy) -> str: 297 | return model.name 298 | 299 | async def load_items(self) -> Iterable[Economy]: 300 | return economies.values() 301 | 302 | 303 | BallTransform = app_commands.Transform[Ball, BallTransformer] 304 | BallInstanceTransform = app_commands.Transform[BallInstance, BallInstanceTransformer] 305 | SpecialTransform = app_commands.Transform[Special, SpecialTransformer] 306 | RegimeTransform = app_commands.Transform[Regime, RegimeTransformer] 307 | EconomyTransform = app_commands.Transform[Economy, EconomyTransformer] 308 | SpecialEnabledTransform = app_commands.Transform[Special, SpecialEnabledTransformer] 309 | BallEnabledTransform = app_commands.Transform[Ball, BallEnabledTransformer] 310 | -------------------------------------------------------------------------------- /ballsdex/logging.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.handlers 3 | from queue import Queue 4 | 5 | from discord.utils import _ColourFormatter 6 | 7 | log = logging.getLogger("ballsdex") 8 | 9 | 10 | def init_logger(disable_rich: bool = False, debug: bool = False) -> logging.handlers.QueueListener: 11 | formatter = logging.Formatter( 12 | "[{asctime}] {levelname} {name}: {message}", datefmt="%Y-%m-%d %H:%M:%S", style="{" 13 | ) 14 | rich_formatter = _ColourFormatter() 15 | 16 | # handlers 17 | stream_handler = logging.StreamHandler() 18 | stream_handler.setLevel(logging.DEBUG if debug else logging.INFO) 19 | stream_handler.setFormatter(formatter if disable_rich else rich_formatter) 20 | 21 | # file handler 22 | file_handler = logging.handlers.RotatingFileHandler( 23 | "ballsdex.log", maxBytes=8**7, backupCount=8 24 | ) 25 | file_handler.setLevel(logging.INFO) 26 | file_handler.setFormatter(formatter) 27 | 28 | queue = Queue(-1) 29 | queue_handler = logging.handlers.QueueHandler(queue) 30 | 31 | root = logging.getLogger() 32 | root.addHandler(queue_handler) 33 | root.setLevel(logging.INFO) 34 | log.setLevel(logging.DEBUG if debug else logging.INFO) 35 | 36 | queue_listener = logging.handlers.QueueListener(queue, stream_handler, file_handler) 37 | queue_listener.start() 38 | 39 | logging.getLogger("aiohttp").setLevel(logging.WARNING) # don't log each prometheus call 40 | 41 | return queue_listener 42 | -------------------------------------------------------------------------------- /ballsdex/packages/admin/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING 3 | 4 | from discord import app_commands 5 | 6 | from ballsdex.packages.admin.cog import Admin 7 | 8 | if TYPE_CHECKING: 9 | from ballsdex.core.bot import BallsDexBot 10 | 11 | log = logging.getLogger("ballsdex.packages.admin") 12 | 13 | 14 | def command_count(cog: Admin) -> int: 15 | total = 0 16 | for command in cog.walk_app_commands(): 17 | total += len(command.name) + len(command.description) 18 | if isinstance(command, app_commands.Group): 19 | continue 20 | for param in command.parameters: 21 | total += len(param.name) + len(param.description) 22 | for choice in param.choices: 23 | total += len(choice.name) + ( 24 | int(choice.value) 25 | if isinstance(choice.value, int | float) 26 | else len(choice.value) 27 | ) 28 | return total 29 | 30 | 31 | def strip_descriptions(cog: Admin): 32 | for command in cog.walk_app_commands(): 33 | command.description = "." 34 | if isinstance(command, app_commands.Group): 35 | continue 36 | for param in command.parameters: 37 | param._Parameter__parent.description = "." # type: ignore 38 | 39 | 40 | async def setup(bot: "BallsDexBot"): 41 | n = Admin(bot) 42 | if command_count(n) > 3900: 43 | strip_descriptions(n) 44 | log.warn("/admin command too long, stripping descriptions") 45 | await bot.add_cog(n) 46 | -------------------------------------------------------------------------------- /ballsdex/packages/balls/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from ballsdex.packages.balls.cog import Balls 4 | 5 | if TYPE_CHECKING: 6 | from ballsdex.core.bot import BallsDexBot 7 | 8 | 9 | async def setup(bot: "BallsDexBot"): 10 | await bot.add_cog(Balls(bot)) 11 | -------------------------------------------------------------------------------- /ballsdex/packages/balls/countryballs_paginator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, List 4 | 5 | import discord 6 | 7 | from ballsdex.core.models import BallInstance 8 | from ballsdex.core.utils import menus 9 | from ballsdex.core.utils.paginator import Pages 10 | 11 | if TYPE_CHECKING: 12 | from ballsdex.core.bot import BallsDexBot 13 | 14 | 15 | class CountryballsSource(menus.ListPageSource): 16 | def __init__(self, entries: List[BallInstance]): 17 | super().__init__(entries, per_page=25) 18 | 19 | async def format_page(self, menu: CountryballsSelector, balls: List[BallInstance]): 20 | menu.set_options(balls) 21 | return True # signal to edit the page 22 | 23 | 24 | class CountryballsSelector(Pages): 25 | def __init__(self, interaction: discord.Interaction["BallsDexBot"], balls: List[BallInstance]): 26 | self.bot = interaction.client 27 | source = CountryballsSource(balls) 28 | super().__init__(source, interaction=interaction) 29 | self.add_item(self.select_ball_menu) 30 | 31 | def set_options(self, balls: List[BallInstance]): 32 | options: List[discord.SelectOption] = [] 33 | for ball in balls: 34 | emoji = self.bot.get_emoji(int(ball.countryball.emoji_id)) 35 | favorite = "❤️ " if ball.favorite else "" 36 | shiny = "✨ " if ball.shiny else "" 37 | special = ball.special_emoji(self.bot, True) 38 | options.append( 39 | discord.SelectOption( 40 | label=f"{favorite}{shiny}{special}#{ball.pk:0X} {ball.countryball.country}", 41 | description=f"ATK: {ball.attack_bonus:+d}% • HP: {ball.health_bonus:+d}% • " 42 | f"Caught on {ball.catch_date.strftime('%d/%m/%y %H:%M')}", 43 | emoji=emoji, 44 | value=f"{ball.pk}", 45 | ) 46 | ) 47 | self.select_ball_menu.options = options 48 | 49 | @discord.ui.select() 50 | async def select_ball_menu(self, interaction: discord.Interaction, item: discord.ui.Select): 51 | await interaction.response.defer(thinking=True) 52 | ball_instance = await BallInstance.get( 53 | id=int(interaction.data.get("values")[0]) # type: ignore 54 | ) 55 | await self.ball_selected(interaction, ball_instance) 56 | 57 | async def ball_selected(self, interaction: discord.Interaction, ball_instance: BallInstance): 58 | raise NotImplementedError() 59 | 60 | 61 | class CountryballsViewer(CountryballsSelector): 62 | async def ball_selected(self, interaction: discord.Interaction, ball_instance: BallInstance): 63 | content, file = await ball_instance.prepare_for_message(interaction) 64 | await interaction.followup.send(content=content, file=file) 65 | file.close() 66 | -------------------------------------------------------------------------------- /ballsdex/packages/config/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from ballsdex.packages.config.cog import Config 4 | 5 | if TYPE_CHECKING: 6 | from ballsdex.core.bot import BallsDexBot 7 | 8 | 9 | async def setup(bot: "BallsDexBot"): 10 | await bot.add_cog(Config(bot)) 11 | -------------------------------------------------------------------------------- /ballsdex/packages/config/cog.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, cast 2 | 3 | import discord 4 | from discord import app_commands 5 | from discord.ext import commands 6 | 7 | from ballsdex.core.models import GuildConfig 8 | from ballsdex.packages.config.components import AcceptTOSView 9 | from ballsdex.settings import settings 10 | 11 | if TYPE_CHECKING: 12 | from ballsdex.core.bot import BallsDexBot 13 | 14 | activation_embed = discord.Embed( 15 | colour=0x00D936, 16 | title=f"{settings.bot_name} activation", 17 | description=f"To enable {settings.bot_name} in your server, you must " 18 | f"read and accept the [Terms of Service]({settings.terms_of_service}).\n\n" 19 | "As a summary, these are the rules of the bot:\n" 20 | f"- No farming (spamming or creating servers for {settings.collectible_name})\n" 21 | f"- Selling or exchanging {settings.collectible_name}s " 22 | "against money or other goods is forbidden\n" 23 | "- Do not attempt to abuse the bot's internals\n" 24 | "**Not respecting these rules will lead to a blacklist**", 25 | ) 26 | 27 | 28 | @app_commands.default_permissions(manage_guild=True) 29 | @app_commands.guild_only() 30 | class Config(commands.GroupCog): 31 | """ 32 | View and manage your countryballs collection. 33 | """ 34 | 35 | def __init__(self, bot: "BallsDexBot"): 36 | self.bot = bot 37 | 38 | @app_commands.command() 39 | @app_commands.describe(channel="The new text channel to set.") 40 | async def channel( 41 | self, 42 | interaction: discord.Interaction, 43 | channel: discord.TextChannel, 44 | ): 45 | """ 46 | Set or change the channel where countryballs will spawn. 47 | """ 48 | guild = cast(discord.Guild, interaction.guild) # guild-only command 49 | user = cast(discord.Member, interaction.user) 50 | if not user.guild_permissions.manage_guild: 51 | await interaction.response.send_message( 52 | "You need the permission to manage the server to use this." 53 | ) 54 | return 55 | if not channel.permissions_for(guild.me).read_messages: 56 | await interaction.response.send_message( 57 | f"I need the permission to read messages in {channel.mention}." 58 | ) 59 | return 60 | if not channel.permissions_for(guild.me).send_messages: 61 | await interaction.response.send_message( 62 | f"I need the permission to send messages in {channel.mention}." 63 | ) 64 | return 65 | if not channel.permissions_for(guild.me).embed_links: 66 | await interaction.response.send_message( 67 | f"I need the permission to send embed links in {channel.mention}." 68 | ) 69 | return 70 | await interaction.response.send_message( 71 | embed=activation_embed, view=AcceptTOSView(interaction, channel) 72 | ) 73 | 74 | @app_commands.command() 75 | async def disable(self, interaction: discord.Interaction): 76 | """ 77 | Disable or enable countryballs spawning. 78 | """ 79 | guild = cast(discord.Guild, interaction.guild) # guild-only command 80 | user = cast(discord.Member, interaction.user) 81 | if not user.guild_permissions.manage_guild: 82 | await interaction.response.send_message( 83 | "You need the permission to manage the server to use this." 84 | ) 85 | return 86 | config, created = await GuildConfig.get_or_create(guild_id=interaction.guild_id) 87 | if config.enabled: 88 | config.enabled = False # type: ignore 89 | await config.save() 90 | self.bot.dispatch("ballsdex_settings_change", guild, enabled=False) 91 | await interaction.response.send_message( 92 | f"{settings.bot_name} is now disabled in this server. Commands will still be " 93 | f"available, but the spawn of new {settings.collectible_name}s is suspended.\n" 94 | "To re-enable the spawn, use the same command." 95 | ) 96 | else: 97 | config.enabled = True # type: ignore 98 | await config.save() 99 | self.bot.dispatch("ballsdex_settings_change", guild, enabled=True) 100 | if config.spawn_channel and (channel := guild.get_channel(config.spawn_channel)): 101 | await interaction.response.send_message( 102 | f"{settings.bot_name} is now enabled in this server, " 103 | f"{settings.collectible_name}s will start spawning soon in {channel.mention}." 104 | ) 105 | else: 106 | await interaction.response.send_message( 107 | f"{settings.bot_name} is now enabled in this server, however there is no " 108 | "spawning channel set. Please configure one with `/config channel`." 109 | ) 110 | -------------------------------------------------------------------------------- /ballsdex/packages/config/components.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ui import Button, View, button 3 | 4 | from ballsdex.core.models import GuildConfig 5 | from ballsdex.settings import settings 6 | 7 | 8 | class AcceptTOSView(View): 9 | """ 10 | Button prompting the admin setting up the bot to accept the terms of service. 11 | """ 12 | 13 | def __init__(self, interaction: discord.Interaction, channel: discord.TextChannel): 14 | super().__init__() 15 | self.original_interaction = interaction 16 | self.channel = channel 17 | self.add_item( 18 | Button( 19 | style=discord.ButtonStyle.link, 20 | label="Terms of Service", 21 | url=settings.terms_of_service, 22 | ) 23 | ) 24 | self.add_item( 25 | Button( 26 | style=discord.ButtonStyle.link, 27 | label="Privacy policy", 28 | url=settings.privacy_policy, 29 | ) 30 | ) 31 | 32 | @button( 33 | label="Accept", 34 | style=discord.ButtonStyle.success, 35 | emoji="\N{HEAVY CHECK MARK}\N{VARIATION SELECTOR-16}", 36 | ) 37 | async def accept_button(self, interaction: discord.Interaction, item: discord.ui.Button): 38 | config, created = await GuildConfig.get_or_create(guild_id=interaction.guild_id) 39 | config.spawn_channel = self.channel.id # type: ignore 40 | await config.save() 41 | interaction.client.dispatch( 42 | "ballsdex_settings_change", interaction.guild, channel=self.channel 43 | ) 44 | self.stop() 45 | await interaction.response.send_message( 46 | f"The new spawn channel was successfully set to {self.channel.mention}.\n" 47 | f"{settings.collectible_name.title()} will start spawning as" 48 | " users talk unless the bot is disabled." 49 | ) 50 | 51 | self.accept_button.disabled = True 52 | try: 53 | await self.original_interaction.followup.edit_message( 54 | "@original", view=self # type: ignore 55 | ) 56 | except discord.HTTPException: 57 | pass 58 | 59 | async def on_timeout(self) -> None: 60 | self.stop() 61 | for item in self.children: 62 | item.disabled = True # type: ignore 63 | try: 64 | await self.original_interaction.followup.edit_message( 65 | "@original", view=self # type: ignore 66 | ) 67 | except discord.HTTPException: 68 | pass 69 | -------------------------------------------------------------------------------- /ballsdex/packages/countryballs/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from ballsdex.packages.countryballs.cog import CountryBallsSpawner 4 | 5 | if TYPE_CHECKING: 6 | from ballsdex.core.bot import BallsDexBot 7 | 8 | 9 | async def setup(bot: "BallsDexBot"): 10 | cog = CountryBallsSpawner(bot) 11 | await bot.add_cog(cog) 12 | await cog.load_cache() 13 | -------------------------------------------------------------------------------- /ballsdex/packages/countryballs/cog.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING, Optional 3 | 4 | import discord 5 | from discord.ext import commands 6 | from tortoise.exceptions import DoesNotExist 7 | 8 | from ballsdex.core.models import GuildConfig 9 | from ballsdex.packages.countryballs.spawn import SpawnManager 10 | 11 | if TYPE_CHECKING: 12 | from ballsdex.core.bot import BallsDexBot 13 | 14 | log = logging.getLogger("ballsdex.packages.countryballs") 15 | 16 | 17 | class CountryBallsSpawner(commands.Cog): 18 | def __init__(self, bot: "BallsDexBot"): 19 | self.spawn_manager = SpawnManager() 20 | self.bot = bot 21 | 22 | async def load_cache(self): 23 | i = 0 24 | async for config in GuildConfig.all(): 25 | if not config.enabled: 26 | continue 27 | if not config.spawn_channel: 28 | continue 29 | self.spawn_manager.cache[config.guild_id] = config.spawn_channel 30 | i += 1 31 | log.info(f"Loaded {i} guilds in cache") 32 | 33 | @commands.Cog.listener() 34 | async def on_message(self, message: discord.Message): 35 | if message.author.bot: 36 | return 37 | guild = message.guild 38 | if not guild: 39 | return 40 | if guild.id not in self.spawn_manager.cache: 41 | return 42 | if guild.id in self.bot.blacklist_guild: 43 | return 44 | await self.spawn_manager.handle_message(message) 45 | 46 | @commands.Cog.listener() 47 | async def on_ballsdex_settings_change( 48 | self, 49 | guild: discord.Guild, 50 | channel: Optional[discord.TextChannel] = None, 51 | enabled: Optional[bool] = None, 52 | ): 53 | if guild.id not in self.spawn_manager.cache: 54 | if enabled is False: 55 | return # do nothing 56 | if channel: 57 | self.spawn_manager.cache[guild.id] = channel.id 58 | else: 59 | try: 60 | config = await GuildConfig.get(guild_id=guild.id) 61 | except DoesNotExist: 62 | return 63 | else: 64 | self.spawn_manager.cache[guild.id] = config.spawn_channel 65 | else: 66 | if enabled is False: 67 | del self.spawn_manager.cache[guild.id] 68 | elif channel: 69 | self.spawn_manager.cache[guild.id] = channel.id 70 | -------------------------------------------------------------------------------- /ballsdex/packages/countryballs/components.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import math 5 | import random 6 | from typing import TYPE_CHECKING, cast 7 | 8 | import discord 9 | from discord.ui import Button, Modal, TextInput, View 10 | from prometheus_client import Counter 11 | from tortoise.timezone import now as datetime_now 12 | 13 | from ballsdex.core.models import BallInstance, Player, specials 14 | from ballsdex.settings import settings 15 | 16 | if TYPE_CHECKING: 17 | from ballsdex.core.bot import BallsDexBot 18 | from ballsdex.core.models import Special 19 | from ballsdex.packages.countryballs.countryball import CountryBall 20 | 21 | log = logging.getLogger("ballsdex.packages.countryballs.components") 22 | caught_balls = Counter( 23 | "caught_cb", "Caught countryballs", ["country", "shiny", "special", "guild_size"] 24 | ) 25 | 26 | 27 | class CountryballNamePrompt(Modal, title=f"Catch this {settings.collectible_name}!"): 28 | name = TextInput( 29 | label=f"Name of this {settings.collectible_name}", 30 | style=discord.TextStyle.short, 31 | placeholder="Your guess", 32 | ) 33 | 34 | def __init__(self, ball: "CountryBall", button: CatchButton): 35 | super().__init__() 36 | self.ball = ball 37 | self.button = button 38 | 39 | async def on_error(self, interaction: discord.Interaction, error: Exception, /) -> None: 40 | log.exception("An error occured in countryball catching prompt", exc_info=error) 41 | if interaction.response.is_done(): 42 | await interaction.followup.send( 43 | f"An error occured with this {settings.collectible_name}." 44 | ) 45 | else: 46 | await interaction.response.send_message( 47 | f"An error occured with this {settings.collectible_name}." 48 | ) 49 | 50 | async def on_submit(self, interaction: discord.Interaction["BallsDexBot"]): 51 | # TODO: use lock 52 | if self.ball.catched: 53 | await interaction.response.send_message( 54 | f"{interaction.user.mention} I was caught already!" 55 | ) 56 | return 57 | if self.ball.model.catch_names: 58 | possible_names = (self.ball.name.lower(), *self.ball.model.catch_names.split(";")) 59 | else: 60 | possible_names = (self.ball.name.lower(),) 61 | if self.name.value.lower().strip() in possible_names: 62 | self.ball.catched = True 63 | await interaction.response.defer(thinking=True) 64 | ball, has_caught_before = await self.catch_ball( 65 | interaction.client, cast(discord.Member, interaction.user) 66 | ) 67 | 68 | special = "" 69 | if ball.shiny: 70 | special += f"✨ ***It's a shiny {settings.collectible_name}!*** ✨\n" 71 | if ball.specialcard and ball.specialcard.catch_phrase: 72 | special += f"*{ball.specialcard.catch_phrase}*\n" 73 | if has_caught_before: 74 | special += ( 75 | f"This is a **new {settings.collectible_name}** " 76 | "that has been added to your completion!" 77 | ) 78 | await interaction.followup.send( 79 | f"{interaction.user.mention} You caught **{self.ball.name}!** " 80 | f"`(#{ball.pk:0X}, {ball.attack_bonus:+}%/{ball.health_bonus:+}%)`\n\n" 81 | f"{special}" 82 | ) 83 | self.button.disabled = True 84 | await interaction.followup.edit_message(self.ball.message.id, view=self.button.view) 85 | else: 86 | await interaction.response.send_message(f"{interaction.user.mention} Wrong name!") 87 | 88 | async def catch_ball( 89 | self, bot: "BallsDexBot", user: discord.Member 90 | ) -> tuple[BallInstance, bool]: 91 | player, created = await Player.get_or_create(discord_id=user.id) 92 | 93 | # stat may vary by +/- 20% of base stat 94 | bonus_attack = random.randint(-20, 20) 95 | bonus_health = random.randint(-20, 20) 96 | shiny = random.randint(1, 2048) == 1 97 | 98 | # check if we can spawn cards with a special background 99 | special: "Special | None" = None 100 | population = [x for x in specials.values() if x.start_date <= datetime_now() <= x.end_date] 101 | if not shiny and population: 102 | # Here we try to determine what should be the chance of having a common card 103 | # since the rarity field is a value between 0 and 1, 1 being no common 104 | # and 0 only common, we get the remaining value by doing (1-rarity) 105 | # We then sum each value for each current event, and we should get an algorithm 106 | # that kinda makes sense. 107 | common_weight = sum(1 - x.rarity for x in population) 108 | 109 | weights = [x.rarity for x in population] + [common_weight] 110 | # None is added representing the common countryball 111 | special = random.choices(population=population + [None], weights=weights, k=1)[0] 112 | 113 | is_new = not await BallInstance.filter(player=player, ball=self.ball.model).exists() 114 | ball = await BallInstance.create( 115 | ball=self.ball.model, 116 | player=player, 117 | shiny=shiny, 118 | special=special, 119 | attack_bonus=bonus_attack, 120 | health_bonus=bonus_health, 121 | server_id=user.guild.id, 122 | spawned_time=self.ball.time, 123 | ) 124 | if user.id in bot.catch_log: 125 | log.info( 126 | f"{user} caught {settings.collectible_name}" 127 | f" {self.ball.model}, {shiny=} {special=}", 128 | ) 129 | else: 130 | log.debug( 131 | f"{user} caught {settings.collectible_name}" 132 | f" {self.ball.model}, {shiny=} {special=}", 133 | ) 134 | if user.guild.member_count: 135 | caught_balls.labels( 136 | country=self.ball.model.country, 137 | shiny=shiny, 138 | special=special, 139 | # observe the size of the server, rounded to the nearest power of 10 140 | guild_size=10 ** math.ceil(math.log(max(user.guild.member_count - 1, 1), 10)), 141 | ).inc() 142 | return ball, is_new 143 | 144 | 145 | class CatchButton(Button): 146 | def __init__(self, ball: "CountryBall"): 147 | super().__init__(style=discord.ButtonStyle.primary, label="Catch me!") 148 | self.ball = ball 149 | 150 | async def callback(self, interaction: discord.Interaction): 151 | if self.ball.catched: 152 | await interaction.response.send_message("I was caught already!", ephemeral=True) 153 | else: 154 | await interaction.response.send_modal(CountryballNamePrompt(self.ball, self)) 155 | 156 | 157 | class CatchView(View): 158 | def __init__(self, ball: "CountryBall"): 159 | super().__init__() 160 | self.ball = ball 161 | self.button = CatchButton(ball) 162 | self.add_item(self.button) 163 | 164 | async def interaction_check(self, interaction: discord.Interaction["BallsDexBot"], /) -> bool: 165 | return await interaction.client.blacklist_check(interaction) 166 | 167 | async def on_timeout(self): 168 | self.button.disabled = True 169 | if self.ball.message: 170 | try: 171 | await self.ball.message.edit(view=self) 172 | except discord.HTTPException: 173 | pass 174 | -------------------------------------------------------------------------------- /ballsdex/packages/countryballs/countryball.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import string 4 | from datetime import datetime 5 | 6 | import discord 7 | 8 | from ballsdex.core.models import Ball, balls 9 | from ballsdex.packages.countryballs.components import CatchView 10 | from ballsdex.settings import settings 11 | 12 | log = logging.getLogger("ballsdex.packages.countryballs") 13 | 14 | 15 | class CountryBall: 16 | def __init__(self, model: Ball): 17 | self.name = model.country 18 | self.model = model 19 | self.message: discord.Message = discord.utils.MISSING 20 | self.catched = False 21 | self.time = datetime.now() 22 | 23 | @classmethod 24 | async def get_random(cls): 25 | countryballs = list(filter(lambda m: m.enabled, balls.values())) 26 | if not countryballs: 27 | raise RuntimeError("No ball to spawn") 28 | rarities = [x.rarity for x in countryballs] 29 | cb = random.choices(population=countryballs, weights=rarities, k=1)[0] 30 | return cls(cb) 31 | 32 | async def spawn(self, channel: discord.TextChannel): 33 | def generate_random_name(): 34 | source = string.ascii_uppercase + string.ascii_lowercase + string.ascii_letters 35 | return "".join(random.choices(source, k=15)) 36 | 37 | extension = self.model.wild_card.split(".")[-1] 38 | file_location = "." + self.model.wild_card 39 | file_name = f"nt_{generate_random_name()}.{extension}" 40 | try: 41 | permissions = channel.permissions_for(channel.guild.me) 42 | if permissions.attach_files and permissions.send_messages: 43 | self.message = await channel.send( 44 | f"A wild {settings.collectible_name} appeared!", 45 | view=CatchView(self), 46 | file=discord.File(file_location, filename=file_name), 47 | ) 48 | else: 49 | log.error("Missing permission to spawn ball in channel %s.", channel) 50 | except discord.Forbidden: 51 | log.error(f"Missing permission to spawn ball in channel {channel}.") 52 | except discord.HTTPException: 53 | log.error("Failed to spawn ball", exc_info=True) 54 | -------------------------------------------------------------------------------- /ballsdex/packages/countryballs/spawn.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import random 4 | from collections import deque, namedtuple 5 | from dataclasses import dataclass, field 6 | from datetime import datetime 7 | from typing import cast 8 | 9 | import discord 10 | 11 | from ballsdex.packages.countryballs.countryball import CountryBall 12 | 13 | log = logging.getLogger("ballsdex.packages.countryballs") 14 | 15 | SPAWN_CHANCE_RANGE = (40, 55) 16 | 17 | CachedMessage = namedtuple("CachedMessage", ["content", "author_id"]) 18 | 19 | 20 | @dataclass 21 | class SpawnCooldown: 22 | """ 23 | Represents the spawn internal system per guild. Contains the counters that will determine 24 | if a countryball should be spawned next or not. 25 | 26 | Attributes 27 | ---------- 28 | time: datetime 29 | Time when the object was initialized. Block spawning when it's been less than two minutes 30 | amount: float 31 | A number starting at 0, incrementing with the messages until reaching `chance`. At this 32 | point, a ball will be spawned next. 33 | chance: int 34 | The number `amount` has to reach for spawn. Determined randomly with `SPAWN_CHANCE_RANGE` 35 | lock: asyncio.Lock 36 | Used to ratelimit messages and ignore fast spam 37 | message_cache: ~collections.deque[CachedMessage] 38 | A list of recent messages used to reduce the spawn chance when too few different chatters 39 | are present. Limited to the 100 most recent messages in the guild. 40 | """ 41 | 42 | time: datetime 43 | # initialize partially started, to reduce the dead time after starting the bot 44 | amount: float = field(default=SPAWN_CHANCE_RANGE[0] // 2) 45 | chance: int = field(default_factory=lambda: random.randint(*SPAWN_CHANCE_RANGE)) 46 | lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False) 47 | message_cache: deque[CachedMessage] = field(default_factory=lambda: deque(maxlen=100)) 48 | 49 | def reset(self, time: datetime): 50 | self.amount = 1.0 51 | self.chance = random.randint(*SPAWN_CHANCE_RANGE) 52 | try: 53 | self.lock.release() 54 | except RuntimeError: # lock is not acquired 55 | pass 56 | self.time = time 57 | 58 | async def increase(self, message: discord.Message) -> bool: 59 | # this is a deque, not a list 60 | # its property is that, once the max length is reached (100 for us), 61 | # the oldest element is removed, thus we only have the last 100 messages in memory 62 | self.message_cache.append( 63 | CachedMessage(content=message.content, author_id=message.author.id) 64 | ) 65 | 66 | if self.lock.locked(): 67 | return False 68 | 69 | async with self.lock: 70 | amount = 1 71 | if message.guild.member_count < 5 or message.guild.member_count > 1000: # type: ignore 72 | amount /= 2 73 | if len(message.content) < 5: 74 | amount /= 2 75 | if len(set(x.author_id for x in self.message_cache)) < 4 or ( 76 | len(list(filter(lambda x: x.author_id == message.author.id, self.message_cache))) 77 | / self.message_cache.maxlen # type: ignore 78 | > 0.4 79 | ): 80 | amount /= 2 81 | self.amount += amount 82 | await asyncio.sleep(10) 83 | return True 84 | 85 | 86 | @dataclass 87 | class SpawnManager: 88 | cooldowns: dict[int, SpawnCooldown] = field(default_factory=dict) 89 | cache: dict[int, int] = field(default_factory=dict) 90 | 91 | async def handle_message(self, message: discord.Message): 92 | guild = message.guild 93 | if not guild: 94 | return 95 | 96 | cooldown = self.cooldowns.get(guild.id, None) 97 | if not cooldown: 98 | cooldown = SpawnCooldown(message.created_at) 99 | self.cooldowns[guild.id] = cooldown 100 | 101 | delta = (message.created_at - cooldown.time).total_seconds() 102 | # change how the threshold varies according to the member count, while nuking farm servers 103 | if not guild.member_count: 104 | return 105 | elif guild.member_count < 5: 106 | multiplier = 0.1 107 | elif guild.member_count < 100: 108 | multiplier = 0.8 109 | elif guild.member_count < 1000: 110 | multiplier = 0.5 111 | else: 112 | multiplier = 0.2 113 | chance = cooldown.chance - multiplier * (delta // 60) 114 | 115 | # manager cannot be increased more than once per 5 seconds 116 | if not await cooldown.increase(message): 117 | return 118 | 119 | # normal increase, need to reach goal 120 | if cooldown.amount <= chance: 121 | return 122 | 123 | # at this point, the goal is reached 124 | if delta < 600: 125 | # wait for at least 10 minutes before spawning 126 | return 127 | 128 | # spawn countryball 129 | cooldown.reset(message.created_at) 130 | await self.spawn_countryball(guild) 131 | 132 | async def spawn_countryball(self, guild: discord.Guild): 133 | channel = guild.get_channel(self.cache[guild.id]) 134 | if not channel: 135 | log.warning(f"Lost channel {self.cache[guild.id]} for guild {guild.name}.") 136 | del self.cache[guild.id] 137 | return 138 | ball = await CountryBall.get_random() 139 | await ball.spawn(cast(discord.TextChannel, channel)) 140 | -------------------------------------------------------------------------------- /ballsdex/packages/info/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from ballsdex.packages.info.cog import Info 4 | 5 | if TYPE_CHECKING: 6 | from ballsdex.core.bot import BallsDexBot 7 | 8 | 9 | async def setup(bot: "BallsDexBot"): 10 | await bot.add_cog(Info(bot)) 11 | -------------------------------------------------------------------------------- /ballsdex/packages/info/cog.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import sys 4 | from typing import TYPE_CHECKING 5 | 6 | import discord 7 | from discord import app_commands 8 | from discord.ext import commands 9 | 10 | from ballsdex import __version__ as ballsdex_version 11 | from ballsdex.core.models import Ball 12 | from ballsdex.core.models import balls as countryballs 13 | from ballsdex.core.utils.tortoise import row_count_estimate 14 | from ballsdex.settings import settings 15 | 16 | if TYPE_CHECKING: 17 | from ballsdex.core.bot import BallsDexBot 18 | 19 | log = logging.getLogger("ballsdex.packages.info") 20 | 21 | 22 | def mention_app_command(app_command: app_commands.Command | app_commands.Group) -> str: 23 | if "mention" in app_command.extras: 24 | return app_command.extras["mention"] 25 | else: 26 | if isinstance(app_command, app_commands.ContextMenu): 27 | return f"`{app_command.name}`" 28 | else: 29 | return f"`/{app_command.name}`" 30 | 31 | 32 | class Info(commands.Cog): 33 | """ 34 | Simple info commands. 35 | """ 36 | 37 | def __init__(self, bot: "BallsDexBot"): 38 | self.bot = bot 39 | 40 | async def _get_10_balls_emojis(self) -> list[discord.Emoji]: 41 | balls: list[Ball] = random.choices( 42 | [x for x in countryballs.values() if x.enabled], k=min(10, len(countryballs)) 43 | ) 44 | emotes: list[discord.Emoji] = [] 45 | 46 | for ball in balls: 47 | if emoji := self.bot.get_emoji(ball.emoji_id): 48 | emotes.append(emoji) 49 | 50 | return emotes 51 | 52 | @app_commands.command() 53 | async def about(self, interaction: discord.Interaction): 54 | """ 55 | Get information about this bot. 56 | """ 57 | embed = discord.Embed( 58 | title=f"{settings.bot_name} Discord bot", color=discord.Colour.blurple() 59 | ) 60 | 61 | try: 62 | balls = await self._get_10_balls_emojis() 63 | except Exception: 64 | log.error("Failed to fetch 10 balls emotes", exc_info=True) 65 | balls = [] 66 | 67 | balls_count = len([x for x in countryballs.values() if x.enabled]) 68 | players_count = await row_count_estimate("player") 69 | balls_instances_count = await row_count_estimate("ballinstance") 70 | 71 | assert self.bot.user 72 | assert self.bot.application 73 | try: 74 | assert self.bot.application.install_params 75 | except AssertionError: 76 | invite_link = discord.utils.oauth_url( 77 | self.bot.application.id, 78 | permissions=discord.Permissions( 79 | manage_webhooks=True, 80 | read_messages=True, 81 | send_messages=True, 82 | manage_messages=True, 83 | embed_links=True, 84 | attach_files=True, 85 | use_external_emojis=True, 86 | add_reactions=True, 87 | ), 88 | scopes=("bot", "applications.commands"), 89 | ) 90 | else: 91 | invite_link = discord.utils.oauth_url( 92 | self.bot.application.id, 93 | permissions=self.bot.application.install_params.permissions, 94 | scopes=self.bot.application.install_params.scopes, 95 | ) 96 | embed.description = ( 97 | f"{' '.join(str(x) for x in balls)}\n" 98 | f"{settings.about_description}\n" 99 | f"*Running version **[{ballsdex_version}]({settings.github_link}/releases)***\n\n" 100 | f"**{balls_count:,}** {settings.collectible_name}s to collect\n" 101 | f"**{players_count:,}** players that caught " 102 | f"**{balls_instances_count:,}** {settings.collectible_name}s\n" 103 | f"**{len(self.bot.guilds):,}** servers playing\n\n" 104 | "This bot was made by **El Laggron**, consider supporting me on my " 105 | "[Patreon](https://patreon.com/retke) :heart:\n\n" 106 | f"[Discord server]({settings.discord_invite}) • [Invite me]({invite_link}) • " 107 | f"[Source code and issues]({settings.github_link})\n" 108 | f"[Terms of Service]({settings.terms_of_service}) • " 109 | f"[Privacy policy]({settings.privacy_policy})" 110 | ) 111 | 112 | embed.set_thumbnail(url=self.bot.user.display_avatar.url) 113 | v = sys.version_info 114 | embed.set_footer( 115 | text=f"Python {v.major}.{v.minor}.{v.micro} • discord.py {discord.__version__}" 116 | ) 117 | 118 | await interaction.response.send_message(embed=embed) 119 | 120 | @app_commands.command() 121 | async def help(self, interaction: discord.Interaction): 122 | """ 123 | Show the list of commands from the bot. 124 | """ 125 | assert self.bot.user 126 | embed = discord.Embed( 127 | title=f"{settings.bot_name} Discord bot - help menu", color=discord.Colour.blurple() 128 | ) 129 | embed.set_thumbnail(url=self.bot.user.display_avatar.url) 130 | 131 | for cog in self.bot.cogs.values(): 132 | if cog.qualified_name == "Admin": 133 | continue 134 | content = "" 135 | for app_command in cog.walk_app_commands(): 136 | content += f"{mention_app_command(app_command)}: {app_command.description}\n" 137 | if not content: 138 | continue 139 | embed.add_field(name=cog.qualified_name, value=content, inline=False) 140 | 141 | await interaction.response.send_message(embed=embed) 142 | -------------------------------------------------------------------------------- /ballsdex/packages/players/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from ballsdex.packages.players.cog import Player 4 | 5 | if TYPE_CHECKING: 6 | from ballsdex.core.bot import BallsDexBot 7 | 8 | 9 | async def setup(bot: "BallsDexBot"): 10 | await bot.add_cog(Player(bot)) 11 | -------------------------------------------------------------------------------- /ballsdex/packages/players/cog.py: -------------------------------------------------------------------------------- 1 | import zipfile 2 | from io import BytesIO 3 | from typing import TYPE_CHECKING 4 | 5 | import discord 6 | from discord import app_commands 7 | from discord.ext import commands 8 | from tortoise.expressions import Q 9 | 10 | from ballsdex.core.models import BallInstance, DonationPolicy 11 | from ballsdex.core.models import Player as PlayerModel 12 | from ballsdex.core.models import PrivacyPolicy, Trade, TradeObject 13 | from ballsdex.core.utils.buttons import ConfirmChoiceView 14 | from ballsdex.settings import settings 15 | 16 | if TYPE_CHECKING: 17 | from ballsdex.core.bot import BallsDexBot 18 | 19 | 20 | class Player(commands.GroupCog): 21 | """ 22 | Manage your account settings. 23 | """ 24 | 25 | def __init__(self, bot: "BallsDexBot"): 26 | self.bot = bot 27 | if not self.bot.intents.members: 28 | self.__cog_app_commands_group__.get_command("privacy").parameters[ # type: ignore 29 | 0 30 | ]._Parameter__parent.choices.pop() # type: ignore 31 | 32 | @app_commands.command() 33 | @app_commands.choices( 34 | policy=[ 35 | app_commands.Choice(name="Open Inventory", value=PrivacyPolicy.ALLOW), 36 | app_commands.Choice(name="Private Inventory", value=PrivacyPolicy.DENY), 37 | app_commands.Choice(name="Same Server", value=PrivacyPolicy.SAME_SERVER), 38 | ] 39 | ) 40 | async def privacy(self, interaction: discord.Interaction, policy: PrivacyPolicy): 41 | """ 42 | Set your privacy policy. 43 | """ 44 | if policy == PrivacyPolicy.SAME_SERVER and not self.bot.intents.members: 45 | await interaction.response.send_message( 46 | "I need the `members` intent to use this policy.", ephemeral=True 47 | ) 48 | return 49 | player, _ = await PlayerModel.get_or_create(discord_id=interaction.user.id) 50 | player.privacy_policy = policy 51 | await player.save() 52 | await interaction.response.send_message( 53 | f"Your privacy policy has been set to **{policy.name}**.", ephemeral=True 54 | ) 55 | 56 | @app_commands.command() 57 | @app_commands.choices( 58 | policy=[ 59 | app_commands.Choice(name="Accept all donations", value=DonationPolicy.ALWAYS_ACCEPT), 60 | app_commands.Choice( 61 | name="Request your approval first", value=DonationPolicy.REQUEST_APPROVAL 62 | ), 63 | app_commands.Choice(name="Deny all donations", value=DonationPolicy.ALWAYS_DENY), 64 | ] 65 | ) 66 | async def donation_policy( 67 | self, interaction: discord.Interaction, policy: app_commands.Choice[int] 68 | ): 69 | """ 70 | Change how you want to receive donations from /balls give 71 | 72 | Parameters 73 | ---------- 74 | policy: DonationPolicy 75 | The new policy for accepting donations 76 | """ 77 | player, _ = await PlayerModel.get_or_create(discord_id=interaction.user.id) 78 | player.donation_policy = DonationPolicy(policy.value) 79 | if policy.value == DonationPolicy.ALWAYS_ACCEPT: 80 | await interaction.response.send_message( 81 | f"Setting updated, you will now receive all donated {settings.collectible_name}s " 82 | "immediately." 83 | ) 84 | elif policy.value == DonationPolicy.REQUEST_APPROVAL: 85 | await interaction.response.send_message( 86 | "Setting updated, you will now have to approve donation requests manually." 87 | ) 88 | elif policy.value == DonationPolicy.ALWAYS_DENY: 89 | await interaction.response.send_message( 90 | "Setting updated, it is now impossible to use " 91 | f"`{settings.players_group_cog_name} give` with " 92 | "you. It is still possible to perform donations using the trade system." 93 | ) 94 | else: 95 | await interaction.response.send_message("Invalid input!") 96 | return 97 | await player.save() # do not save if the input is invalid 98 | 99 | @app_commands.command() 100 | async def delete(self, interaction: discord.Interaction): 101 | """ 102 | Delete your player data. 103 | """ 104 | view = ConfirmChoiceView(interaction) 105 | await interaction.response.send_message( 106 | "Are you sure you want to delete your player data?", view=view, ephemeral=True 107 | ) 108 | await view.wait() 109 | if view.value is None or not view.value: 110 | return 111 | player, _ = await PlayerModel.get_or_create(discord_id=interaction.user.id) 112 | await player.delete() 113 | 114 | @app_commands.command() 115 | @app_commands.choices( 116 | type=[ 117 | app_commands.Choice(name=settings.collectible_name.title(), value="balls"), 118 | app_commands.Choice(name="Trades", value="trades"), 119 | app_commands.Choice(name="All", value="all"), 120 | ] 121 | ) 122 | async def export(self, interaction: discord.Interaction, type: str): 123 | """ 124 | Export your player data. 125 | """ 126 | player = await PlayerModel.get_or_none(discord_id=interaction.user.id) 127 | if player is None: 128 | await interaction.response.send_message( 129 | "You don't have any player data to export.", ephemeral=True 130 | ) 131 | return 132 | await interaction.response.defer() 133 | files = [] 134 | if type == "balls": 135 | data = await get_items_csv(player) 136 | filename = f"{interaction.user.id}_{settings.collectible_name}.csv" 137 | data.filename = filename # type: ignore 138 | files.append(data) 139 | elif type == "trades": 140 | data = await get_trades_csv(player) 141 | filename = f"{interaction.user.id}_trades.csv" 142 | data.filename = filename # type: ignore 143 | files.append(data) 144 | elif type == "all": 145 | balls = await get_items_csv(player) 146 | trades = await get_trades_csv(player) 147 | balls_filename = f"{interaction.user.id}_{settings.collectible_name}.csv" 148 | trades_filename = f"{interaction.user.id}_trades.csv" 149 | balls.filename = balls_filename # type: ignore 150 | trades.filename = trades_filename # type: ignore 151 | files.append(balls) 152 | files.append(trades) 153 | else: 154 | await interaction.followup.send("Invalid input!", ephemeral=True) 155 | return 156 | zip_file = BytesIO() 157 | with zipfile.ZipFile(zip_file, "w") as z: 158 | for file in files: 159 | z.writestr(file.filename, file.getvalue()) 160 | zip_file.seek(0) 161 | if zip_file.tell() > 25_000_000: 162 | await interaction.followup.send( 163 | "Your data is too large to export." 164 | "Please contact the bot support for more information.", 165 | ephemeral=True, 166 | ) 167 | return 168 | files = [discord.File(zip_file, "player_data.zip")] 169 | try: 170 | await interaction.user.send("Here is your player data:", files=files) 171 | await interaction.followup.send( 172 | "Your player data has been sent via DMs.", ephemeral=True 173 | ) 174 | except discord.Forbidden: 175 | await interaction.followup.send( 176 | "I couldn't send the player data to you in DM. " 177 | "Either you blocked me or you disabled DMs in this server.", 178 | ephemeral=True, 179 | ) 180 | 181 | 182 | async def get_items_csv(player: PlayerModel) -> BytesIO: 183 | """ 184 | Get a CSV file with all items of the player. 185 | """ 186 | balls = await BallInstance.filter(player=player).prefetch_related( 187 | "ball", "trade_player", "special" 188 | ) 189 | txt = ( 190 | f"id,hex id,{settings.collectible_name},catch date,trade_player" 191 | ",special,shiny,attack,attack bonus,hp,hp_bonus\n" 192 | ) 193 | for ball in balls: 194 | txt += ( 195 | f"{ball.id},{ball.id:0X},{ball.ball.country},{ball.catch_date}," # type: ignore 196 | f"{ball.trade_player.discord_id if ball.trade_player else 'None'},{ball.special}," 197 | f"{ball.shiny},{ball.attack},{ball.attack_bonus},{ball.health},{ball.health_bonus}\n" 198 | ) 199 | return BytesIO(txt.encode("utf-8")) 200 | 201 | 202 | async def get_trades_csv(player: PlayerModel) -> BytesIO: 203 | """ 204 | Get a CSV file with all trades of the player. 205 | """ 206 | trade_history = ( 207 | await Trade.filter(Q(player1=player) | Q(player2=player)) 208 | .order_by("date") 209 | .prefetch_related("player1", "player2") 210 | ) 211 | txt = "id,date,player1,player2,player1 received,player2 received\n" 212 | for trade in trade_history: 213 | player1_items = await TradeObject.filter( 214 | trade=trade, player=trade.player1 215 | ).prefetch_related("ballinstance") 216 | player2_items = await TradeObject.filter( 217 | trade=trade, player=trade.player2 218 | ).prefetch_related("ballinstance") 219 | txt += ( 220 | f"{trade.id},{trade.date},{trade.player1.discord_id},{trade.player2.discord_id}," 221 | f"{','.join([i.ballinstance.to_string() for i in player2_items])}," # type: ignore 222 | f"{','.join([i.ballinstance.to_string() for i in player1_items])}\n" # type: ignore 223 | ) 224 | return BytesIO(txt.encode("utf-8")) 225 | -------------------------------------------------------------------------------- /ballsdex/packages/trade/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from ballsdex.packages.trade.cog import Trade 4 | 5 | if TYPE_CHECKING: 6 | from ballsdex.core.bot import BallsDexBot 7 | 8 | 9 | async def setup(bot: "BallsDexBot"): 10 | await bot.add_cog(Trade(bot)) 11 | -------------------------------------------------------------------------------- /ballsdex/packages/trade/cog.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from collections import defaultdict 3 | from typing import TYPE_CHECKING, Optional, cast 4 | 5 | import discord 6 | from discord import app_commands 7 | from discord.ext import commands 8 | from discord.utils import MISSING 9 | from tortoise.expressions import Q 10 | 11 | from ballsdex.core.models import Player 12 | from ballsdex.core.models import Trade as TradeModel 13 | from ballsdex.core.utils.buttons import ConfirmChoiceView 14 | from ballsdex.core.utils.paginator import Pages 15 | from ballsdex.core.utils.transformers import ( 16 | BallInstanceTransform, 17 | SpecialEnabledTransform, 18 | TradeCommandType, 19 | ) 20 | from ballsdex.packages.trade.display import TradeViewFormat 21 | from ballsdex.packages.trade.menu import TradeMenu 22 | from ballsdex.packages.trade.trade_user import TradingUser 23 | from ballsdex.settings import settings 24 | 25 | if TYPE_CHECKING: 26 | from ballsdex.core.bot import BallsDexBot 27 | 28 | 29 | class Trade(commands.GroupCog): 30 | """ 31 | Trade countryballs with other players 32 | """ 33 | 34 | def __init__(self, bot: "BallsDexBot"): 35 | self.bot = bot 36 | self.trades: dict[int, dict[int, list[TradeMenu]]] = defaultdict(lambda: defaultdict(list)) 37 | 38 | def get_trade( 39 | self, 40 | interaction: discord.Interaction | None = None, 41 | *, 42 | channel: discord.TextChannel | None = None, 43 | user: discord.User | discord.Member = MISSING, 44 | ) -> tuple[TradeMenu, TradingUser] | tuple[None, None]: 45 | """ 46 | Find an ongoing trade for the given interaction. 47 | 48 | Parameters 49 | ---------- 50 | interaction: discord.Interaction 51 | The current interaction, used for getting the guild, channel and author. 52 | 53 | Returns 54 | ------- 55 | tuple[TradeMenu, TradingUser] | tuple[None, None] 56 | A tuple with the `TradeMenu` and `TradingUser` if found, else `None`. 57 | """ 58 | guild: discord.Guild 59 | if interaction: 60 | guild = cast(discord.Guild, interaction.guild) 61 | channel = cast(discord.TextChannel, interaction.channel) 62 | user = interaction.user 63 | elif channel: 64 | guild = channel.guild 65 | else: 66 | raise TypeError("Missing interaction or channel") 67 | 68 | if guild.id not in self.trades: 69 | return (None, None) 70 | if channel.id not in self.trades[guild.id]: 71 | return (None, None) 72 | to_remove: list[TradeMenu] = [] 73 | for trade in self.trades[guild.id][channel.id]: 74 | if ( 75 | trade.current_view.is_finished() 76 | or trade.trader1.cancelled 77 | or trade.trader2.cancelled 78 | ): 79 | # remove what was supposed to have been removed 80 | to_remove.append(trade) 81 | continue 82 | try: 83 | trader = trade._get_trader(user) 84 | except RuntimeError: 85 | continue 86 | else: 87 | break 88 | else: 89 | for trade in to_remove: 90 | self.trades[guild.id][channel.id].remove(trade) 91 | return (None, None) 92 | 93 | for trade in to_remove: 94 | self.trades[guild.id][channel.id].remove(trade) 95 | return (trade, trader) 96 | 97 | @app_commands.command() 98 | async def begin(self, interaction: discord.Interaction["BallsDexBot"], user: discord.User): 99 | """ 100 | Begin a trade with the chosen user. 101 | 102 | Parameters 103 | ---------- 104 | user: discord.User 105 | The user you want to trade with 106 | """ 107 | if user.bot: 108 | await interaction.response.send_message("You cannot trade with bots.", ephemeral=True) 109 | return 110 | if user.id == interaction.user.id: 111 | await interaction.response.send_message( 112 | "You cannot trade with yourself.", ephemeral=True 113 | ) 114 | return 115 | 116 | trade1, trader1 = self.get_trade(interaction) 117 | trade2, trader2 = self.get_trade(channel=interaction.channel, user=user) # type: ignore 118 | if trade1 or trader1: 119 | await interaction.response.send_message( 120 | "You already have an ongoing trade.", ephemeral=True 121 | ) 122 | return 123 | if trade2 or trader2: 124 | await interaction.response.send_message( 125 | "The user you are trying to trade with is already in a trade.", ephemeral=True 126 | ) 127 | return 128 | 129 | player1, _ = await Player.get_or_create(discord_id=interaction.user.id) 130 | player2, _ = await Player.get_or_create(discord_id=user.id) 131 | if player2.discord_id in self.bot.blacklist: 132 | await interaction.response.send_message( 133 | "You cannot trade with a blacklisted user.", ephemeral=True 134 | ) 135 | return 136 | 137 | menu = TradeMenu( 138 | self, interaction, TradingUser(interaction.user, player1), TradingUser(user, player2) 139 | ) 140 | self.trades[interaction.guild.id][interaction.channel.id].append(menu) # type: ignore 141 | await menu.start() 142 | await interaction.response.send_message("Trade started!", ephemeral=True) 143 | 144 | @app_commands.command(extras={"trade": TradeCommandType.PICK}) 145 | async def add( 146 | self, 147 | interaction: discord.Interaction, 148 | countryball: BallInstanceTransform, 149 | special: SpecialEnabledTransform | None = None, 150 | shiny: bool | None = None, 151 | ): 152 | """ 153 | Add a countryball to the ongoing trade. 154 | 155 | Parameters 156 | ---------- 157 | countryball: BallInstance 158 | The countryball you want to add to your proposal 159 | special: Special 160 | Filter the results of autocompletion to a special event. Ignored afterwards. 161 | shiny: bool 162 | Filter the results of autocompletion to shinies. Ignored afterwards. 163 | """ 164 | if not countryball: 165 | return 166 | if not countryball.is_tradeable: 167 | await interaction.response.send_message( 168 | "You cannot trade this countryball.", ephemeral=True 169 | ) 170 | return 171 | await interaction.response.defer(ephemeral=True, thinking=True) 172 | if countryball.favorite: 173 | view = ConfirmChoiceView(interaction) 174 | await interaction.followup.send( 175 | "This countryball is a favorite, are you sure you want to trade it?", 176 | view=view, 177 | ephemeral=True, 178 | ) 179 | await view.wait() 180 | if not view.value: 181 | return 182 | 183 | trade, trader = self.get_trade(interaction) 184 | if not trade or not trader: 185 | await interaction.followup.send("You do not have an ongoing trade.", ephemeral=True) 186 | return 187 | if trader.locked: 188 | await interaction.followup.send( 189 | "You have locked your proposal, it cannot be edited! " 190 | "You can click the cancel button to stop the trade instead.", 191 | ephemeral=True, 192 | ) 193 | return 194 | if countryball in trader.proposal: 195 | await interaction.followup.send( 196 | f"You already have this {settings.collectible_name} in your proposal.", 197 | ephemeral=True, 198 | ) 199 | return 200 | if await countryball.is_locked(): 201 | await interaction.followup.send( 202 | "This countryball is currently in an active trade or donation, " 203 | "please try again later.", 204 | ephemeral=True, 205 | ) 206 | return 207 | 208 | await countryball.lock_for_trade() 209 | trader.proposal.append(countryball) 210 | await interaction.followup.send( 211 | f"{countryball.countryball.country} added.", ephemeral=True 212 | ) 213 | 214 | @app_commands.command(extras={"trade": TradeCommandType.REMOVE}) 215 | async def remove(self, interaction: discord.Interaction, countryball: BallInstanceTransform): 216 | """ 217 | Remove a countryball from what you proposed in the ongoing trade. 218 | 219 | Parameters 220 | ---------- 221 | countryball: BallInstance 222 | The countryball you want to remove from your proposal 223 | """ 224 | if not countryball: 225 | return 226 | 227 | trade, trader = self.get_trade(interaction) 228 | if not trade or not trader: 229 | await interaction.response.send_message( 230 | "You do not have an ongoing trade.", ephemeral=True 231 | ) 232 | return 233 | if trader.locked: 234 | await interaction.response.send_message( 235 | "You have locked your proposal, it cannot be edited! " 236 | "You can click the cancel button to stop the trade instead.", 237 | ephemeral=True, 238 | ) 239 | return 240 | if countryball not in trader.proposal: 241 | await interaction.response.send_message( 242 | f"That {settings.collectible_name} is not in your proposal.", ephemeral=True 243 | ) 244 | return 245 | trader.proposal.remove(countryball) 246 | await interaction.response.send_message( 247 | f"{countryball.countryball.country} removed.", ephemeral=True 248 | ) 249 | await countryball.unlock() 250 | 251 | @app_commands.command() 252 | async def cancel(self, interaction: discord.Interaction): 253 | """ 254 | Cancel the ongoing trade. 255 | """ 256 | trade, trader = self.get_trade(interaction) 257 | if not trade or not trader: 258 | await interaction.response.send_message( 259 | "You do not have an ongoing trade.", ephemeral=True 260 | ) 261 | return 262 | 263 | await trade.user_cancel(trader) 264 | await interaction.response.send_message("Trade cancelled.", ephemeral=True) 265 | 266 | @app_commands.command() 267 | @app_commands.choices( 268 | sorting=[ 269 | app_commands.Choice(name="Most Recent", value="-date"), 270 | app_commands.Choice(name="Oldest", value="date"), 271 | ] 272 | ) 273 | async def history( 274 | self, 275 | interaction: discord.Interaction["BallsDexBot"], 276 | sorting: app_commands.Choice[str], 277 | trade_user: discord.User | None = None, 278 | days: Optional[int] = None, 279 | ): 280 | """ 281 | Show the history of your trades. 282 | 283 | Parameters 284 | ---------- 285 | sorting: str 286 | The sorting order of the trades 287 | trade_user: discord.User | None 288 | The user you want to see your trade history with 289 | days: Optional[int] 290 | Retrieve trade history from last x days. 291 | """ 292 | await interaction.response.defer(ephemeral=True, thinking=True) 293 | user = interaction.user 294 | 295 | if days is not None and days < 0: 296 | await interaction.followup.send( 297 | "Invalid number of days. Please provide a non-negative value.", ephemeral=True 298 | ) 299 | return 300 | 301 | if trade_user: 302 | queryset = TradeModel.filter( 303 | (Q(player1__discord_id=user.id, player2__discord_id=trade_user.id)) 304 | | (Q(player1__discord_id=trade_user.id, player2__discord_id=user.id)) 305 | ) 306 | else: 307 | queryset = TradeModel.filter( 308 | Q(player1__discord_id=user.id) | Q(player2__discord_id=user.id) 309 | ) 310 | 311 | if days is not None and days > 0: 312 | end_date = datetime.datetime.now() 313 | start_date = end_date - datetime.timedelta(days=days) 314 | queryset = queryset.filter(date__range=(start_date, end_date)) 315 | 316 | history = await queryset.order_by(sorting.value).prefetch_related("player1", "player2") 317 | 318 | if not history: 319 | await interaction.followup.send("No history found.", ephemeral=True) 320 | return 321 | source = TradeViewFormat(history, interaction.user.name, self.bot) 322 | pages = Pages(source=source, interaction=interaction) 323 | await pages.start() 324 | -------------------------------------------------------------------------------- /ballsdex/packages/trade/display.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Iterable 2 | 3 | import discord 4 | 5 | from ballsdex.core.models import Trade as TradeModel 6 | from ballsdex.core.utils import menus 7 | from ballsdex.core.utils.paginator import Pages 8 | from ballsdex.packages.trade.trade_user import TradingUser 9 | 10 | if TYPE_CHECKING: 11 | from ballsdex.core.bot import BallsDexBot 12 | 13 | 14 | class TradeViewFormat(menus.ListPageSource): 15 | def __init__(self, entries: Iterable[TradeModel], header: str, bot: "BallsDexBot"): 16 | self.header = header 17 | self.bot = bot 18 | super().__init__(entries, per_page=1) 19 | 20 | async def format_page(self, menu: Pages, trade: TradeModel) -> discord.Embed: 21 | embed = discord.Embed( 22 | title=f"Trade history for {self.header}", 23 | description=f"Trade ID: {trade.pk:0X}", 24 | timestamp=trade.date, 25 | ) 26 | embed.set_footer( 27 | text=f"Trade {menu.current_page + 1 }/{menu.source.get_max_pages()} | Trade date: " 28 | ) 29 | fill_trade_embed_fields( 30 | embed, 31 | self.bot, 32 | await TradingUser.from_trade_model(trade, trade.player1, self.bot), 33 | await TradingUser.from_trade_model(trade, trade.player2, self.bot), 34 | ) 35 | return embed 36 | 37 | 38 | def _get_prefix_emote(trader: TradingUser) -> str: 39 | if trader.cancelled: 40 | return "\N{NO ENTRY SIGN}" 41 | elif trader.accepted: 42 | return "\N{WHITE HEAVY CHECK MARK}" 43 | elif trader.locked: 44 | return "\N{LOCK}" 45 | else: 46 | return "" 47 | 48 | 49 | def _build_list_of_strings( 50 | trader: TradingUser, bot: "BallsDexBot", short: bool = False 51 | ) -> list[str]: 52 | # this builds a list of strings always lower than 1024 characters 53 | # while not cutting in the middle of a line 54 | proposal: list[str] = [""] 55 | i = 0 56 | 57 | for countryball in trader.proposal: 58 | cb_text = countryball.description(short=short, include_emoji=True, bot=bot, is_trade=True) 59 | if trader.locked: 60 | text = f"- *{cb_text}*\n" 61 | else: 62 | text = f"- {cb_text}\n" 63 | if trader.cancelled: 64 | text = f"~~{text}~~" 65 | 66 | if len(text) + len(proposal[i]) > 950: 67 | # move to a new list element 68 | i += 1 69 | proposal.append("") 70 | proposal[i] += text 71 | 72 | if not proposal[0]: 73 | proposal[0] = "*Empty*" 74 | 75 | return proposal 76 | 77 | 78 | def fill_trade_embed_fields( 79 | embed: discord.Embed, 80 | bot: "BallsDexBot", 81 | trader1: TradingUser, 82 | trader2: TradingUser, 83 | compact: bool = False, 84 | ): 85 | """ 86 | Fill the fields of an embed with the items part of a trade. 87 | 88 | This handles embed limits and will shorten the content if needed. 89 | 90 | Parameters 91 | ---------- 92 | embed: discord.Embed 93 | The embed being updated. Its fields are cleared. 94 | bot: BallsDexBot 95 | The bot object, used for getting emojis. 96 | trader1: TradingUser 97 | The player that initiated the trade, displayed on the left side. 98 | trader2: TradingUser 99 | The player that was invited to trade, displayed on the right side. 100 | compact: bool 101 | If `True`, display countryballs in a compact way. This should not be used directly. 102 | """ 103 | embed.clear_fields() 104 | 105 | # first, build embed strings 106 | # to play around the limit of 1024 characters per field, we'll be using multiple fields 107 | # these vars are list of fields, being a list of lines to include 108 | trader1_proposal = _build_list_of_strings(trader1, bot, compact) 109 | trader2_proposal = _build_list_of_strings(trader2, bot, compact) 110 | 111 | # then display the text. first page is easy 112 | embed.add_field( 113 | name=f"{_get_prefix_emote(trader1)} {trader1.user.name}", 114 | value=trader1_proposal[0], 115 | inline=True, 116 | ) 117 | embed.add_field( 118 | name=f"{_get_prefix_emote(trader2)} {trader2.user.name}", 119 | value=trader2_proposal[0], 120 | inline=True, 121 | ) 122 | 123 | if len(trader1_proposal) > 1 or len(trader2_proposal) > 1: 124 | # we'll have to trick for displaying the other pages 125 | # fields have to stack themselves vertically 126 | # to do this, we add a 3rd empty field on each line (since 3 fields per line) 127 | i = 1 128 | while i < len(trader1_proposal) or i < len(trader2_proposal): 129 | embed.add_field(name="\u200B", value="\u200B", inline=True) # empty 130 | 131 | if i < len(trader1_proposal): 132 | embed.add_field(name="\u200B", value=trader1_proposal[i], inline=True) 133 | else: 134 | embed.add_field(name="\u200B", value="\u200B", inline=True) 135 | 136 | if i < len(trader2_proposal): 137 | embed.add_field(name="\u200B", value=trader2_proposal[i], inline=True) 138 | else: 139 | embed.add_field(name="\u200B", value="\u200B", inline=True) 140 | i += 1 141 | 142 | # always add an empty field at the end, otherwise the alignment is off 143 | embed.add_field(name="\u200B", value="\u200B", inline=True) 144 | 145 | if len(embed) > 6000: 146 | if not compact: 147 | return fill_trade_embed_fields(embed, bot, trader1, trader2, compact=True) 148 | else: 149 | embed.clear_fields() 150 | embed.add_field( 151 | name=f"{_get_prefix_emote(trader1)} {trader1.user.name}", 152 | value=f"Trade too long, only showing last page:\n{trader1_proposal[-1]}", 153 | inline=True, 154 | ) 155 | embed.add_field( 156 | name=f"{_get_prefix_emote(trader2)} {trader2.user.name}", 157 | value=f"Trade too long, only showing last page:\n{trader2_proposal[-1]}", 158 | inline=True, 159 | ) 160 | -------------------------------------------------------------------------------- /ballsdex/packages/trade/menu.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from datetime import datetime, timedelta 6 | from typing import TYPE_CHECKING, cast 7 | 8 | import discord 9 | from discord.ui import Button, View, button 10 | 11 | from ballsdex.core.models import BallInstance, Trade, TradeObject 12 | from ballsdex.packages.trade.display import fill_trade_embed_fields 13 | from ballsdex.packages.trade.trade_user import TradingUser 14 | from ballsdex.settings import settings 15 | 16 | if TYPE_CHECKING: 17 | from ballsdex.core.bot import BallsDexBot 18 | from ballsdex.packages.trade.cog import Trade as TradeCog 19 | 20 | log = logging.getLogger("ballsdex.packages.trade.menu") 21 | 22 | 23 | class InvalidTradeOperation(Exception): 24 | pass 25 | 26 | 27 | class TradeView(View): 28 | def __init__(self, trade: TradeMenu): 29 | super().__init__(timeout=60 * 30) 30 | self.trade = trade 31 | 32 | async def interaction_check(self, interaction: discord.Interaction, /) -> bool: 33 | try: 34 | self.trade._get_trader(interaction.user) 35 | except RuntimeError: 36 | await interaction.response.send_message( 37 | "You are not allowed to interact with this trade.", ephemeral=True 38 | ) 39 | return False 40 | else: 41 | return True 42 | 43 | @button(label="Lock proposal", emoji="\N{LOCK}", style=discord.ButtonStyle.primary) 44 | async def lock(self, interaction: discord.Interaction, button: Button): 45 | trader = self.trade._get_trader(interaction.user) 46 | if trader.locked: 47 | await interaction.response.send_message( 48 | "You have already locked your proposal!", ephemeral=True 49 | ) 50 | return 51 | await self.trade.lock(trader) 52 | if self.trade.trader1.locked and self.trade.trader2.locked: 53 | await interaction.response.send_message( 54 | "Your proposal has been locked. Now confirm again to end the trade.", 55 | ephemeral=True, 56 | ) 57 | else: 58 | await interaction.response.send_message( 59 | "Your proposal has been locked. " 60 | "You can wait for the other user to lock their proposal.", 61 | ephemeral=True, 62 | ) 63 | 64 | @button(label="Reset", emoji="\N{DASH SYMBOL}", style=discord.ButtonStyle.secondary) 65 | async def clear(self, interaction: discord.Interaction, button: Button): 66 | trader = self.trade._get_trader(interaction.user) 67 | if trader.locked: 68 | await interaction.response.send_message( 69 | "You have locked your proposal, it cannot be edited! " 70 | "You can click the cancel button to stop the trade instead.", 71 | ephemeral=True, 72 | ) 73 | else: 74 | for countryball in trader.proposal: 75 | await countryball.unlock() 76 | trader.proposal.clear() 77 | await interaction.response.send_message("Proposal cleared.", ephemeral=True) 78 | 79 | @button( 80 | label="Cancel trade", 81 | emoji="\N{HEAVY MULTIPLICATION X}\N{VARIATION SELECTOR-16}", 82 | style=discord.ButtonStyle.danger, 83 | ) 84 | async def cancel(self, interaction: discord.Interaction, button: Button): 85 | await self.trade.user_cancel(self.trade._get_trader(interaction.user)) 86 | await interaction.response.send_message("Trade has been cancelled.", ephemeral=True) 87 | 88 | 89 | class ConfirmView(View): 90 | def __init__(self, trade: TradeMenu): 91 | super().__init__(timeout=90) 92 | self.trade = trade 93 | 94 | async def interaction_check(self, interaction: discord.Interaction, /) -> bool: 95 | try: 96 | self.trade._get_trader(interaction.user) 97 | except RuntimeError: 98 | await interaction.response.send_message( 99 | "You are not allowed to interact with this trade.", ephemeral=True 100 | ) 101 | return False 102 | else: 103 | return True 104 | 105 | @discord.ui.button( 106 | style=discord.ButtonStyle.success, emoji="\N{HEAVY CHECK MARK}\N{VARIATION SELECTOR-16}" 107 | ) 108 | async def accept_button(self, interaction: discord.Interaction, button: Button): 109 | trader = self.trade._get_trader(interaction.user) 110 | if trader.accepted: 111 | await interaction.response.send_message( 112 | "You have already accepted this trade.", ephemeral=True 113 | ) 114 | return 115 | await interaction.response.defer(ephemeral=True, thinking=True) 116 | result = await self.trade.confirm(trader) 117 | if self.trade.trader1.accepted and self.trade.trader2.accepted: 118 | if result: 119 | await interaction.followup.send("The trade is now concluded.", ephemeral=True) 120 | else: 121 | await interaction.followup.send( 122 | ":warning: An error occurred while concluding the trade.", ephemeral=True 123 | ) 124 | else: 125 | await interaction.followup.send( 126 | "You have accepted the trade, waiting for the other user...", ephemeral=True 127 | ) 128 | 129 | @discord.ui.button( 130 | style=discord.ButtonStyle.danger, 131 | emoji="\N{HEAVY MULTIPLICATION X}\N{VARIATION SELECTOR-16}", 132 | ) 133 | async def deny_button(self, interaction: discord.Interaction, button: Button): 134 | await self.trade.user_cancel(self.trade._get_trader(interaction.user)) 135 | await interaction.response.send_message("Trade has been cancelled.", ephemeral=True) 136 | 137 | 138 | class TradeMenu: 139 | def __init__( 140 | self, 141 | cog: TradeCog, 142 | interaction: discord.Interaction["BallsDexBot"], 143 | trader1: TradingUser, 144 | trader2: TradingUser, 145 | ): 146 | self.cog = cog 147 | self.bot = interaction.client 148 | self.channel: discord.TextChannel = cast(discord.TextChannel, interaction.channel) 149 | self.trader1 = trader1 150 | self.trader2 = trader2 151 | self.embed = discord.Embed() 152 | self.task: asyncio.Task | None = None 153 | self.current_view: TradeView | ConfirmView = TradeView(self) 154 | self.message: discord.Message 155 | 156 | def _get_trader(self, user: discord.User | discord.Member) -> TradingUser: 157 | if user.id == self.trader1.user.id: 158 | return self.trader1 159 | elif user.id == self.trader2.user.id: 160 | return self.trader2 161 | raise RuntimeError(f"User with ID {user.id} cannot be found in the trade") 162 | 163 | def _generate_embed(self): 164 | add_command = self.cog.add.extras.get("mention", "`/trade add`") 165 | remove_command = self.cog.remove.extras.get("mention", "`/trade remove`") 166 | 167 | self.embed.title = f"{settings.collectible_name.title()}s trading" 168 | self.embed.color = discord.Colour.blurple() 169 | self.embed.description = ( 170 | f"Add or remove {settings.collectible_name}s you want to propose to the other player " 171 | f"using the {add_command} and {remove_command} commands.\n" 172 | "Once you're finished, click the lock button below to confirm your proposal.\n" 173 | "You can also lock with nothing if you're receiving a gift.\n\n" 174 | "*You have 30 minutes before this interaction ends.*" 175 | ) 176 | self.embed.set_footer( 177 | text="This message is updated every 15 seconds, " 178 | "but you can keep on editing your proposal." 179 | ) 180 | 181 | async def update_message_loop(self): 182 | """ 183 | A loop task that updates each 5 second the menu with the new content. 184 | """ 185 | 186 | assert self.task 187 | start_time = datetime.utcnow() 188 | 189 | while True: 190 | await asyncio.sleep(15) 191 | if datetime.utcnow() - start_time > timedelta(minutes=15): 192 | self.embed.colour = discord.Colour.dark_red() 193 | await self.cancel("The trade timed out") 194 | return 195 | 196 | try: 197 | fill_trade_embed_fields(self.embed, self.bot, self.trader1, self.trader2) 198 | await self.message.edit(embed=self.embed) 199 | except Exception: 200 | log.exception( 201 | "Failed to refresh the trade menu " 202 | f"guild={self.message.guild.id} " # type: ignore 203 | f"trader1={self.trader1.user.id} trader2={self.trader2.user.id}" 204 | ) 205 | self.embed.colour = discord.Colour.dark_red() 206 | await self.cancel("The trade timed out") 207 | return 208 | 209 | async def start(self): 210 | """ 211 | Start the trade by sending the initial message and opening up the proposals. 212 | """ 213 | self._generate_embed() 214 | fill_trade_embed_fields(self.embed, self.bot, self.trader1, self.trader2) 215 | self.message = await self.channel.send( 216 | content=f"Hey {self.trader2.user.mention}, {self.trader1.user.name} " 217 | "is proposing a trade with you!", 218 | embed=self.embed, 219 | view=self.current_view, 220 | ) 221 | self.task = self.bot.loop.create_task(self.update_message_loop()) 222 | 223 | async def cancel(self, reason: str = "The trade has been cancelled."): 224 | """ 225 | Cancel the trade immediately. 226 | """ 227 | if self.task: 228 | self.task.cancel() 229 | 230 | for countryball in self.trader1.proposal + self.trader2.proposal: 231 | await countryball.unlock() 232 | 233 | self.current_view.stop() 234 | for item in self.current_view.children: 235 | item.disabled = True # type: ignore 236 | 237 | fill_trade_embed_fields(self.embed, self.bot, self.trader1, self.trader2) 238 | self.embed.description = f"**{reason}**" 239 | await self.message.edit(content=None, embed=self.embed, view=self.current_view) 240 | 241 | async def lock(self, trader: TradingUser): 242 | """ 243 | Mark a user's proposal as locked, ready for next stage 244 | """ 245 | trader.locked = True 246 | if self.trader1.locked and self.trader2.locked: 247 | if self.task: 248 | self.task.cancel() 249 | self.current_view.stop() 250 | fill_trade_embed_fields(self.embed, self.bot, self.trader1, self.trader2) 251 | 252 | self.embed.colour = discord.Colour.yellow() 253 | self.embed.description = ( 254 | "Both users locked their propositions! Now confirm to conclude this trade." 255 | ) 256 | self.current_view = ConfirmView(self) 257 | await self.message.edit(content=None, embed=self.embed, view=self.current_view) 258 | 259 | async def user_cancel(self, trader: TradingUser): 260 | """ 261 | Register a user request to cancel the trade 262 | """ 263 | trader.cancelled = True 264 | self.embed.colour = discord.Colour.red() 265 | await self.cancel() 266 | 267 | async def perform_trade(self): 268 | valid_transferable_countryballs: list[BallInstance] = [] 269 | 270 | trade = await Trade.create(player1=self.trader1.player, player2=self.trader2.player) 271 | 272 | for countryball in self.trader1.proposal: 273 | await countryball.refresh_from_db() 274 | if countryball.player.discord_id != self.trader1.player.discord_id: 275 | # This is a invalid mutation, the player is not the owner of the countryball 276 | raise InvalidTradeOperation() 277 | countryball.player = self.trader2.player 278 | countryball.trade_player = self.trader1.player 279 | countryball.favorite = False 280 | valid_transferable_countryballs.append(countryball) 281 | await TradeObject.create( 282 | trade=trade, ballinstance=countryball, player=self.trader1.player 283 | ) 284 | 285 | for countryball in self.trader2.proposal: 286 | if countryball.player.discord_id != self.trader2.player.discord_id: 287 | # This is a invalid mutation, the player is not the owner of the countryball 288 | raise InvalidTradeOperation() 289 | countryball.player = self.trader1.player 290 | countryball.trade_player = self.trader2.player 291 | countryball.favorite = False 292 | valid_transferable_countryballs.append(countryball) 293 | await TradeObject.create( 294 | trade=trade, ballinstance=countryball, player=self.trader2.player 295 | ) 296 | 297 | for countryball in valid_transferable_countryballs: 298 | await countryball.unlock() 299 | await countryball.save() 300 | 301 | async def confirm(self, trader: TradingUser) -> bool: 302 | """ 303 | Mark a user's proposal as accepted. If both user accept, end the trade now 304 | 305 | If the trade is concluded, return True, otherwise if an error occurs, return False 306 | """ 307 | result = True 308 | trader.accepted = True 309 | fill_trade_embed_fields(self.embed, self.bot, self.trader1, self.trader2) 310 | if self.trader1.accepted and self.trader2.accepted: 311 | if self.task and not self.task.cancelled(): 312 | # shouldn't happen but just in case 313 | self.task.cancel() 314 | 315 | self.embed.description = "Trade concluded!" 316 | self.embed.colour = discord.Colour.green() 317 | self.current_view.stop() 318 | for item in self.current_view.children: 319 | item.disabled = True # type: ignore 320 | 321 | try: 322 | await self.perform_trade() 323 | except InvalidTradeOperation: 324 | log.warning(f"Illegal trade operation between {self.trader1=} and {self.trader2=}") 325 | self.embed.description = ( 326 | f":warning: An attempt to modify the {settings.collectible_name}s " 327 | "during the trade was detected and the trade was cancelled." 328 | ) 329 | self.embed.colour = discord.Colour.red() 330 | result = False 331 | except Exception: 332 | log.exception(f"Failed to conclude trade {self.trader1=} {self.trader2=}") 333 | self.embed.description = "An error occured when concluding the trade." 334 | self.embed.colour = discord.Colour.red() 335 | result = False 336 | 337 | await self.message.edit(content=None, embed=self.embed, view=self.current_view) 338 | return result 339 | -------------------------------------------------------------------------------- /ballsdex/packages/trade/trade_user.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: 5 | import discord 6 | 7 | from ballsdex.core.bot import BallsDexBot 8 | from ballsdex.core.models import BallInstance, Player, Trade 9 | 10 | 11 | @dataclass(slots=True) 12 | class TradingUser: 13 | user: "discord.User | discord.Member" 14 | player: "Player" 15 | proposal: list["BallInstance"] = field(default_factory=list) 16 | locked: bool = False 17 | cancelled: bool = False 18 | accepted: bool = False 19 | 20 | @classmethod 21 | async def from_trade_model(cls, trade: "Trade", player: "Player", bot: "BallsDexBot"): 22 | proposal = await trade.tradeobjects.filter(player=player).prefetch_related("ballinstance") 23 | user = await bot.fetch_user(player.discord_id) 24 | return cls(user, player, [x.ballinstance for x in proposal]) 25 | -------------------------------------------------------------------------------- /ballsdex/settings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass, field 3 | from typing import TYPE_CHECKING 4 | 5 | import yaml 6 | 7 | if TYPE_CHECKING: 8 | from pathlib import Path 9 | 10 | log = logging.getLogger("ballsdex.settings") 11 | 12 | 13 | @dataclass 14 | class Settings: 15 | """ 16 | Global bot settings 17 | 18 | Attributes 19 | ---------- 20 | bot_token: str 21 | Discord token for the bot to connect 22 | gateway_url: str | None 23 | The URL of the Discord gateway that this instance of the bot should connect to and use. 24 | shard_count: int | None 25 | The number of shards to use for this bot instance. 26 | Must be equal to the one set in the gateway proxy if used. 27 | prefix: str 28 | Prefix for text commands, mostly unused. Defaults to "b." 29 | collectible_name: str 30 | Usually "countryball", can be replaced when possible 31 | bot_name: str 32 | Usually "BallsDex", can be replaced when possible 33 | players_group_cog_name: str 34 | Set the name of the base command of the "players" cog, /balls by default 35 | about_description: str 36 | Used in the /about command 37 | github_link: str 38 | Used in the /about command 39 | discord_invite: str 40 | Used in the /about command 41 | terms_of_service: str 42 | Used in the /about command 43 | privacy_policy: str 44 | Used in the /about command 45 | admin_guild_ids: list[int] 46 | List of guilds where the /admin command must be registered 47 | root_role_ids: list[int] 48 | List of roles that have full access to the /admin command 49 | admin_role_ids: list[int] 50 | List of roles that have partial access to the /admin command (only blacklist and guilds) 51 | """ 52 | 53 | bot_token: str = "" 54 | gateway_url: str | None = None 55 | shard_count: int | None = None 56 | prefix: str = "b." 57 | 58 | collectible_name: str = "countryball" 59 | bot_name: str = "BallsDex" 60 | players_group_cog_name: str = "balls" 61 | 62 | max_favorites: int = 50 63 | 64 | # /about 65 | about_description: str = "" 66 | github_link: str = "" 67 | discord_invite: str = "" 68 | terms_of_service: str = "" 69 | privacy_policy: str = "" 70 | 71 | # /admin 72 | admin_guild_ids: list[int] = field(default_factory=list) 73 | root_role_ids: list[int] = field(default_factory=list) 74 | admin_role_ids: list[int] = field(default_factory=list) 75 | 76 | log_channel: int | None = None 77 | 78 | team_owners: bool = False 79 | co_owners: list[int] = field(default_factory=list) 80 | 81 | # metrics and prometheus 82 | prometheus_enabled: bool = False 83 | prometheus_host: str = "0.0.0.0" 84 | prometheus_port: int = 15260 85 | 86 | 87 | settings = Settings() 88 | 89 | 90 | def read_settings(path: "Path"): 91 | content = yaml.load(path.read_text(), yaml.Loader) 92 | 93 | settings.bot_token = content["discord-token"] 94 | settings.gateway_url = content.get("gateway-url") 95 | settings.shard_count = content.get("shard-count") 96 | settings.prefix = content["text-prefix"] 97 | settings.team_owners = content.get("owners", {}).get("team-members-are-owners", False) 98 | settings.co_owners = content.get("owners", {}).get("co-owners", []) 99 | 100 | settings.collectible_name = content["collectible-name"] 101 | settings.bot_name = content["bot-name"] 102 | settings.players_group_cog_name = content["players-group-cog-name"] 103 | 104 | settings.about_description = content["about"]["description"] 105 | settings.github_link = content["about"]["github-link"] 106 | settings.discord_invite = content["about"]["discord-invite"] 107 | settings.terms_of_service = content["about"]["terms-of-service"] 108 | settings.privacy_policy = content["about"]["privacy-policy"] 109 | 110 | settings.admin_guild_ids = content["admin-command"]["guild-ids"] or [] 111 | settings.root_role_ids = content["admin-command"]["root-role-ids"] or [] 112 | settings.admin_role_ids = content["admin-command"]["admin-role-ids"] or [] 113 | 114 | settings.log_channel = content.get("log-channel", None) 115 | 116 | settings.prometheus_enabled = content["prometheus"]["enabled"] 117 | settings.prometheus_host = content["prometheus"]["host"] 118 | settings.prometheus_port = content["prometheus"]["port"] 119 | 120 | settings.max_favorites = content.get("max-favorites", 50) 121 | log.info("Settings loaded.") 122 | 123 | 124 | def write_default_settings(path: "Path"): 125 | path.write_text( 126 | """# yaml-language-server: $schema=json-config-ref.json 127 | 128 | # paste the bot token after regenerating it here 129 | discord-token: 130 | 131 | # prefix for old-style text commands, mostly unused 132 | text-prefix: b. 133 | 134 | # define the elements given with the /about command 135 | about: 136 | 137 | # define the beginning of the description of /about 138 | # the other parts is automatically generated 139 | description: > 140 | Collect countryballs on Discord, exchange them and battle with friends! 141 | 142 | # override this if you have a fork 143 | github-link: https://github.com/laggron42/BallsDex-DiscordBot 144 | 145 | # valid invite for a Discord server 146 | discord-invite: https://discord.gg/ballsdex # BallsDex official server 147 | 148 | terms-of-service: https://gist.github.com/laggron42/52ae099c55c6ee1320a260b0a3ecac4e 149 | privacy-policy: https://gist.github.com/laggron42/1eaa122013120cdfcc6d27f9485fe0bf 150 | 151 | # WORK IN PROGRESS, DOES NOT FULLY WORK 152 | # override the name "countryballs" in the bot 153 | collectible-name: countryball 154 | 155 | # WORK IN PROGRESS, DOES NOT FULLY WORK 156 | # override the name "BallsDex" in the bot 157 | bot-name: BallsDex 158 | 159 | # players group cog command name 160 | # this is /balls by default, but you can change it for /animals or /rocks for example 161 | players-group-cog-name: balls 162 | 163 | # enables the /admin command 164 | admin-command: 165 | 166 | # all items here are list of IDs. example on how to write IDs in a list: 167 | # guild-ids: 168 | # - 1049118743101452329 169 | # - 1078701108500897923 170 | 171 | # list of guild IDs where /admin should be registered 172 | guild-ids: 173 | 174 | # list of role IDs having full access to /admin 175 | root-role-ids: 176 | 177 | # list of role IDs having partial access to /admin 178 | admin-role-ids: 179 | 180 | # log channel for moderation actions 181 | log-channel: 182 | 183 | # manage bot ownership 184 | owners: 185 | # if enabled and the application is under a team, all team members will be considered as owners 186 | team-members-are-owners: false 187 | 188 | # a list of IDs that must be considered owners in addition to the application/team owner 189 | co-owners: 190 | 191 | # prometheus metrics collection, leave disabled if you don't know what this is 192 | prometheus: 193 | enabled: false 194 | host: "0.0.0.0" 195 | port: 15260 196 | """ # noqa: W291 197 | ) 198 | 199 | 200 | def update_settings(path: "Path"): 201 | content = path.read_text() 202 | 203 | add_owners = True 204 | add_config_ref = "# yaml-language-server: $schema=json-config-ref.json" not in content 205 | 206 | for line in content.splitlines(): 207 | if line.startswith("owners:"): 208 | add_owners = False 209 | 210 | if add_owners: 211 | content += """ 212 | # manage bot ownership 213 | owners: 214 | # if enabled and the application is under a team, all team members will be considered as owners 215 | team-members-are-owners: false 216 | 217 | # a list of IDs that must be considered owners in addition to the application/team owner 218 | co-owners: 219 | """ 220 | if add_config_ref: 221 | if "# yaml-language-server: $schema=config-ref.json" in content: 222 | # old file name replacement 223 | content = content.replace("$schema=config-ref.json", "$schema=json-config-ref.json") 224 | else: 225 | content = "# yaml-language-server: $schema=json-config-ref.json\n" + content 226 | 227 | if any((add_owners, add_config_ref)): 228 | path.write_text(content) 229 | -------------------------------------------------------------------------------- /ballsdex/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% if request.app.favicon_url %} 13 | 14 | {% endif %} 15 | 16 | 17 | 18 | 19 | 21 | {% block head %} 22 | 23 | 33 | {% endblock %} 34 |Ptn c bo
6 |{{ request.state.admin.username }}
{{ ball_count }}
18 | {{ guild_count }}
21 | {{ player_count }}
24 |