├── static
└── uploads
│ └── .gitkeep
├── ballsdex
├── core
│ ├── __init__.py
│ ├── utils
│ │ ├── __init__.py
│ │ ├── logging.py
│ │ ├── discord-ext-menus.LIENSE
│ │ ├── tortoise.py
│ │ ├── buttons.py
│ │ ├── transformers.py
│ │ └── paginator.py
│ ├── image_generator
│ │ ├── __init__.py
│ │ ├── src
│ │ │ ├── arial.ttf
│ │ │ ├── shiny.png
│ │ │ ├── union.png
│ │ │ ├── fr_test.png
│ │ │ ├── capitalist.png
│ │ │ ├── communist.png
│ │ │ ├── democracy.png
│ │ │ ├── dictatorship.png
│ │ │ ├── OpenSans-Bold.ttf
│ │ │ ├── Bobby Jones Soft.otf
│ │ │ ├── OpenSans-Semibold.ttf
│ │ │ └── ArsenicaTrial-Extrabold.ttf
│ │ └── image_gen.py
│ ├── admin
│ │ ├── routes.py
│ │ ├── __init__.py
│ │ └── resources.py
│ ├── commands.py
│ └── metrics.py
├── __init__.py
├── packages
│ ├── info
│ │ ├── __init__.py
│ │ └── cog.py
│ ├── balls
│ │ ├── __init__.py
│ │ └── countryballs_paginator.py
│ ├── config
│ │ ├── __init__.py
│ │ ├── components.py
│ │ └── cog.py
│ ├── trade
│ │ ├── __init__.py
│ │ ├── trade_user.py
│ │ ├── display.py
│ │ ├── cog.py
│ │ └── menu.py
│ ├── players
│ │ ├── __init__.py
│ │ └── cog.py
│ ├── countryballs
│ │ ├── __init__.py
│ │ ├── countryball.py
│ │ ├── cog.py
│ │ ├── spawn.py
│ │ └── components.py
│ └── admin
│ │ └── __init__.py
├── templates
│ ├── dashboard.html
│ ├── password.html
│ ├── providers
│ │ └── login
│ │ │ └── password.html
│ └── base.html
├── logging.py
├── settings.py
└── __main__.py
├── .env
├── migrations
└── models
│ ├── 12_20230224112031_update.sql
│ ├── 17_20230420000356_update.sql
│ ├── 6_20220924014717_update.sql
│ ├── 11_20230127140747_update.sql
│ ├── 13_20230301173448_update.sql
│ ├── 21_20231113175415_update.sql
│ ├── 28_20240225164026_update.sql
│ ├── 3_20220913220923_update.sql
│ ├── 16_20230410144614_update.sql
│ ├── 24_20231219112238_update.sql
│ ├── 29_20240320142916_update.sql
│ ├── 14_20230326131235_update.sql
│ ├── 26_20240121004430_update.sql
│ ├── 4_20220915164836_update.sql
│ ├── 15_20230404133020_update.sql
│ ├── 27_20240123140205_update.sql
│ ├── 2_20220910020800_update.sql
│ ├── 7_20220926000044_update.sql
│ ├── 5_20220924005726_update.sql
│ ├── 23_20231205113247_update.sql
│ ├── 8_20220926000601_update.sql
│ ├── 25_20240109160009_update.sql
│ ├── 19_20230522100203_update.sql
│ ├── 10_20221022184330_update.sql
│ ├── 1_20220909223048_update.sql
│ ├── 22_20231115171322_update.sql
│ ├── 9_20221007231725_update.sql
│ ├── 20_20230725165036_update.sql
│ └── 0_20220909204856_init.sql
├── .gitignore
├── gatewayproxy
└── config.json.example
├── Dockerfile
├── .github
└── workflows
│ └── pre-commit.yml
├── .pre-commit-config.yaml
├── LICENSE
├── pyproject.toml
├── README.md
├── docker-compose.yml
├── CONTRIBUTING.md
└── json-config-ref.json
/static/uploads/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/ballsdex/core/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/ballsdex/core/utils/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | POSTGRES_PASSWORD=defaultballsdexpassword
--------------------------------------------------------------------------------
/ballsdex/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "2.16.0"
2 |
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/src/arial.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/super-dev03/discbot-python/HEAD/ballsdex/core/image_generator/src/arial.ttf
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/src/shiny.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/super-dev03/discbot-python/HEAD/ballsdex/core/image_generator/src/shiny.png
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/src/union.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/super-dev03/discbot-python/HEAD/ballsdex/core/image_generator/src/union.png
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/src/fr_test.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/super-dev03/discbot-python/HEAD/ballsdex/core/image_generator/src/fr_test.png
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/src/capitalist.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/super-dev03/discbot-python/HEAD/ballsdex/core/image_generator/src/capitalist.png
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/src/communist.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/super-dev03/discbot-python/HEAD/ballsdex/core/image_generator/src/communist.png
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/src/democracy.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/super-dev03/discbot-python/HEAD/ballsdex/core/image_generator/src/democracy.png
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/src/dictatorship.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/super-dev03/discbot-python/HEAD/ballsdex/core/image_generator/src/dictatorship.png
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/src/OpenSans-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/super-dev03/discbot-python/HEAD/ballsdex/core/image_generator/src/OpenSans-Bold.ttf
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/src/Bobby Jones Soft.otf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/super-dev03/discbot-python/HEAD/ballsdex/core/image_generator/src/Bobby Jones Soft.otf
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/src/OpenSans-Semibold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/super-dev03/discbot-python/HEAD/ballsdex/core/image_generator/src/OpenSans-Semibold.ttf
--------------------------------------------------------------------------------
/migrations/models/12_20230224112031_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ball" ADD "catch_names" TEXT;
3 | -- downgrade --
4 | ALTER TABLE "ball" DROP COLUMN "catch_names";
5 |
--------------------------------------------------------------------------------
/migrations/models/17_20230420000356_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "special" ADD "emoji" VARCHAR(20);
3 | -- downgrade --
4 | ALTER TABLE "special" DROP COLUMN "emoji";
5 |
--------------------------------------------------------------------------------
/ballsdex/core/image_generator/src/ArsenicaTrial-Extrabold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/super-dev03/discbot-python/HEAD/ballsdex/core/image_generator/src/ArsenicaTrial-Extrabold.ttf
--------------------------------------------------------------------------------
/migrations/models/6_20220924014717_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ball" ADD "short_name" VARCHAR(12);
3 | -- downgrade --
4 | ALTER TABLE "ball" DROP COLUMN "short_name";
5 |
--------------------------------------------------------------------------------
/migrations/models/11_20230127140747_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "blacklistedid" ADD "reason" TEXT;
3 | -- downgrade --
4 | ALTER TABLE "blacklistedid" DROP COLUMN "reason";
5 |
--------------------------------------------------------------------------------
/migrations/models/13_20230301173448_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ballinstance" DROP COLUMN "count";
3 | -- downgrade --
4 | ALTER TABLE "ballinstance" ADD "count" INT NOT NULL;
5 |
--------------------------------------------------------------------------------
/migrations/models/21_20231113175415_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ballinstance" ADD "server_id" BIGINT;
3 | -- downgrade --
4 | ALTER TABLE "ballinstance" DROP COLUMN "spawn_time";
5 |
--------------------------------------------------------------------------------
/migrations/models/28_20240225164026_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ballinstance" ADD "locked" TIMESTAMPTZ;
3 | -- downgrade --
4 | ALTER TABLE "ballinstance" DROP COLUMN "locked";
5 |
--------------------------------------------------------------------------------
/migrations/models/3_20220913220923_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ball" ADD "enabled" BOOL NOT NULL DEFAULT True;
3 | -- downgrade --
4 | ALTER TABLE "ball" DROP COLUMN "enabled";
5 |
--------------------------------------------------------------------------------
/migrations/models/16_20230410144614_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ball" ADD "tradeable" BOOL NOT NULL DEFAULT True;
3 | -- downgrade --
4 | ALTER TABLE "ball" DROP COLUMN "tradeable";
5 |
--------------------------------------------------------------------------------
/migrations/models/24_20231219112238_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "special" ADD "hidden" BOOL NOT NULL DEFAULT False;
3 | -- downgrade --
4 | ALTER TABLE "special" DROP COLUMN "hidden";
5 |
--------------------------------------------------------------------------------
/migrations/models/29_20240320142916_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ballinstance" ADD "spawned_time" TIMESTAMPTZ;
3 | -- downgrade --
4 | ALTER TABLE "ballinstance" DROP COLUMN "spawned_time";
5 |
--------------------------------------------------------------------------------
/migrations/models/14_20230326131235_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "player" ADD "donation_policy" SMALLINT NOT NULL DEFAULT 1;
3 | -- downgrade --
4 | ALTER TABLE "player" DROP COLUMN "donation_policy";
5 |
--------------------------------------------------------------------------------
/migrations/models/26_20240121004430_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "player" ADD "privacy_policy" SMALLINT NOT NULL DEFAULT 2;
3 | -- downgrade --
4 | ALTER TABLE "player" DROP COLUMN "privacy_policy";
5 |
--------------------------------------------------------------------------------
/migrations/models/4_20220915164836_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ballinstance" ADD "favorite" BOOL NOT NULL DEFAULT False;
3 | -- downgrade --
4 | ALTER TABLE "ballinstance" DROP COLUMN "favorite";
5 |
--------------------------------------------------------------------------------
/migrations/models/15_20230404133020_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "blacklistedid" ADD "date" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP;
3 | -- downgrade --
4 | ALTER TABLE "blacklistedid" DROP COLUMN "date";
5 |
--------------------------------------------------------------------------------
/migrations/models/27_20240123140205_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ballinstance" ADD "extra_data" JSONB NOT NULL DEFAULT '{}'::JSONB;
3 | -- downgrade --
4 | ALTER TABLE "ballinstance" DROP COLUMN "extra_data";
5 |
--------------------------------------------------------------------------------
/migrations/models/2_20220910020800_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ballinstance" ALTER COLUMN "trade_player_id" DROP NOT NULL;
3 | -- downgrade --
4 | ALTER TABLE "ballinstance" ALTER COLUMN "trade_player_id" SET NOT NULL;
5 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/migrations/models/7_20220926000044_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | CREATE TABLE IF NOT EXISTS "blacklistedid" (
3 | "discord_id" BIGSERIAL NOT NULL PRIMARY KEY
4 | );
5 | COMMENT ON COLUMN "blacklistedid"."discord_id" IS 'Discord user ID';
6 | -- downgrade --
7 | DROP TABLE IF EXISTS "blacklistedid";
8 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/migrations/models/5_20220924005726_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ballinstance" ADD "shiny" BOOL NOT NULL DEFAULT False;
3 | ALTER TABLE "ballinstance" DROP COLUMN "special";
4 | -- downgrade --
5 | ALTER TABLE "ballinstance" ADD "special" INT NOT NULL DEFAULT 0;
6 | ALTER TABLE "ballinstance" DROP COLUMN "shiny";
7 |
--------------------------------------------------------------------------------
/migrations/models/23_20231205113247_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ballinstance" ADD "tradeable" BOOL NOT NULL DEFAULT True;
3 | ALTER TABLE "special" ADD "tradeable" BOOL NOT NULL DEFAULT True;
4 | -- downgrade --
5 | ALTER TABLE "special" DROP COLUMN "tradeable";
6 | ALTER TABLE "ballinstance" DROP COLUMN "tradeable";
7 |
--------------------------------------------------------------------------------
/migrations/models/8_20220926000601_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "blacklistedid" RENAME COLUMN "discord_id" TO "id";
3 | ALTER TABLE "blacklistedid" ADD "discord_id" BIGINT NOT NULL UNIQUE;
4 | -- downgrade --
5 | ALTER TABLE "blacklistedid" RENAME COLUMN "id" TO "discord_id";
6 | ALTER TABLE "blacklistedid" DROP COLUMN "discord_id";
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/migrations/models/25_20240109160009_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ball" ADD "created_at" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP;
3 | UPDATE ball b
4 | SET created_at = bi.catch_date
5 | FROM (
6 | SELECT ball_id, MIN(catch_date) AS catch_date
7 | FROM ballinstance
8 | GROUP BY ball_id
9 | ) AS bi
10 | WHERE b.id = bi.ball_id;
11 | -- downgrade --
12 | ALTER TABLE "ball" DROP COLUMN "created_at";
13 |
--------------------------------------------------------------------------------
/migrations/models/19_20230522100203_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | CREATE TABLE IF NOT EXISTS "blacklistedguild" (
3 | "id" SERIAL NOT NULL PRIMARY KEY,
4 | "discord_id" BIGINT NOT NULL UNIQUE,
5 | "reason" TEXT,
6 | "date" TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
7 | );
8 | COMMENT ON COLUMN "blacklistedguild"."discord_id" IS 'Discord Guild ID';
9 | -- downgrade --
10 | DROP TABLE IF EXISTS "blacklistedguild";
11 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/migrations/models/10_20221022184330_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ballinstance" DROP CONSTRAINT "ballinstance_trade_player_id_fkey";
3 | ALTER TABLE "ballinstance" ADD CONSTRAINT "fk_ballinst_player_6b1aca0e" FOREIGN KEY ("trade_player_id") REFERENCES "player" ("id") ON DELETE SET NULL;
4 | -- downgrade --
5 | ALTER TABLE "ballinstance" DROP CONSTRAINT "fk_ballinst_player_6b1aca0e";
6 | ALTER TABLE "ballinstance" ADD CONSTRAINT "ballinstance_trade_player_id_fkey" FOREIGN KEY ("trade_player_id") REFERENCES "player" ("id") ON DELETE CASCADE;
7 |
--------------------------------------------------------------------------------
/gatewayproxy/config.json.example:
--------------------------------------------------------------------------------
1 | {
2 | "log_level": "info",
3 | "token": "",
4 | "intents": 33289,
5 | "port": 5421,
6 | "status": "online",
7 | "backpressure": 250,
8 | "externally_accessible_url": "ws://localhost:5421",
9 | "cache": {
10 | "channels": false,
11 | "presences": false,
12 | "emojis": false,
13 | "current_member": false,
14 | "members": false,
15 | "roles": false,
16 | "stage_instances": false,
17 | "stickers": false,
18 | "users": false,
19 | "voice_states": false
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/migrations/models/1_20220909223048_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ball" ALTER COLUMN "emoji_id" TYPE BIGINT USING "emoji_id"::BIGINT;
3 | ALTER TABLE "guildconfig" ALTER COLUMN "spawn_channel" TYPE BIGINT USING "spawn_channel"::BIGINT;
4 | ALTER TABLE "guildconfig" ALTER COLUMN "guild_id" TYPE BIGINT USING "guild_id"::BIGINT;
5 | ALTER TABLE "player" ALTER COLUMN "discord_id" TYPE BIGINT USING "discord_id"::BIGINT;
6 | -- downgrade --
7 | ALTER TABLE "ball" ALTER COLUMN "emoji_id" TYPE INT USING "emoji_id"::INT;
8 | ALTER TABLE "player" ALTER COLUMN "discord_id" TYPE INT USING "discord_id"::INT;
9 | ALTER TABLE "guildconfig" ALTER COLUMN "spawn_channel" TYPE INT USING "spawn_channel"::INT;
10 | ALTER TABLE "guildconfig" ALTER COLUMN "guild_id" TYPE INT USING "guild_id"::INT;
11 |
--------------------------------------------------------------------------------
/migrations/models/22_20231115171322_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | CREATE TABLE IF NOT EXISTS "trade" (
3 | "id" SERIAL NOT NULL PRIMARY KEY,
4 | "date" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
5 | "player1_id" INT NOT NULL REFERENCES "player" ("id") ON DELETE CASCADE,
6 | "player2_id" INT NOT NULL REFERENCES "player" ("id") ON DELETE CASCADE
7 | );;
8 | CREATE TABLE IF NOT EXISTS "tradeobject" (
9 | "id" SERIAL NOT NULL PRIMARY KEY,
10 | "ballinstance_id" INT NOT NULL REFERENCES "ballinstance" ("id") ON DELETE CASCADE,
11 | "player_id" INT NOT NULL REFERENCES "player" ("id") ON DELETE CASCADE,
12 | "trade_id" INT NOT NULL REFERENCES "trade" ("id") ON DELETE CASCADE
13 | );-- downgrade --
14 | DROP TABLE IF EXISTS "trade";
15 | DROP TABLE IF EXISTS "tradeobject";
16 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/migrations/models/9_20221007231725_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ballinstance" ADD "special_id" INT;
3 | CREATE TABLE IF NOT EXISTS "special" (
4 | "id" SERIAL NOT NULL PRIMARY KEY,
5 | "name" VARCHAR(64) NOT NULL,
6 | "catch_phrase" VARCHAR(128),
7 | "start_date" TIMESTAMPTZ NOT NULL,
8 | "end_date" TIMESTAMPTZ NOT NULL,
9 | "rarity" DOUBLE PRECISION NOT NULL,
10 | "democracy_card" VARCHAR(200) NOT NULL,
11 | "dictatorship_card" VARCHAR(200) NOT NULL,
12 | "union_card" VARCHAR(200) NOT NULL
13 | );
14 | COMMENT ON COLUMN "special"."catch_phrase" IS 'Sentence sent in bonus when someone catches a special card';
15 | COMMENT ON COLUMN "special"."rarity" IS 'Value between 0 and 1, chances of using this special background.';;
16 | ALTER TABLE "ballinstance" ADD CONSTRAINT "fk_ballinst_special_25656e1a" FOREIGN KEY ("special_id") REFERENCES "special" ("id") ON DELETE SET NULL;
17 | -- downgrade --
18 | ALTER TABLE "ballinstance" DROP CONSTRAINT "fk_ballinst_special_25656e1a";
19 | ALTER TABLE "ballinstance" DROP COLUMN "special_id";
20 | DROP TABLE IF EXISTS "special";
21 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/templates/dashboard.html:
--------------------------------------------------------------------------------
1 | {% extends 'layout.html' %}
2 | {% block page_body %}
3 |
4 |
BallsDex bot admin panel
5 |
Ptn c bo
6 |
7 |
8 |
9 |
10 |
Logged in user: {{ request.state.admin.username }}
11 |

12 |
13 |
14 |
Statistics
15 |
16 | -
17 | Number of balls registered:
{{ ball_count }}
18 |
19 | -
20 | Number of guilds with config:
{{ guild_count }}
21 |
22 | -
23 | Number of players:
{{ player_count }}
24 |
25 |
26 |
27 |
28 |
29 | {% endblock %}
30 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "ballsdex"
3 | version = "1.0.0a1"
4 | description = ""
5 | authors = ["laggron42 "]
6 | license = "MIT"
7 |
8 | [tool.poetry.dependencies]
9 | python = "^3.11"
10 |
11 | # asyncio
12 | uvloop = { version = "^0.17.0", markers = "sys_platform != 'win32'" }
13 |
14 | # discord
15 | "discord.py" = "^2.3.2"
16 |
17 | # fastapi
18 | fastapi = "^0.109.1"
19 | fastapi-admin = {git = "https://github.com/fastapi-admin/fastapi-admin", rev = "0ed771026c776138aceb4ae98eeb5c976786a193"}
20 | uvicorn = "^0.24.0"
21 |
22 | # database ORM
23 | tortoise-orm = {extras = ["asyncpg"], version = "^0.20.0"}
24 | tortoise-cli = "^0.1.2"
25 |
26 | # misc
27 | rich = "^13.6.0"
28 | python-dateutil = "^2.8.2"
29 | Pillow = "^10.3.0"
30 | aerich = "^0.6.3"
31 | pyyaml = "^6.0"
32 | cachetools = "^5.3.1"
33 |
34 | [tool.poetry.group.dev.dependencies]
35 | pre-commit = "^3.5.0"
36 | black = {version = "^24.3.0", allow-prereleases = true}
37 | flake8-pyproject = "^1.2.3"
38 | pyright = "^1.1.335"
39 | isort = "^5.12.0"
40 |
41 |
42 | [tool.poetry.group.metrics.dependencies]
43 | prometheus-client = "^0.16.0"
44 |
45 | [tool.aerich]
46 | tortoise_orm = "ballsdex.__main__.TORTOISE_ORM"
47 | location = "./migrations"
48 | src_folder = "./ballsdex"
49 |
50 | [build-system]
51 | requires = ["poetry-core>=1.0.0"]
52 | build-backend = "poetry.core.masonry.api"
53 |
54 | [tool.black]
55 | line-length = 99
56 |
57 | [tool.flake8]
58 | ignore = "W503,E203"
59 | max-line-length = 99
60 |
61 | [tool.isort]
62 | profile = "black"
63 | line_length = 99
64 |
--------------------------------------------------------------------------------
/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/templates/password.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 | {% block page_body %}
3 | {% include "components/alert_error.html" %}
4 |
37 | {% endblock %}
38 |
--------------------------------------------------------------------------------
/ballsdex/templates/providers/login/password.html:
--------------------------------------------------------------------------------
1 | {% extends "layout.html" %}
2 | {% block page_body %}
3 | {% include "components/alert_error.html" %}
4 |
38 | {% endblock %}
39 |
--------------------------------------------------------------------------------
/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/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/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 | {{ title }}
35 |
36 | {% block outer_body %}
37 |
38 | {% block body %}
39 | {% endblock %}
40 |
41 | {% endblock %}
42 |
43 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/migrations/models/20_20230725165036_update.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | ALTER TABLE "ball" ADD "economy_id" INT;
3 | ALTER TABLE "ball" ADD "regime_id" INT; -- Add NOT NULL after we filled the table --
4 | CREATE TABLE IF NOT EXISTS "economy" (
5 | "id" SERIAL NOT NULL PRIMARY KEY,
6 | "name" VARCHAR(64) NOT NULL,
7 | "icon" VARCHAR(200) NOT NULL
8 | );
9 | COMMENT ON COLUMN "ball"."economy_id" IS 'Economical regime of this country';
10 | COMMENT ON COLUMN "ball"."regime_id" IS 'Political regime of this country';
11 | COMMENT ON COLUMN "economy"."icon" IS '512x512 PNG image';;
12 | CREATE TABLE IF NOT EXISTS "regime" (
13 | "id" SERIAL NOT NULL PRIMARY KEY,
14 | "name" VARCHAR(64) NOT NULL,
15 | "background" VARCHAR(200) NOT NULL
16 | );
17 | COMMENT ON COLUMN "regime"."background" IS '1428x2000 PNG image';;
18 | ALTER TABLE "special" ADD "background" VARCHAR(200);
19 | UPDATE "special" SET "background" = "democracy_card";
20 | ALTER TABLE "special" DROP COLUMN "democracy_card";
21 | ALTER TABLE "special" DROP COLUMN "union_card";
22 | ALTER TABLE "special" DROP COLUMN "dictatorship_card";
23 | ALTER TABLE "ball" ADD CONSTRAINT "fk_ball_regime_d7fd92a9" FOREIGN KEY ("regime_id") REFERENCES "regime" ("id") ON DELETE CASCADE;
24 | ALTER TABLE "ball" ADD CONSTRAINT "fk_ball_economy_cfe9c5c3" FOREIGN KEY ("economy_id") REFERENCES "economy" ("id") ON DELETE SET NULL;
25 | INSERT INTO "economy" ("name", "icon") VALUES
26 | ('Capitalist', '/ballsdex/core/image_generator/src/capitalist.png'),
27 | ('Communist', '/ballsdex/core/image_generator/src/communist.png');
28 | INSERT INTO "regime" ("name", "background") VALUES
29 | ('Democracy', '/ballsdex/core/image_generator/src/democracy.png'),
30 | ('Dictatorship', '/ballsdex/core/image_generator/src/dictatorship.png'),
31 | ('Union', '/ballsdex/core/image_generator/src/union.png');
32 | UPDATE "ball" SET "economy_id" = "economy" WHERE "economy" != 3;
33 | UPDATE "ball" SET "economy_id" = null WHERE "economy" = 3;
34 | UPDATE "ball" SET "regime_id" = "regime";
35 | ALTER TABLE "ball" ALTER COLUMN "regime_id" SET NOT NULL; -- Table filled, now we can put non-nullable constraint --
36 | ALTER TABLE "ball" DROP COLUMN "economy";
37 | ALTER TABLE "ball" DROP COLUMN "regime";
38 | -- downgrade --
39 | ALTER TABLE "ball" DROP CONSTRAINT "fk_ball_economy_cfe9c5c3";
40 | ALTER TABLE "ball" DROP CONSTRAINT "fk_ball_regime_d7fd92a9";
41 | ALTER TABLE "ball" ADD "regime" SMALLINT;
42 | ALTER TABLE "ball" ADD "economy" SMALLINT;
43 | UPDATE "ball" SET "regime" = "regime_id";
44 | UPDATE "ball" SET "economy" = "economy_id";
45 | ALTER TABLE "ball" DROP COLUMN "economy_id";
46 | ALTER TABLE "ball" DROP COLUMN "regime_id";
47 | ALTER TABLE "special" ADD "democracy_card" VARCHAR(200);
48 | ALTER TABLE "special" ADD "union_card" VARCHAR(200);
49 | ALTER TABLE "special" ADD "dictatorship_card" VARCHAR(200);
50 | ALTER TABLE "special" DROP COLUMN "background";
51 | DROP TABLE IF EXISTS "economy";
52 | DROP TABLE IF EXISTS "regime";
53 | ALTER TABLE "ball" ALTER COLUMN "regime" SET NOT NULL;
54 | ALTER TABLE "ball" ALTER COLUMN "regime" SET NOT NULL;
55 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.4'
2 |
3 | x-common-env-vars:
4 | - &postgres-db
5 | POSTGRES_DB=ballsdex
6 | - &postgres-user
7 | POSTGRES_USER=ballsdex
8 | - &postgres-url
9 | "BALLSDEXBOT_DB_URL=postgres://ballsdex:${POSTGRES_PASSWORD}@postgres:5432/ballsdex"
10 |
11 | services:
12 |
13 | bot:
14 | restart: "no"
15 | image: ballsdex
16 | build: .
17 | environment:
18 | - *postgres-url
19 | depends_on:
20 | - postgres-db
21 | # ports:
22 | # - "15260:15260"
23 | networks:
24 | - internal
25 | volumes:
26 | - type: bind
27 | source: ./
28 | target: /code
29 | tty: true
30 | command: poetry run python3 -m ballsdex --dev
31 |
32 | admin-panel:
33 | image: ballsdex
34 | ports:
35 | - "8000:8000"
36 | networks:
37 | - internal
38 | environment:
39 | - *postgres-url
40 | - "BALLSDEXBOT_REDIS_URL=redis://redis"
41 | depends_on:
42 | - postgres-db
43 | - redis-cache
44 | volumes:
45 | - type: bind
46 | source: ./
47 | target: /code
48 | tty: true
49 | command: poetry run uvicorn ballsdex.core.admin:_app --host 0.0.0.0
50 |
51 | postgres-db:
52 | image: postgres
53 | restart: always
54 | hostname: postgres
55 | environment:
56 | - *postgres-db
57 | - *postgres-user
58 | - POSTGRES_PASSWORD
59 | # WARNING: before exposing ports, change the default db password in the .env file!
60 | # ports:
61 | # - "5432:5432"
62 | networks:
63 | - internal
64 | volumes: # Persist the db data
65 | - database-data:/var/lib/postgresql/data
66 |
67 | postgres-backup:
68 | image: prodrigestivill/postgres-backup-local
69 | restart: always
70 | user: postgres:postgres
71 | volumes:
72 | - /var/opt/pgbackups:/backups
73 | depends_on:
74 | - postgres-db
75 | networks:
76 | - internal
77 | environment:
78 | - *postgres-db
79 | - *postgres-user
80 | - POSTGRES_PASSWORD
81 | - POSTGRES_HOST=postgres
82 | - SCHEDULE=@daily
83 | - BACKUP_KEEP_DAYS=7
84 | - BACKUP_KEEP_WEEKS=4
85 | - BACKUP_KEEP_MONTHS=6
86 | - HEALTHCHECK_PORT=3928
87 |
88 | redis-cache:
89 | image: redis:latest
90 | restart: always
91 | hostname: redis
92 | # ports:
93 | # - "6379:6379"
94 | networks:
95 | - internal
96 | environment:
97 | - ALLOW_EMPTY_PASSWORD=yes
98 | command: redis-server --save 20 1 --loglevel warning
99 | volumes:
100 | - cache-data:/data
101 |
102 | # Uncomment to enable gateway proxy feature and
103 | # add "gateway-url: ws://gateway_proxy:5421" to your config.yaml
104 | # Also change tag corresponding to your platform if not x86-64
105 | #
106 | # gateway-proxy:
107 | # container_name: gateway_proxy
108 | # image: ghcr.io/martinebot/gateway-proxy:x86-64
109 | # restart: always
110 | # volumes:
111 | # - ./gatewayproxy/config.json:/config.json
112 | # networks:
113 | # - internal
114 |
115 | volumes:
116 | database-data:
117 | cache-data:
118 |
119 | networks:
120 | internal:
121 |
--------------------------------------------------------------------------------
/migrations/models/0_20220909204856_init.sql:
--------------------------------------------------------------------------------
1 | -- upgrade --
2 | CREATE TABLE IF NOT EXISTS "ball" (
3 | "id" SERIAL NOT NULL PRIMARY KEY,
4 | "country" VARCHAR(48) NOT NULL UNIQUE,
5 | "regime" SMALLINT NOT NULL,
6 | "economy" SMALLINT NOT NULL,
7 | "health" INT NOT NULL,
8 | "attack" INT NOT NULL,
9 | "rarity" DOUBLE PRECISION NOT NULL,
10 | "emoji_id" INT NOT NULL,
11 | "wild_card" VARCHAR(200) NOT NULL,
12 | "collection_card" VARCHAR(200) NOT NULL,
13 | "credits" VARCHAR(64) NOT NULL,
14 | "capacity_name" VARCHAR(64) NOT NULL,
15 | "capacity_description" VARCHAR(256) NOT NULL,
16 | "capacity_logic" JSONB NOT NULL
17 | );
18 | COMMENT ON COLUMN "ball"."regime" IS 'Political regime of this country';
19 | COMMENT ON COLUMN "ball"."economy" IS 'Economical regime of this country';
20 | COMMENT ON COLUMN "ball"."health" IS 'Ball health stat';
21 | COMMENT ON COLUMN "ball"."attack" IS 'Ball attack stat';
22 | COMMENT ON COLUMN "ball"."rarity" IS 'Rarity of this ball';
23 | COMMENT ON COLUMN "ball"."emoji_id" IS 'Emoji ID for this ball';
24 | COMMENT ON COLUMN "ball"."wild_card" IS 'Image used when a new ball spawns in the wild';
25 | COMMENT ON COLUMN "ball"."collection_card" IS 'Image used when displaying balls';
26 | COMMENT ON COLUMN "ball"."credits" IS 'Author of the collection artwork';
27 | COMMENT ON COLUMN "ball"."capacity_name" IS 'Name of the countryball''s capacity';
28 | COMMENT ON COLUMN "ball"."capacity_description" IS 'Description of the countryball''s capacity';
29 | COMMENT ON COLUMN "ball"."capacity_logic" IS 'Effect of this capacity';
30 | CREATE TABLE IF NOT EXISTS "guildconfig" (
31 | "id" SERIAL NOT NULL PRIMARY KEY,
32 | "guild_id" INT NOT NULL UNIQUE,
33 | "spawn_channel" INT,
34 | "enabled" BOOL NOT NULL DEFAULT True
35 | );
36 | COMMENT ON COLUMN "guildconfig"."guild_id" IS 'Discord guild ID';
37 | COMMENT ON COLUMN "guildconfig"."spawn_channel" IS 'Discord channel ID where balls will spawn';
38 | COMMENT ON COLUMN "guildconfig"."enabled" IS 'Whether the bot will spawn countryballs in this guild';
39 | CREATE TABLE IF NOT EXISTS "player" (
40 | "id" SERIAL NOT NULL PRIMARY KEY,
41 | "discord_id" INT NOT NULL UNIQUE
42 | );
43 | COMMENT ON COLUMN "player"."discord_id" IS 'Discord user ID';
44 | CREATE TABLE IF NOT EXISTS "ballinstance" (
45 | "id" SERIAL NOT NULL PRIMARY KEY,
46 | "count" INT NOT NULL,
47 | "catch_date" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
48 | "special" INT NOT NULL DEFAULT 0,
49 | "health_bonus" INT NOT NULL DEFAULT 0,
50 | "attack_bonus" INT NOT NULL DEFAULT 0,
51 | "ball_id" INT NOT NULL REFERENCES "ball" ("id") ON DELETE CASCADE,
52 | "player_id" INT NOT NULL REFERENCES "player" ("id") ON DELETE CASCADE,
53 | "trade_player_id" INT NOT NULL REFERENCES "player" ("id") ON DELETE CASCADE,
54 | CONSTRAINT "uid_ballinstanc_player__f154f9" UNIQUE ("player_id", "id")
55 | );
56 | COMMENT ON COLUMN "ballinstance"."special" IS 'Defines rare instances, like a shiny';
57 | CREATE TABLE IF NOT EXISTS "user" (
58 | "id" SERIAL NOT NULL PRIMARY KEY,
59 | "username" VARCHAR(50) NOT NULL UNIQUE,
60 | "password" VARCHAR(200) NOT NULL,
61 | "last_login" TIMESTAMPTZ NOT NULL,
62 | "avatar" VARCHAR(200) NOT NULL DEFAULT '',
63 | "intro" TEXT NOT NULL,
64 | "created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
65 | );
66 | COMMENT ON COLUMN "user"."last_login" IS 'Last Login';
67 | CREATE TABLE IF NOT EXISTS "aerich" (
68 | "id" SERIAL NOT NULL PRIMARY KEY,
69 | "version" VARCHAR(255) NOT NULL,
70 | "app" VARCHAR(100) NOT NULL,
71 | "content" JSONB NOT NULL
72 | );
73 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/json-config-ref.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json-schema.org/draft/2020-12/schema",
3 | "title": "Ballsdex configuration",
4 | "description": "Core settings for the Ballsdex Discord bot",
5 | "type": "object",
6 | "required": ["discord-token", "text-prefix", "about", "collectible-name", "bot-name", "players-group-cog-name", "admin-command", "prometheus", "owners"],
7 | "properties": {
8 | "discord-token": {
9 | "description": "The Discord bot token",
10 | "type": "string",
11 | "minLength": 72,
12 | "maxLength": 72
13 | },
14 | "text-prefix": {
15 | "description": "The prefix for old text commands",
16 | "type": "string",
17 | "minLength": 1,
18 | "default": "b."
19 | },
20 | "about": {
21 | "type": "object",
22 | "description": "Information used in /about",
23 | "properties": {
24 | "description": {
25 | "type": "string",
26 | "description": "Defines the beginning of the description of /about. The other parts are automatically generated"
27 | },
28 | "github-link": {
29 | "type": "string",
30 | "description": "A GitHub repository for source code, if using a fork",
31 | "format": "uri"
32 | },
33 | "discord-invite": {
34 | "type": "string",
35 | "description": "A Discord server to link for support",
36 | "pattern": "^https?://(discord.gg|discord(app)?.com/invite)/[a-zA-Z0-9]+$"
37 | },
38 | "terms-of-service": {
39 | "type": "string",
40 | "description": "A link to the terms of service",
41 | "format": "uri"
42 | },
43 | "privacy-policy": {
44 | "type": "string",
45 | "description": "A link to the privacy policy",
46 | "format": "uri"
47 | }
48 | },
49 | "required": ["description", "github-link", "discord-invite", "terms-of-service", "privacy-policy"]
50 | },
51 | "collectible-name": {
52 | "type": "string",
53 | "description": "The name of the collectible, used everywhere except command descriptions. An 's' is appended to the end of this string for pluralization",
54 | "example": "ball"
55 | },
56 | "bot-name": {
57 | "type": "string",
58 | "description": "The name of the bot, used in places such as the /completion command",
59 | "example": "BallsDex"
60 | },
61 | "players-group-cog-name": {
62 | "type": "string",
63 | "description": "Name of the slash command managing the collectibles (list, info, completion...). Do not add a leading slash!",
64 | "example": "balls"
65 | },
66 | "admin-command": {
67 | "type": "object",
68 | "description": "Manages access to the admin command",
69 | "properties": {
70 | "guild-ids": {
71 | "type": ["array", "null"],
72 | "description": "IDs of guilds with the /admin command registered",
73 | "items": {
74 | "type": "integer",
75 | "minimum": 10000000000000000,
76 | "maximum": 99999999999999999999
77 | }
78 | },
79 | "root-role-ids": {
80 | "type": ["array", "null"],
81 | "description": "IDs of roles with full access to /admin",
82 | "items": {
83 | "type": "integer",
84 | "minimum": 10000000000000000,
85 | "maximum": 99999999999999999999
86 | }
87 | },
88 | "admin-role-ids": {
89 | "type": ["array", "null"],
90 | "description": "IDs of roles with partial access to /admin: blacklist control and guilds ownership inspection",
91 | "items": {
92 | "type": "integer",
93 | "minimum": 10000000000000000,
94 | "maximum": 99999999999999999999
95 | }
96 | }
97 | },
98 | "required": ["guild-ids", "root-role-ids", "admin-role-ids"]
99 | },
100 | "prometheus": {
101 | "type": "object",
102 | "description": "Prometheus metrics configuration",
103 | "properties": {
104 | "enabled": {
105 | "type": "boolean",
106 | "description": "Whether to enable Prometheus metrics server",
107 | "default": false
108 | },
109 | "host": {
110 | "type": "string",
111 | "description": "Host to bind to",
112 | "default": "0.0.0.0"
113 | },
114 | "port": {
115 | "type": "integer",
116 | "description": "Port to bind to",
117 | "default": 15260
118 | }
119 | }
120 | },
121 | "log-channel": {
122 | "type": ["integer", "null"],
123 | "description": "ID of the channel to log events to",
124 | "minimum": 10000000000000000,
125 | "maximum": 99999999999999999999
126 | },
127 | "owners": {
128 | "type": "object",
129 | "description": "Manages ownership of the bot",
130 | "properties": {
131 | "team-members-are-owners": {
132 | "type": "boolean",
133 | "description": "Whether to consider Discord developer team members as owners",
134 | "default": false
135 | },
136 | "co-owners": {
137 | "type": ["array", "null"],
138 | "description": "IDs of users with ownership",
139 | "items": {
140 | "type": "integer",
141 | "minimum": 10000000000000000,
142 | "maximum": 99999999999999999999
143 | }
144 | }
145 | }
146 | },
147 | "max-favorites": {
148 | "type": "integer",
149 | "default": 50,
150 | "description": "Maximum number of favorite countryballs allowed per player",
151 | "minimum": 0
152 | }
153 | }
154 | }
--------------------------------------------------------------------------------
/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/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/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/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/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/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/__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/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/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 |
--------------------------------------------------------------------------------