├── .github └── workflows │ ├── CI.yml │ ├── black.yml │ └── greetings.yml ├── .gitignore ├── .markdownlint.json ├── .pre-commit-config.yaml ├── Cargo.toml ├── _run_bot.sh ├── _run_downtime_bot.sh ├── cogs ├── __init__.py ├── animal_commands.py ├── beg_command.py ├── casino_commands.py ├── compare_command.py ├── daily_command.py ├── dig_command.py ├── donating_command.py ├── error_handling.py ├── fish_command.py ├── grow_command.py ├── help_commands.py ├── hospital_command.py ├── hunt_command.py ├── leaderboard_command.py ├── loading.py ├── new_command.py ├── ping_command.py ├── rename_command.py ├── reply_command.py ├── shop_commands.py ├── show_commands.py ├── utils │ ├── __init__.py │ ├── bot.py │ ├── cards.py │ ├── command.py │ ├── donations.py │ ├── errors.py │ ├── formatters.py │ ├── generate_rewards.py │ ├── helpers.py │ ├── items.py │ ├── managers.py │ ├── minigames.py │ ├── paginator.py │ ├── pps.py │ └── streaks.py └── voting_events.py ├── config ├── config.example.toml ├── database.pgsql ├── items.toml ├── minigames.toml └── minigames │ ├── beg.toml │ ├── dig.toml │ ├── fish.toml │ ├── global.toml │ └── hunt.toml ├── downtime_bot ├── README.txt └── src │ ├── .gitignore │ ├── cogs │ └── command_handler.py │ └── requirements.txt ├── dump.rdb ├── pyo3_rust_utils └── src │ └── lib.rs ├── pyproject.toml ├── readme.md ├── requirements.txt └── typings └── rust_utils └── __init__.pyi /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | # This file is autogenerated by maturin v1.8.3 2 | # To update, run 3 | # 4 | # maturin generate-ci github -o .\.github\workflows\CI.yml 5 | # 6 | name: CI 7 | 8 | on: 9 | push: 10 | branches: 11 | - main 12 | - master 13 | tags: 14 | - '*' 15 | pull_request: 16 | workflow_dispatch: 17 | 18 | permissions: 19 | contents: read 20 | 21 | jobs: 22 | linux: 23 | runs-on: ${{ matrix.platform.runner }} 24 | strategy: 25 | matrix: 26 | platform: 27 | - runner: ubuntu-22.04 28 | target: x86_64 29 | - runner: ubuntu-22.04 30 | target: x86 31 | - runner: ubuntu-22.04 32 | target: aarch64 33 | - runner: ubuntu-22.04 34 | target: armv7 35 | - runner: ubuntu-22.04 36 | target: s390x 37 | - runner: ubuntu-22.04 38 | target: ppc64le 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions/setup-python@v5 42 | with: 43 | python-version: 3.x 44 | - name: Build wheels 45 | uses: PyO3/maturin-action@v1 46 | with: 47 | target: ${{ matrix.platform.target }} 48 | args: --release --out dist --find-interpreter 49 | sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} 50 | manylinux: auto 51 | - name: Upload wheels 52 | uses: actions/upload-artifact@v4 53 | with: 54 | name: wheels-linux-${{ matrix.platform.target }} 55 | path: dist 56 | 57 | musllinux: 58 | runs-on: ${{ matrix.platform.runner }} 59 | strategy: 60 | matrix: 61 | platform: 62 | - runner: ubuntu-22.04 63 | target: x86_64 64 | - runner: ubuntu-22.04 65 | target: x86 66 | - runner: ubuntu-22.04 67 | target: aarch64 68 | - runner: ubuntu-22.04 69 | target: armv7 70 | steps: 71 | - uses: actions/checkout@v4 72 | - uses: actions/setup-python@v5 73 | with: 74 | python-version: 3.x 75 | - name: Build wheels 76 | uses: PyO3/maturin-action@v1 77 | with: 78 | target: ${{ matrix.platform.target }} 79 | args: --release --out dist --find-interpreter 80 | sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} 81 | manylinux: musllinux_1_2 82 | - name: Upload wheels 83 | uses: actions/upload-artifact@v4 84 | with: 85 | name: wheels-musllinux-${{ matrix.platform.target }} 86 | path: dist 87 | 88 | windows: 89 | runs-on: ${{ matrix.platform.runner }} 90 | strategy: 91 | matrix: 92 | platform: 93 | - runner: windows-latest 94 | target: x64 95 | - runner: windows-latest 96 | target: x86 97 | steps: 98 | - uses: actions/checkout@v4 99 | - uses: actions/setup-python@v5 100 | with: 101 | python-version: 3.x 102 | architecture: ${{ matrix.platform.target }} 103 | - name: Build wheels 104 | uses: PyO3/maturin-action@v1 105 | with: 106 | target: ${{ matrix.platform.target }} 107 | args: --release --out dist --find-interpreter 108 | sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} 109 | - name: Upload wheels 110 | uses: actions/upload-artifact@v4 111 | with: 112 | name: wheels-windows-${{ matrix.platform.target }} 113 | path: dist 114 | 115 | macos: 116 | runs-on: ${{ matrix.platform.runner }} 117 | strategy: 118 | matrix: 119 | platform: 120 | - runner: macos-13 121 | target: x86_64 122 | - runner: macos-14 123 | target: aarch64 124 | steps: 125 | - uses: actions/checkout@v4 126 | - uses: actions/setup-python@v5 127 | with: 128 | python-version: 3.x 129 | - name: Build wheels 130 | uses: PyO3/maturin-action@v1 131 | with: 132 | target: ${{ matrix.platform.target }} 133 | args: --release --out dist --find-interpreter 134 | sccache: ${{ !startsWith(github.ref, 'refs/tags/') }} 135 | - name: Upload wheels 136 | uses: actions/upload-artifact@v4 137 | with: 138 | name: wheels-macos-${{ matrix.platform.target }} 139 | path: dist 140 | 141 | sdist: 142 | runs-on: ubuntu-latest 143 | steps: 144 | - uses: actions/checkout@v4 145 | - name: Build sdist 146 | uses: PyO3/maturin-action@v1 147 | with: 148 | command: sdist 149 | args: --out dist 150 | - name: Upload sdist 151 | uses: actions/upload-artifact@v4 152 | with: 153 | name: wheels-sdist 154 | path: dist 155 | 156 | release: 157 | name: Release 158 | runs-on: ubuntu-latest 159 | if: ${{ startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' }} 160 | needs: [linux, musllinux, windows, macos, sdist] 161 | permissions: 162 | # Use to sign the release artifacts 163 | id-token: write 164 | # Used to upload release artifacts 165 | contents: write 166 | # Used to generate artifact attestation 167 | attestations: write 168 | steps: 169 | - uses: actions/download-artifact@v4 170 | - name: Generate artifact attestation 171 | uses: actions/attest-build-provenance@v2 172 | with: 173 | subject-path: 'wheels-*/*' 174 | - name: Publish to PyPI 175 | if: ${{ startsWith(github.ref, 'refs/tags/') }} 176 | uses: PyO3/maturin-action@v1 177 | env: 178 | MATURIN_PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} 179 | with: 180 | command: upload 181 | args: --non-interactive --skip-existing wheels-*/* 182 | -------------------------------------------------------------------------------- /.github/workflows/black.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | lint: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: psf/black@stable 11 | -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request_target, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | steps: 12 | - uses: actions/first-interaction@v1 13 | with: 14 | repo-token: ${{ secrets.GITHUB_TOKEN }} 15 | issue-message: "Hey there, thank you submitting this issue! I'll take a look at it soon. Or maybe I won't. This is an automated message. I don't owe you anything. Bitch.\n\nSorry, that was a little rude of me. I'll take a look at this issue ASAP.\n - schlopp" 16 | pr-message: "Hey there, thank you submitting this PR! I'll take a look at it soon. Or maybe I won't. This is an automated message. I don't owe you anything. Bitch.\n\nSorry, that was a little rude of me. I'll take a look at this PR ASAP.\n - schlopp" 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | config/config.toml 3 | config/website.toml 4 | cogs/testing_commands.py 5 | .vscode/ 6 | .venv/ 7 | .mypy_cache/ 8 | .git/ 9 | 10 | # rust 11 | target/ 12 | Cargo.lock 13 | 14 | # folder for local unrelated scripts 15 | local_scripts/ -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, 3 | "MD014": false, 4 | "MD026": false 5 | } 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.6.0 4 | hooks: 5 | - id: check-yaml 6 | - id: check-toml 7 | - repo: https://github.com/psf/black 8 | rev: 25.1.0 9 | hooks: 10 | - id: black 11 | - repo: https://github.com/doublify/pre-commit-rust 12 | rev: v1.0 13 | hooks: 14 | - id: fmt 15 | - id: cargo-check -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rust_utils" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | [lib] 8 | name = "rust_utils" 9 | path = "pyo3_rust_utils/src/lib.rs" 10 | crate-type = ["cdylib"] 11 | 12 | [dependencies] 13 | pyo3 = "0.24.0" 14 | -------------------------------------------------------------------------------- /_run_bot.sh: -------------------------------------------------------------------------------- 1 | vbu run-bot . 2 | -------------------------------------------------------------------------------- /_run_downtime_bot.sh: -------------------------------------------------------------------------------- 1 | ( 2 | cd ./downtime_bot/src && exec vbu run-bot . ../../config/config.toml 3 | ) -------------------------------------------------------------------------------- /cogs/__init__.py: -------------------------------------------------------------------------------- 1 | from . import * 2 | -------------------------------------------------------------------------------- /cogs/animal_commands.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | import aiohttp 4 | from typing import Literal, TypedDict 5 | 6 | import discord 7 | from discord.ext import commands, vbu 8 | 9 | from . import utils 10 | 11 | 12 | AnimalLiterals = Literal[ 13 | "bird", 14 | "cat", 15 | "dog", 16 | "fox", 17 | "kangaroo", 18 | "koala", 19 | "panda", 20 | "raccoon", 21 | "red_panda", 22 | ] 23 | 24 | 25 | class AnimalPayload(TypedDict): 26 | image: str 27 | fact: str 28 | 29 | 30 | class AnimalCommandsCog(vbu.Cog[utils.Bot]): 31 | BASE_URL = "https://some-random-api.com" 32 | animal_payload_cache: dict[AnimalLiterals, list[AnimalPayload]] = {} 33 | 34 | async def fetch_animal( 35 | self, 36 | animal: AnimalLiterals, 37 | ) -> AnimalPayload: 38 | async with aiohttp.ClientSession() as session: 39 | endpoint = f"{self.BASE_URL}/animal/{animal}" 40 | async with session.get(endpoint) as response: 41 | if response.status != 200: 42 | self.logger.warning( 43 | f"Received {response.status} from endpoint {endpoint}" 44 | ) 45 | payload = random.choice(self.animal_payload_cache[animal]) 46 | 47 | payload: AnimalPayload = await response.json() 48 | 49 | try: 50 | self.animal_payload_cache[animal].append(payload) 51 | except KeyError: 52 | self.animal_payload_cache[animal] = [payload] 53 | 54 | return payload 55 | 56 | async def send_animal_embed( 57 | self, 58 | ctx: commands.SlashContext[utils.Bot], 59 | animal: AnimalLiterals, 60 | titles: list[str], 61 | ) -> None: 62 | payload = await self.fetch_animal(animal) 63 | 64 | embed = utils.Embed() 65 | embed.set_image(url=payload["image"]) 66 | embed.title = random.choice(titles) 67 | embed.description = f"**Fun Fact:** {payload["fact"]}" 68 | 69 | await ctx.interaction.response.send_message(embed=embed) 70 | 71 | @commands.command( 72 | "bird", 73 | utils.Command, 74 | category=utils.CommandCategory.FUN, 75 | application_command_meta=commands.ApplicationCommandMeta(), 76 | ) 77 | @commands.is_slash_command() 78 | async def bird_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 79 | """ 80 | Bird pics for bird lovers 81 | """ 82 | 83 | await self.send_animal_embed( 84 | ctx, 85 | "bird", 86 | [ 87 | "birb pic for u!", 88 | "yes yes bird yes", 89 | "nice feathered lad", 90 | "round birb detected", 91 | "caw caw", 92 | "chirp chirp", 93 | "i require this birb", 94 | "This bird knows state secrets and must be watched.", 95 | ], 96 | ) 97 | 98 | @commands.command( 99 | "cat", 100 | utils.Command, 101 | category=utils.CommandCategory.FUN, 102 | application_command_meta=commands.ApplicationCommandMeta(), 103 | ) 104 | @commands.is_slash_command() 105 | async def cat_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 106 | """ 107 | mrowww mrow cute cat pictures 108 | """ 109 | 110 | await self.send_animal_embed( 111 | ctx, 112 | "cat", 113 | [ 114 | "cat pic for u!", 115 | "yes yes cat yes", 116 | "nice cat there", 117 | "chonky loaf", 118 | "mrow", 119 | "meow meow", 120 | "i must acquire this cat", 121 | "This cat runs an underground crime syndicate.", 122 | ], 123 | ) 124 | 125 | @commands.command( 126 | "dog", 127 | utils.Command, 128 | category=utils.CommandCategory.FUN, 129 | application_command_meta=commands.ApplicationCommandMeta(), 130 | ) 131 | @commands.is_slash_command() 132 | async def dog_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 133 | """ 134 | Cute dog pics!! 135 | """ 136 | 137 | await self.send_animal_embed( 138 | ctx, 139 | "dog", 140 | [ 141 | "dog pic for u!", 142 | "yes yes dog yes", 143 | "nice dog there", 144 | "big ol pup", 145 | "rawr", 146 | "woof woof", 147 | "i want this dog", 148 | "This dog is wanted for manslaughter in 38 states.", 149 | ], 150 | ) 151 | 152 | @commands.command( 153 | "fox", 154 | utils.Command, 155 | category=utils.CommandCategory.FUN, 156 | application_command_meta=commands.ApplicationCommandMeta(), 157 | ) 158 | @commands.is_slash_command() 159 | async def fox_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 160 | """ 161 | BIG fox pictures. BIG 162 | """ 163 | 164 | await self.send_animal_embed( 165 | ctx, 166 | "fox", 167 | [ 168 | "fox pic for u!", 169 | "yes yes fox yes", 170 | "spicy woodland dog", 171 | "look at this orange rascal", 172 | "yip yip", 173 | "sneaky squeaky", 174 | "i would follow this fox into the forest, no questions asked", 175 | "This fox has three fake passports and zero regrets.", 176 | ], 177 | ) 178 | 179 | @commands.command( 180 | "kangaroo", 181 | utils.Command, 182 | category=utils.CommandCategory.FUN, 183 | application_command_meta=commands.ApplicationCommandMeta(), 184 | ) 185 | @commands.is_slash_command() 186 | async def kangaroo_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 187 | """ 188 | Pics of big jumpy guys jumping around 189 | """ 190 | 191 | await self.send_animal_embed( 192 | ctx, 193 | "kangaroo", 194 | [ 195 | "kangaroo pic for u!", 196 | "yes yes roo yes", 197 | "pocket puppy", 198 | "look at this buff jumper", 199 | "boing boing", 200 | "thump thump", 201 | "i would let this kangaroo file my taxes", 202 | "This kangaroo is banned from five boxing rings and one Outback Steakhouse.", 203 | ], 204 | ) 205 | 206 | @commands.command( 207 | "koala", 208 | utils.Command, 209 | category=utils.CommandCategory.FUN, 210 | application_command_meta=commands.ApplicationCommandMeta(), 211 | ) 212 | @commands.is_slash_command() 213 | async def koala_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 214 | """ 215 | Cool koala pics 216 | """ 217 | 218 | await self.send_animal_embed( 219 | ctx, 220 | "koala", 221 | [ 222 | "koala pic for u!", 223 | "yes yes koala yes", 224 | "eucalyptus gremlin", 225 | "look at this sleepy menace", 226 | "nom nom leaf", 227 | "climb climb nap", 228 | "i trust this koala with my deepest secrets", 229 | "This koala owes me $20 and pretends not to remember.", 230 | ], 231 | ) 232 | 233 | @commands.command( 234 | "panda", 235 | utils.Command, 236 | category=utils.CommandCategory.FUN, 237 | application_command_meta=commands.ApplicationCommandMeta(), 238 | ) 239 | @commands.is_slash_command() 240 | async def panda_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 241 | """ 242 | cute little (big) pandas 243 | """ 244 | 245 | await self.send_animal_embed( 246 | ctx, 247 | "panda", 248 | [ 249 | "panda pic for u!", 250 | "yes yes panda yes", 251 | "monochrome chaos bear", 252 | "look at this bamboo addict", 253 | "roll roll", 254 | "nom nom crunch", 255 | "i would commit tax fraud for this panda", 256 | "This panda has diplomatic immunity and no one knows why.", 257 | ], 258 | ) 259 | 260 | @commands.command( 261 | "raccoon", 262 | utils.Command, 263 | category=utils.CommandCategory.FUN, 264 | application_command_meta=commands.ApplicationCommandMeta(), 265 | ) 266 | @commands.is_slash_command() 267 | async def raccoon_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 268 | """ 269 | pics of little trash bears 270 | """ 271 | 272 | await self.send_animal_embed( 273 | ctx, 274 | "raccoon", 275 | [ 276 | "raccoon pic for u!", 277 | "yes yes raccoon yes", 278 | "tiny bandit", 279 | "look at this fuzzy dumpster wizard", 280 | "skitter skitter", 281 | "snatch and dash", 282 | "i would rob a convenience store with this raccoon", 283 | "This raccoon has five aliases and a court date in Nevada.", 284 | ], 285 | ) 286 | 287 | @commands.command( 288 | "red-panda", 289 | utils.Command, 290 | category=utils.CommandCategory.FUN, 291 | application_command_meta=commands.ApplicationCommandMeta(), 292 | ) 293 | @commands.is_slash_command() 294 | async def red_panda_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 295 | """ 296 | pics of pandas.. but red!! 297 | """ 298 | 299 | await self.send_animal_embed( 300 | ctx, 301 | "red_panda", 302 | [ 303 | "red panda pic for u!", 304 | "yes yes red panda yes", 305 | "fire fox IRL", 306 | "look at this fluffy tree gremlin", 307 | "wiggle wiggle", 308 | "sniff sniff snoot", 309 | "i would protect this red panda with my life and legal team", 310 | "This red panda is on an international watchlist for being too adorable to trust.", 311 | ], 312 | ) 313 | 314 | 315 | async def setup(bot: utils.Bot): 316 | await bot.add_cog(AnimalCommandsCog(bot)) 317 | -------------------------------------------------------------------------------- /cogs/beg_command.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import random 3 | from typing import Literal, cast 4 | 5 | import asyncpg 6 | import discord 7 | from discord.ext import commands, vbu 8 | 9 | from cogs.utils.bot import Bot 10 | 11 | from . import utils 12 | 13 | 14 | class Activity(enum.Enum): 15 | DONATION = 0.8 16 | REJECTION = 0.1 17 | FILL_IN_THE_BLANK_MINIGAME = 0.1 / 3 18 | REVERSE_MINIGAME = 0.1 / 3 19 | REPEAT_MINIGAME = 0.1 / 3 20 | 21 | @classmethod 22 | def random(cls): 23 | return random.choices( 24 | list(Activity), weights=list(activity.value for activity in Activity) 25 | )[0] 26 | 27 | 28 | MinigameActivity = Literal[ 29 | Activity.FILL_IN_THE_BLANK_MINIGAME, 30 | Activity.REVERSE_MINIGAME, 31 | Activity.REPEAT_MINIGAME, 32 | ] 33 | 34 | 35 | class BegCommandCog(vbu.Cog[utils.Bot]): 36 | RESPONSES: list[str] = [ 37 | "ew poor", 38 | "don't touch my pp", 39 | "my wife has a bigger pp than you", 40 | "broke ass bitch", 41 | "cringe poor", 42 | "beg harder", 43 | "poor people make me scared", 44 | "dont touch me poor person", 45 | "get a job", 46 | "im offended", 47 | "no u", 48 | "i dont speak poor", 49 | "you should take a shower", 50 | "i love my wife... i love my wife... i love my wife..", 51 | "drink some water", 52 | "begone beggar", 53 | "No.", 54 | "no wtf?", 55 | 'try being a little "cooler" next time', 56 | "womp womp", 57 | ( 58 | "i just came back from one of Diddy's parties it was deck n balls everywhere you" 59 | " shoulda been there" 60 | ), 61 | ] 62 | DONATORS: dict[str, str | list[str] | None] = { 63 | "obama": None, 64 | "roblox noob": None, 65 | "dick roberts": None, 66 | "johnny from johnny johnny yes papa": None, 67 | "shrek": None, 68 | 'kae "little twink boy"': None, 69 | "bob": None, 70 | "walter": None, 71 | "napoleon bonaparte": None, 72 | "bob ross": None, 73 | "coco": None, 74 | "thanos": ["begone before i snap you", "i'll snap ur pp out of existence"], 75 | "don vito": None, 76 | "bill cosby": [ 77 | "dude im a registered sex offender what do you want from me", 78 | "im too busy touching people", 79 | ], 80 | "your step-sis": "i cant give any inches right now, im stuck", 81 | "pp god": "begone mortal", 82 | "random guy": None, 83 | "genie": "rub me harder next time 😩", 84 | "the guy u accidentally made eye contact with at the urinal": "eyes on your own pp man", 85 | "your mom": ["you want WHAT?", "im saving my pp for your dad"], 86 | "ur daughter": None, 87 | "Big Man Tyrone": "Every 60 seconds in Africa a minute passes.", 88 | "speed": None, 89 | "catdotjs": "Meow", 90 | "Meek Mill": "Get UHHPPP 😩", 91 | "Diddy": None, 92 | "schlöpp": None, 93 | } 94 | 95 | def __init__(self, bot: Bot, logger_name: str | None = None): 96 | super().__init__(bot, logger_name) 97 | 98 | async def start_minigame( 99 | self, 100 | minigame_activity: MinigameActivity, 101 | *, 102 | bot: utils.Bot, 103 | connection: asyncpg.Connection, 104 | pp: utils.Pp, 105 | interaction: discord.Interaction, 106 | ): 107 | minigame_types: dict[Activity, type[utils.Minigame]] = { 108 | Activity.FILL_IN_THE_BLANK_MINIGAME: utils.FillInTheBlankMinigame, 109 | Activity.REPEAT_MINIGAME: utils.RepeatMinigame, 110 | Activity.REVERSE_MINIGAME: utils.ReverseMinigame, 111 | } 112 | 113 | minigame_type = minigame_types[minigame_activity] 114 | minigame = minigame_type( 115 | bot=bot, 116 | connection=connection, 117 | pp=pp, 118 | context=minigame_type.generate_random_dialogue("beg"), 119 | ) 120 | 121 | await minigame.start(interaction) 122 | 123 | @commands.command( 124 | "beg", 125 | utils.Command, 126 | category=utils.CommandCategory.GROWING_PP, 127 | application_command_meta=commands.ApplicationCommandMeta(), 128 | ) 129 | @utils.Command.tiered_cooldown( 130 | default=30, 131 | voter=10, 132 | ) 133 | @commands.is_slash_command() 134 | async def beg_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 135 | """ 136 | Beg for some inches 137 | """ 138 | async with ( 139 | utils.DatabaseWrapper() as db, 140 | db.conn.transaction(), 141 | utils.DatabaseTimeoutManager.notify( 142 | ctx.author.id, "You're still busy begging!" 143 | ), 144 | ): 145 | pp = await utils.Pp.fetch_from_user(db.conn, ctx.author.id, edit=True) 146 | 147 | activity = Activity.random() 148 | 149 | if activity.name.endswith("_MINIGAME"): 150 | activity = cast(MinigameActivity, activity) 151 | await self.start_minigame( 152 | activity, 153 | bot=self.bot, 154 | connection=db.conn, 155 | pp=pp, 156 | interaction=ctx.interaction, 157 | ) 158 | return 159 | 160 | donator = random.choice(list(self.DONATORS)) 161 | embed = utils.Embed() 162 | 163 | if activity == Activity.DONATION: 164 | pp.grow_with_multipliers( 165 | random.randint(1, 15), voted=await pp.has_voted() 166 | ) 167 | embed.colour = utils.GREEN 168 | embed.description = f"**{donator}** donated {pp.format_growth()} inches to {ctx.author.mention}" 169 | 170 | elif activity == Activity.REJECTION: 171 | embed.colour = utils.BLUE 172 | response = self.DONATORS[donator] 173 | 174 | if isinstance(response, list): 175 | quote = random.choice(response) 176 | elif isinstance(response, str): 177 | quote = response 178 | else: 179 | quote = random.choice(self.RESPONSES) 180 | 181 | embed.description = f"**{donator}:** {quote}" 182 | 183 | else: 184 | raise ValueError( 185 | f"Can't complete begging command: No handling for activity {activity!r}" 186 | " available" 187 | ) 188 | 189 | await pp.update(db.conn) 190 | embed.add_tip() 191 | 192 | await ctx.interaction.response.send_message(embed=embed) 193 | 194 | 195 | async def setup(bot: utils.Bot): 196 | await bot.add_cog(BegCommandCog(bot)) 197 | -------------------------------------------------------------------------------- /cogs/compare_command.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | import uuid 4 | from typing import Literal, overload 5 | 6 | import discord 7 | from discord.ext import commands, vbu 8 | 9 | from . import utils 10 | 11 | 12 | class CompareCommandCog(vbu.Cog[utils.Bot]): 13 | @overload 14 | def compare_amounts( 15 | self, 16 | author: discord.Member | discord.User, 17 | opponent: discord.Member | discord.User, 18 | author_amount: int, 19 | opponent_amount: int, 20 | *, 21 | with_ratio: Literal[False] = False, 22 | ) -> tuple[ 23 | discord.Member | discord.User, discord.Member | discord.User, int, str 24 | ]: ... 25 | 26 | @overload 27 | def compare_amounts( 28 | self, 29 | author: discord.Member | discord.User, 30 | opponent: discord.Member | discord.User, 31 | author_amount: int, 32 | opponent_amount: int, 33 | *, 34 | with_ratio: Literal[True], 35 | ) -> tuple[ 36 | discord.Member | discord.User, discord.Member | discord.User, int, str, float 37 | ]: ... 38 | 39 | def compare_amounts( 40 | self, 41 | author: discord.Member | discord.User, 42 | opponent: discord.Member | discord.User, 43 | author_amount: int, 44 | opponent_amount: int, 45 | *, 46 | with_ratio: bool = False, 47 | ) -> ( 48 | tuple[discord.Member | discord.User, discord.Member | discord.User, int, str] 49 | | tuple[ 50 | discord.Member | discord.User, 51 | discord.Member | discord.User, 52 | int, 53 | str, 54 | float, 55 | ] 56 | ): 57 | """ 58 | Returns tuple[winner: discord.Member | discord.User, loser: discord.Member | discord.User, difference: int, percentage_difference: str] 59 | """ 60 | 61 | if author_amount > opponent_amount: 62 | winner = author 63 | loser = opponent 64 | else: 65 | winner = opponent 66 | loser = author 67 | 68 | difference = abs(author_amount - opponent_amount) 69 | 70 | try: 71 | ratio = max(author_amount, opponent_amount) / min( 72 | author_amount, opponent_amount 73 | ) 74 | 75 | percentage_difference_raw = ratio * 100 - 100 76 | percentage_difference = f"{percentage_difference_raw:{'.1f' if percentage_difference_raw < 100 else '.0f'}}%" 77 | except ZeroDivisionError: 78 | ratio = float("inf") 79 | percentage_difference = "literally infinity%" 80 | 81 | if with_ratio: 82 | return ( 83 | winner, 84 | loser, 85 | difference, 86 | percentage_difference, 87 | ratio, 88 | ) 89 | 90 | return winner, loser, difference, percentage_difference 91 | 92 | @commands.command( 93 | "compare", 94 | utils.Command, 95 | category=utils.CommandCategory.STATS, 96 | application_command_meta=commands.ApplicationCommandMeta( 97 | options=[ 98 | discord.ApplicationCommandOption( 99 | name="opponent", 100 | type=discord.ApplicationCommandOptionType.user, 101 | description="Whoever's pp you want to compare with", 102 | ) 103 | ] 104 | ), 105 | ) 106 | @commands.is_slash_command() 107 | async def compare_command( 108 | self, ctx: commands.SlashContext[utils.Bot], opponent: discord.Member 109 | ) -> None: 110 | """ 111 | Compare your pp with someone else in the ultimate pp showdown 112 | """ 113 | 114 | opponent = opponent 115 | 116 | if opponent == ctx.author: 117 | raise commands.BadArgument("You can't compare against yourself silly!") 118 | 119 | async with utils.DatabaseWrapper() as db: 120 | pp = await utils.Pp.fetch_from_user(db.conn, ctx.author.id) 121 | 122 | try: 123 | opponent_pp = await utils.Pp.fetch_from_user(db.conn, opponent.id) 124 | except utils.PpMissing: 125 | raise utils.PpMissing( 126 | f"{opponent.mention} ain't got a pp :(", user=opponent 127 | ) 128 | 129 | inventory = await utils.InventoryItem.fetch( 130 | db.conn, 131 | {"user_id": ctx.author.id}, 132 | fetch_multiple_rows=True, 133 | ) 134 | item_count = 0 135 | for item in inventory: 136 | item_count += item.amount.value 137 | 138 | opponent_inventory = await utils.InventoryItem.fetch( 139 | db.conn, 140 | {"user_id": opponent.id}, 141 | fetch_multiple_rows=True, 142 | ) 143 | opponent_item_count = 0 144 | for item in opponent_inventory: 145 | opponent_item_count += item.amount.value 146 | 147 | display_name = utils.clean(ctx.author.display_name) 148 | opponent_display_name = utils.clean(opponent.display_name) 149 | 150 | embed = utils.Embed() 151 | embed.title = f"{display_name} VS. {opponent_display_name}" 152 | 153 | segments: list[tuple[str, str, str]] = [] 154 | 155 | # comparing size 156 | winner, loser, difference, percentage_difference = self.compare_amounts( 157 | ctx.author, opponent, pp.size.value, opponent_pp.size.value 158 | ) 159 | 160 | match utils.find_nearest_number(utils.REAL_LIFE_COMPARISONS, difference): 161 | case nearest_number, -1: 162 | comparison_text = f"{utils.format_int(difference - nearest_number)} inches bigger than" 163 | case nearest_number, 0: 164 | comparison_text = f"the same size as" 165 | case nearest_number, _: 166 | comparison_text = f"{utils.format_int(nearest_number - difference)} inches smaller than" 167 | 168 | segments.append( 169 | ( 170 | "size", 171 | f"{winner.mention}'s pp is {utils.format_inches(difference)} bigger than {loser.mention}'s! `{percentage_difference} bigger`", 172 | f"That difference is {comparison_text} {utils.REAL_LIFE_COMPARISONS[nearest_number]}", 173 | ) 174 | ) 175 | 176 | # Comparing multiplier 177 | winner, loser, difference, percentage_difference, ratio = self.compare_amounts( 178 | ctx.author, 179 | opponent, 180 | pp.multiplier.value, 181 | opponent_pp.multiplier.value, 182 | with_ratio=True, 183 | ) 184 | loser_display_name = ( 185 | display_name if loser == ctx.author else opponent_display_name 186 | ) 187 | 188 | segments.append( 189 | ( 190 | "multiplier", 191 | f"{winner.mention}'s multiplier is **{ratio:{'.1f' if ratio < 100 else '.0f'}}x**" 192 | f" bigger than {loser.mention}'s! `{percentage_difference} bigger`", 193 | ( 194 | f"(NOT INCLUDING BOOSTS) {loser_display_name} will have to take" 195 | f" {utils.format_int(difference)} pills to make up for that difference" 196 | ), 197 | ) 198 | ) 199 | 200 | # Comparing item count 201 | winner, loser, difference, percentage_difference = self.compare_amounts( 202 | ctx.author, opponent, item_count, opponent_item_count 203 | ) 204 | 205 | if winner == ctx.author: 206 | winner_display_name = display_name 207 | winner_item_count = item_count 208 | else: 209 | winner_display_name = opponent_display_name 210 | winner_item_count = opponent_item_count 211 | 212 | comment = utils.ITEM_COUNT_COMMENTS[ 213 | utils.find_nearest_number(utils.ITEM_COUNT_COMMENTS, winner_item_count)[0] 214 | ][1] 215 | 216 | segments.append( 217 | ( 218 | "items", 219 | f"{winner.mention} has {utils.format_int(difference)} more items than {loser.mention}! `{percentage_difference} more`", 220 | ( 221 | f"{winner_display_name} has a total of" 222 | f" {utils.format_int(winner_item_count)} items. {comment}" 223 | ), 224 | ) 225 | ) 226 | 227 | for title, comparison, subtext in segments: 228 | embed.add_field( 229 | name=title.upper(), value=f"{comparison}\n-# {subtext}", inline=False 230 | ) 231 | 232 | await ctx.interaction.response.send_message(embed=embed) 233 | 234 | 235 | async def setup(bot: utils.Bot): 236 | await bot.add_cog(CompareCommandCog(bot)) 237 | -------------------------------------------------------------------------------- /cogs/daily_command.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import datetime, UTC 3 | 4 | import asyncpg 5 | from discord.ext import commands, vbu 6 | 7 | from . import utils 8 | 9 | 10 | class StreakReward(utils.Object): 11 | __slots__ = ("multiplier", "note", "items") 12 | 13 | def __init__( 14 | self, 15 | *, 16 | multiplier: int | None = None, 17 | note: str | None = None, 18 | items: dict[str, int], 19 | ) -> None: 20 | self.multiplier = multiplier 21 | self.note = note 22 | self.items = items 23 | 24 | 25 | class DailyCommandCog(vbu.Cog[utils.Bot]): 26 | MIN_DAILY_GROWTH = 240 27 | MAX_DAILY_GROWTH = 260 28 | STREAK_REWARDS: dict[int, StreakReward] = { 29 | 3: StreakReward( 30 | items={ 31 | "FISHING_ROD": 5, 32 | } 33 | ), 34 | 10: StreakReward( 35 | multiplier=5, 36 | items={ 37 | "FISHING_ROD": 20, 38 | "RIFLE": 10, 39 | }, 40 | ), 41 | 25: StreakReward( 42 | multiplier=20, 43 | items={ 44 | "FISHING_ROD": 100, 45 | "RIFLE": 50, 46 | "SHOVEL": 25, 47 | }, 48 | ), 49 | 50: StreakReward( 50 | multiplier=50, 51 | items={ 52 | "FISHING_ROD": 500, 53 | "RIFLE": 250, 54 | "SHOVEL": 100, 55 | }, 56 | ), 57 | 69: StreakReward( 58 | items={ 59 | "GOLDEN_COIN": 420, 60 | "COOKIE": 6969, 61 | }, 62 | ), 63 | } 64 | LOOP_STREAK_REWARD = 50 65 | 66 | async def give_reward( 67 | self, pp: utils.Pp, streak: int, *, connection: asyncpg.Connection 68 | ) -> str: 69 | reward_message_chunks: list[str] = [] 70 | 71 | pp.grow_with_multipliers( 72 | random.randint( 73 | DailyCommandCog.MIN_DAILY_GROWTH, DailyCommandCog.MAX_DAILY_GROWTH 74 | ), 75 | voted=await pp.has_voted(), 76 | ) 77 | reward_message_chunks.append(pp.format_growth()) 78 | await pp.update(connection) 79 | 80 | if streak in self.STREAK_REWARDS: 81 | streak_reward = self.STREAK_REWARDS[streak] 82 | for item_id, amount in streak_reward.items.items(): 83 | reward_item = utils.InventoryItem(pp.user_id, item_id, amount) 84 | await reward_item.update(connection, additional=True) 85 | reward_message_chunks.append( 86 | reward_item.format_item(article=utils.Article.INDEFINITE) 87 | ) 88 | 89 | return utils.format_iterable(reward_message_chunks, inline=True) 90 | 91 | def get_next_streak_reward(self, streak: int) -> tuple[StreakReward, int]: 92 | """Returns `(next_streak_reward: StreakReward, required_streak: int)`""" 93 | max_streak = list(self.STREAK_REWARDS)[-1] 94 | 95 | if streak >= max_streak: 96 | required_streak = streak // self.LOOP_STREAK_REWARD + 1 97 | next_streak_award = self.STREAK_REWARDS[self.LOOP_STREAK_REWARD] 98 | return next_streak_award, required_streak 99 | 100 | for required_streak, streak_award in self.STREAK_REWARDS.items(): 101 | if streak < required_streak: 102 | return streak_award, required_streak 103 | 104 | raise ValueError 105 | 106 | @commands.command( 107 | "daily", 108 | utils.Command, 109 | category=utils.CommandCategory.GROWING_PP, 110 | application_command_meta=commands.ApplicationCommandMeta(), 111 | ) 112 | @utils.Command.tiered_cooldown( 113 | default=60 * 60 * 24, 114 | voter=commands.Cooldown(2, 60 * 60 * 24), 115 | ) 116 | @commands.is_slash_command() 117 | async def daily_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 118 | """ 119 | Collect your daily reward! 120 | """ 121 | async with ( 122 | utils.DatabaseWrapper() as db, 123 | db.conn.transaction(), 124 | utils.DatabaseTimeoutManager.notify( 125 | ctx.author.id, f"You're still busy collecting your daily reward!" 126 | ), 127 | ): 128 | pp = await utils.Pp.fetch_from_user(db.conn, ctx.author.id, edit=True) 129 | streaks = await utils.Streaks.fetch_from_user( 130 | db.conn, ctx.author.id, edit=True 131 | ) 132 | 133 | # store daily_expired as the property might change values throughout the 134 | # span of the command 135 | daily_expired = streaks.daily_expired 136 | 137 | streaks.last_daily.value = datetime.now(UTC).replace(tzinfo=None) 138 | 139 | if daily_expired: 140 | streaks.daily.value = 1 141 | else: 142 | streaks.daily.value += 1 143 | 144 | await streaks.update(db.conn) 145 | 146 | reward_message = await self.give_reward( 147 | pp, streaks.daily.value, connection=db.conn 148 | ) 149 | 150 | embed = utils.Embed() 151 | 152 | if daily_expired: 153 | embed.title = f"Streak lost :( Back to day 1" 154 | else: 155 | embed.title = f"Day {streaks.daily.value} streak 🔥" 156 | 157 | embed.colour = utils.PINK 158 | embed.description = f"{ctx.author.mention}, you received {reward_message}!" 159 | 160 | if streaks.daily.value in self.STREAK_REWARDS: 161 | embed.description = ( 162 | f"[**STREAK BONUS!**]({utils.MEME_URL}) {embed.description}" 163 | ) 164 | 165 | _, next_streak = self.get_next_streak_reward(streaks.daily.value) 166 | days_left = next_streak - streaks.daily.value 167 | 168 | embed.description += ( 169 | f"\n\nYour next streak bonus is in **{days_left}**" 170 | f" day{'' if days_left == 1 else 's'}" 171 | " " 172 | ) 173 | 174 | embed.add_tip() 175 | 176 | await ctx.interaction.response.send_message(embed=embed) 177 | 178 | 179 | async def setup(bot: utils.Bot): 180 | await bot.add_cog(DailyCommandCog(bot)) 181 | -------------------------------------------------------------------------------- /cogs/dig_command.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | import enum 3 | import itertools 4 | import math 5 | import random 6 | from typing import Literal, cast 7 | 8 | import asyncpg 9 | import discord 10 | from discord.ext import commands, vbu 11 | 12 | from cogs.utils.bot import Bot 13 | 14 | from . import utils 15 | 16 | 17 | class Activity(enum.Enum): 18 | SUCCESS_GROW = 0.55 19 | SUCCESS_MULTIPLIER = 0.05 20 | SHOVEL_BREAK = 0.3 21 | CLICK_THAT_BUTTON_MINIGAME = 0.1 22 | 23 | @classmethod 24 | def random(cls): 25 | return random.choices( 26 | list(Activity), weights=list(activity.value for activity in Activity) 27 | )[0] 28 | 29 | 30 | @dataclass 31 | class DepthReward: 32 | name: str 33 | name_plural: str 34 | name_stylised: str 35 | growth_range: tuple[int, int] 36 | max_item_value: int 37 | 38 | 39 | class DepthRewardType(enum.Enum): 40 | COOL_BOX = DepthReward( 41 | "cool box of stuff", "cool boxes of stuff", "cool box of stuff :)", (1, 10), 5 42 | ) 43 | TREASURE_CHEST = DepthReward( 44 | "treasure chest", "treasure chests", "treasure chest!", (10, 30), 20 45 | ) 46 | AWESOME_TREASURE_CHEST = DepthReward( 47 | "SUPER MEGA AWESOME TREASURE CHEST", 48 | "SUPER MEGA AWESOME TREASURE CHESTS", 49 | "SUPER MEGA AWESOME TREASURE CHEST!!", 50 | (30, 60), 51 | 45, 52 | ) 53 | GIFT_FROM_THE_PP_GODS = DepthReward( 54 | "<:ppEvil:902894209160347708> gift from the pp gods <:ppEvil:902894209160347708>", 55 | "<:ppEvil:902894209160347708> gifts from the pp gods <:ppEvil:902894209160347708>", 56 | "gifts from the pp gods", 57 | (60, 90), 58 | 75, 59 | ) 60 | 61 | 62 | MinigameActivity = Literal[Activity.CLICK_THAT_BUTTON_MINIGAME] 63 | 64 | 65 | class DigCommandCog(vbu.Cog[utils.Bot]): 66 | SHOVEL_BREAK_RESPONSES: list[str] = [ 67 | "{} broke their shovel while trying to shovel through bedrock", 68 | "{}'s shovel literally snapped in half while trying to dig", 69 | "{}'s shovel shattered into a thousand pieces", 70 | ] 71 | UNIQUE_DEPTH_REWARDS: dict[int, DepthRewardType] = { 72 | 69: DepthRewardType.GIFT_FROM_THE_PP_GODS, 73 | 420: DepthRewardType.GIFT_FROM_THE_PP_GODS, 74 | 666: DepthRewardType.GIFT_FROM_THE_PP_GODS, 75 | 6969: DepthRewardType.GIFT_FROM_THE_PP_GODS, 76 | } 77 | 78 | def __init__(self, bot: Bot, logger_name: str | None = None): 79 | super().__init__(bot, logger_name) 80 | 81 | def _get_depth_reward(self, depth: int) -> DepthRewardType | None: 82 | try: 83 | return self.UNIQUE_DEPTH_REWARDS[depth] 84 | except: 85 | pass 86 | 87 | if depth == 10: 88 | return DepthRewardType.COOL_BOX 89 | elif depth % 1000 == 0: 90 | return DepthRewardType.GIFT_FROM_THE_PP_GODS 91 | elif depth % 100 == 0: 92 | return DepthRewardType.AWESOME_TREASURE_CHEST 93 | elif depth % 25 == 0: 94 | return DepthRewardType.TREASURE_CHEST 95 | 96 | def _get_depth_rewards( 97 | self, depth_range: range 98 | ) -> dict[int, DepthRewardType] | None: 99 | rewards: dict[int, DepthRewardType] = {} 100 | 101 | for depth in depth_range: 102 | try: 103 | rewards[depth] = self.UNIQUE_DEPTH_REWARDS[depth] 104 | except: 105 | pass 106 | 107 | if depth == 10: 108 | rewards[depth] = DepthRewardType.COOL_BOX 109 | elif depth % 1000 == 0: 110 | rewards[depth] = DepthRewardType.GIFT_FROM_THE_PP_GODS 111 | elif depth % 100 == 0: 112 | rewards[depth] = DepthRewardType.AWESOME_TREASURE_CHEST 113 | elif depth % 25 == 0: 114 | rewards[depth] = DepthRewardType.TREASURE_CHEST 115 | 116 | if not rewards: 117 | return None 118 | 119 | return dict(sorted(rewards.items())) 120 | 121 | def _generate_reward_visual(self, pp: utils.Pp) -> str: 122 | unformatted_segments: list[tuple[str, str]] = [] 123 | 124 | if pp.digging_depth.value % 5 == 0: 125 | closest_standard_depth = pp.digging_depth.value + 5 126 | else: 127 | closest_standard_depth = math.ceil(pp.digging_depth.value / 5) * 5 128 | closest_depth = closest_standard_depth 129 | future_rewards: dict[int, DepthRewardType] = {} 130 | 131 | rewards_before_closest_depth = self._get_depth_rewards( 132 | range(pp.digging_depth.value + 1, closest_standard_depth) 133 | ) 134 | if rewards_before_closest_depth: 135 | closest_depth = next(iter(rewards_before_closest_depth)) 136 | future_rewards.update(rewards_before_closest_depth) 137 | 138 | for depth in itertools.count(closest_depth): 139 | if len(unformatted_segments) >= 4: 140 | break 141 | 142 | depth_segment = utils.format_int( 143 | depth, format_type=utils.IntFormatType.FULL 144 | ) 145 | depth_segment = f"{depth_segment} ft" 146 | if depth in future_rewards: 147 | segment = f"{{}} [{future_rewards[depth].name}]" 148 | unformatted_segments.append((depth_segment, segment)) 149 | continue 150 | 151 | reward = self._get_depth_reward(depth) 152 | if reward: 153 | segment = f"{{}} [{reward.value.name_stylised}]" 154 | unformatted_segments.append((depth_segment, segment)) 155 | continue 156 | 157 | if depth % 5 == 0: 158 | segment = f"{{}} ..." 159 | unformatted_segments.append((depth_segment, segment)) 160 | continue 161 | 162 | max_depth_segment_len = max( 163 | len(depth_segment) for depth_segment, _ in unformatted_segments 164 | ) 165 | segments = [ 166 | segment.format(f"{depth_segment:<{max_depth_segment_len}}") 167 | for depth_segment, segment in unformatted_segments 168 | ] 169 | 170 | formatted_depth = utils.format_int( 171 | pp.digging_depth.value, format_type=utils.IntFormatType.FULL 172 | ) 173 | segment = f"{f'{formatted_depth} ft':<{max_depth_segment_len}} (you are here!)" 174 | return f"```css\n{segment}\n\n" + "\n".join(segments) + "```" 175 | 176 | async def start_minigame( 177 | self, 178 | minigame_activity: MinigameActivity, 179 | *, 180 | bot: utils.Bot, 181 | connection: asyncpg.Connection, 182 | pp: utils.Pp, 183 | interaction: discord.Interaction, 184 | ): 185 | minigame_types: dict[Activity, type[utils.Minigame]] = { 186 | Activity.CLICK_THAT_BUTTON_MINIGAME: utils.ClickThatButtonMinigame, 187 | } 188 | 189 | minigame_type = minigame_types[minigame_activity] 190 | minigame = minigame_type( 191 | bot=bot, 192 | connection=connection, 193 | pp=pp, 194 | context=minigame_type.generate_random_dialogue("dig"), 195 | ) 196 | 197 | await minigame.start(interaction) 198 | 199 | @commands.command( 200 | "dig", 201 | utils.Command, 202 | category=utils.CommandCategory.GROWING_PP, 203 | application_command_meta=commands.ApplicationCommandMeta(), 204 | ) 205 | @utils.Command.tiered_cooldown( 206 | default=60, 207 | voter=30, 208 | ) 209 | @commands.is_slash_command() 210 | async def dig_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 211 | """ 212 | Dig down deep for some seggsy rewards 213 | """ 214 | 215 | async with ( 216 | utils.DatabaseWrapper() as db, 217 | db.conn.transaction(), 218 | utils.DatabaseTimeoutManager.notify( 219 | ctx.author.id, "You're still busy digging!" 220 | ), 221 | ): 222 | pp = await utils.Pp.fetch_from_user(db.conn, ctx.author.id, edit=True) 223 | tool = utils.ItemManager.get_command_tool("dig") 224 | 225 | if not await utils.InventoryItem.user_has_item( 226 | db.conn, ctx.author.id, tool.id 227 | ): 228 | raise utils.MissingTool(tool=tool) 229 | 230 | activity = Activity.random() 231 | 232 | if activity.name.endswith("_MINIGAME"): 233 | activity = cast(MinigameActivity, activity) 234 | await self.start_minigame( 235 | activity, 236 | bot=self.bot, 237 | connection=db.conn, 238 | pp=pp, 239 | interaction=ctx.interaction, 240 | ) 241 | return 242 | 243 | embeds = [] 244 | 245 | if activity in { 246 | Activity.SUCCESS_GROW, 247 | Activity.SUCCESS_MULTIPLIER, 248 | Activity.SHOVEL_BREAK, 249 | }: 250 | embed = utils.Embed() 251 | embeds.append(embed) 252 | 253 | depth = random.randint(1, 3) 254 | pp.digging_depth.value += depth 255 | 256 | embed.description = ( 257 | f"**{ctx.author.mention}** dug another {utils.format_int(depth)} feet down" 258 | f" and found {{}}" 259 | ) 260 | 261 | if activity in {Activity.SUCCESS_GROW, Activity.SHOVEL_BREAK}: 262 | growth = random.randint(30, 60) 263 | pp.grow_with_multipliers(growth, voted=await pp.has_voted()) 264 | 265 | embed.colour = utils.GREEN 266 | embed.description = embed.description.format( 267 | f"{pp.format_growth()}!" 268 | ) 269 | 270 | elif activity == Activity.SUCCESS_MULTIPLIER: 271 | increase = random.choices( 272 | [5, 4, 3, 2, 1], 273 | [1, 2, 4, 8, 16], 274 | k=1, 275 | )[0] 276 | pp.multiplier.value += increase 277 | 278 | embed.colour = utils.PINK 279 | embed.description = embed.description.format( 280 | f" a [**{utils.format_int(increase)}x multiplier!!!**]({utils.MEME_URL})" 281 | ) 282 | 283 | new_rewards = self._get_depth_rewards( 284 | range(pp.digging_depth.start_value + 1, pp.digging_depth.value + 1) 285 | ) 286 | 287 | embed.description += ( 288 | "\n\n<:shovel:1258091579843809321> You've dug" 289 | f" **{utils.format_int(pp.digging_depth.value)}** feet deep" 290 | ) 291 | 292 | if not new_rewards: 293 | embed.description += ". " 294 | else: 295 | new_rewards_compiled: dict[DepthRewardType, int] = {} 296 | for reward in new_rewards.values(): 297 | try: 298 | new_rewards_compiled[reward] += 1 299 | except KeyError: 300 | new_rewards_compiled[reward] = 1 301 | 302 | segments = [ 303 | utils.format_amount( 304 | reward.value.name, 305 | reward.value.name_plural, 306 | amount, 307 | markdown=utils.MarkdownFormat.BOLD_BLUE, 308 | full_markdown=True, 309 | ) 310 | for reward, amount in new_rewards_compiled.items() 311 | ] 312 | embed.description += ( 313 | f" and found {utils.format_iterable(segments, inline=True)}." 314 | ) 315 | 316 | segments: list[str] = [] 317 | 318 | for reward, amount in new_rewards_compiled.items(): 319 | if amount == 1: 320 | message, _, _ = await utils.give_random_reward( 321 | db.conn, 322 | pp, 323 | growth_range=reward.value.growth_range, 324 | max_item_reward_price=reward.value.max_item_value, 325 | ) 326 | segments.append( 327 | f"The **[{reward.value.name}]({utils.MEME_URL})**" 328 | f" contained {message}." 329 | ) 330 | continue 331 | 332 | for ordinal in range(1, amount + 1): 333 | message, _, _ = await utils.give_random_reward( 334 | db.conn, 335 | pp, 336 | growth_range=reward.value.growth_range, 337 | max_item_reward_price=reward.value.max_item_value, 338 | ) 339 | segments.append( 340 | f"The {utils.format_ordinal(ordinal)}" 341 | f" **[{reward.value.name}]({utils.MEME_URL})**" 342 | f" contained {message}." 343 | ) 344 | 345 | embed.description += f"\n{utils.format_iterable(segments)}\n\n" 346 | 347 | embed.description += ( 348 | f"Here are your next rewards:\n{self._generate_reward_visual(pp)}" 349 | ) 350 | 351 | embed.add_tip() 352 | 353 | if activity == Activity.SHOVEL_BREAK: 354 | 355 | inv_tool = await utils.InventoryItem.fetch( 356 | db.conn, 357 | {"user_id": ctx.author.id, "id": tool.id}, 358 | lock=utils.RowLevelLockMode.FOR_UPDATE, 359 | ) 360 | inv_tool.amount.value -= 1 361 | await inv_tool.update(db.conn) 362 | 363 | embed = utils.Embed() 364 | embed.colour = utils.RED 365 | embed.set_author(name=f"but... your {tool.name} broke") 366 | embed.description = random.choice( 367 | self.SHOVEL_BREAK_RESPONSES 368 | ).format(ctx.author.mention) + ( 369 | f"\n\n(You now have {inv_tool.format_item(article=utils.Article.NUMERAL)}" 370 | " left)" 371 | ) 372 | 373 | if inv_tool.amount.value == 0: 374 | embed.description += ( 375 | f" 😢 better {utils.format_slash_command('buy')} a new one" 376 | ) 377 | 378 | embeds.append(embed) 379 | 380 | else: 381 | raise ValueError( 382 | f"Can't complete digging command: No handling for activity {activity!r}" 383 | " available" 384 | ) 385 | 386 | await pp.update(db.conn) 387 | 388 | await ctx.interaction.response.send_message(embeds=embeds) 389 | 390 | 391 | async def setup(bot: utils.Bot): 392 | await bot.add_cog(DigCommandCog(bot)) 393 | -------------------------------------------------------------------------------- /cogs/donating_command.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | import uuid 4 | 5 | import discord 6 | from discord.ext import commands, vbu 7 | 8 | from . import utils 9 | 10 | 11 | class DonateCommandCog(vbu.Cog[utils.Bot]): 12 | DONATION_LIMIT = 1000 13 | 14 | @commands.command( 15 | "donate", 16 | utils.Command, 17 | category=utils.CommandCategory.OTHER, 18 | application_command_meta=commands.ApplicationCommandMeta( 19 | options=[ 20 | discord.ApplicationCommandOption( 21 | name="recipiant", 22 | type=discord.ApplicationCommandOptionType.user, 23 | description="The recipiant of your donation", 24 | ), 25 | discord.ApplicationCommandOption( 26 | name="amount", 27 | type=discord.ApplicationCommandOptionType.integer, 28 | description="The amount of inches u wanna donate", 29 | ), 30 | ] 31 | ), 32 | ) 33 | @commands.is_slash_command() 34 | async def donate_command( 35 | self, 36 | ctx: commands.SlashContext[utils.Bot], 37 | recipiant: discord.Member | discord.User, 38 | amount: int, 39 | ) -> None: 40 | """ 41 | Donate some inches to another pp! 42 | """ 43 | async with ( 44 | utils.DatabaseWrapper() as db, 45 | db.conn.transaction(), 46 | utils.DatabaseTimeoutManager.notify( 47 | ctx.author.id, "You're still busy donating!" 48 | ), 49 | ): 50 | pp = await utils.Pp.fetch_from_user(db.conn, ctx.author.id, edit=True) 51 | 52 | if recipiant == ctx.author: 53 | raise commands.BadArgument( 54 | f"{ctx.author.mention} dawg u can't donate to yourself :/" 55 | ) 56 | 57 | try: 58 | recipiant_pp = await utils.Pp.fetch_from_user(db.conn, recipiant.id) 59 | except utils.PpMissing: 60 | raise utils.PpMissing( 61 | f"{recipiant.mention} doesn't have a pp 🫵😂😂" 62 | f" tell em to go make one with {utils.format_slash_command('new')}" 63 | " and get started on the pp grind", 64 | user=recipiant, 65 | ) 66 | 67 | if amount <= 0: 68 | raise commands.BadArgument( 69 | f"{ctx.author.mention} ??? 🫵😂😂" 70 | f" you gotta donate atleast {utils.format_inches(1)} lil bro" 71 | ) 72 | 73 | if amount > pp.size.value: 74 | raise utils.PpNotBigEnough( 75 | f"{ctx.author.mention} your pp isn't big enough to donate that much 🫵😂😂" 76 | f" you only have {utils.format_inches(pp.size.value)} lil bro" 77 | ) 78 | 79 | relevant_received_donation_sum = ( 80 | await utils.Donation.fetch_relevant_received_donation_sum( 81 | db.conn, recipiant.id 82 | ) 83 | ) 84 | 85 | if relevant_received_donation_sum >= self.DONATION_LIMIT: 86 | raise commands.CheckFailure( 87 | f"{recipiant.mention} has already hit the daily donation limit" 88 | f" of {utils.format_inches(self.DONATION_LIMIT)}" 89 | ) 90 | 91 | interaction_id = uuid.uuid4().hex 92 | 93 | if amount + relevant_received_donation_sum > self.DONATION_LIMIT: 94 | embed = utils.Embed(color=utils.RED) 95 | embed.description = ( 96 | f"{ctx.author.mention} you can't donate that much to {recipiant.mention} bro the limit" 97 | f" is {utils.format_inches(self.DONATION_LIMIT)}" 98 | ) 99 | 100 | if relevant_received_donation_sum: 101 | embed.description += ( 102 | " and they already received" 103 | f" {utils.format_inches(relevant_received_donation_sum)}" 104 | " in the last 24 hours" 105 | ) 106 | 107 | components = discord.ui.MessageComponents( 108 | discord.ui.ActionRow( 109 | discord.ui.Button( 110 | label=f"Change amount to {utils.format_inches(self.DONATION_LIMIT - relevant_received_donation_sum, markdown=None)}", 111 | custom_id=f"{interaction_id}_DONATE", 112 | style=discord.ButtonStyle.green, 113 | ), 114 | discord.ui.Button( 115 | label=f"Cancel donation", 116 | custom_id=f"{interaction_id}_CANCEL_DONATION", 117 | style=discord.ButtonStyle.red, 118 | ), 119 | ) 120 | ) 121 | 122 | amount = self.DONATION_LIMIT - relevant_received_donation_sum 123 | 124 | else: 125 | embed = utils.Embed() 126 | embed.description = ( 127 | f"{ctx.author.mention} are u sure you want to donate" 128 | f" {utils.format_inches(amount)} to {recipiant.mention}?" 129 | ) 130 | 131 | components = discord.ui.MessageComponents( 132 | discord.ui.ActionRow( 133 | discord.ui.Button( 134 | label=f"yes!!", 135 | custom_id=f"{interaction_id}_DONATE", 136 | style=discord.ButtonStyle.green, 137 | ), 138 | discord.ui.Button( 139 | label=f"no", 140 | custom_id=f"{interaction_id}_CANCEL_DONATION", 141 | style=discord.ButtonStyle.red, 142 | ), 143 | ) 144 | ) 145 | 146 | await ctx.interaction.response.send_message( 147 | embed=embed, components=components 148 | ) 149 | 150 | try: 151 | interaction, action = await utils.wait_for_component_interaction( 152 | self.bot, 153 | interaction_id, 154 | users=[ctx.author], 155 | actions=["DONATE", "CANCEL_DONATION"], 156 | timeout=10, 157 | ) 158 | except asyncio.TimeoutError: 159 | try: 160 | await ctx.interaction.edit_original_message( 161 | embed=utils.Embed.as_timeout("Donation cancelled"), 162 | components=components.disable_components(), 163 | ) 164 | except discord.HTTPException: 165 | pass 166 | return 167 | 168 | if action == "CANCEL_DONATION": 169 | embed = utils.Embed(color=utils.RED) 170 | embed.title = "Donation cancelled" 171 | embed.description = ( 172 | f"I guess {ctx.author.mention} really hates {recipiant.mention}" 173 | ) 174 | await interaction.response.edit_message(embed=embed, components=None) 175 | self.donate_command.reset_cooldown(ctx) 176 | return 177 | 178 | # Fetch the recipiant pp again with edit=true 179 | # Do this only now so that the recipiant isn't locked from using 180 | # other commands while acceping a donation 181 | async with ( 182 | utils.DatabaseWrapper() as recipiant_db, 183 | recipiant_db.conn.transaction(), 184 | utils.DatabaseTimeoutManager.notify( 185 | recipiant.id, 186 | "You're receiving a donation right now and it's still being processed! Please try again!!", 187 | ), 188 | ): 189 | try: 190 | recipiant_pp = await utils.Pp.fetch_from_user( 191 | recipiant_db.conn, recipiant.id, edit=True 192 | ) 193 | except utils.DatabaseTimeout: 194 | await ctx.interaction.edit_original_message( 195 | components=components.disable_components() 196 | ) 197 | raise commands.CheckFailure( 198 | f"{recipiant.mention} seems to be busy right now! Try donating another time :)" 199 | ) 200 | 201 | await utils.Donation.register( 202 | db.conn, recipiant.id, ctx.author.id, amount 203 | ) 204 | 205 | pp.size.value -= amount 206 | recipiant_pp.size.value += amount 207 | await pp.update(db.conn) 208 | await recipiant_pp.update(recipiant_db.conn) 209 | 210 | embed = utils.Embed(color=utils.GREEN) 211 | embed.title = "Donation successful!" 212 | embed.description = ( 213 | f"u successfully donated {utils.format_inches(amount)} to {recipiant}" 214 | f"\n\n {ctx.author.mention} now has {utils.format_inches(pp.size.value)}" 215 | f"\n {recipiant.mention} now has {utils.format_inches(recipiant_pp.size.value)}" 216 | ) 217 | 218 | await interaction.response.edit_message(embed=embed, components=None) 219 | 220 | 221 | async def setup(bot: utils.Bot): 222 | await bot.add_cog(DonateCommandCog(bot)) 223 | -------------------------------------------------------------------------------- /cogs/fish_command.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import random 3 | from typing import Literal, cast 4 | 5 | import asyncpg 6 | import discord 7 | from discord.ext import commands, vbu 8 | 9 | from cogs.utils.bot import Bot 10 | 11 | from . import utils 12 | 13 | 14 | class Activity(enum.Enum): 15 | SUCCESS = 0.8 16 | ROD_BREAK = 0.1 17 | CLICK_THAT_BUTTON_MINIGAME = 0.1 18 | 19 | @classmethod 20 | def random(cls): 21 | return random.choices( 22 | list(Activity), weights=list(activity.value for activity in Activity) 23 | )[0] 24 | 25 | 26 | MinigameActivity = Literal[Activity.CLICK_THAT_BUTTON_MINIGAME] 27 | 28 | 29 | class FishCommandCog(vbu.Cog[utils.Bot]): 30 | # Things you can catch while fishing, in order of best to worst. 31 | CATCHES: list[str] = [ 32 | "an old rusty can", 33 | "a little tiny stupid dumb fish", 34 | "a fish", 35 | "a big fish", 36 | "a REALLY big fish", 37 | ] 38 | 39 | ROD_BREAK_RESPONSES: list[str] = [ 40 | "{} flung their fishing rod too hard and it broke lmaoooo", 41 | "{} accidentally threw their fishing rod in the water lmao what a fucking loser", 42 | ] 43 | 44 | def __init__(self, bot: Bot, logger_name: str | None = None): 45 | super().__init__(bot, logger_name) 46 | 47 | async def start_minigame( 48 | self, 49 | minigame_activity: MinigameActivity, 50 | *, 51 | bot: utils.Bot, 52 | connection: asyncpg.Connection, 53 | pp: utils.Pp, 54 | interaction: discord.Interaction, 55 | ): 56 | minigame_types: dict[Activity, type[utils.Minigame]] = { 57 | Activity.CLICK_THAT_BUTTON_MINIGAME: utils.ClickThatButtonMinigame, 58 | } 59 | 60 | minigame_type = minigame_types[minigame_activity] 61 | minigame = minigame_type( 62 | bot=bot, 63 | connection=connection, 64 | pp=pp, 65 | context=minigame_type.generate_random_dialogue("fish"), 66 | ) 67 | 68 | await minigame.start(interaction) 69 | 70 | def get_catch(self, worth: float) -> str: 71 | worth_index = round(worth * (len(self.CATCHES) - 1)) 72 | fishing_catch = self.CATCHES[worth_index] 73 | 74 | # avoid different behaviour for different worths with the same fishing_catch value 75 | worth = worth_index / (len(self.CATCHES) - 1) 76 | 77 | if worth > 0.8: 78 | return f"**[{fishing_catch}](<{utils.MEME_URL}>)**" 79 | 80 | if worth > 0.5: 81 | return f"**{fishing_catch}**" 82 | 83 | return fishing_catch 84 | 85 | @commands.command( 86 | "fish", 87 | utils.Command, 88 | category=utils.CommandCategory.GROWING_PP, 89 | application_command_meta=commands.ApplicationCommandMeta(), 90 | ) 91 | @utils.Command.tiered_cooldown( 92 | default=60, 93 | voter=30, 94 | ) 95 | @commands.is_slash_command() 96 | async def fish_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 97 | """ 98 | Go fishing for some inches! Don't question it! 99 | """ 100 | 101 | async with ( 102 | utils.DatabaseWrapper() as db, 103 | db.conn.transaction(), 104 | utils.DatabaseTimeoutManager.notify( 105 | ctx.author.id, "You're still busy fishing!" 106 | ), 107 | ): 108 | pp = await utils.Pp.fetch_from_user(db.conn, ctx.author.id, edit=True) 109 | tool = utils.ItemManager.get_command_tool("fish") 110 | 111 | if not await utils.InventoryItem.user_has_item( 112 | db.conn, ctx.author.id, tool.id 113 | ): 114 | raise utils.MissingTool(tool=tool) 115 | 116 | activity = Activity.random() 117 | 118 | if activity.name.endswith("_MINIGAME"): 119 | activity = cast(MinigameActivity, activity) 120 | await self.start_minigame( 121 | activity, 122 | bot=self.bot, 123 | connection=db.conn, 124 | pp=pp, 125 | interaction=ctx.interaction, 126 | ) 127 | return 128 | 129 | embed = utils.Embed() 130 | 131 | if activity == Activity.ROD_BREAK: 132 | inv_tool = await utils.InventoryItem.fetch( 133 | db.conn, 134 | {"user_id": ctx.author.id, "id": tool.id}, 135 | lock=utils.RowLevelLockMode.FOR_UPDATE, 136 | ) 137 | inv_tool.amount.value -= 1 138 | await inv_tool.update(db.conn) 139 | 140 | embed.colour = utils.RED 141 | embed.description = random.choice(self.ROD_BREAK_RESPONSES).format( 142 | ctx.author.mention 143 | ) + ( 144 | f"\n\n(You now have {inv_tool.format_item(article=utils.Article.NUMERAL)}" 145 | " left)" 146 | ) 147 | 148 | if inv_tool.amount.value == 0: 149 | embed.description += " 😢" 150 | 151 | elif activity == Activity.SUCCESS: 152 | growth = random.randint(1, 15) 153 | pp.grow_with_multipliers( 154 | growth, 155 | voted=await pp.has_voted(), 156 | ) 157 | 158 | worth = growth / 15 159 | catch = self.get_catch(worth) 160 | 161 | embed.colour = utils.GREEN 162 | embed.description = ( 163 | f"**{ctx.author.mention}** went fishing, caught" 164 | f" {catch} and sold it for {pp.format_growth()}!" 165 | ) 166 | 167 | else: 168 | raise ValueError( 169 | f"Can't complete fishing command: No handling for activity {activity!r}" 170 | " available" 171 | ) 172 | 173 | await pp.update(db.conn) 174 | embed.add_tip() 175 | 176 | await ctx.interaction.response.send_message(embed=embed) 177 | 178 | 179 | async def setup(bot: utils.Bot): 180 | await bot.add_cog(FishCommandCog(bot)) 181 | -------------------------------------------------------------------------------- /cogs/grow_command.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from discord.ext import commands, vbu 4 | 5 | from . import utils 6 | 7 | 8 | class GrowCommandCog(vbu.Cog[utils.Bot]): 9 | @commands.command( 10 | "grow", 11 | utils.Command, 12 | category=utils.CommandCategory.GROWING_PP, 13 | application_command_meta=commands.ApplicationCommandMeta(), 14 | ) 15 | @utils.Command.tiered_cooldown( 16 | default=30, 17 | voter=10, 18 | ) 19 | @commands.is_slash_command() 20 | async def grow_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 21 | """ 22 | Grow your pp to get more inches! 23 | """ 24 | async with ( 25 | utils.DatabaseWrapper() as db, 26 | db.conn.transaction(), 27 | utils.DatabaseTimeoutManager.notify( 28 | ctx.author.id, "You're still busy with the grow command!" 29 | ), 30 | ): 31 | pp = await utils.Pp.fetch_from_user(db.conn, ctx.author.id, edit=True) 32 | 33 | pp.grow_with_multipliers( 34 | random.randint(1, 15), 35 | voted=await pp.has_voted(), 36 | ) 37 | await pp.update(db.conn) 38 | 39 | embed = utils.Embed() 40 | embed.colour = utils.GREEN 41 | embed.description = ( 42 | f"{ctx.author.mention}, ur pp grew {pp.format_growth()}!" 43 | ) 44 | embed.add_tip() 45 | 46 | await ctx.interaction.response.send_message(embed=embed) 47 | 48 | 49 | async def setup(bot: utils.Bot): 50 | await bot.add_cog(GrowCommandCog(bot)) 51 | -------------------------------------------------------------------------------- /cogs/help_commands.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import random 3 | 4 | import discord 5 | from discord.ext import commands, vbu 6 | 7 | from . import utils 8 | 9 | 10 | class HelpCommandsCog(vbu.Cog[utils.Bot]): 11 | INVITE_URL = "https://discord.com/api/oauth2/authorize?client_id=735147633076863027&permissions=517543939136&scope=bot%20applications.commands" 12 | QUOTES = [ 13 | "wtf is this bot", 14 | "i love my wife", 15 | "schlopp is SO HOT", 16 | "my gf has a bigger pp than me", 17 | ] 18 | 19 | def generate_help_embed(self) -> utils.Embed: 20 | embed = utils.Embed() 21 | embed.color = utils.BLUE 22 | embed.title = "u need some help?" 23 | embed.url = utils.MEME_URL 24 | embed.set_footer(text=f'"{random.choice(self.QUOTES)}"') 25 | 26 | for category in utils.CommandCategory: 27 | category_commands: list[commands.Command] = [] 28 | for command in self.bot.commands: 29 | if command.hidden: 30 | continue 31 | 32 | if not command.application_command_meta: 33 | continue 34 | 35 | if isinstance(command, utils.Command): 36 | if command.category == category: 37 | category_commands.append(command) 38 | 39 | elif category == utils.CommandCategory.OTHER: 40 | category_commands.append(command) 41 | 42 | if not category_commands: 43 | continue 44 | 45 | category_commands.sort(key=lambda c: c.name) 46 | 47 | text = " ".join( 48 | utils.format_slash_command(command.name) 49 | for command in category_commands 50 | ) 51 | embed.add_field(name=category.value, value=text) 52 | 53 | return embed 54 | 55 | def generate_new_user_embed(self) -> utils.Embed: 56 | embed = utils.Embed() 57 | embed.color = utils.PINK 58 | 59 | embed.set_author(name="IMPORTANT!!!!!1!!") 60 | embed.title = "u dont have a pp yet!!!" 61 | embed.url = f"{utils.MEME_URL}#" 62 | 63 | embed.description = ( 64 | f"use {utils.format_slash_command('new')}" 65 | " to make a pp and start growing it :)" 66 | ) 67 | 68 | return embed 69 | 70 | @commands.command( 71 | "help", 72 | utils.Command, 73 | category=utils.CommandCategory.HELP, 74 | application_command_meta=commands.ApplicationCommandMeta(), 75 | ) 76 | @commands.is_slash_command() 77 | async def help_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 78 | """ 79 | Need some help? Here you go :) 80 | """ 81 | 82 | await ctx.interaction.response.send_message(embed=self.generate_help_embed()) 83 | 84 | async with utils.DatabaseWrapper() as db: 85 | try: 86 | await utils.Pp.fetch_from_user(db.conn, ctx.author.id) 87 | except utils.PpMissing: 88 | await ctx.interaction.followup.send( 89 | embed=self.generate_new_user_embed(), ephemeral=True 90 | ) 91 | 92 | @commands.command( 93 | "invite", 94 | utils.Command, 95 | category=utils.CommandCategory.HELP, 96 | application_command_meta=commands.ApplicationCommandMeta(), 97 | ) 98 | @commands.is_slash_command() 99 | async def invite_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 100 | """ 101 | Invite pp bot to your server!! NOW!!!!!! 102 | """ 103 | 104 | embed = utils.Embed(color=utils.PINK) 105 | embed.description = ( 106 | f"**INVITE ME !!! [PLEASE!!!!!!!!!!!]({self.INVITE_URL})" 107 | " I WANNA BE IN YOUR SERVER!!!!**" 108 | ) 109 | 110 | action_row = discord.ui.ActionRow( 111 | discord.ui.Button( 112 | label="Invite me!!!", 113 | emoji="<:ppMalding:902894208795435031>", 114 | style=discord.ButtonStyle.url, 115 | url=self.INVITE_URL, 116 | ) 117 | ) 118 | 119 | components = discord.ui.MessageComponents(action_row) 120 | 121 | await ctx.interaction.response.send_message( 122 | embed=embed, 123 | components=components, 124 | ) 125 | 126 | await asyncio.sleep(2) 127 | 128 | action_row.add_component( 129 | discord.ui.Button( 130 | label="Invite me (evil version)", 131 | emoji="<:ppevil:871396299830861884>", 132 | style=discord.ButtonStyle.url, 133 | url=self.INVITE_URL, 134 | ) 135 | ) 136 | 137 | try: 138 | await ctx.interaction.edit_original_message(components=components) 139 | except discord.HTTPException: 140 | pass 141 | 142 | await asyncio.sleep(5) 143 | 144 | action_row.add_component( 145 | discord.ui.Button( 146 | label="Invite me (SUPER evil version)", 147 | emoji="<:ppevil:871396299830861884>", 148 | style=discord.ButtonStyle.url, 149 | url=self.INVITE_URL, 150 | ) 151 | ) 152 | 153 | try: 154 | await ctx.interaction.edit_original_message(components=components) 155 | except discord.HTTPException: 156 | pass 157 | 158 | 159 | async def setup(bot: utils.Bot): 160 | await bot.add_cog(HelpCommandsCog(bot)) 161 | -------------------------------------------------------------------------------- /cogs/hospital_command.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from discord.ext import commands, vbu 4 | from . import utils 5 | 6 | 7 | class HospitalCommandCog(vbu.Cog[utils.Bot]): 8 | MIN_SIZE = 100 9 | REWARD_RANGE = range(25, MIN_SIZE + 1) 10 | SUCCESS_RATE = 0.8 11 | 12 | @commands.command( 13 | "hospital", 14 | utils.Command, 15 | aliases=["h"], 16 | category=utils.CommandCategory.GROWING_PP, 17 | application_command_meta=commands.ApplicationCommandMeta(), 18 | ) 19 | @utils.Command.tiered_cooldown( 20 | default=60 * 5, 21 | voter=30 * 2, 22 | ) 23 | @commands.is_slash_command() 24 | async def hospital_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 25 | """ 26 | Take a risky surgery that can increase your pp size 27 | """ 28 | async with ( 29 | utils.DatabaseWrapper() as db, 30 | db.conn.transaction(), 31 | utils.DatabaseTimeoutManager.notify( 32 | ctx.author.id, "You're still busy with the hospital command!" 33 | ), 34 | ): 35 | pp = await utils.Pp.fetch_from_user(db.conn, ctx.author.id, edit=True) 36 | 37 | voted = await pp.has_voted() 38 | multiplier = pp.get_full_multiplier(voted=voted)[0] 39 | 40 | min_size = self.MIN_SIZE * multiplier 41 | 42 | if pp.size.value < min_size: 43 | assert isinstance(ctx.command, utils.Command) 44 | await ctx.command.async_reset_cooldown(ctx) 45 | raise utils.PpNotBigEnough( 46 | f"Your pp isn't big enough! You need at least **{utils.format_int(min_size)}" 47 | " inches** to visit the hospital crodie" 48 | ) 49 | 50 | embed = utils.Embed() 51 | embed.title = "HOSPITAL" 52 | embed.description = ( 53 | f"{ctx.author.mention} goes to the hospital for some pp surgery..." 54 | ) 55 | 56 | growth = random.choice(self.REWARD_RANGE) * multiplier 57 | 58 | # success! 59 | if random.random() < self.SUCCESS_RATE: 60 | pp.grow(growth) 61 | embed.color = utils.GREEN 62 | embed.add_field( 63 | name="SUCCESSFUL", 64 | value=( 65 | "The operation was successful!" 66 | f" Your pp gained {pp.format_growth()}!" 67 | f" It is now {pp.format_growth(pp.size.value)}." 68 | ), 69 | ) 70 | 71 | # L moves 72 | else: 73 | pp.grow(-growth) 74 | embed.color = utils.RED 75 | embed.add_field( 76 | name="FAILED", 77 | value=( 78 | "The operation failed." 79 | f" Your pp snapped and you {pp.format_growth(prefixed=True)} 😭" 80 | f" It is now {pp.format_growth(pp.size.value)}." 81 | ), 82 | ) 83 | 84 | await pp.update(db.conn) 85 | 86 | embed.add_tip() 87 | 88 | await ctx.interaction.response.send_message(embed=embed) 89 | 90 | 91 | async def setup(bot: utils.Bot): 92 | await bot.add_cog(HospitalCommandCog(bot)) 93 | -------------------------------------------------------------------------------- /cogs/hunt_command.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import random 3 | from typing import Literal, cast 4 | 5 | import asyncpg 6 | import discord 7 | from discord.ext import commands, vbu 8 | 9 | from cogs.utils.bot import Bot 10 | 11 | from . import utils 12 | 13 | 14 | class Activity(enum.Enum): 15 | SUCCESS = 0.8 16 | RIFLE_BREAK = 0.1 17 | CLICK_THAT_BUTTON_MINIGAME = 0.1 18 | 19 | @classmethod 20 | def random(cls): 21 | return random.choices( 22 | list(Activity), weights=list(activity.value for activity in Activity) 23 | )[0] 24 | 25 | 26 | MinigameActivity = Literal[Activity.CLICK_THAT_BUTTON_MINIGAME] 27 | 28 | 29 | class HuntCommandCog(vbu.Cog[utils.Bot]): 30 | HUNTING_OPTIONS: dict[str, range] = { 31 | "shot a homeless man": range(1, 21), 32 | "deadass just killed a man": range(5, 21), 33 | "shot up a mall": range(5, 21), 34 | "hijacked a fucking orphanage and sold all the kids": range(30, 51), 35 | "KILLED THE PP GODS": range(50, 101), 36 | } 37 | 38 | ITEM_BREAK_RESPONSES: list[str] = [ 39 | "{} got arrested and their rifle got confiscated!!1!", 40 | ] 41 | 42 | def __init__(self, bot: Bot, logger_name: str | None = None): 43 | super().__init__(bot, logger_name) 44 | 45 | async def start_minigame( 46 | self, 47 | minigame_activity: MinigameActivity, 48 | *, 49 | bot: utils.Bot, 50 | connection: asyncpg.Connection, 51 | pp: utils.Pp, 52 | interaction: discord.Interaction, 53 | ): 54 | minigame_types: dict[Activity, type[utils.Minigame]] = { 55 | Activity.CLICK_THAT_BUTTON_MINIGAME: utils.ClickThatButtonMinigame, 56 | } 57 | 58 | minigame_type = minigame_types[minigame_activity] 59 | minigame = minigame_type( 60 | bot=bot, 61 | connection=connection, 62 | pp=pp, 63 | context=minigame_type.generate_random_dialogue("hunt"), 64 | ) 65 | 66 | await minigame.start(interaction) 67 | 68 | def get_hunting_option(self) -> tuple[str, int]: 69 | """Returns `(hunting_option: str, growth: int)`""" 70 | worth_index = random.randrange(0, len(self.HUNTING_OPTIONS)) 71 | hunting_option = list(self.HUNTING_OPTIONS)[worth_index] 72 | growth = random.choice(self.HUNTING_OPTIONS[hunting_option]) 73 | 74 | worth = worth_index / (len(self.HUNTING_OPTIONS) - 1) 75 | 76 | if worth > 0.8: 77 | return f"**[{hunting_option}](<{utils.MEME_URL}>)**", growth 78 | 79 | if worth > 0.5: 80 | return f"**{hunting_option}**", growth 81 | 82 | return hunting_option, growth 83 | 84 | @commands.command( 85 | "hunt", 86 | utils.Command, 87 | category=utils.CommandCategory.GROWING_PP, 88 | application_command_meta=commands.ApplicationCommandMeta(), 89 | ) 90 | @utils.Command.tiered_cooldown( 91 | default=60, 92 | voter=30, 93 | ) 94 | @commands.is_slash_command() 95 | async def hunt_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 96 | """ 97 | Hunt for some inches, nothing wrong with that 98 | """ 99 | 100 | async with ( 101 | utils.DatabaseWrapper() as db, 102 | db.conn.transaction(), 103 | utils.DatabaseTimeoutManager.notify( 104 | ctx.author.id, "You're still busy hunting!" 105 | ), 106 | ): 107 | pp = await utils.Pp.fetch_from_user(db.conn, ctx.author.id, edit=True) 108 | tool = utils.ItemManager.get_command_tool("hunt") 109 | 110 | if not await utils.InventoryItem.user_has_item( 111 | db.conn, ctx.author.id, tool.id 112 | ): 113 | raise utils.MissingTool(tool=tool) 114 | 115 | activity = Activity.random() 116 | 117 | if activity.name.endswith("_MINIGAME"): 118 | activity = cast(MinigameActivity, activity) 119 | await self.start_minigame( 120 | activity, 121 | bot=self.bot, 122 | connection=db.conn, 123 | pp=pp, 124 | interaction=ctx.interaction, 125 | ) 126 | return 127 | 128 | embed = utils.Embed() 129 | 130 | if activity == Activity.RIFLE_BREAK: 131 | inv_tool = await utils.InventoryItem.fetch( 132 | db.conn, 133 | {"user_id": ctx.author.id, "id": tool.id}, 134 | lock=utils.RowLevelLockMode.FOR_UPDATE, 135 | ) 136 | inv_tool.amount.value -= 1 137 | await inv_tool.update(db.conn) 138 | 139 | embed.colour = utils.RED 140 | embed.description = random.choice(self.ITEM_BREAK_RESPONSES).format( 141 | ctx.author.mention 142 | ) + ( 143 | f"\n\n(You now have {inv_tool.format_item(article=utils.Article.NUMERAL)}" 144 | " left)" 145 | ) 146 | 147 | if inv_tool.amount.value == 0: 148 | embed.description += " 😢" 149 | 150 | elif activity == Activity.SUCCESS: 151 | option, growth = self.get_hunting_option() 152 | pp.grow_with_multipliers( 153 | growth, 154 | voted=await pp.has_voted(), 155 | ) 156 | 157 | embed.colour = utils.GREEN 158 | embed.description = ( 159 | f"**{ctx.author.mention}** {option} and for {pp.format_growth()}!" 160 | ) 161 | 162 | else: 163 | raise ValueError( 164 | f"Can't complete hunting command: No handling for activity {activity!r}" 165 | " available" 166 | ) 167 | 168 | await pp.update(db.conn) 169 | embed.add_tip() 170 | 171 | await ctx.interaction.response.send_message(embed=embed) 172 | 173 | 174 | async def setup(bot: utils.Bot): 175 | await bot.add_cog(HuntCommandCog(bot)) 176 | -------------------------------------------------------------------------------- /cogs/leaderboard_command.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import uuid 4 | from datetime import timedelta 5 | from typing import Generic, TypeVar 6 | 7 | import discord 8 | from discord.ext import commands, vbu, tasks 9 | 10 | from . import utils 11 | 12 | 13 | _T_co = TypeVar("_T_co", covariant=True) 14 | 15 | 16 | class LeaderboardCache(utils.Object, Generic[_T_co]): 17 | """ 18 | Logic in case I forget: 19 | You take the user's ID, put it into positions_per_user_id to 20 | get their position, use that minus 1 as the index for leaderboard_items 21 | and get the stats 22 | """ 23 | 24 | __slots__ = ("positions_per_user_id", "leaderboard_items") 25 | positions_per_user_id: dict[int, int] 26 | leaderboard_items: list[tuple[utils.Pp, _T_co]] 27 | title: str 28 | label: str 29 | 30 | def __init__( 31 | self, 32 | *, 33 | logger: logging.Logger, 34 | ) -> None: 35 | self.logger = logger 36 | self.positions_per_user_id = {} 37 | self.leaderboard_items = [] 38 | 39 | async def update(self) -> None: 40 | raise NotImplementedError 41 | 42 | def podium_value_formatter(self, position: int) -> str: 43 | raise NotImplementedError 44 | 45 | def comparison_formatter(self, position: int) -> str | None: 46 | raise NotImplementedError 47 | 48 | def generate_embed(self, ctx: commands.SlashContext[utils.Bot]) -> utils.Embed: 49 | embed = utils.Embed() 50 | embed.set_author( 51 | name=self.title, 52 | url=utils.MEME_URL, 53 | ) 54 | 55 | position = self.positions_per_user_id.get(ctx.author.id) 56 | 57 | if position is not None: 58 | comparison = self.comparison_formatter(position) 59 | 60 | if comparison: 61 | embed.set_footer( 62 | text=( 63 | f"ur {utils.format_ordinal(position)} place on the leaderboard," 64 | f" {comparison}" 65 | ) 66 | ) 67 | 68 | elif position == 1: 69 | embed.set_footer(text="you're in first place!! loser") 70 | 71 | else: 72 | embed.set_footer( 73 | text=( 74 | f"ur {utils.format_ordinal(position)} place on the leaderboard" 75 | ) 76 | ) 77 | 78 | else: 79 | embed.set_footer(text="use /new to make your own pp :3") 80 | 81 | segments: list[str] = [] 82 | 83 | for position, (pp, _) in enumerate(self.leaderboard_items, start=1): 84 | if position <= 3: 85 | prefix = ["🥇", "🥈", "🥉"][position - 1] 86 | elif position == 10: 87 | prefix = "" 88 | else: 89 | prefix = "🔹" 90 | 91 | segments.append( 92 | f"{prefix} {self.podium_value_formatter(position)}" 93 | f" - {pp.name.value} `({pp.user_id})`" 94 | ) 95 | 96 | embed.description = "\n".join(segments) 97 | 98 | return embed 99 | 100 | 101 | class SizeLeaderboardCache(LeaderboardCache[int]): 102 | title = "the biggest pps in the entire universe" 103 | label = "Size" 104 | 105 | async def update(self) -> None: 106 | self.logger.debug("Updating size leaderboard cache...") 107 | 108 | new_position_per_user_id: dict[int, int] = {} 109 | new_leaderboard_items: list[tuple[utils.Pp, int]] = [] 110 | 111 | async with utils.DatabaseWrapper() as db: 112 | records = await db( 113 | """ 114 | SELECT * 115 | FROM pps 116 | ORDER BY pp_size DESC 117 | """ 118 | ) 119 | for n, record in enumerate(records): 120 | if n < 10: 121 | pp_data = dict(record) 122 | pp = utils.Pp.from_record(pp_data) 123 | new_leaderboard_items.append((pp, pp.size.value)) 124 | new_position_per_user_id[record["user_id"]] = n + 1 125 | 126 | self.positions_per_user_id = new_position_per_user_id 127 | self.leaderboard_items = new_leaderboard_items 128 | 129 | self.logger.debug("Size leaderboard cache updated") 130 | 131 | def podium_value_formatter(self, position: int) -> str: 132 | size = self.leaderboard_items[position - 1][1] 133 | return utils.format_inches(size) 134 | 135 | def comparison_formatter(self, position: int) -> str | None: 136 | better_pp_size: int | None = None 137 | 138 | if position == 1: 139 | return 140 | 141 | pp_size = self.leaderboard_items[position - 1][1] 142 | better_pp_size = self.leaderboard_items[position - 2][1] 143 | 144 | difference = better_pp_size - pp_size 145 | 146 | return ( 147 | f"{utils.format_inches(difference, markdown=None)} behind" 148 | f" {utils.format_ordinal(position - 1)} place" 149 | ) 150 | 151 | 152 | class MultiplierLeaderboardCache(LeaderboardCache[int]): 153 | title = "the craziest multipliers across all of pp bot (boosts not included)" 154 | label = "Multiplier" 155 | 156 | async def update(self) -> None: 157 | self.logger.debug("Updating multiplier leaderboard cache...") 158 | 159 | new_position_per_user_id: dict[int, int] = {} 160 | new_leaderboard_items: list[tuple[utils.Pp, int]] = [] 161 | 162 | async with utils.DatabaseWrapper() as db: 163 | records = await db( 164 | """ 165 | SELECT * 166 | FROM pps 167 | ORDER BY pp_multiplier DESC 168 | """ 169 | ) 170 | for n, record in enumerate(records): 171 | if n < 10: 172 | pp_data = dict(record) 173 | pp = utils.Pp.from_record(pp_data) 174 | new_leaderboard_items.append((pp, pp.multiplier.value)) 175 | new_position_per_user_id[record["user_id"]] = n + 1 176 | 177 | self.positions_per_user_id = new_position_per_user_id 178 | self.leaderboard_items = new_leaderboard_items 179 | 180 | self.logger.debug("Multiplier leaderboard cache updated") 181 | 182 | def podium_value_formatter(self, position: int) -> str: 183 | multiplier = self.leaderboard_items[position - 1][1] 184 | return f"**{utils.format_int(multiplier)}x** multiplier" 185 | 186 | def comparison_formatter(self, position: int) -> str | None: 187 | better_multiplier: int | None = None 188 | 189 | if position == 1: 190 | return 191 | 192 | multiplier = self.leaderboard_items[position - 1][1] 193 | better_multiplier = self.leaderboard_items[position - 2][1] 194 | 195 | difference = better_multiplier - multiplier 196 | 197 | return ( 198 | f"{utils.format_int(difference)} multipliers behind" 199 | f" {utils.format_ordinal(position - 1)} place" 200 | ) 201 | 202 | 203 | class DonationLeaderboardCache(LeaderboardCache[int]): 204 | title = "the most generous people sharing their pp with everyone" 205 | label = "Donations" 206 | 207 | async def update(self) -> None: 208 | self.logger.debug("Updating donation leaderboard cache...") 209 | 210 | new_position_per_user_id: dict[int, int] = {} 211 | new_leaderboard_items: list[tuple[utils.Pp, int]] = [] 212 | 213 | async with utils.DatabaseWrapper() as db: 214 | records = await db( 215 | """ 216 | WITH donation_totals AS ( 217 | SELECT 218 | donor_id, 219 | SUM(amount) AS total_donations 220 | FROM donations 221 | GROUP BY donor_id 222 | ) 223 | SELECT 224 | pps.*, 225 | donation_totals.total_donations 226 | FROM donation_totals 227 | JOIN pps 228 | ON donation_totals.donor_id = pps.user_id 229 | ORDER BY donation_totals.total_donations DESC 230 | """ 231 | ) 232 | for n, record in enumerate(records): 233 | if n < 10: 234 | pp_data = dict(record) 235 | total_donations = pp_data.pop("total_donations") 236 | pp = utils.Pp.from_record(pp_data) 237 | new_leaderboard_items.append((pp, total_donations)) 238 | new_position_per_user_id[record["user_id"]] = n + 1 239 | 240 | self.positions_per_user_id = new_position_per_user_id 241 | self.leaderboard_items = new_leaderboard_items 242 | 243 | self.logger.debug("Donation leaderboard cache updated") 244 | 245 | def podium_value_formatter(self, position: int) -> str: 246 | amount = self.leaderboard_items[position - 1][1] 247 | return f"{utils.format_inches(amount)} donated" 248 | 249 | def comparison_formatter(self, position: int) -> str | None: 250 | better_multiplier: int | None = None 251 | 252 | if position == 1: 253 | return 254 | 255 | multiplier = self.leaderboard_items[position - 1][1] 256 | better_multiplier = self.leaderboard_items[position - 2][1] 257 | 258 | difference = better_multiplier - multiplier 259 | 260 | return ( 261 | f"{utils.format_int(difference)} multipliers behind" 262 | f" {utils.format_ordinal(position - 1)} place" 263 | ) 264 | 265 | 266 | class LeaderboardCommandCog(vbu.Cog[utils.Bot]): 267 | LEADERBOARD_CACHE_REFRESH_TIME = timedelta(seconds=15) 268 | size_leaderboard_cache = SizeLeaderboardCache( 269 | logger=logging.getLogger( 270 | "vbu.bot.cog.LeaderboardCommandCog.SizeLeaderboardCache" 271 | ) 272 | ) 273 | multiplier_leaderboard_cache = MultiplierLeaderboardCache( 274 | logger=logging.getLogger( 275 | "vbu.bot.cog.LeaderboardCommandCog.MultiplierLeaderboardCache" 276 | ) 277 | ) 278 | donation_leaderboard_cache = DonationLeaderboardCache( 279 | logger=logging.getLogger( 280 | "vbu.bot.cog.LeaderboardCommandCog.DonationLeaderboardCache" 281 | ) 282 | ) 283 | CATEGORIES: dict[str, LeaderboardCache] = { 284 | "SIZE": size_leaderboard_cache, 285 | "MULTIPLIER": multiplier_leaderboard_cache, 286 | "DONATION": donation_leaderboard_cache, 287 | } 288 | 289 | def __init__(self, bot: utils.Bot, logger_name: str | None = None): 290 | super().__init__(bot, logger_name) 291 | self.cache_leaderboard.start() 292 | 293 | async def cog_unload(self) -> None: 294 | self.cache_leaderboard.cancel() 295 | 296 | @tasks.loop(seconds=int(LEADERBOARD_CACHE_REFRESH_TIME.total_seconds())) 297 | async def cache_leaderboard(self) -> None: 298 | self.logger.debug("Updating leaderboard caches...") 299 | await self.size_leaderboard_cache.update() 300 | await self.multiplier_leaderboard_cache.update() 301 | await self.donation_leaderboard_cache.update() 302 | 303 | @commands.command( 304 | "leaderboard", 305 | utils.Command, 306 | category=utils.CommandCategory.STATS, 307 | application_command_meta=commands.ApplicationCommandMeta(), 308 | ) 309 | @commands.cooldown(1, 10, commands.BucketType.user) 310 | @commands.is_slash_command() 311 | async def leaderboard_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 312 | """ 313 | Check out the biggest pps in the world 314 | """ 315 | 316 | embed = self.size_leaderboard_cache.generate_embed(ctx) 317 | 318 | interaction_id = uuid.uuid4().hex 319 | select_menu = discord.ui.SelectMenu( 320 | custom_id=f"{interaction_id}_CATEGORY", 321 | options=[ 322 | discord.ui.SelectOption( 323 | label=leaderboard_cache.label, 324 | value=category, 325 | default=category == "SIZE", 326 | ) 327 | for category, leaderboard_cache in self.CATEGORIES.items() 328 | ], 329 | ) 330 | components = discord.ui.MessageComponents(discord.ui.ActionRow(select_menu)) 331 | 332 | await ctx.interaction.response.send_message( 333 | embed=embed, 334 | components=components, 335 | ) 336 | 337 | while True: 338 | try: 339 | interaction, _ = await utils.wait_for_component_interaction( 340 | self.bot, interaction_id, users=[ctx.author], actions=["CATEGORY"] 341 | ) 342 | except asyncio.TimeoutError: 343 | components.disable_components() 344 | try: 345 | await ctx.interaction.edit_original_message(components=components) 346 | except discord.HTTPException: 347 | pass 348 | break 349 | 350 | category = interaction.values[0] 351 | leaderboard_cache = self.CATEGORIES[category] 352 | 353 | embed = leaderboard_cache.generate_embed(ctx) 354 | 355 | for option in select_menu.options: 356 | if option.value == category: 357 | option.default = True 358 | else: 359 | option.default = False 360 | 361 | await interaction.response.edit_message(embed=embed, components=components) 362 | 363 | 364 | async def setup(bot: utils.Bot): 365 | await bot.add_cog(LeaderboardCommandCog(bot)) 366 | -------------------------------------------------------------------------------- /cogs/loading.py: -------------------------------------------------------------------------------- 1 | from discord.ext import vbu 2 | 3 | from . import utils 4 | 5 | 6 | class LoadingCog(vbu.Cog[utils.Bot]): 7 | def __init__(self, bot: utils.Bot, logger_name: str | None = None): 8 | super().__init__(bot, logger_name) 9 | bot_ready_on_init = bot.is_ready() 10 | 11 | self.load_sync_managers() 12 | 13 | if bot_ready_on_init: 14 | bot.loop.create_task(self.load_async_managers()) 15 | 16 | def load_sync_managers(self) -> None: 17 | self.logger.info("Loading SYNC managers...") 18 | 19 | utils.ItemManager.load() 20 | self.logger.info(" * Loading ItemManager... success") 21 | 22 | utils.MinigameDialogueManager.load() 23 | self.logger.info(" * Loading MinigameDialogueManager... success") 24 | 25 | @vbu.Cog.listener("on_ready") 26 | async def load_async_managers(self) -> None: 27 | self.logger.info("Loading ASYNC managers...") 28 | 29 | await utils.SlashCommandMappingManager.load(self.bot) 30 | self.logger.info(" * Loading SlashCommandMappingManager... success") 31 | 32 | 33 | async def setup(bot: utils.Bot): 34 | await bot.add_cog(LoadingCog(bot)) 35 | -------------------------------------------------------------------------------- /cogs/new_command.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands, vbu 2 | 3 | from . import utils 4 | 5 | 6 | class NewCommandCog(vbu.Cog[utils.Bot]): 7 | @commands.command( 8 | "new", 9 | utils.Command, 10 | category=utils.CommandCategory.GETTING_STARTED, 11 | application_command_meta=commands.ApplicationCommandMeta(), 12 | ) 13 | @commands.is_slash_command() 14 | async def new_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 15 | """ 16 | Create your very own pp! 17 | """ 18 | async with utils.DatabaseWrapper() as db: 19 | embed = utils.Embed() 20 | 21 | try: 22 | await utils.Pp.fetch_from_user(db.conn, ctx.author.id) 23 | except utils.PpMissing: 24 | await db("INSERT INTO pps VALUES ($1)", ctx.author.id) 25 | embed.colour = utils.GREEN 26 | embed.description = f"{ctx.author.mention}, you now have a pp!" 27 | new_pp = True 28 | else: 29 | embed.colour = utils.RED 30 | embed.description = f"{ctx.author.mention}, you already have a pp!" 31 | new_pp = False 32 | 33 | embed.add_tip() 34 | 35 | await ctx.interaction.response.send_message(embed=embed) 36 | 37 | if not new_pp: 38 | return 39 | 40 | embed = utils.Embed() 41 | embed.color = utils.PINK 42 | embed.title = "welcome to pp bot!!" 43 | embed.url = utils.MEME_URL 44 | embed.description = ( 45 | "hello and welcome to the cock growing experience." 46 | " you've just created your very own pp," 47 | " but right now it's only **0 inches** (small as fuck)" 48 | "\n\n" 49 | "to start growing ur pp," 50 | f" use {utils.format_slash_command('grow')} and {utils.format_slash_command('beg')}" 51 | "\n\n" 52 | "when ur pp gets big enough," 53 | f" you can go to the {utils.format_slash_command('shop')}" 54 | " and buy yourself some pp-growing pills." 55 | " these pills increase your multiplier," 56 | " which makes you get more inches when using commands :)" 57 | "\n\n" 58 | "eventually you'll be able to buy some items that unlock new pp-growing commands." 59 | f" check {utils.format_slash_command('unlocked-commands')}" 60 | " to see what items unlock which commands" 61 | ) 62 | await ctx.interaction.followup.send(embed=embed, ephemeral=True) 63 | 64 | 65 | async def setup(bot: utils.Bot): 66 | await bot.add_cog(NewCommandCog(bot)) 67 | -------------------------------------------------------------------------------- /cogs/ping_command.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | from functools import partial 4 | 5 | import discord 6 | from discord.ext import commands, vbu 7 | 8 | from . import utils 9 | 10 | 11 | class PingCommandCog(vbu.Cog[utils.Bot]): 12 | 13 | @commands.command( 14 | "ping", 15 | utils.Command, 16 | category=utils.CommandCategory.OTHER, 17 | application_command_meta=commands.ApplicationCommandMeta(), 18 | ) 19 | @commands.is_slash_command() 20 | async def ping_command(self, ctx: commands.SlashContext[utils.Bot]) -> None: 21 | """ 22 | Check pp bot's reponse time 23 | """ 24 | 25 | embed = utils.Embed() 26 | embed.title = "Ping... 🏓 " 27 | 28 | start = time.time() 29 | 30 | await ctx.interaction.response.send_message(embed=embed) 31 | 32 | rest_api_ping = time.time() - start 33 | 34 | format_time = partial(utils.format_time, smallest_unit="millisecond") 35 | 36 | embed.title = "🏓 Pong!" 37 | embed.description = ( 38 | f"Discord Rest API ping: **{format_time(rest_api_ping)}**" 39 | f"\nDiscord Gateway avg. ping: **{format_time(self.bot.latency)}**" 40 | f"\n {utils.format_iterable(f'Shard #{shard_id}: **{format_time(latency)}**' for shard_id, latency in self.bot.latencies)}" 41 | ) 42 | self.bot.latency 43 | 44 | await ctx.interaction.edit_original_message(embed=embed) 45 | 46 | 47 | async def setup(bot: utils.Bot): 48 | await bot.add_cog(PingCommandCog(bot)) 49 | -------------------------------------------------------------------------------- /cogs/rename_command.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | import discord 4 | from discord.ext import commands, vbu 5 | 6 | from . import utils 7 | 8 | 9 | class RenameCommandCog(vbu.Cog[utils.Bot]): 10 | MAX_NAME_LENGTH = 32 11 | 12 | @commands.command( 13 | "rename", 14 | utils.Command, 15 | category=utils.CommandCategory.STATS, 16 | application_command_meta=commands.ApplicationCommandMeta( 17 | options=[ 18 | discord.ApplicationCommandOption( 19 | name="name", 20 | type=discord.ApplicationCommandOptionType.string, 21 | description="Your new name", 22 | ) 23 | ] 24 | ), 25 | ) 26 | @commands.is_slash_command() 27 | async def rename_command( 28 | self, ctx: commands.SlashContext[utils.Bot], name: str 29 | ) -> None: 30 | """ 31 | Rename your big ol' Johnson 32 | """ 33 | async with ( 34 | utils.DatabaseWrapper() as db, 35 | db.conn.transaction(), 36 | utils.DatabaseTimeoutManager.notify( 37 | ctx.author.id, "You're still busy renaming your pp!" 38 | ), 39 | ): 40 | pp = await utils.Pp.fetch_from_user(db.conn, ctx.author.id, edit=True) 41 | 42 | name = utils.clean(name) 43 | 44 | if len(name) > self.MAX_NAME_LENGTH: 45 | raise commands.BadArgument( 46 | f"That name is {len(name)} characters long," 47 | f" but the max is {self.MAX_NAME_LENGTH}" 48 | ) 49 | 50 | if pp.name.value == name: 51 | raise commands.BadArgument("Bro that's literally the same name lmao") 52 | 53 | pp.name.value = name 54 | await pp.update(db.conn) 55 | 56 | embed = utils.Embed() 57 | embed.colour = utils.GREEN 58 | embed.title = ( 59 | random.choice( 60 | [ 61 | "no problm", 62 | "here u go", 63 | "done and dusted", 64 | "nice name", 65 | ] 66 | ) 67 | + " :)" 68 | ) 69 | embed.description = ( 70 | f"{ctx.author.mention}, ur pp's name is now ~~{pp.name.start_value}~~" 71 | f" **{pp.name.value}**" 72 | ) 73 | embed.add_tip() 74 | 75 | await ctx.interaction.response.send_message(embed=embed) 76 | 77 | 78 | async def setup(bot: utils.Bot): 79 | await bot.add_cog(RenameCommandCog(bot)) 80 | -------------------------------------------------------------------------------- /cogs/reply_command.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from discord.ext import commands, vbu 3 | 4 | from . import utils 5 | 6 | 7 | class ReplyCommandCog(vbu.Cog[utils.Bot]): 8 | @commands.command( 9 | "reply", 10 | utils.Command, 11 | category=utils.CommandCategory.OTHER, 12 | application_command_meta=commands.ApplicationCommandMeta( 13 | options=[ 14 | discord.ApplicationCommandOption( 15 | name="content", 16 | type=discord.ApplicationCommandOptionType.string, 17 | description="Your reply to the minigame", 18 | ) 19 | ] 20 | ), 21 | ) 22 | @commands.is_slash_command() 23 | async def reply_command( 24 | self, ctx: commands.SlashContext[utils.Bot], content: str 25 | ) -> None: 26 | """ 27 | Only used to reply to pp bot events/minigames! 28 | """ 29 | 30 | if not isinstance(ctx.channel, discord.TextChannel): 31 | return 32 | 33 | try: 34 | future, check = utils.ReplyManager.active_listeners[ctx.channel] 35 | except KeyError: 36 | await ctx.interaction.response.send_message( 37 | f"There's nothing to reply to! If you've randomly stumbled across this command, don't worry. The {utils.format_slash_command('reply')} command is only meant to be used when the bot tells you to, i.e., during a random event.", 38 | ephemeral=True, 39 | ) 40 | return 41 | 42 | if check(ctx, content): 43 | future.set_result((ctx, content)) 44 | 45 | 46 | async def setup(bot: utils.Bot): 47 | await bot.add_cog(ReplyCommandCog(bot)) 48 | -------------------------------------------------------------------------------- /cogs/utils/__init__.py: -------------------------------------------------------------------------------- 1 | import discord 2 | 3 | MEME_URL = "https://youtu.be/4rgxdf-fVw0" 4 | VOTE_URL = "https://top.gg/bot/735147633076863027/vote" 5 | RED = discord.Colour(16007990) 6 | GREEN = discord.Colour(5025616) 7 | BLUE = discord.Colour(2201331) 8 | PINK = discord.Colour(15418782) 9 | REAL_LIFE_COMPARISONS: dict[int, str] = { 10 | 0: "your IRL pp", 11 | 60: "the average door", 12 | 4_133: "a football field", 13 | 11_800: "the Eiffel Tower", 14 | 14_519: "the depth of the ocean", 15 | 15_000: "the Empire State Building", 16 | 145_200: "the depth of the ocean", 17 | 348_385: "Mount Everest", 18 | 434_412: "the Mariana Trench", 19 | 4_588_228: "the 405 freeway", 20 | 219_173_228: "the distance of New York to London", 21 | 501_653_543: "the diameter of the fucking earth", 22 | 15_157_486_080: "the distance from the earth to the moon", 23 | 5_984_252_000_000: "the distance from the earth to THE SUN", 24 | } 25 | ITEM_COUNT_COMMENTS: dict[int, tuple[str, str]] = { 26 | 0: ("r u poor?", "r they poor?"), 27 | 1: ("Not much bro", "Not much"), 28 | 5: ("You must be new here", "They must be new here"), 29 | 10: ("You're getting there", "They're getting there"), 30 | 20: ("Not bad", "Not bad"), 31 | 100: ("That's pretty good", "That's pretty good"), 32 | 200: ( 33 | "You're either rich, or you don't know how to spend your inches wisely", 34 | "They're either rich, or they don't know how to spend their inches wisely", 35 | ), 36 | 500: ("God DAMN", "God DAMN"), 37 | 1000: ("You must be a collector or sum", "They must be a collector or sum"), 38 | 5000: ("Jesus fucking christ man", "Jesus fucking christ man"), 39 | 10_000: ( 40 | "You use this bot way too fucking much", 41 | "They use this bot way too fucking much", 42 | ), 43 | 20_000: ( 44 | "Are you mentally OK? Do u need a hug??", 45 | "Are they mentally OK? Do they need a hug??", 46 | ), 47 | 100_000: ( 48 | "Dude just give up this is too much", 49 | "Dude tell them to just give up this is too much", 50 | ), 51 | 1_000_000: ( 52 | "Okay. You win. I give up. I fucking quit. You win the game. Fuck you.", 53 | "Okay. They win. I give up. I fucking quit. They win the game. Fuck em.", 54 | ), 55 | } 56 | 57 | from .formatters import ( 58 | IntFormatType as IntFormatType, 59 | MarkdownFormat as MarkdownFormat, 60 | Article as Article, 61 | format_int as format_int, 62 | format_inches as format_inches, 63 | format_time as format_time, 64 | format_cooldown as format_cooldown, 65 | format_iterable as format_iterable, 66 | format_ordinal as format_ordinal, 67 | format_amount as format_amount, 68 | clean as clean, 69 | ) 70 | from .errors import ( 71 | PpMissing as PpMissing, 72 | PpNotBigEnough as PpNotBigEnough, 73 | InvalidArgumentAmount as InvalidArgumentAmount, 74 | ) 75 | from .helpers import ( 76 | limit_text as limit_text, 77 | compare as compare, 78 | find_nearest_number as find_nearest_number, 79 | Record as Record, 80 | Object as Object, 81 | DatabaseWrapperObject as DatabaseWrapperObject, 82 | DifferenceTracker as DifferenceTracker, 83 | RecordNotFoundError as RecordNotFoundError, 84 | Embed as Embed, 85 | RowLevelLockMode as RowLevelLockMode, 86 | IntegerHolder as IntegerHolder, 87 | is_weekend as is_weekend, 88 | ) 89 | from .cards import Rank as Rank, Suit as Suit, Deck as Deck, Hand as Hand 90 | from .bot import DatabaseWrapper as DatabaseWrapper, Bot as Bot 91 | from .command import ( 92 | ExtendBucketType as ExtendBucketType, 93 | CooldownFactory as CooldownFactory, 94 | CooldownTierInfoDict as CooldownTierInfoDict, 95 | CommandOnCooldown as CommandOnCooldown, 96 | RedisCooldownMapping as RedisCooldownMapping, 97 | CommandCategory as CommandCategory, 98 | Command as Command, 99 | ) 100 | from .managers import ( 101 | DuplicateReplyListenerError as DuplicateReplyListenerError, 102 | ReplyManager as ReplyManager, 103 | DatabaseTimeoutManager as DatabaseTimeoutManager, 104 | wait_for_component_interaction as wait_for_component_interaction, 105 | SlashCommandMappingManager as SlashCommandMappingManager, 106 | format_slash_command as format_slash_command, 107 | ) 108 | from .streaks import Streaks as Streaks 109 | from .items import ( 110 | UnknownItemError as UnknownItemError, 111 | UselessItem as UselessItem, 112 | LegacyItem as LegacyItem, 113 | MultiplierItem as MultiplierItem, 114 | BuffItem as BuffItem, 115 | ToolItem as ToolItem, 116 | Item as Item, 117 | InventoryItem as InventoryItem, 118 | ItemManager as ItemManager, 119 | MissingTool as MissingTool, 120 | ) 121 | from .pps import ( 122 | BoostType as BoostType, 123 | Pp as Pp, 124 | PpExtras as PpExtras, 125 | DatabaseTimeout as DatabaseTimeout, 126 | ) 127 | from .paginator import ( 128 | PaginatorActions as PaginatorActions, 129 | CategorisedPaginatorActions as CategorisedPaginatorActions, 130 | Paginator as Paginator, 131 | CategorisedPaginator as CategorisedPaginator, 132 | ) 133 | from .generate_rewards import give_random_reward as give_random_reward 134 | from .minigames import ( 135 | Minigame as Minigame, 136 | ReverseMinigame as ReverseMinigame, 137 | RepeatMinigame as RepeatMinigame, 138 | FillInTheBlankMinigame as FillInTheBlankMinigame, 139 | ClickThatButtonMinigame as ClickThatButtonMinigame, 140 | MinigameDialogueManager as MinigameDialogueManager, 141 | ) 142 | from .donations import Donation 143 | -------------------------------------------------------------------------------- /cogs/utils/bot.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | from typing import cast 3 | 4 | 5 | from discord.ext import vbu 6 | import asyncpg 7 | 8 | 9 | class DatabaseWrapper(vbu.DatabaseWrapper): 10 | conn: asyncpg.Connection 11 | 12 | async def __aenter__(self) -> DatabaseWrapper: 13 | return cast(DatabaseWrapper, await super().__aenter__()) 14 | 15 | 16 | class Bot(vbu.Bot): 17 | database: type[DatabaseWrapper] 18 | -------------------------------------------------------------------------------- /cogs/utils/cards.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import random 3 | from typing import overload, Literal 4 | from enum import StrEnum, IntEnum 5 | from . import Object, IntegerHolder 6 | 7 | 8 | class Suit(StrEnum): 9 | CLUBS = "♣" 10 | DIAMONDS = "♦" 11 | HEARTS = "♥" 12 | SPADES = "♠" 13 | 14 | 15 | class Rank(IntEnum): 16 | TWO = 2 17 | THREE = 3 18 | FOUR = 4 19 | FIVE = 5 20 | SIX = 6 21 | SEVEN = 7 22 | EIGHT = 8 23 | NINE = 9 24 | TEN = IntegerHolder(10) 25 | QUEEN = IntegerHolder(10) 26 | KING = IntegerHolder(10) 27 | JACK = IntegerHolder(10) 28 | ACE = 11 29 | 30 | 31 | class Card(Object): 32 | _repr_attributes = ("rank", "suit") 33 | 34 | def __init__(self, rank: Rank, suit: Suit): 35 | self.rank = rank 36 | self.suit = suit 37 | 38 | def __str__(self) -> str: 39 | return f"{self.rank.name.title()} of {self.suit.name.lower()}" 40 | 41 | def __format__(self, format_spec: str): 42 | if format_spec == "": 43 | return str(self) 44 | if format_spec == "blackjack": 45 | return f"`{self.suit.value} {self.rank.name.title()}`" 46 | raise ValueError(f"Invalid format specification {format_spec!r}") 47 | 48 | 49 | class Deck(Object): 50 | _repr_attributes = ("cards",) 51 | 52 | def __init__(self): 53 | self.cards = [Card(rank, suit) for rank in Rank for suit in Suit] 54 | 55 | def shuffle(self): 56 | random.shuffle(self.cards) 57 | 58 | @overload 59 | def draw(self, amount: Literal[1] = 1, *, hand: Hand | None = None) -> Card: ... 60 | 61 | @overload 62 | def draw(self, amount: int, *, hand: Hand | None = None) -> list[Card]: ... 63 | 64 | def draw(self, amount: int = 1, *, hand: Hand | None = None) -> Card | list[Card]: 65 | cards = [self.cards.pop() for _ in range(amount)] 66 | if hand is not None: 67 | hand.give(*cards) 68 | if len(cards) == 1: 69 | return cards[0] 70 | return cards 71 | 72 | 73 | class Hand(Object): 74 | _repr_attributes = ("cards",) 75 | 76 | def __init__(self): 77 | self.cards: list[Card] = [] 78 | 79 | def give(self, *cards: Card): 80 | self.cards.extend(cards) 81 | 82 | def __str__(self) -> str: 83 | return ", ".join(map(str, self.cards)) 84 | 85 | def __format__(self, format_spec: str): 86 | if format_spec == "": 87 | return str(self) 88 | if format_spec == "blackjack": 89 | return " ".join(f"{card:blackjack}" for card in self.cards) 90 | raise ValueError(f"Invalid format specification {format_spec!r}") 91 | 92 | 93 | class BlackjackHand(Hand): 94 | _repr_attributes = ("cards", "hide_second_card") 95 | 96 | def __init__(self, *, hide_second_card: bool = False): 97 | super().__init__() 98 | self.hide_second_card = hide_second_card 99 | 100 | @property 101 | def total(self) -> int: 102 | total = 0 103 | for card in self.cards: 104 | total += card.rank.value 105 | return total 106 | 107 | @property 108 | def visual_total(self) -> int: 109 | total = 0 110 | if self.hide_second_card and len(self.cards) == 2: 111 | return self.cards[0].rank.value 112 | return total 113 | -------------------------------------------------------------------------------- /cogs/utils/command.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import time 3 | from datetime import timezone 4 | from enum import Enum 5 | from typing import Any, cast, Callable, Coroutine, Self, TypedDict 6 | 7 | import discord 8 | from discord.ext import commands, vbu 9 | 10 | from . import Bot, format_cooldown, VOTE_URL 11 | 12 | 13 | type ExtendBucketType = commands.BucketType | Callable[ 14 | [discord.Message | discord.Interaction], Any 15 | ] 16 | type CooldownFactory = Callable[ 17 | [commands.Context[Bot]], 18 | Coroutine[Any, Any, tuple[commands.Cooldown, ExtendBucketType]], 19 | ] 20 | 21 | 22 | class RedisCooldownMapping(commands.CooldownMapping): 23 | 24 | def __init__(self, original: commands.Cooldown | None, type: ExtendBucketType): 25 | super().__init__(original, type) # pyright: ignore[reportArgumentType] 26 | 27 | def redis_bucket_key( 28 | self, 29 | ctx: commands.Context[Bot], 30 | identifier: discord.Message | discord.Interaction, 31 | ) -> Any: 32 | ctx.command = cast(Command, ctx.command) 33 | if ( 34 | self._type == commands.BucketType.default 35 | ): # pyright: ignore [reportUnnecessaryComparison] 36 | return f"cooldowns:{ctx.command.name}:default" 37 | return f"cooldowns:{ctx.command.name}:{self._type(identifier)}" 38 | 39 | async def redis_get_bucket( 40 | self, redis: vbu.Redis, key: str, current: float | None = None 41 | ) -> tuple[int, float]: 42 | """Returns `(tokens: int, window: float)`""" 43 | assert self._cooldown is not None 44 | 45 | if current is None: 46 | current = time.time() 47 | 48 | data = await redis.get(key) 49 | 50 | if data is None: 51 | tokens = self._cooldown.rate 52 | window = 0.0 53 | else: 54 | tokens_data, window_data = data.split(":") 55 | tokens = int(tokens_data) 56 | window = float(window_data) 57 | 58 | if current > window + self._cooldown.per: 59 | assert redis.conn is not None 60 | assert redis.pool is not None 61 | await redis.pool.delete(key) 62 | tokens = self._cooldown.rate 63 | 64 | return tokens, window 65 | 66 | async def redis_update_rate_limit( 67 | self, key: str, current: float | None = None 68 | ) -> tuple[commands.Cooldown, float | None]: 69 | """Returns `(cooldown: Cooldown, retry_after: float | None)`""" 70 | assert self._cooldown is not None 71 | if current is None: 72 | current = time.time() 73 | 74 | async with vbu.Redis() as redis: 75 | tokens, window = await self.redis_get_bucket(redis, key, current) 76 | 77 | if tokens == 0: 78 | return self._cooldown.copy(), self._cooldown.per - (current - window) 79 | 80 | if tokens == self._cooldown.rate: 81 | window = current 82 | 83 | tokens -= 1 84 | await redis.set(key, f"{tokens}:{window}") 85 | 86 | return self._cooldown.copy(), None 87 | 88 | async def redis_get_retry_after( 89 | self, key: str, current: float | None = None 90 | ) -> float: 91 | assert self._cooldown is not None 92 | if current is None: 93 | current = time.time() 94 | 95 | async with vbu.Redis() as redis: 96 | tokens, window = await self.redis_get_bucket(redis, key, current) 97 | 98 | if tokens == 0: 99 | return self._cooldown.per - (current - window) 100 | 101 | return 0.0 102 | 103 | async def redis_reset(self, key: str): 104 | assert self._cooldown is not None 105 | async with vbu.Redis() as redis: 106 | await redis.set(key, f"{self._cooldown._tokens}:0") 107 | 108 | 109 | class CommandCategory(Enum): 110 | GETTING_STARTED = "getting started" 111 | STATS = "see ur stats" 112 | GROWING_PP = "growing ur pp" 113 | SHOP = "shop stuff" 114 | GAMBLING = "gambling time" 115 | FUN = "fun shit" 116 | OTHER = "other pp things" 117 | HELP = "help & info" 118 | 119 | 120 | class CooldownTierInfoDict(TypedDict): 121 | default: commands.Cooldown 122 | voter: commands.Cooldown 123 | 124 | 125 | class CommandOnCooldown(commands.CommandOnCooldown): 126 | def __init__( 127 | self, 128 | cooldown: commands.Cooldown, 129 | retry_after: float, 130 | type: commands.BucketType, 131 | *, 132 | tier_info: CooldownTierInfoDict | None = None, 133 | ) -> None: 134 | super().__init__(cooldown, retry_after, type) 135 | 136 | self.tier_info = tier_info 137 | 138 | @property 139 | def tier(self) -> str: 140 | if self.tier_info is None: 141 | return "default" 142 | 143 | for tier_name, tier_cooldown in self.tier_info.items(): 144 | assert isinstance(tier_cooldown, commands.Cooldown) 145 | if ( 146 | tier_cooldown.rate == self.cooldown.rate 147 | and tier_cooldown.per == self.cooldown.per 148 | ): 149 | return tier_name 150 | 151 | raise Exception(f"No tier matches cooldown {self.cooldown}") 152 | 153 | def format_tiers(self) -> str: 154 | if self.tier_info is None: 155 | return f"Cooldown: `{format_cooldown(self.cooldown)}`" 156 | 157 | tiers: list[str] = [] 158 | for tier_name, tier_cooldown in self.tier_info.items(): 159 | assert isinstance(tier_cooldown, commands.Cooldown) 160 | 161 | if tier_name == "voter": 162 | tier_name = f"[**{tier_name}**]({VOTE_URL})" 163 | 164 | tier = f"{tier_name} cooldown: `{format_cooldown(tier_cooldown)}`" 165 | 166 | if ( 167 | tier_cooldown.rate == self.cooldown.rate 168 | and tier_cooldown.per == self.cooldown.per 169 | ): 170 | tier += " (This is you!)" 171 | 172 | tiers.append(tier) 173 | 174 | return "\n".join(tiers) 175 | 176 | 177 | class Command(commands.Command): 178 | category: CommandCategory 179 | _cooldown_factory: CooldownFactory | None 180 | _buckets: RedisCooldownMapping 181 | 182 | def __init__( 183 | self, 184 | func, 185 | *, 186 | category: CommandCategory = CommandCategory.OTHER, 187 | cooldown_factory: CooldownFactory | None = None, 188 | cooldown_tier_info: CooldownTierInfoDict | None = None, 189 | **kwargs, 190 | ): 191 | super().__init__(func, **kwargs) 192 | self.category = category 193 | 194 | try: 195 | self._cooldown_factory = cast( 196 | CooldownFactory | None, func.__commands_cooldown_factory__ 197 | ) 198 | except AttributeError: 199 | self._cooldown_factory = cooldown_factory 200 | 201 | try: 202 | self._cooldown_tier_info = cast( 203 | CooldownTierInfoDict | None, func.__commands_cooldown_tier_info__ 204 | ) 205 | except AttributeError: 206 | self._cooldown_tier_info = cooldown_tier_info 207 | 208 | self._buckets = RedisCooldownMapping( 209 | self._buckets._cooldown, self._buckets._type 210 | ) 211 | 212 | def _ensure_assignment_on_copy[CommandT: commands.Command]( 213 | self: Self, other: CommandT 214 | ) -> CommandT: 215 | super()._ensure_assignment_on_copy(other) 216 | 217 | try: 218 | other._cooldown_factory = ( # pyright: ignore[reportAttributeAccessIssue] 219 | self._cooldown_factory 220 | ) 221 | except AttributeError: 222 | pass 223 | 224 | return other 225 | 226 | async def _get_buckets(self, ctx: commands.Context[Bot]) -> RedisCooldownMapping: 227 | if self._cooldown_factory is not None: 228 | cooldown, bucket_type = await self._cooldown_factory(ctx) 229 | return RedisCooldownMapping(cooldown, bucket_type) 230 | return self._buckets 231 | 232 | async def _async_prepare_cooldowns(self, ctx: commands.Context[Bot]) -> None: 233 | assert isinstance(ctx.command, Command) 234 | buckets = await self._get_buckets(ctx) 235 | if buckets.valid: 236 | dt = (ctx.message.edited_at or ctx.message.created_at) if ctx.message else discord.utils.snowflake_time(ctx.interaction.id) # type: ignore 237 | current = dt.replace(tzinfo=timezone.utc).timestamp() 238 | cooldown, retry_after = await buckets.redis_update_rate_limit( 239 | buckets.redis_bucket_key(ctx, buckets.get_message(ctx)), 240 | current, 241 | ) 242 | if retry_after: 243 | raise CommandOnCooldown(cooldown, retry_after, buckets.type, tier_info=self._cooldown_tier_info) # type: ignore 244 | 245 | async def _prepare_text(self, ctx: commands.Context[Bot]) -> None: 246 | ctx.command = self 247 | 248 | if not await self.can_run(ctx): 249 | raise commands.CheckFailure( 250 | f"The check functions for command {self.qualified_name} failed." 251 | ) 252 | 253 | if self._max_concurrency is not None: 254 | # For this application, context can be duck-typed as a Message 255 | await self._max_concurrency.acquire(ctx) # type: ignore 256 | 257 | try: 258 | if self.cooldown_after_parsing: 259 | await self._parse_arguments(ctx) 260 | await self._async_prepare_cooldowns(ctx) 261 | else: 262 | await self._async_prepare_cooldowns(ctx) 263 | await self._parse_arguments(ctx) 264 | 265 | await self.call_before_hooks(ctx) 266 | except: 267 | if self._max_concurrency is not None: 268 | await self._max_concurrency.release(ctx) # type: ignore 269 | raise 270 | 271 | async def _prepare_slash(self, ctx: commands.SlashContext[Bot]) -> None: 272 | ctx.command = self 273 | 274 | if not await self.can_run(ctx): 275 | raise commands.CheckFailure( 276 | f"The check functions for command {self.qualified_name} failed." 277 | ) 278 | 279 | if self._max_concurrency is not None: 280 | # For this application, context can be duck-typed as a Message 281 | await self._max_concurrency.acquire(ctx) # type: ignore 282 | 283 | try: 284 | if self.cooldown_after_parsing: 285 | await self._parse_slash_arguments(ctx) 286 | await self._async_prepare_cooldowns(ctx) 287 | else: 288 | await self._async_prepare_cooldowns(ctx) 289 | await self._parse_slash_arguments(ctx) 290 | 291 | await self.call_before_hooks(ctx) 292 | except: 293 | if self._max_concurrency is not None: 294 | await self._max_concurrency.release(ctx) # type: ignore 295 | raise 296 | 297 | def is_on_cooldown(self, *_, **_1): 298 | raise NotImplementedError("Use async_is_on_cooldown instead.") 299 | 300 | async def async_is_on_cooldown(self, ctx: commands.Context[Bot]) -> bool: 301 | buckets = await self._get_buckets(ctx) 302 | if not buckets.valid: 303 | return False 304 | dt = (ctx.message.edited_at or ctx.message.created_at) if ctx.message else discord.utils.snowflake_time(ctx.interaction.id) # type: ignore 305 | current = dt.replace(tzinfo=timezone.utc).timestamp() 306 | async with vbu.Redis() as redis: 307 | return ( 308 | await buckets.redis_get_bucket( 309 | redis, 310 | buckets.redis_bucket_key(ctx, buckets.get_message(ctx)), 311 | current, 312 | ) 313 | )[0] == 0 314 | 315 | def reset_cooldown(self, *_, **_1): 316 | raise NotImplementedError("Use async_reset_cooldown instead.") 317 | 318 | async def async_reset_cooldown(self, ctx: commands.Context[Bot]) -> None: 319 | buckets = await self._get_buckets(ctx) 320 | if buckets.valid: 321 | await buckets.redis_reset( 322 | buckets.redis_bucket_key(ctx, buckets.get_message(ctx)) 323 | ) 324 | 325 | def get_cooldown_retry_after(self, *_, **_1): 326 | raise NotImplementedError("Use async_get_cooldown_retry_after instead.") 327 | 328 | async def async_get_cooldown_retry_after(self, ctx: commands.Context[Bot]): 329 | buckets = await self._get_buckets(ctx) 330 | if buckets.valid: 331 | dt = (ctx.message.edited_at or ctx.message.created_at) if ctx.message else discord.utils.snowflake_time(ctx.interaction.id) # type: ignore 332 | current = dt.replace(tzinfo=timezone.utc).timestamp() 333 | return await buckets.redis_get_retry_after( 334 | buckets.redis_bucket_key(ctx, buckets.get_message(ctx)), 335 | current, 336 | ) 337 | 338 | return 0.0 339 | 340 | @classmethod 341 | def cooldown_factory[T]( 342 | cls: type[Self], 343 | cooldown_factory: CooldownFactory, 344 | *, 345 | cooldown_tier_info: CooldownTierInfoDict | None = None, 346 | ) -> Callable[[T], T]: 347 | def decorator( 348 | func: Self | Callable[..., Coroutine[Any, Any, Any]], 349 | ) -> Self | Callable[..., Coroutine[Any, Any, Any]]: 350 | if isinstance(func, cls): 351 | func._cooldown_factory = cooldown_factory 352 | func._cooldown_tier_info = cooldown_tier_info 353 | else: 354 | func.__commands_cooldown_factory__ = ( # pyright: ignore[reportAttributeAccessIssue, reportFunctionMemberAccess] 355 | cooldown_factory 356 | ) 357 | func.__commands_cooldown_tier_info__ = ( # pyright: ignore[reportAttributeAccessIssue, reportFunctionMemberAccess] 358 | cooldown_tier_info 359 | ) 360 | return func 361 | 362 | return decorator # type: ignore 363 | 364 | @classmethod 365 | def tiered_cooldown( 366 | cls: type[Self], 367 | *, 368 | default: commands.Cooldown | int, 369 | voter: commands.Cooldown | int, 370 | ): 371 | 372 | if isinstance(default, commands.Cooldown): 373 | default = default 374 | else: 375 | default = commands.Cooldown(1, default) 376 | 377 | if isinstance(voter, commands.Cooldown): 378 | voter = voter 379 | else: 380 | voter = commands.Cooldown(1, voter) 381 | 382 | cooldown_tier_info: CooldownTierInfoDict = { 383 | "default": default, 384 | "voter": voter, 385 | } 386 | 387 | async def cooldown_factory( 388 | ctx: commands.Context[Bot], 389 | ) -> tuple[commands.Cooldown, ExtendBucketType]: 390 | if await vbu.user_has_voted(ctx.author.id): 391 | tier = voter 392 | else: 393 | tier = default 394 | 395 | return tier, commands.BucketType.user 396 | 397 | return cls.cooldown_factory( 398 | cooldown_factory, cooldown_tier_info=cooldown_tier_info 399 | ) 400 | -------------------------------------------------------------------------------- /cogs/utils/donations.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Self 3 | 4 | import asyncpg 5 | 6 | from . import ( 7 | DatabaseWrapperObject, 8 | ) 9 | 10 | 11 | class Donation(DatabaseWrapperObject): 12 | __slots__ = ("user_id", "created_at", "amount") 13 | _repr_attributes = __slots__ 14 | _table = "donations" 15 | _columns = { 16 | "recipiant_id": "recipiant_id", 17 | "donor_id": "donor_id", 18 | "created_at": "created_at", 19 | "amount": "amount", 20 | } 21 | _column_attributes = {attribute: column for column, attribute in _columns.items()} 22 | 23 | def __init__( 24 | self, recipiant_id: int, donor_id: int, created_at: datetime, amount: int 25 | ) -> None: 26 | self.recipiant_id = recipiant_id 27 | self.donor_id = donor_id 28 | self.created_at = created_at 29 | self.amount = amount 30 | 31 | @classmethod 32 | async def register( 33 | cls, 34 | connection: asyncpg.Connection, 35 | recipiant_id: int, 36 | donor_id: int, 37 | amount: int, 38 | ) -> None: 39 | await connection.execute( 40 | """ 41 | INSERT INTO donations (recipiant_id, donor_id, amount) 42 | VALUES ($1, $2, $3) 43 | """, 44 | recipiant_id, 45 | donor_id, 46 | amount, 47 | ) 48 | 49 | @classmethod 50 | async def fetch_received_donations( 51 | cls, 52 | connection: asyncpg.Connection, 53 | user_id: int, 54 | *, 55 | timeout: float | None = 2, 56 | ) -> list[Self]: 57 | records = await connection.fetch( 58 | f""" 59 | SELECT * 60 | FROM {cls._table} 61 | WHERE recipiant_id = $1 62 | """, 63 | user_id, 64 | timeout=timeout, 65 | ) 66 | return [cls.from_record(record) for record in records] 67 | 68 | @classmethod 69 | async def fetch_relevant_received_donation_sum( 70 | cls, 71 | connection: asyncpg.Connection, 72 | user_id: int, 73 | *, 74 | timeout: float | None = 2, 75 | ) -> int: 76 | record = await connection.fetchrow( 77 | f""" 78 | SELECT sum(amount) AS total_amount 79 | FROM {cls._table} 80 | WHERE recipiant_id = $1 81 | AND created_at > timezone('UTC', now()) - INTERVAL '24 hours' 82 | """, 83 | user_id, 84 | timeout=timeout, 85 | ) 86 | 87 | if record is None or record["total_amount"] is None: 88 | return 0 89 | 90 | return record["total_amount"] 91 | -------------------------------------------------------------------------------- /cogs/utils/errors.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import discord 4 | from discord.ext import commands 5 | 6 | 7 | class PpMissing(commands.CheckFailure): 8 | def __init__( 9 | self, 10 | message: str | None = None, 11 | *args: Any, 12 | user: discord.User | discord.Member | None = None, 13 | ) -> None: 14 | super().__init__(message, *args) 15 | self.user = user 16 | 17 | 18 | class PpNotBigEnough(commands.CheckFailure): 19 | pass 20 | 21 | 22 | class InvalidArgumentAmount(commands.BadArgument): 23 | def __init__( 24 | self, 25 | message: str | None = None, 26 | *args: Any, 27 | argument: str, 28 | min: int | None = None, 29 | max: int | None = None, 30 | special_amounts: list[str] | None = None, 31 | ) -> None: 32 | super().__init__(message, *args) 33 | self.argument = argument 34 | self.min = min 35 | self.max = max 36 | 37 | if special_amounts is not None: 38 | self.special_amounts = special_amounts 39 | else: 40 | special_amounts = [] 41 | -------------------------------------------------------------------------------- /cogs/utils/formatters.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import math 3 | from collections.abc import Iterable 4 | from datetime import timedelta 5 | from typing import Any, Literal, overload 6 | 7 | from discord.ext import commands 8 | 9 | from . import MEME_URL 10 | 11 | 12 | TimeUnitLiteral = Literal[ 13 | "year", "week", "day", "hour", "minute", "second", "millisecond" 14 | ] 15 | 16 | _UNITS = { 17 | "million": "m", 18 | "billion": "b", 19 | "trillion": "t", 20 | "quadrillion": "q", 21 | "quintillion": " qt", 22 | "sextillion": " sext.", 23 | "septillion": " sept.", 24 | "octillion": " oct.", 25 | "nonillion": " non.", 26 | "decillion": " dec.", 27 | "undecillion": " und.", 28 | "duodecillion": " duo.", 29 | "tredicillion": " tre.", 30 | "quattuordecillion": " qua.", 31 | "quindecillion": " qui.", 32 | "sexdecillion": " sexd.", 33 | "septendecillion": "s epte.", 34 | "octodecillion": " octo.", 35 | "novemdecillion": " nov.", 36 | "vigintillion": " vig.", 37 | } 38 | _TIME_UNITS: dict[TimeUnitLiteral, float] = { 39 | "year": 60 * 60 * 24 * 365, 40 | "week": 60 * 60 * 24 * 7, 41 | "day": 60 * 60 * 24, 42 | "hour": 60 * 60, 43 | "minute": 60, 44 | "second": 1, 45 | "millisecond": 1 / 1000, 46 | } 47 | 48 | 49 | class IntFormatType(enum.Enum): 50 | FULL = enum.auto() 51 | FULL_UNIT = enum.auto() 52 | ABBREVIATED_UNIT = enum.auto() 53 | 54 | 55 | class MarkdownFormat(enum.Enum): 56 | BOLD_BLUE = enum.auto() 57 | BOLD = enum.auto() 58 | 59 | 60 | class Article(enum.Enum): 61 | DEFINITE = enum.auto() 62 | INDEFINITE_A = enum.auto() 63 | INDEFINITE_AN = enum.auto() 64 | INDEFINITE = enum.auto() 65 | NUMERAL = enum.auto() 66 | 67 | 68 | def format_int( 69 | __int: int, /, format_type: IntFormatType = IntFormatType.FULL_UNIT 70 | ) -> str: 71 | """ 72 | IntFormatType.FULL_UNIT -> 123.45 million 73 | 74 | IntFormatType.ABBREVIATED_UNIT -> 123.45M 75 | 76 | IntFormatType.FULL -> 123,456,789 77 | """ 78 | if format_type == IntFormatType.FULL or -(10**6) < __int < 10**6: 79 | return f"{__int:,}" 80 | else: 81 | unit = math.floor(math.log10(__int + 1 if not __int else abs(__int))) // 3 82 | unit_value = math.floor(__int / 10 ** (unit * 3) * 100) / 100 83 | 84 | if unit_value.is_integer(): 85 | unit_value = math.floor(unit_value) 86 | 87 | try: 88 | unit = list(_UNITS)[unit - 2] 89 | except IndexError: 90 | if format_type == IntFormatType.FULL_UNIT: 91 | return "infinity" 92 | return "inf." 93 | 94 | if format_type == IntFormatType.FULL_UNIT: 95 | return f"{unit_value} {unit}" 96 | 97 | return f"{unit_value}{_UNITS[unit].upper()}" 98 | 99 | 100 | def format_inches( 101 | __inches: int, 102 | /, 103 | *, 104 | markdown: MarkdownFormat | None = MarkdownFormat.BOLD, 105 | in_between: str | None = None, 106 | ) -> str: 107 | if in_between is None: 108 | in_between = "" 109 | else: 110 | in_between += " " 111 | 112 | if markdown is None: 113 | return f"{format_int(__inches)} {in_between}inch{'' if __inches == 1 else 'es'}" 114 | 115 | if markdown == MarkdownFormat.BOLD: 116 | return f"**{format_int(__inches)}** {in_between}inch{'' if __inches == 1 else 'es'}" 117 | 118 | return f"**[{format_int(__inches)}]({MEME_URL}) {in_between}inch{'' if __inches == 1 else 'es'}**" 119 | 120 | 121 | @overload 122 | def format_time( 123 | duration: timedelta, 124 | /, 125 | smallest_unit: TimeUnitLiteral | None = "second", 126 | *, 127 | adjective: bool = False, 128 | max_decimals: int = 0, 129 | ) -> str: ... 130 | 131 | 132 | @overload 133 | def format_time( 134 | seconds: float, 135 | /, 136 | smallest_unit: TimeUnitLiteral | None = "second", 137 | *, 138 | adjective: bool = False, 139 | max_decimals: int = 0, 140 | ) -> str: ... 141 | 142 | 143 | def format_time( 144 | __time: timedelta | float, 145 | /, 146 | smallest_unit: TimeUnitLiteral | None = "second", 147 | *, 148 | adjective: bool = False, 149 | max_decimals: int = 0, 150 | ) -> str: 151 | durations: list[str] = [] 152 | 153 | if isinstance(__time, timedelta): 154 | seconds = __time.total_seconds() 155 | else: 156 | seconds = __time 157 | 158 | biggest_unit: TimeUnitLiteral | None = None 159 | 160 | for time_unit, time_unit_value in _TIME_UNITS.items(): 161 | if biggest_unit is None: 162 | if seconds // time_unit_value: 163 | biggest_unit = time_unit 164 | elif ( 165 | time_unit == smallest_unit 166 | and (seconds * 10**max_decimals) // time_unit_value 167 | ): 168 | biggest_unit = time_unit 169 | 170 | if ( 171 | time_unit == smallest_unit 172 | and biggest_unit == smallest_unit 173 | and (seconds * 10**max_decimals) // time_unit_value 174 | ): 175 | suffix = "s" if not adjective else "" 176 | durations.append( 177 | f"{seconds / time_unit_value:,.{max_decimals}f} {time_unit}{suffix}" 178 | ) 179 | 180 | elif seconds // time_unit_value: 181 | suffix = "s" if seconds // time_unit_value != 1 and not adjective else "" 182 | durations.append(f"{int(seconds // time_unit_value)} {time_unit}{suffix}") 183 | 184 | if time_unit == smallest_unit: 185 | break 186 | 187 | seconds -= seconds // time_unit_value * time_unit_value 188 | 189 | try: 190 | last_duration = durations.pop() 191 | except IndexError: 192 | return f"0 {smallest_unit}s" 193 | 194 | if durations: 195 | return f"{', '.join(durations)} and {last_duration}" 196 | 197 | return last_duration 198 | 199 | 200 | def format_cooldown(__cooldown: commands.Cooldown, /) -> str: 201 | if __cooldown.rate == 1: 202 | return format_time(timedelta(seconds=__cooldown.per)) 203 | 204 | per = format_time(timedelta(seconds=__cooldown.per)) 205 | if per.startswith("1 "): 206 | per = per[2:] 207 | 208 | return f"{__cooldown.rate} times per {per}" 209 | 210 | 211 | def format_iterable( 212 | __iterable: Iterable[Any], /, *, inline: bool = False, joiner: str = "- " 213 | ) -> str: 214 | """ 215 | Example: 216 | format_iterable([1, 2, 3]) -> "\\- 1\n\\- 2\n\\- 3" 217 | format_iterable([1, 2, 3], inline=True) -> "1, 2 and 3" 218 | """ 219 | 220 | values = [str(i) for i in __iterable] or ["nothing"] 221 | 222 | if not inline: 223 | return joiner + f"\n{joiner}".join(values) 224 | 225 | if len(values) < 3: 226 | return " and ".join(values) 227 | 228 | last_value = values.pop() 229 | return f"{', '.join(values)} and {last_value}" 230 | 231 | 232 | def format_ordinal(__ordinal: int, /) -> str: 233 | """ 234 | Example: 235 | format_ordinal(1) -> "1st" 236 | format_ordinal(2) -> "2nd" 237 | format_ordinal(48) -> "48th" 238 | """ 239 | 240 | if 10 <= __ordinal % 100 <= 20: 241 | suffix = "th" 242 | else: 243 | suffixes = {1: "st", 2: "nd", 3: "rd"} 244 | suffix = suffixes.get(__ordinal % 10, "th") 245 | 246 | return f"{__ordinal:,}{suffix}" 247 | 248 | 249 | @overload 250 | def format_amount( 251 | singular: str, 252 | plural: str, 253 | amount: int, 254 | *, 255 | markdown: MarkdownFormat = MarkdownFormat.BOLD, 256 | full_markdown: bool = False, 257 | article: Article | None = None, 258 | ) -> str: ... 259 | 260 | 261 | @overload 262 | def format_amount( 263 | singular: str, 264 | plural: str, 265 | amount: int, 266 | *, 267 | markdown: None, 268 | article: Article | None = None, 269 | ) -> str: ... 270 | 271 | 272 | @overload 273 | def format_amount( 274 | singular: str, 275 | plural: str, 276 | amount: int, 277 | *, 278 | markdown: MarkdownFormat | None = MarkdownFormat.BOLD, 279 | full_markdown: bool = False, 280 | article: Article | None = None, 281 | ) -> str: ... 282 | 283 | 284 | def format_amount( 285 | singular: str, 286 | plural: str, 287 | amount: int, 288 | *, 289 | markdown: MarkdownFormat | None = MarkdownFormat.BOLD, 290 | full_markdown: bool = False, 291 | article: Article | None = None, 292 | ) -> str: 293 | """ 294 | Example: 295 | format_amount('knife', 'knives', 1, full_markdown=True}) -> "a **knife**" 296 | 297 | format_amount('knife', 'knives', 2}) -> "**2** knives" 298 | """ 299 | 300 | if amount == 1: 301 | prefix = "" 302 | prefix_markdown = False 303 | 304 | if article == Article.DEFINITE: 305 | prefix = "the " 306 | 307 | elif article == Article.INDEFINITE_A: 308 | prefix = "a " 309 | 310 | elif article == Article.INDEFINITE_AN: 311 | prefix = "an " 312 | 313 | elif article == Article.INDEFINITE: 314 | prefix = "an " if singular[0] in "aeiou" else "a " 315 | 316 | elif article == Article.NUMERAL: 317 | prefix = f"1 " 318 | prefix_markdown = True 319 | 320 | if not full_markdown or markdown is None: 321 | return f"{prefix}{singular}" 322 | 323 | if prefix_markdown: 324 | if markdown == MarkdownFormat.BOLD: 325 | return f"**{prefix}{singular}**" 326 | 327 | return f"**[{prefix}{singular}]({MEME_URL})**" 328 | 329 | if markdown == MarkdownFormat.BOLD: 330 | return f"{prefix}**{singular}**" 331 | 332 | return f"{prefix}**[{singular}]({MEME_URL})**" 333 | 334 | if markdown is None: 335 | return f"{format_int(amount)} {plural}" 336 | 337 | if not full_markdown: 338 | if markdown == MarkdownFormat.BOLD: 339 | return f"**{format_int(amount)}** {plural}" 340 | 341 | return f"**[{format_int(amount)}]({MEME_URL})** {plural}" 342 | 343 | if markdown == MarkdownFormat.BOLD: 344 | return f"**{format_int(amount)} {plural}**" 345 | 346 | return f"**[{format_int(amount)} {plural}]({MEME_URL})**" 347 | 348 | 349 | def clean(text: str) -> str: 350 | markdown_replacements: dict[str, str] = { 351 | "*": "", 352 | "~": "", 353 | "_": "", 354 | "`": "", 355 | "\\": "", 356 | "[": "(", 357 | "]": ")", 358 | "https://": "", 359 | "http://": "", 360 | "#": "", 361 | "@": "", 362 | " tuple[str, int, dict[InventoryItem, int]]: 20 | """ 21 | Returns `(message: str, growth: int, reward_items: dict[reward_item: InventoryItem, amount: int])` 22 | """ 23 | segments: list[str] = [] 24 | 25 | growth = pp.grow_with_multipliers( 26 | random.randint(*growth_range), 27 | voted=await pp.has_voted(), 28 | ) 29 | await pp.update(connection) 30 | segments.append(pp.format_growth()) 31 | 32 | reward_item_ids: list[str] = [] 33 | reward_items: dict[InventoryItem, int] = {} 34 | 35 | while True: 36 | # 100% chance of 1+ items 37 | # 50% chance of 2+ items 38 | # 17% chance of 3+ items 39 | # 4% chance of 4+ items 40 | # etc.. 41 | if reward_item_ids and random.randint(0, len(reward_item_ids)): 42 | break 43 | try: 44 | reward_item = InventoryItem( 45 | pp.user_id, 46 | random.choice( 47 | [ 48 | item_id 49 | for item_id, item in itertools.chain( 50 | ItemManager.tools.items(), 51 | ItemManager.useless.items(), 52 | ItemManager.buffs.items(), 53 | ) 54 | if item.price < max_item_reward_price * pp.multiplier.value 55 | ] 56 | ), 57 | 0, 58 | ) 59 | except IndexError: 60 | break 61 | 62 | if reward_item.id in reward_item_ids: 63 | break 64 | 65 | reward_item.amount.value += random.randint( 66 | 1, 67 | max_item_reward_price * pp.multiplier.value // reward_item.item.price * 3, 68 | ) 69 | 70 | reward_items[reward_item] = reward_item.amount.value 71 | 72 | await reward_item.update(connection, additional=True) 73 | reward_item_ids.append(reward_item.id) 74 | segments.append( 75 | reward_item.format_item(article=reward_item.item.indefinite_article) 76 | ) 77 | 78 | return formatter(segments), growth, reward_items 79 | -------------------------------------------------------------------------------- /cogs/utils/managers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import asyncio 3 | import random 4 | from collections.abc import Callable 5 | 6 | import discord 7 | from discord.ext import commands 8 | 9 | from . import MEME_URL, Bot, Object 10 | 11 | 12 | class DuplicateReplyListenerError(Exception): 13 | pass 14 | 15 | 16 | class ReplyManager: 17 | """See further implementation in /cogs/reply_command.py""" 18 | 19 | DEFAULT_TIMEOUT = 30 20 | 21 | active_listeners: dict[ 22 | discord.TextChannel, 23 | tuple[ 24 | asyncio.Future[tuple[commands.SlashContext[Bot], str]], 25 | Callable[[commands.SlashContext[Bot], str], bool], 26 | ], 27 | ] = {} 28 | 29 | @classmethod 30 | async def wait_for_reply( 31 | cls, 32 | channel: discord.TextChannel, 33 | *, 34 | check: Callable[ 35 | [commands.SlashContext[Bot], str], bool 36 | ] = lambda ctx, reply: True, 37 | timeout: float = DEFAULT_TIMEOUT, 38 | ) -> tuple[commands.SlashContext[Bot], str]: 39 | """Returns `(ctx: commands.SlashContext[Bot], reply: str)`""" 40 | if channel in cls.active_listeners: 41 | raise DuplicateReplyListenerError(repr(channel)) 42 | 43 | future: asyncio.Future[tuple[commands.SlashContext[Bot], str]] = ( 44 | asyncio.Future() 45 | ) 46 | cls.active_listeners[channel] = (future, check) 47 | 48 | try: 49 | result = await asyncio.wait_for(future, timeout=timeout) 50 | return result 51 | finally: 52 | cls.active_listeners.pop(channel) 53 | 54 | 55 | class DatabaseTimeoutManager: 56 | DEFAULT_REASON = ("You're busy doing something else right now!", None) 57 | REASONS: dict[int, list[tuple[str, str | None]]] = {} 58 | 59 | @classmethod 60 | def get_reason(cls, user_id: int) -> tuple[str, str | None]: 61 | try: 62 | return cls.REASONS.get(user_id, [cls.DEFAULT_REASON])[0] 63 | except IndexError: 64 | return cls.DEFAULT_REASON 65 | 66 | @classmethod 67 | def get_notification(cls, user_id: int) -> tuple[str, str | None]: 68 | reason, casino_id = cls.get_reason(user_id) 69 | return f"{reason} Try again later.", casino_id 70 | 71 | @classmethod 72 | def add_notification( 73 | cls, user_id: int, notification: str, casino_id: str | None = None 74 | ) -> None: 75 | try: 76 | cls.REASONS[user_id].append((notification, casino_id)) 77 | except KeyError: 78 | cls.REASONS[user_id] = [(notification, casino_id)] 79 | 80 | @classmethod 81 | def clear_notification(cls, user_id: int, *, index: int = 0) -> None: 82 | try: 83 | cls.REASONS[user_id].pop(index) 84 | except KeyError: 85 | pass 86 | 87 | @classmethod 88 | def notify( 89 | cls, user_id: int, notification: str, casino_id: str | None = None 90 | ) -> NotificationContextManager: 91 | return NotificationContextManager(user_id, notification, casino_id=casino_id) 92 | 93 | 94 | class NotificationContextManager(Object): 95 | __slots__ = ("user_id", "notification") 96 | _repr_attributes = __slots__ 97 | 98 | def __init__( 99 | self, user_id: int, notification: str, *, casino_id: str | None = None 100 | ) -> None: 101 | self.user_id = user_id 102 | self.notification = notification 103 | self.casino_id = casino_id 104 | 105 | def __enter__(self) -> None: 106 | DatabaseTimeoutManager.add_notification( 107 | self.user_id, self.notification, casino_id=self.casino_id 108 | ) 109 | 110 | def __exit__(self, exc_type: type[BaseException] | None, *_): 111 | if exc_type is None or not issubclass(exc_type, commands.CheckFailure): 112 | DatabaseTimeoutManager.clear_notification(self.user_id) 113 | return 114 | DatabaseTimeoutManager.clear_notification(self.user_id, index=-1) 115 | 116 | async def __aenter__(self) -> None: 117 | return self.__enter__() 118 | 119 | async def __aexit__(self, *args, **kwargs) -> None: 120 | return self.__exit__(*args, **kwargs) 121 | 122 | 123 | async def wait_for_component_interaction( 124 | bot: Bot, 125 | interaction_id: str, 126 | *, 127 | users: list[discord.User | discord.Member] | None = None, 128 | actions: list[str] | None = None, 129 | timeout: float | None = 30, 130 | ) -> tuple[discord.ComponentInteraction, str]: 131 | """Returns `(component_interaction: commands.ComponentInteraction, action: str)`""" 132 | 133 | def component_interaction_check( 134 | component_interaction: discord.ComponentInteraction, 135 | ) -> bool: 136 | try: 137 | found_interaction_id, found_action = component_interaction.custom_id.split( 138 | "_", 1 139 | ) 140 | except ValueError: 141 | return False 142 | 143 | if found_interaction_id != interaction_id: 144 | return False 145 | 146 | if users and component_interaction.user not in users: 147 | asyncio.create_task( 148 | component_interaction.response.send_message( 149 | random.choice( 150 | [ 151 | "This button ain't for you lil bra.", 152 | "Don't click no random ahh buttons that aren't meant for you", 153 | "You not supposed to click that button gang", 154 | ( 155 | "You got a rare reward reward for clicking random buttons!!!" 156 | f" Claim it **[here!!!!!](<{MEME_URL}>)**" 157 | ), 158 | ] 159 | ), 160 | ephemeral=True, 161 | ) 162 | ) 163 | return False 164 | 165 | if actions and found_action not in actions: 166 | return False 167 | 168 | return True 169 | 170 | component_interaction = await bot.wait_for( 171 | "component_interaction", 172 | check=component_interaction_check, 173 | timeout=timeout, 174 | ) 175 | 176 | found_action = component_interaction.custom_id.split("_", 1)[1] 177 | return component_interaction, found_action 178 | 179 | 180 | class SlashCommandMappingManager: 181 | slash_command_ids: dict[str, int] = {} 182 | 183 | @classmethod 184 | async def load(cls, bot: Bot) -> None: 185 | app_commands = await bot.fetch_global_application_commands() 186 | cls.slash_command_ids.clear() 187 | 188 | for app_command in app_commands: 189 | if app_command.type != discord.ApplicationCommandType.chat_input: 190 | continue 191 | 192 | assert app_command.id is not None 193 | cls.slash_command_ids[app_command.name] = app_command.id 194 | 195 | @classmethod 196 | def format_slash_command(cls, command_name: str) -> str: 197 | command_base = command_name.split()[0] 198 | 199 | try: 200 | return f"" 201 | except KeyError: 202 | return f"/{command_name}" 203 | 204 | 205 | def format_slash_command(command_name: str) -> str: 206 | return SlashCommandMappingManager.format_slash_command(command_name) 207 | -------------------------------------------------------------------------------- /cogs/utils/paginator.py: -------------------------------------------------------------------------------- 1 | import math 2 | import uuid 3 | from collections.abc import Awaitable, Callable, Iterable 4 | from typing import TypeVar, Self, Generic, cast, Literal 5 | 6 | import discord 7 | 8 | from . import Object, Embed, Bot 9 | 10 | 11 | PaginatorActions = Literal["START", "PREVIOUS", "NEXT", "END"] 12 | CategorisedPaginatorActions = PaginatorActions | Literal["SELECT_CATEGORY"] 13 | _ItemT = TypeVar("_ItemT") 14 | _ActionsT = TypeVar("_ActionsT", bound=str) 15 | 16 | 17 | class Paginator(Object, Generic[_ItemT, _ActionsT]): 18 | def __init__( 19 | self, 20 | bot: Bot, 21 | items: Iterable[_ItemT], 22 | *, 23 | loader: Callable[[Self, tuple[_ItemT, ...]], Awaitable[Embed]], 24 | per_page: int = 5, 25 | ) -> None: 26 | self.bot = bot 27 | self._items = items 28 | self.loader = loader 29 | self.per_page = per_page 30 | self.current_page = 0 31 | self.max_pages = math.ceil(len(self.items) / self.per_page) 32 | self.id = uuid.uuid4().hex 33 | self._paginator_action_row = discord.ui.ActionRow( 34 | discord.ui.Button( 35 | custom_id=f"{self.id}_START", 36 | emoji="<:start:1108655615410196520>", 37 | disabled=True, 38 | ), 39 | discord.ui.Button( 40 | custom_id=f"{self.id}_PREVIOUS", 41 | emoji="<:previous:1108442605718610051>", 42 | disabled=True, 43 | ), 44 | discord.ui.Button( 45 | custom_id=f"{self.id}_NEXT", emoji="<:next:1108442607039811735>" 46 | ), 47 | discord.ui.Button( 48 | custom_id=f"{self.id}_END", emoji="<:end:1108655617029193740>" 49 | ), 50 | ) 51 | self._components = discord.ui.MessageComponents(self._paginator_action_row) 52 | 53 | @property 54 | def items(self) -> tuple[_ItemT, ...]: 55 | """Returns `(item: _ItemT, ...)`""" 56 | return tuple(self._items) 57 | 58 | def _update_components(self, *, disable_all: bool = False) -> None: 59 | if disable_all: 60 | self._components.disable_components() 61 | return 62 | 63 | self._components.enable_components() 64 | buttons = cast( 65 | list[discord.ui.Button], 66 | self._paginator_action_row.components, 67 | ) 68 | 69 | start_button = buttons[0] 70 | previous_button = buttons[1] 71 | next_button = buttons[2] 72 | end_button = buttons[3] 73 | 74 | if not self.current_page: 75 | start_button.disable() 76 | previous_button.disable() 77 | 78 | if self.current_page + 1 == self.max_pages: 79 | next_button.disable() 80 | end_button.disable() 81 | 82 | def _get_current_items(self) -> tuple[_ItemT, ...]: 83 | """Returns `(current_item: _ItemT, ...)`""" 84 | return self.items[ 85 | self.current_page * self.per_page : (self.current_page + 1) * self.per_page 86 | ] 87 | 88 | async def wait_for_interaction( 89 | self, user: discord.User | discord.Member 90 | ) -> tuple[discord.ComponentInteraction, _ActionsT]: 91 | """Returns `(component_interaction: discord.ComponentInteraction, action: _ActionsT)`""" 92 | component_interaction = await self.bot.wait_for( 93 | "component_interaction", 94 | check=lambda i: i.user == user and i.custom_id.startswith(self.id), 95 | timeout=180, 96 | ) 97 | return component_interaction, cast( 98 | _ActionsT, 99 | component_interaction.custom_id.split("_", 1)[1], 100 | ) 101 | 102 | def handle_interaction( 103 | self, 104 | interaction: discord.ComponentInteraction, # keep for subclass customisability 105 | action: _ActionsT, 106 | ): 107 | if action == "START": 108 | self.current_page = 0 109 | elif action == "PREVIOUS": 110 | self.current_page -= 1 111 | elif action == "NEXT": 112 | self.current_page += 1 113 | elif action == "END": 114 | self.current_page = self.max_pages - 1 115 | 116 | async def start(self, interaction: discord.Interaction): 117 | await interaction.response.send_message( 118 | embed=await self.loader(self, self._get_current_items()), 119 | components=self._components, 120 | ) 121 | 122 | while True: 123 | try: 124 | component_interaction, action = await self.wait_for_interaction( 125 | interaction.user 126 | ) 127 | except TimeoutError: 128 | self._update_components(disable_all=True) 129 | await interaction.edit_original_message(components=self._components) 130 | return 131 | 132 | self.handle_interaction(component_interaction, action) 133 | 134 | self._update_components() 135 | await component_interaction.response.edit_message( 136 | embed=await self.loader(self, self._get_current_items()), 137 | components=self._components, 138 | ) 139 | 140 | 141 | class CategorisedPaginator(Paginator[_ItemT, CategorisedPaginatorActions]): 142 | def __init__( 143 | self, 144 | bot: Bot, 145 | items: Iterable[_ItemT], 146 | *, 147 | categories: dict[str, str], 148 | loader: Callable[[Self, tuple[_ItemT, ...]], Awaitable[Embed]], 149 | categoriser: Callable[[_ItemT, set[str]], bool], 150 | per_page: int = 5, 151 | ) -> None: 152 | # Has to be defined before super() due to (indirect) usage in superclass __init__ 153 | self.active_categories: set[str] = {"ALL"} 154 | 155 | super().__init__(bot, items, loader=loader, per_page=per_page) 156 | self.categories = categories 157 | self.categoriser = categoriser 158 | self.category_options = [ 159 | discord.ui.SelectOption(label="All", value="ALL", default=True) 160 | ] 161 | 162 | for category, category_name in self.categories.items(): 163 | self.category_options.append( 164 | discord.ui.SelectOption( 165 | label=category_name, 166 | value=category, 167 | default=False, 168 | ) 169 | ) 170 | 171 | self._paginator_category_select_menu = discord.ui.SelectMenu( 172 | custom_id=f"{self.id}_SELECT_CATEGORY", 173 | options=self.category_options, 174 | placeholder="Categories", 175 | max_values=len(self.category_options), 176 | ) 177 | 178 | self._components.components.insert( 179 | 0, discord.ui.ActionRow(self._paginator_category_select_menu) 180 | ) 181 | 182 | def _update_components(self, *, disable_all: bool = False) -> None: 183 | super()._update_components(disable_all=disable_all) 184 | if disable_all: 185 | return 186 | 187 | for category_option in self._paginator_category_select_menu.options: 188 | if category_option.value in self.active_categories: 189 | category_option.default = True 190 | continue 191 | category_option.default = False 192 | 193 | def handle_interaction( 194 | self, 195 | interaction: discord.ComponentInteraction, 196 | action: CategorisedPaginatorActions, 197 | ) -> None: 198 | if action == "SELECT_CATEGORY": 199 | categories = interaction.data.get("values") 200 | assert categories is not None 201 | 202 | # Only use category "ALL" if every single category is individually selected 203 | if ( 204 | len(categories) == len(self.category_options) 205 | or len(categories) == len(self.category_options) - 1 206 | and "ALL" not in categories 207 | ): 208 | self.active_categories = {"ALL"} 209 | 210 | elif "ALL" in categories: 211 | if "ALL" in self.active_categories: 212 | self.active_categories = set(categories) - {"ALL"} 213 | else: 214 | self.active_categories = {"ALL"} 215 | 216 | else: 217 | self.active_categories = set(categories) 218 | 219 | self.max_pages = math.ceil(len(self.items) / self.per_page) 220 | self.current_page = min(self.current_page, self.max_pages - 1) 221 | 222 | else: 223 | super().handle_interaction(interaction, action) 224 | 225 | @property 226 | def items(self) -> tuple[_ItemT, ...]: 227 | return tuple( 228 | item 229 | for item in self._items 230 | if "ALL" in self.active_categories 231 | or self.categoriser(item, self.active_categories) 232 | ) 233 | -------------------------------------------------------------------------------- /cogs/utils/pps.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import enum 3 | import math 4 | from datetime import datetime, timedelta, UTC 5 | from decimal import Decimal 6 | from typing import Self, Literal 7 | 8 | import asyncpg 9 | from discord.ext import commands, vbu 10 | 11 | from . import ( 12 | Object, 13 | DatabaseWrapperObject, 14 | DifferenceTracker, 15 | format_int, 16 | MEME_URL, 17 | MarkdownFormat, 18 | RowLevelLockMode, 19 | RecordNotFoundError, 20 | DatabaseTimeoutManager, 21 | format_slash_command, 22 | is_weekend, 23 | PpMissing, 24 | ) 25 | 26 | 27 | BoostLiteral = Literal["voter", "weekend", "pp_bot_channel"] 28 | 29 | 30 | class BoostType(enum.Enum): 31 | VOTE = Decimal("3") 32 | WEEKEND = Decimal(".5") 33 | PP_BOT_CHANNEL = Decimal(".10") 34 | 35 | @property 36 | def percentage(self) -> int: 37 | return int(self.value * 100) 38 | 39 | 40 | class DatabaseTimeout(commands.CheckFailure): 41 | def __init__( 42 | self, 43 | message: str | None = None, 44 | *args, 45 | reason: str, 46 | casino_id: str | None = None, 47 | ) -> None: 48 | super().__init__(message, *args) 49 | self.reason = reason 50 | self.casino_id = casino_id 51 | 52 | 53 | class Pp(DatabaseWrapperObject): 54 | __slots__ = ("user_id", "multiplier", "size", "name", "digging_depth", "created_at") 55 | _repr_attributes = __slots__ 56 | _table = "pps" 57 | _columns = { 58 | "user_id": "user_id", 59 | "pp_multiplier": "multiplier", 60 | "pp_size": "size", 61 | "pp_name": "name", 62 | "digging_depth": "digging_depth", 63 | "created_at": "created_at", 64 | } 65 | _column_attributes = {attribute: column for column, attribute in _columns.items()} 66 | _identifier_attributes = ("user_id",) 67 | _trackers = ("multiplier", "size", "name", "digging_depth") 68 | 69 | def __init__( 70 | self, 71 | user_id: int, 72 | multiplier: int, 73 | size: int, 74 | name: str, 75 | digging_depth: int, 76 | created_at: datetime, 77 | ) -> None: 78 | self.user_id = user_id 79 | self.multiplier = DifferenceTracker(multiplier, column="pp_multiplier") 80 | self.size = DifferenceTracker(size, column="pp_size") 81 | self.name = DifferenceTracker(name, column="pp_name") 82 | self.digging_depth = DifferenceTracker(digging_depth, column="digging_depth") 83 | self.created_at = created_at 84 | 85 | @property 86 | def age(self) -> timedelta: 87 | return datetime.now(UTC).replace(tzinfo=None) - self.created_at 88 | 89 | def get_full_multiplier( 90 | self, *, voted: bool, channel_name: str | None = None 91 | ) -> tuple[int, dict[BoostLiteral, int | Decimal], int | Decimal]: 92 | """Returns `(full_multiplier: int, boosts: dict[boost: L[...], boost_percentage: int | Decimal], total_boost: int | Decimal)`""" 93 | boosts: dict[BoostLiteral, int | Decimal] = {} 94 | 95 | multiplier = self.multiplier.value 96 | 97 | if voted: 98 | boosts["voter"] = BoostType.VOTE.value 99 | 100 | if channel_name is not None and ( 101 | "pp-bot" in channel_name or "ppbot" in channel_name 102 | ): 103 | boosts["pp_bot_channel"] = BoostType.PP_BOT_CHANNEL.value 104 | 105 | if is_weekend(): 106 | boosts["weekend"] = BoostType.WEEKEND.value 107 | 108 | total_boost = 1 109 | for multiplier_percentage in boosts.values(): 110 | total_boost += multiplier_percentage 111 | 112 | return math.ceil(multiplier * total_boost), boosts, total_boost 113 | 114 | @classmethod 115 | async def fetch_from_user( 116 | cls, 117 | connection: asyncpg.Connection, 118 | user_id: int, 119 | *, 120 | edit: bool = False, 121 | timeout: float | None = 2, 122 | ) -> Self: 123 | try: 124 | return await cls.fetch( 125 | connection, 126 | {"user_id": user_id}, 127 | lock=RowLevelLockMode.FOR_UPDATE if edit else None, 128 | timeout=timeout, 129 | ) 130 | except RecordNotFoundError: 131 | raise PpMissing( 132 | f"You don't have a pp! Go make one with {format_slash_command('new')} and get" 133 | " started :)" 134 | ) 135 | except asyncio.TimeoutError: 136 | reason, casino_id = DatabaseTimeoutManager.get_reason(user_id) 137 | raise DatabaseTimeout( 138 | DatabaseTimeoutManager.get_notification(user_id)[0], 139 | reason=reason, 140 | casino_id=casino_id, 141 | ) 142 | 143 | async def has_voted(self) -> bool: 144 | return await vbu.user_has_voted(self.user_id) 145 | 146 | def grow(self, growth: int) -> int: 147 | self.size.value += growth 148 | return growth 149 | 150 | def grow_with_multipliers(self, growth: int, *, voted: bool) -> int: 151 | growth *= self.get_full_multiplier(voted=voted)[0] 152 | self.size.value += growth 153 | return growth 154 | 155 | def format_growth( 156 | self, 157 | growth: int | None = None, 158 | *, 159 | markdown: MarkdownFormat | None = MarkdownFormat.BOLD, 160 | prefixed: bool = False, 161 | in_between: str | None = None, 162 | ) -> str: 163 | if in_between is None: 164 | in_between = "" 165 | 166 | if growth is None: 167 | growth = self.size.difference or 0 168 | 169 | if prefixed: 170 | if growth < 0: 171 | prefix = "lost " 172 | growth = abs(growth) 173 | else: 174 | prefix = "earned " 175 | else: 176 | prefix = "" 177 | 178 | if markdown is None: 179 | return ( 180 | prefix 181 | + f"{format_int(growth)}{in_between} inch{'' if growth == 1 else 'es'}" 182 | ) 183 | 184 | if markdown == MarkdownFormat.BOLD: 185 | return ( 186 | prefix 187 | + f"**{format_int(growth)}**{in_between} inch{'' if growth == 1 else 'es'}" 188 | ) 189 | 190 | return ( 191 | prefix 192 | + f"**[{format_int(growth)}]({MEME_URL}){in_between} inch{'' if growth == 1 else 'es'}**" 193 | ) 194 | 195 | 196 | class PpExtras(DatabaseWrapperObject): 197 | __slots__ = ("user_id", "is_og") 198 | _repr_attributes = __slots__ 199 | _table = "pp_extras" 200 | _columns = { 201 | "user_id": "user_id", 202 | "is_og": "is_og", 203 | } 204 | _column_attributes = {attribute: column for column, attribute in _columns.items()} 205 | _identifier_attributes = ("user_id",) 206 | _trackers = ("is_og",) 207 | 208 | def __init__( 209 | self, 210 | user_id: int, 211 | is_og: bool, 212 | ) -> None: 213 | self.user_id = user_id 214 | self.is_og = DifferenceTracker(is_og, column="is_og") 215 | 216 | @classmethod 217 | async def fetch_from_user( 218 | cls, 219 | connection: asyncpg.Connection, 220 | user_id: int, 221 | *, 222 | edit: bool = False, 223 | timeout: float | None = 2, 224 | ) -> Self: 225 | try: 226 | return await cls.fetch( 227 | connection, 228 | {"user_id": user_id}, 229 | lock=RowLevelLockMode.FOR_UPDATE if edit else None, 230 | timeout=timeout, 231 | insert_if_not_found=True, 232 | ) 233 | except asyncio.TimeoutError: 234 | reason, casino_id = DatabaseTimeoutManager.get_reason(user_id) 235 | raise DatabaseTimeout( 236 | DatabaseTimeoutManager.get_notification(user_id)[0], 237 | reason=reason, 238 | casino_id=casino_id, 239 | ) 240 | -------------------------------------------------------------------------------- /cogs/utils/streaks.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, UTC 2 | from typing import Self 3 | 4 | import asyncpg 5 | 6 | from . import ( 7 | DatabaseWrapperObject, 8 | DifferenceTracker, 9 | RowLevelLockMode, 10 | ) 11 | 12 | 13 | class Streaks(DatabaseWrapperObject): 14 | __slots__ = ("user_id", "daily") 15 | _repr_attributes = __slots__ 16 | _table = "streaks" 17 | _columns = { 18 | "user_id": "user_id", 19 | "daily_streak": "daily", 20 | "last_daily": "last_daily", 21 | } 22 | _column_attributes = {attribute: column for column, attribute in _columns.items()} 23 | _identifier_attributes = ("user_id",) 24 | _trackers = ("daily", "last_daily") 25 | 26 | def __init__(self, user_id: int, daily: int, last_daily: datetime) -> None: 27 | self.user_id = user_id 28 | self.daily = DifferenceTracker(daily, column="daily_streak") 29 | self.last_daily = DifferenceTracker(last_daily, column="last_daily") 30 | 31 | @property 32 | def daily_expired(self) -> bool: 33 | return self.last_daily.value + timedelta(days=2) < datetime.now(UTC).replace( 34 | tzinfo=None 35 | ) 36 | 37 | @classmethod 38 | async def fetch_from_user( 39 | cls, 40 | connection: asyncpg.Connection, 41 | user_id: int, 42 | *, 43 | edit: bool = False, 44 | timeout: float | None = 2, 45 | ) -> Self: 46 | return await cls.fetch( 47 | connection, 48 | {"user_id": user_id}, 49 | lock=RowLevelLockMode.FOR_UPDATE if edit else None, 50 | timeout=timeout, 51 | insert_if_not_found=True, 52 | ) 53 | -------------------------------------------------------------------------------- /cogs/voting_events.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import time 3 | import random 4 | from datetime import timedelta 5 | 6 | import discord 7 | from discord.ext import vbu 8 | 9 | from . import utils 10 | 11 | 12 | class VotingEventsCog(vbu.Cog[utils.Bot]): 13 | MIN_VOTE_GROWTH = 90 14 | MAX_VOTE_GROWTH = 110 15 | 16 | def __init__(self, bot: utils.Bot, logger_name: str | None = None): 17 | super().__init__(bot, logger_name) 18 | 19 | def vote_acknowledgement_component_factory(self) -> discord.ui.MessageComponents: 20 | return discord.ui.MessageComponents( 21 | discord.ui.ActionRow( 22 | discord.ui.Button( 23 | label="Remind me to vote again!", 24 | custom_id=f"DM_VOTE_REMINDER", 25 | style=discord.ButtonStyle.green, 26 | ) 27 | ) 28 | ) 29 | 30 | async def _send_reminder( 31 | self, 32 | user: discord.User | discord.Member, 33 | *, 34 | bot_down: bool = False, 35 | ) -> None: 36 | self.logger.info(f"Sending reminder to {user} ({user.id})") 37 | dm_channel = user.dm_channel or await user.create_dm() 38 | 39 | embed = utils.Embed() 40 | embed.color = utils.PINK 41 | 42 | embed.title = random.choice( 43 | [ 44 | "BEEP BEEP BEEP VOTE NOW!!!", 45 | "wakey wakey its voting time", 46 | "you can vote again !!", 47 | ] 48 | ) 49 | 50 | embed.description = ( 51 | f"[**Vote now**]({utils.VOTE_URL}) to get your **{utils.BoostType.VOTE.percentage}%**" 52 | f" voting boost back!" 53 | ) 54 | 55 | if bot_down: 56 | embed.set_footer( 57 | text="This notification is late because the bot was down earlier :(" 58 | ) 59 | 60 | await dm_channel.send(embed=embed) 61 | 62 | def _schedule_reminder( 63 | self, 64 | user: discord.User | discord.Member, 65 | timestamp: float, 66 | *, 67 | bot_down: bool = False, 68 | ) -> None: 69 | self.logger.info( 70 | f"Scheduling reminder for {user} ({user.id}) at {timestamp} (UNIX)" 71 | ) 72 | 73 | async def reminder() -> None: 74 | now = time.time() 75 | late = timestamp < now 76 | 77 | if not late: 78 | await asyncio.sleep(timestamp - now) 79 | 80 | async with vbu.Redis() as redis: 81 | await redis.delete(f"reminders:voting:{user.id}") 82 | 83 | await self._send_reminder(user, bot_down=bot_down) 84 | 85 | self.bot.loop.create_task(reminder()) 86 | 87 | @vbu.Cog.listener("on_ready") 88 | async def reschedule_existing_reminders(self) -> None: 89 | async with vbu.Redis() as redis: 90 | async for reminder_key in redis.pool.iscan(match="reminders:voting:*"): 91 | user_id = int(reminder_key.split(b":")[-1]) 92 | user = await self.bot.fetch_user(user_id) 93 | 94 | reminder_timestamp_data = await redis.get(reminder_key) 95 | assert reminder_timestamp_data 96 | 97 | reminder_timestamp = int(reminder_timestamp_data) 98 | self._schedule_reminder(user, reminder_timestamp, bot_down=True) 99 | 100 | @vbu.Cog.listener("on_component_interaction") 101 | async def handle_vote_reminder_button_interaction( 102 | self, component_interaction: discord.ComponentInteraction 103 | ) -> None: 104 | if component_interaction.custom_id.split("_", maxsplit=1)[0] != "DM": 105 | return 106 | 107 | _, action = component_interaction.custom_id.split("_", maxsplit=1) 108 | 109 | if action != "VOTE_REMINDER": 110 | return 111 | 112 | user = component_interaction.user 113 | 114 | async with vbu.Redis() as redis: 115 | redis_key = f"pending-interactions:vote-reminder:{user.id}" 116 | vote_reminder_data = await redis.get(redis_key) 117 | 118 | if vote_reminder_data is None: 119 | raise Exception( 120 | f"No registered pending interaction for vote reminder on {user.id}," 121 | " yet component interaction was received." 122 | ) 123 | 124 | await redis.delete(redis_key) 125 | 126 | vote_timestamp = int(vote_reminder_data.split(":")[0]) 127 | next_vote_timestamp = vote_timestamp + timedelta(hours=12).seconds 128 | self._schedule_reminder(user, next_vote_timestamp) 129 | 130 | await redis.set(f"reminders:voting:{user.id}", str(next_vote_timestamp)) 131 | 132 | components = self.vote_acknowledgement_component_factory() 133 | components.disable_components() 134 | await component_interaction.response.edit_message(components=components) 135 | 136 | if next_vote_timestamp > time.time(): 137 | await component_interaction.followup.send( 138 | f"You will be reminded to vote at !" 139 | " Thank u so much for ur support :)", 140 | ephemeral=True, 141 | ) 142 | 143 | @vbu.Cog.listener("on_vote") 144 | async def acknowledge_vote_event(self, user: discord.User) -> None: 145 | vote_timestamp = int(time.time()) 146 | dm_channel = user.dm_channel or await user.create_dm() 147 | 148 | async with ( 149 | utils.DatabaseWrapper() as db, 150 | utils.DatabaseTimeoutManager.notify( 151 | user.id, "We're still processing your vote." 152 | ), 153 | ): 154 | transaction = db.conn.transaction() 155 | await transaction.start() 156 | 157 | # Very generous timeout, just in case someone votes while they're busy 158 | # (e.g. using casino) 159 | timeout = 60 * 2 160 | 161 | try: 162 | pp = await utils.Pp.fetch_from_user( 163 | db.conn, user.id, edit=True, timeout=timeout 164 | ) 165 | except utils.DatabaseTimeout as error: 166 | await transaction.rollback() 167 | transaction = db.conn.transaction() 168 | await transaction.start() 169 | 170 | try: 171 | # Even more generous timeout 172 | timeout = 60 * 10 173 | 174 | await dm_channel.send( 175 | ( 176 | "We can't give you your voting gift <:ppMalding:902894208795435031>" 177 | " {reason} We'll try to send your reward again in" 178 | " {duration} <:ppHappy:902894208703156257>" 179 | ).format( 180 | reason=error.reason, 181 | duration=utils.format_time(timeout), 182 | ) 183 | ) 184 | 185 | try: 186 | pp = await utils.Pp.fetch_from_user( 187 | db.conn, user.id, edit=True, timeout=timeout 188 | ) 189 | except utils.DatabaseTimeout as error: 190 | try: 191 | await dm_channel.send( 192 | ( 193 | "It's been {duration} and we still can't give you your" 194 | " voting gift. {reason}" 195 | "\nSucks to suck, atleast you get the **{boost_percentage}%" 196 | " BOOST** :)" 197 | ).format( 198 | duration=utils.format_time(timeout), 199 | reason=error.reason, 200 | boost_percentage=utils.BoostType.VOTE.percentage, 201 | ) 202 | ) 203 | except discord.HTTPException as error: 204 | self.logger.info( 205 | "Could'nt DM vote and 2nd database timeout acknowledgement to user" 206 | f" {user} ({user.id}): {error}" 207 | ) 208 | await transaction.rollback() 209 | return 210 | 211 | except discord.HTTPException as error: 212 | self.logger.info( 213 | "Could'nt DM vote and database timeout acknowledgement to user" 214 | f" {user} ({user.id}): {error}" 215 | ) 216 | await transaction.rollback() 217 | return 218 | 219 | except: 220 | await transaction.rollback() 221 | raise 222 | 223 | # Emulate async-with block, which we can't use due to the possibility 224 | # of multiple transactions being necessary (see code above) 225 | try: 226 | pp.grow_with_multipliers( 227 | random.randint(self.MIN_VOTE_GROWTH, self.MAX_VOTE_GROWTH), 228 | voted=await pp.has_voted(), 229 | ) 230 | await pp.update(db.conn) 231 | except: 232 | await transaction.rollback() 233 | else: 234 | await transaction.commit() 235 | 236 | embed = utils.Embed() 237 | embed.color = utils.PINK 238 | embed.title = random.choice( 239 | [ 240 | "THANKS 4 VOTING", 241 | "TY FOR THE VOTE", 242 | "I LOVE VOTERS I LOVE VOTERS I LOVE VOTERS", 243 | "YOU VOTED? FUCK YEAH", 244 | "AUUUGGGHHHH I LOVE VOTERS", 245 | "THANK YOU FOR VOTING !!", 246 | ] 247 | ) 248 | embed.description = ( 249 | "Here's {reward} as a gift :))" 250 | "\nYou're also getting a **{boost_percentage}% BOOST** for the next 12 hours" 251 | " until you vote again <:ppHappy:902894208703156257>" 252 | ).format( 253 | reward=pp.format_growth(), 254 | boost_percentage=utils.BoostType.VOTE.percentage, 255 | ) 256 | 257 | async with vbu.Redis() as redis: 258 | vote_reminder_key = f"pending-interactions:vote-reminder:{user.id}" 259 | vote_reminder_data = await redis.get(vote_reminder_key) 260 | 261 | # Disable reminder component of previous vote notification 262 | if vote_reminder_data is not None: 263 | message_id = int(vote_reminder_data.split(":")[1]) 264 | message = dm_channel.get_partial_message(message_id) 265 | components = self.vote_acknowledgement_component_factory() 266 | components.disable_components() 267 | await message.edit(components=components) 268 | 269 | try: 270 | message = await dm_channel.send( 271 | embed=embed, 272 | components=self.vote_acknowledgement_component_factory(), 273 | ) 274 | except discord.HTTPException as error: 275 | self.logger.info( 276 | f"Could'nt DM vote acknowledgement to user {user} ({user.id}): {error}" 277 | ) 278 | return 279 | await redis.set( 280 | f"pending-interactions:vote-reminder:{user.id}", 281 | f"{vote_timestamp}:{message.id}", 282 | ) 283 | 284 | 285 | async def setup(bot: utils.Bot): 286 | if not bot.shard_ids or 0 in bot.shard_ids: 287 | await bot.add_cog(VotingEventsCog(bot)) 288 | -------------------------------------------------------------------------------- /config/config.example.toml: -------------------------------------------------------------------------------- 1 | token = "bot_token" # The token for the bot. 2 | pubkey = "bot_interaction_pubkey" # The HTTP interactions pubkey for your bot. 3 | owners = [] # List of owner IDs - these people override all permission checks. 4 | dm_uncaught_errors = false # Whether or not to DM the owners when unhandled errors are encountered. 5 | user_agent = "" # A custom string to populate Bot.user_agent with. 6 | guild_settings_prefix_column = "prefix" # Used if multiple bots connect to the same database and need to seperate their prefixes. 7 | ephemeral_error_messages = false # Whether or not error messages [from slash commands] should be ephemeral. 8 | owners_ignore_command_errors = false # Whether or not owners ignore command errors. 9 | owners_ignore_check_failures = false # Whether or not owners ignore check failures. 10 | enable_error_handler = false # Whether or not the error handler at vbu/cogs/error_handler.py is activated. 11 | 12 | # These are used with the on_message event. As such, they will likely soon be deprecated. 13 | default_prefix = "pp " # The prefix for the bot's commands. 14 | cached_messages = 1000 # The number of messages to cache within the bot. 15 | 16 | # These are used by non-global commands. As such, they may be removed when the message intent becomes privileged. 17 | support_guild_id = 0 # The ID for the support guild - used by `Bot.fetch_support_guild()`. 18 | bot_support_role_id = 0 # The ID used to determine whether or not the user is part of the bot's support team - used for `.checks.is_bot_support()` check. 19 | 20 | # Event webhook information - some of the events (noted) will be sent to the specified url. 21 | [event_webhook] 22 | event_webhook_url = "" 23 | [event_webhook.events] # If you use true then your `event_webhook_url` will be used. 24 | guild_join = true 25 | guild_remove = true 26 | shard_connect = true 27 | shard_disconnect = true 28 | shard_ready = true 29 | bot_ready = true 30 | unhandled_error = true 31 | 32 | # The intents that the bot should start with 33 | [intents] 34 | guilds = true # Guilds - Used for guild join/remove, channel create/delete/update, Bot.get_channel, Bot.guilds, Bot.get_guild. This is REALLY needed. 35 | members = false # Members (privileged intent) - Used for member join/remove/update, Member.roles, Member.nick, User.name, Bot.get_user, Guild.get_member etc. 36 | bans = false # Bans - Used for member ban/unban. 37 | emojis = false # Emojis - Used for guild emojis update, Bot.get_emoji, Guild.emojis. 38 | integrations = false # Integrations - Used for guild integrations update. 39 | webhooks = false # Webhooks - Used for guild webhooks update. 40 | invites = false # Invites - Used for invite create/delete. 41 | voice_states = false # Voice states - Used for voice state update, VoiceChannel.members, Member.voice. 42 | presences = false # Presences (privileged intent) - Used for member update (for activities and status), Member.status. 43 | guild_messages = false # Guild messages (privileged intent) - Used for message events in guilds. 44 | dm_messages = false # DM messages (privileged intent) - Used for message events in DMs. 45 | guild_reactions = false # Guild reactions - Used for [raw] reaction add/remove/clear events in guilds. 46 | dm_reactions = false # DM reactions - Used for [raw] reaction add/remove/clear events in DMs. 47 | guild_typing = false # Guild typing - Used for the typing event in guilds. 48 | dm_typing = false # DM typing - Used for the typing event in DMs. 49 | message_content = false # Message content (privileged intent) - Whether or not you get message content. 50 | 51 | # Data used to send API requests to whatever service. If these are set, vote links will be added via the `/vote` command. 52 | [bot_listing_api_keys] 53 | override_bot_id = 0 # Overrides the bot ID in methods such as bot.get_user_topgg_vote, and disables posting data to bot listing sites. Useful for testing on different bot accounts. 54 | topgg_token = "" # The token used to post data to top.gg. 55 | discordbotlist_token = "" # The token used to post data to discordbotlist.com. 56 | 57 | # Data used to set up a webhook server and register top.gg votes as they come in. 58 | [topgg_webhook] 59 | enabled = true # Enables the creation of the top.gg webhook server/cog. 60 | host = "127.0.0.1" # The host of the webhook server. Should correspond to the one entered in your bot's top.gg settings. 61 | port = 8080 # The port of the webhook server. Should correspond to the one entered in your bot's top.gg settings. 62 | authorization = "" # The authorization token of the webhook server. Should correspond to the one entered in your bot's top.gg settings. 63 | 64 | # The info command is the slash command equivelant of "help", giving all relevant data. 65 | [bot_info] 66 | enabled = false 67 | include_stats = true 68 | content = """ 69 | Welcome to pp bot! 70 | """ 71 | thumbnail = "" # A url to an image to be added to the embed 72 | image = "" # A url to an image to be added to the embed 73 | 74 | # These are added as link buttons the bottom of the message. 75 | # Your bot invite (if enabled) will always be added as the first button. 76 | [bot_info.links] 77 | [bot_info.links.Git] 78 | url = "https://example.com/#your-git-link" 79 | 80 | # Info pertaining to the help command 81 | [help_command] 82 | enabled = false 83 | use_vbu_implementation = false 84 | dm_help = false 85 | content = "" 86 | 87 | # Used to generate the invite link. 88 | [oauth] 89 | enabled = false # Whether or not an invite link is enabled via the !invite command. 90 | response_type = "" # The response type given to the redirect URI. 91 | redirect_uri = "" # Where the user should be redirected to upon authorizing. 92 | client_id = "" # If not set then will use the bot's ID. 93 | scope = "bot applications.commands" # The scope that will be generated with the invite link, space seperated (applications.commands for slash). 94 | permissions = [] # args here are passed directly to discord.Permissions as True. 95 | 96 | # Data needed for SQL database connections 97 | [database] 98 | type = "postgres" # postgres, sqlite, mysql 99 | enabled = true 100 | user = "" 101 | password = "" 102 | database = "ppbot" 103 | host = "127.0.0.1" 104 | port = 5432 105 | 106 | # This data is passed directly over to `aioredis.connect()`. 107 | [redis] 108 | enabled = true 109 | host = "127.0.0.1" 110 | port = 6379 111 | db = 0 112 | 113 | [shard_manager] 114 | enabled = false 115 | host = "127.0.0.1" 116 | port = 8888 117 | 118 | # The data that gets shoves into custom context for the embed. 119 | [embed] 120 | enabled = false # Whether or not to embed messages by default. 121 | content = "" # Default content to be added to the embed message. 122 | colour = 0 # A specific colour for the embed - 0 means random. 123 | [embed.author] 124 | enabled = false 125 | name = "{ctx.bot.user}" 126 | url = "" # The url added to the author. 127 | [[embed.footer]] # An array of possible footers. 128 | text = "Add the bot to your server! ({ctx.clean_prefix}invite)" # Text to appear in the footer. 129 | amount = 1 # The amount of times this particular text is added to the pool. 130 | 131 | # What the bot is playing 132 | [presence] 133 | activity_type = "playing" # Should be one of 'playing', 'listening', 'watching', 'competing' 134 | text = "with myself (sexual style)" 135 | status = "online" # Should be one of 'online', 'invisible', 'idle', 'dnd' 136 | include_shard_id = true # Whether or not to append "(shard N)" to the presence text; only present if there's more than 1 shard 137 | [presence.streaming] # This is used to automatically set the bot's status to your Twitch stream when you go live 138 | twitch_usernames = [] # The username of your Twitch.tv channel 139 | twitch_client_id = "" # Your client ID - https://dev.twitch.tv/console/apps 140 | twitch_client_secret = "" # Your client secret 141 | 142 | # UpgradeChat API key data - https://upgrade.chat/developers 143 | [upgrade_chat] 144 | client_id = "" 145 | client_secret = "" 146 | 147 | # Statsd analytics port using the aiodogstatsd package 148 | [statsd] 149 | host = "127.0.0.1" 150 | port = 8125 # This is the DataDog default, 9125 is the general statsd default 151 | [statsd.constant_tags] 152 | service = "" # Put your bot name here - leave blank to disable stats collection 153 | -------------------------------------------------------------------------------- /config/database.pgsql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS guild_settings( 2 | guild_id BIGINT PRIMARY KEY, 3 | prefix TEXT, 4 | custom_role_requirement_role_id BIGINT, 5 | custom_role_parent_role_id BIGINT 6 | ); 7 | 8 | 9 | CREATE TABLE IF NOT EXISTS user_settings( 10 | user_id BIGINT PRIMARY KEY 11 | ); 12 | 13 | 14 | CREATE TABLE IF NOT EXISTS pps ( 15 | user_id BIGINT PRIMARY KEY, 16 | pp_multiplier BIGINT NOT NULL DEFAULT 1, 17 | pp_size BIGINT NOT NULL DEFAULT 0, 18 | pp_name TEXT NOT NULL DEFAULT 'Unnamed Pp', 19 | digging_depth INTEGER NOT NULL DEFAULT 0, 20 | created_at TIMESTAMP NOT NULL DEFAULT timezone('UTC', now()) 21 | ); 22 | 23 | CREATE TABLE IF NOT EXISTS pp_extras ( 24 | user_id BIGINT PRIMARY KEY, 25 | is_og BOOLEAN DEFAULT FALSE 26 | ); 27 | 28 | CREATE TABLE IF NOT EXISTS inventories ( 29 | user_id BIGINT, 30 | item_id TEXT, 31 | item_amount BIGINT, 32 | PRIMARY KEY (user_id, item_id) 33 | ); 34 | 35 | 36 | CREATE TABLE IF NOT EXISTS streaks ( 37 | user_id BIGINT PRIMARY KEY, 38 | daily_streak SMALLINT NOT NULL DEFAULT 0, 39 | last_daily TIMESTAMP NOT NULL DEFAULT timezone('UTC', now()) 40 | ); 41 | 42 | 43 | CREATE TABLE IF NOT EXISTS donations ( 44 | recipiant_id BIGINT NOT NULL, 45 | donor_id BIGINT NOT NULL, 46 | created_at TIMESTAMP NOT NULL DEFAULT timezone('UTC', now()), 47 | amount INTEGER NOT NULL DEFAULT 0 48 | ); 49 | 50 | 51 | -- CREATE TABLE IF NOT EXISTS role_list( 52 | -- guild_id BIGINT, 53 | -- role_id BIGINT, 54 | -- key TEXT, 55 | -- value TEXT, 56 | -- PRIMARY KEY (guild_id, role_id, key) 57 | -- ); 58 | -- A list of role: value mappings should you need one. 59 | -- This is not required for VBU, so is commented out by default. 60 | 61 | 62 | -- CREATE TABLE IF NOT EXISTS channel_list( 63 | -- guild_id BIGINT, 64 | -- channel_id BIGINT, 65 | -- key TEXT, 66 | -- value TEXT, 67 | -- PRIMARY KEY (guild_id, channel_id, key) 68 | -- ); 69 | -- A list of channel: value mappings should you need one. 70 | -- This is not required for VBU, so is commented out by default. -------------------------------------------------------------------------------- /config/items.toml: -------------------------------------------------------------------------------- 1 | [multipliers] 2 | [multipliers.PILLS] 3 | name = "pills" 4 | indefinite_article = "none" 5 | description = "I LOVE DRUGS!!!" 6 | price = 60 7 | gain = 1 8 | 9 | [multipliers.STRONG_PILLS] 10 | name = "stronger pills" 11 | indefinite_article = "none" 12 | description = "I think there's some cocaine and some sildenafil in these..." 13 | price = 500 14 | gain = 20 15 | 16 | [buffs] 17 | [buffs.HAPPY_FLOUR] 18 | name = "happy flour" 19 | indefinite_article = "none" 20 | description = "Definitely not cocaine, trust me." 21 | price = 500 22 | duration = 0.5 23 | cooldown = 12 24 | multiplier = 0.05 25 | 26 | [buffs.APPLE] 27 | name = "apple" 28 | plural = "apples" 29 | description = "An apple a day keeps the doctor away!" 30 | price = 1000 31 | duration = 0.5 32 | specified_details = ["Gives all surgeries a **100% success rate**"] 33 | 34 | [tools] 35 | [tools.FISHING_ROD] 36 | name = "fishing rod" 37 | plural = "fishing rods" 38 | description = "Perfect for fishing pps." 39 | price = 250 40 | associated_command_name = "fish" 41 | 42 | [tools.RIFLE] 43 | name = "rifle" 44 | plural = "rifles" 45 | description = "Ever wanted to kill hobos and steal their pps?" 46 | price = 1000 47 | associated_command_name = "hunt" 48 | 49 | [tools.SHOVEL] 50 | name = "fragile shovel" 51 | plural = "fragile shovels" 52 | description = "This shovel is super fragile, so ONLY buy it if ur loaded as fuck" 53 | price = 25_000 54 | associated_command_name = "dig" 55 | 56 | 57 | [useless] 58 | [useless.BRONZE_COIN] 59 | name = "bronze coin" 60 | plural = "bronze coins" 61 | description = "Completely worthless. Doesn't do anything. Don't buy this." 62 | price = 1000 63 | 64 | [useless.GOLDEN_COIN] 65 | name = "golden coin" 66 | plural = "bronze coins" 67 | description = "This coin is also completely useless. It doesn't do anything, and it's even more expensive. Why on earth would you buy this?" 68 | price = 100_000 69 | 70 | [useless.COOKIE] 71 | name = "cookie" 72 | plural = "cookies" 73 | description = "A nice, tasty chocolate chip cookie." 74 | price = 50 75 | 76 | [useless.KNIFE] 77 | name = "knife" 78 | plural = "knives" 79 | description = "Sometimes drastic measures are needed..." 80 | price = 50 81 | -------------------------------------------------------------------------------- /config/minigames.toml: -------------------------------------------------------------------------------- 1 | [[FILL_IN_THE_BLANK]] 2 | person = "LowTierGod" 3 | situations = [ 4 | "LowTierGod spots you in his chat and decides you're not worthy of living.", 5 | ] 6 | reasons = [ 7 | "avoid getting bullied to death", 8 | "stand up to him" 9 | ] 10 | prompts = [ 11 | [ 12 | "You're gonna stay on my {0} until you die. Your purpose in life is to be on my stream sucking on my {0} daily.", 13 | "dick" 14 | ], 15 | ["Your life is worth {}, you serve zero purpose", "nothing"], 16 | ["You should {} yourself, NOW!", "kill"], 17 | [ 18 | "Let's go to the 99-cent store, let's pick out a {} together. I'm gonna give you an assisted suicide.", 19 | "rope" 20 | ] 21 | ] 22 | fails = [ 23 | "LowTierGod convinces you to end it all.", 24 | ] 25 | wins = [ 26 | "LowTierGod backs off from you and decides to bully a little kid with autism instead. The kid was brutally beat to death, but atleast you're okay!", 27 | "LowTierGod gets distracted and starts bullying others instead. You're safe for now.", 28 | ] 29 | 30 | [[beg.FILL_IN_THE_BLANK]] 31 | person = "local crackhead" 32 | situations = [ 33 | "A local crackhead points at you while you're begging and starts yelling.", 34 | "Some crackhead sees you begging and starts sprinting towards you.", 35 | ] 36 | reasons = [ 37 | "avoid the crackhead", 38 | "get a sweet reward from him", 39 | "beat him up and take his shit", 40 | ] 41 | prompts = [ 42 | ["Got any {} for me to smoke?", "crack"], 43 | ["I need a new {}, my old one broke.", "crack pipe"], 44 | ["Jesse, we need to {}.", "cook"], 45 | ] 46 | fails = [ 47 | "The crackhead pulls a broken glass bottle out of his bootyhole and stabs you with it.", 48 | "You're suddenly surrounded by crack junkies who start pissing on you.", 49 | "The crackhead walks away unsatisfied.", 50 | ] 51 | wins = [ 52 | "The crackhead suddenly dies from a heart attack. Who could've seen that coming? You start looting his dead body and find {}", 53 | "You catch the crackhead off guard and knock him out in one swift punch. You steal {} from his bleeding corpse. Are you happy now?", 54 | ] 55 | 56 | [[beg.REVERSE]] 57 | person = "rich guy" 58 | situations = [ 59 | "You ask a rich guy for some inches while begging, but he doesn't wanna give them to you.", 60 | ] 61 | reasons = [ 62 | "steal all his inches", 63 | "take his inches non-consentually", 64 | ] 65 | phrases = [ 66 | "ew no", 67 | "get away from me", 68 | "hell nah", 69 | "broke ass mf", 70 | ] 71 | fails = [ 72 | "The rich guy calls the police and has you arrested.", 73 | "You attempted to rob him but he shot you 12 times.", 74 | ] 75 | wins = [ 76 | "You fuck up the rich guy and take {} off his unconscious body.", 77 | ] 78 | 79 | [[beg.REPEAT]] 80 | person = "tiktok guy" 81 | situations = [ 82 | "A random tiktokker sees you begging and decides to put his phone up in your face.", 83 | "Some guy from tiktok making interview videos comes up to you and decides to annoy you.", 84 | ] 85 | reasons = ["get a donation from him"] 86 | sentences = [ 87 | "gay son or thot daughter?", 88 | "kiss or slap?", 89 | ] 90 | fails = [ 91 | "The tiktokker didn't find you entertaining enough and left.", 92 | ] 93 | wins = [ 94 | "The tiktokker gave you a mystery gift, containing {}.", 95 | ] -------------------------------------------------------------------------------- /config/minigames/beg.toml: -------------------------------------------------------------------------------- 1 | [chance_weights] 2 | FILL_IN_THE_BLANK = 1 3 | REVERSE = 1 4 | REPEAT = 1 5 | 6 | 7 | [[FILL_IN_THE_BLANK]] 8 | person = "local crackhead" 9 | situations = [ 10 | "A local crackhead points at you while you're begging and starts yelling.", 11 | "Some crackhead sees you begging and starts sprinting towards you.", 12 | ] 13 | reasons = [ 14 | "avoid the crackhead", 15 | "get a sweet reward from him", 16 | "beat him up and take his shit", 17 | ] 18 | prompts = [ 19 | ["Got any {} for me to smoke?", "crack"], 20 | ["I need a new {}, my old one broke.", "crack pipe"], 21 | ["Jesse, we need to {}.", "cook"], 22 | ] 23 | fails = [ 24 | "The crackhead pulls a broken glass bottle out of his bootyhole and stabs you with it.", 25 | "You're suddenly surrounded by crack junkies who start pissing on you.", 26 | "The crackhead walks away unsatisfied.", 27 | ] 28 | wins = [ 29 | "The crackhead suddenly dies from a heart attack. Who could've seen that coming? You start looting his dead body and find {}", 30 | "You catch the crackhead off guard and knock him out in one swift punch. You steal {} from his bleeding corpse. Are you happy now?", 31 | ] 32 | 33 | [[REVERSE]] 34 | person = "rich guy" 35 | situations = [ 36 | "You ask a rich guy for some inches while begging, but he doesn't wanna give them to you.", 37 | ] 38 | reasons = [ 39 | "steal all his inches", 40 | "take his inches non-consentually", 41 | ] 42 | phrases = [ 43 | "ew no", 44 | "get away from me", 45 | "hell nah", 46 | "broke ass mf", 47 | ] 48 | fails = [ 49 | "The rich guy calls the police and has you arrested.", 50 | "You attempted to rob the rich guy but he shot you 12 times.", 51 | ] 52 | wins = [ 53 | "You fuck up the rich guy and take {} off his unconscious body.", 54 | ] 55 | 56 | [[REPEAT]] 57 | person = "tiktok guy" 58 | situations = [ 59 | "A random tiktokker sees you begging and decides to put his phone up in your face.", 60 | "Some guy from tiktok making interview videos comes up to you and decides to annoy you.", 61 | ] 62 | reasons = ["get a donation from him"] 63 | sentences = [ 64 | "gay son or thot daughter?", 65 | "kiss or slap?", 66 | ] 67 | fails = [ 68 | "The tiktokker didn't find you entertaining enough and left.", 69 | ] 70 | wins = [ 71 | "The tiktokker gave you a mystery gift, containing {}.", 72 | ] -------------------------------------------------------------------------------- /config/minigames/dig.toml: -------------------------------------------------------------------------------- 1 | [chance_weights] 2 | CLICK_THAT_BUTTON = 1 3 | 4 | 5 | [[CLICK_THAT_BUTTON]] 6 | object = "diamond ore" 7 | action = "mine" 8 | target_emoji = "<:diamond_ore:1244992032922144798>" 9 | fails = ["You didn't find any diamonds this time :("] 10 | wins = [ 11 | "You mine the diamonds and sell them for {}.", 12 | ] 13 | foreground_style = "grey" 14 | background_emoji = "<:stone:1244992043156115537>" 15 | 16 | 17 | [[CLICK_THAT_BUTTON]] 18 | object = "gold ore" 19 | action = "mine" 20 | target_emoji = "<:gold_ore:1245012149064040559>" 21 | fails = ["You didn't find any gold this time :("] 22 | wins = [ 23 | "You mine the gold and sell it for {}.", 24 | ] 25 | foreground_style = "grey" 26 | background_emoji = "<:stone:1244992043156115537>" 27 | -------------------------------------------------------------------------------- /config/minigames/fish.toml: -------------------------------------------------------------------------------- 1 | [chance_weights] 2 | CLICK_THAT_BUTTON = 1 3 | 4 | 5 | [[CLICK_THAT_BUTTON]] 6 | object = "fish" 7 | action = "catch" 8 | target_emoji = "🐟" 9 | fails = ["The fish swims away and you're left empty-handed."] 10 | wins = [ 11 | "You sell the fish and get {}", 12 | "Some guy gives you {} in exchange for the fish. He intends to make love to it. That's crazy" 13 | ] 14 | foreground_style = "blurple" 15 | background_style = "blurple" 16 | background_emoji = "🌊" 17 | -------------------------------------------------------------------------------- /config/minigames/global.toml: -------------------------------------------------------------------------------- 1 | [chance_weights] 2 | FILL_IN_THE_BLANK = 1 3 | 4 | 5 | [[FILL_IN_THE_BLANK]] 6 | person = "LowTierGod" 7 | situations = [ 8 | "LowTierGod spots you in his chat and decides you're not worthy of living.", 9 | ] 10 | reasons = [ 11 | "avoid getting bullied to death", 12 | "stand up to him" 13 | ] 14 | prompts = [ 15 | [ 16 | "You're gonna stay on my {0} until you die. Your purpose in life is to be on my stream sucking on my {0} daily.", 17 | "dick" 18 | ], 19 | ["Your life is worth {}, you serve zero purpose", "nothing"], 20 | ["You should {} yourself, NOW!", "kill"], 21 | [ 22 | "Let's go to the 99-cent store, let's pick out a {} together. I'm gonna give you an assisted suicide.", 23 | "rope" 24 | ] 25 | ] 26 | fails = [ 27 | "LowTierGod convinces you to end it all.", 28 | ] 29 | wins = [ 30 | "LowTierGod backs off from you and decides to bully a little kid with autism instead. The kid was brutally beat to death, but atleast you're okay!", 31 | "LowTierGod gets distracted and starts bullying others instead. You're safe for now.", 32 | ] -------------------------------------------------------------------------------- /config/minigames/hunt.toml: -------------------------------------------------------------------------------- 1 | [chance_weights] 2 | CLICK_THAT_BUTTON = 1 3 | 4 | 5 | [[CLICK_THAT_BUTTON]] 6 | object = "homeless guy" 7 | action = "shoot" 8 | target_emoji = "🧔" 9 | fails = ["The homeless guy runs away and you get arrested."] 10 | wins = [ 11 | "You take {} off the homeless guy's corpse. I hope you feel good about yourself.", 12 | ] 13 | foreground_style = "grey" 14 | background_emoji = "🗑️" 15 | 16 | 17 | [[CLICK_THAT_BUTTON]] 18 | object = "grandma" 19 | action = "mug" 20 | target_emoji = "👵" 21 | fails = [ 22 | "The grandma unleashed her hidden Kung Fu and fucking murders you.", 23 | "The grandma spits in your face and says she's dissapointed. Don't you feel bad about yourself?", 24 | ] 25 | wins = [ 26 | "You take the poor old lady's purse and find {}. But was it really worth it?", 27 | ] 28 | foreground_style = "grey" 29 | background_emoji = "👩" 30 | -------------------------------------------------------------------------------- /downtime_bot/README.txt: -------------------------------------------------------------------------------- 1 | This folder contains the source code to a seperate Discord bot which is meant to be deployed during downtime. 2 | 3 | To run this bot, -------------------------------------------------------------------------------- /downtime_bot/src/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | config/config.toml 3 | config/website.toml 4 | -------------------------------------------------------------------------------- /downtime_bot/src/cogs/command_handler.py: -------------------------------------------------------------------------------- 1 | from discord.ext import commands, vbu 2 | 3 | 4 | class CommandHandlerCog(vbu.Cog): 5 | @vbu.Cog.listener("command") 6 | async def on_command( 7 | self, ctx: commands.Context[vbu.Bot] | commands.SlashContext[vbu.Bot] 8 | ): 9 | await ctx.send(repr(ctx)) 10 | 11 | 12 | async def setup(bot: vbu.Bot): 13 | x = CommandHandlerCog(bot) 14 | await bot.add_cog(x) 15 | -------------------------------------------------------------------------------- /downtime_bot/src/requirements.txt: -------------------------------------------------------------------------------- 1 | novus[vbu] 2 | -------------------------------------------------------------------------------- /dump.rdb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/schlopp/ppbot/1bbd2ae144d00e45146f3c2d2573297909bacbcd/dump.rdb -------------------------------------------------------------------------------- /pyo3_rust_utils/src/lib.rs: -------------------------------------------------------------------------------- 1 | // use num_traits::Zero; 2 | use pyo3::prelude::*; 3 | 4 | #[pyfunction] 5 | fn compute_multiplier_item_cost( 6 | amount: usize, 7 | current_multiplier: usize, 8 | item_price: usize, 9 | item_gain: usize, 10 | ) -> (usize, usize) { 11 | let cost = ((current_multiplier..=amount + current_multiplier - 1) 12 | .map(|x| (x as f64).powf(1.3)) 13 | .sum::() 14 | * (item_price as f64)) 15 | .floor() as usize; 16 | 17 | let gain = item_gain * amount; 18 | 19 | (cost, gain) 20 | } 21 | 22 | #[pyfunction] 23 | fn compute_max_multiplier_item_purchase_amount( 24 | available_inches: usize, 25 | current_multiplier: usize, 26 | item_price: usize, 27 | item_gain: usize, 28 | ) -> (usize, usize, usize) { 29 | let mut max_reached = false; 30 | let mut min_amount = 0usize; 31 | let mut max_amount = 10usize; 32 | let mut amount = max_amount; 33 | 34 | loop { 35 | let old_amount = amount; 36 | amount = min_amount + ((max_amount as f64 - min_amount as f64) / 2.0).floor() as usize; 37 | 38 | let (cost, gain) = 39 | compute_multiplier_item_cost(amount, current_multiplier, item_price, item_gain); 40 | 41 | if amount == old_amount { 42 | return (amount, cost, gain); 43 | } 44 | 45 | if cost > available_inches { 46 | max_amount = amount; 47 | max_reached = true; 48 | } else { 49 | min_amount = amount; 50 | 51 | if !max_reached { 52 | max_amount *= 2; 53 | } 54 | } 55 | } 56 | } 57 | 58 | #[pymodule] 59 | fn rust_utils(m: &Bound<'_, PyModule>) -> PyResult<()> { 60 | m.add_function(wrap_pyfunction!(compute_multiplier_item_cost, m)?)?; 61 | m.add_function(wrap_pyfunction!( 62 | compute_max_multiplier_item_purchase_amount, 63 | m 64 | )?)?; 65 | Ok(()) 66 | } 67 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["maturin>=1.8.3,<2.0"] 3 | build-backend = "maturin" 4 | 5 | [project] 6 | name = "ppbot" 7 | requires-python = ">=3.13" 8 | dynamic = ["version"] 9 | 10 | [tool.pyright] 11 | reportPrivateUsage = false 12 | reportImportCycles = false 13 | reportUnknownMemberType = false 14 | reportUnknownParameterType = false 15 | reportUnknownArgumentType = false 16 | reportUnknownVariableType = false 17 | reportMissingParameterType = false 18 | reportMissingTypeArgument = false 19 | reportMissingTypeStubs = false 20 | reportUnknownLambdaType = false 21 | reportIncompatibleVariableOverride = false 22 | 23 | [tool.maturin] 24 | features = ["pyo3/extension-module"] 25 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # pp bot 2 | 3 | The official GitHub repository of the Discord bot "pp bot", currently being rewritten. 4 | 5 | ## The #1 most addictive, weird, and stupid pp growing Discord bot. 6 | 7 | But you probably already know that. You're just curious about how this whole thing works. See all the code and shit like that. Feel free to snoop around! You'll find the majority of the code inside the `/cogs` folder, where groups of commands are - somewhat - neatly packed into different files called "cogs". These are built using a mix of Novus, VoxelBotUtils and our own internal tools up in the `/cogs/utils` folder. If you're a bit fimiliar with Python and discord.py or pycord, it'll be pretty fimiliar to you. 8 | 9 | ## I want to run the bot on my own machine! 10 | 11 | Good for you! To get started with self-hosting pp bot, there's a couple requirements you need. 12 | 13 | - PostgeSQL server 14 | - Redis server 15 | - Python (check `pyproject.toml` for version) 16 | - Rust/Cargo (check `Cargo.toml` for edition) 17 | - A local clone of this repository 18 | 19 | Also, assume any console commands shown in this tutorial are executed inside of the `ppbot` folder/repo. 20 | 21 | Your first step is creating a virtual environment. To do this, run: 22 | 23 | ```sh 24 | $ python -m venv .venv 25 | ``` 26 | 27 | This step is not optional, as our Rust packages rely on it (More on that later.) Now that your venv is set up, install the packages listed in `/requirements.txt`: 28 | 29 | ```sh 30 | $ pip install -r requirements.txt 31 | ``` 32 | 33 | Next up you're gonna have to compile the internal Rust packages, using Marutin. This is already included inside of `/requirements.txt`, so if you've completed the steps until now, it should already be installed. To compile the internal Rust packages, run: 34 | 35 | ```sh 36 | $ python -m maturin develop --release 37 | ``` 38 | 39 | Now head on over to the `/config` folder and create a file called `config.toml`. Copy over the contents of `config.example.toml` into it and edit all the values to your liking. Some important things you should change include the bot token, PostgreSQL and Redis details. Pp bot needs both PostgreSQL and Redis to run successfully, so make sure you got those two installed as well. 40 | 41 | Running the bot for the first time ain't that complicated. Assuming you haven't skipped the previous steps, all you have to do is run: 42 | 43 | ```sh 44 | $ vbu run-bot 45 | ``` 46 | 47 | Having some issues with `vbu` not being a detected command on your system? You can instead do `python -m vbu run-bot` or, if you're on Windows, look up a tutorial on how to add your Python scripts folder to path. Preferably one from an Indian guy, they generally know best. 48 | 49 | From here on out, go wild! Give yourself a gazillion inches with the admin commands! (I wont tell you where they're hidden, muhahaha.) Create entirely new commands/items! If you went through the trouble of running this thing yourself, you deserve all the power. Just make it clear you're not the official bot. Don't want any confusion. 50 | 51 | ## Can I contribute my own features? 52 | 53 | As of right now, what you're able to contribute will be very limited. This repository doesn't even contain the code of the current pp bot; in fact it contains the rewrite I'm currently developing. Pp bot was one of the projects I've ever coded, and as a result, the old code is entirely dogshit. For the last year I've been rewriting every single thing about pp bot, making it look better, feel smoother, load faster and more entertaining. 54 | 55 | As a result of this rewrite, code contributions will **likely be rejected.** It's nothing personal, it's just that the project is very incomplete and I need a lil' more time before I can start dealing with other peoples' code. Once the rewrite is officially released, I'll be more open to contributions from you, the community. 56 | 57 | An exception to this is dialogue contributions. All the minigames in pp bot need custom, funny dialogue. The bulk of this dialogue is contained inside the `config/minigames.toml` file. Feel free to add new dialogue options to this file, and submit them as PR's. I'll be sure to take a look at them <3 58 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | git+https://github.com/schlopp/Novus.git 2 | novus[speed,vbu] 3 | asyncpg 4 | pre-commit 5 | maturin 6 | black 7 | toml 8 | -------------------------------------------------------------------------------- /typings/rust_utils/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | Utility module written in Rust using PyO3 and Maturin. Typestubs maintained manually. 3 | 4 | 5 | Type conversions for Rust (PyO3) -> Python: https://pyo3.rs/v0.19.2/conversions/tables 6 | """ 7 | 8 | __all__ = [ 9 | "compute_multiplier_item_cost", 10 | "compute_max_multiplier_item_purchase_amount", 11 | ] 12 | 13 | def compute_multiplier_item_cost( 14 | amount: int, current_multiplier: int, item_price: int, item_gain: int 15 | ) -> tuple[int, int]: 16 | """ 17 | Computes the price of buying a certain amount of a multiplier item, along with the gain it'll 18 | give. Returns tuple `(cost: int, gain: int)`. 19 | """ 20 | ... 21 | 22 | def compute_max_multiplier_item_purchase_amount( 23 | available_inches: int, current_multiplier: int, item_price: int, item_gain: int 24 | ) -> tuple[int, int, int]: 25 | """ 26 | Computes the maximum purchasable amount of a multiplier item, along with the cost and the gain 27 | it'll give. Returns tuple `(amount: int, cost: int, gain: int)`. 28 | """ 29 | ... 30 | --------------------------------------------------------------------------------