├── .devcontainer ├── devcontainer.json └── docker-compose.yml ├── .dockerignore ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── mirror.yml │ └── pytest.yml ├── .gitignore ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── common ├── classes.py ├── device_code.py ├── fuzzy.py ├── graph_template.py ├── help_tools.py ├── models.py ├── playerlist_events.py ├── playerlist_utils.py ├── premium_utils.py ├── realm_stories.py ├── stats_utils.py └── utils.py ├── config_example.toml ├── db_settings.py ├── docker-compose.yml ├── exts ├── autorunners.py ├── etc_events.py ├── general_cmds.py ├── guild_config.py ├── help_cmd.py ├── on_cmd_error.py ├── owner_cmds.py ├── pl_event_handling.py ├── playerlist.py ├── premium.py ├── statistics.py └── voting.py ├── license_header.txt ├── main.py ├── migrations └── models │ ├── 5_20250506001949_None.py │ ├── 6_20250506005003_create_dbs.py │ ├── 7_20250506115832_adjust_columns.py │ └── 8_20250507205909_playerlist_chan_null.py ├── pyproject.toml ├── requirements.txt ├── rpl_config.py └── tests ├── common ├── stats_utils_models.py └── test_stats_utils.py └── conftest.py /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose 3 | { 4 | "name": "Existing Docker Compose (Extend)", 5 | 6 | // Update the 'dockerComposeFile' list if you have more compose files or use different names. 7 | // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make. 8 | "dockerComposeFile": [ 9 | "../docker-compose.yml", 10 | "docker-compose.yml" 11 | ], 12 | 13 | // The 'service' property is the name of the service for the container that VS Code should 14 | // use. Update this value and .devcontainer/docker-compose.yml to the real service name. 15 | "service": "bot", 16 | 17 | // The optional 'workspaceFolder' property is the path VS Code should open by default when 18 | // connected. This is typically a file mount in .devcontainer/docker-compose.yml 19 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}" 20 | 21 | // Features to add to the dev container. More info: https://containers.dev/features. 22 | // "features": {}, 23 | 24 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 25 | // "forwardPorts": [], 26 | 27 | // Uncomment the next line if you want start specific services in your Docker Compose config. 28 | // "runServices": [], 29 | 30 | // Uncomment the next line if you want to keep your containers running after VS Code shuts down. 31 | // "shutdownAction": "none", 32 | 33 | // Uncomment the next line to run commands after the container is created. 34 | // "postCreateCommand": "cat /etc/os-release", 35 | 36 | // Configure tool-specific properties. 37 | // "customizations": {}, 38 | 39 | // Uncomment to connect as an existing user other than the container default. More info: https://aka.ms/dev-containers-non-root. 40 | // "remoteUser": "devcontainer" 41 | } 42 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | # Update this to the name of the service you want to work with in your docker-compose.yml file 4 | bot: 5 | # Uncomment if you want to override the service's Dockerfile to one in the .devcontainer 6 | # folder. Note that the path of the Dockerfile and context is relative to the *primary* 7 | # docker-compose.yml file (the first in the devcontainer.json "dockerComposeFile" 8 | # array). The sample below assumes your primary file is in the root of your project. 9 | # 10 | # build: 11 | # context: . 12 | # dockerfile: .devcontainer/Dockerfile 13 | 14 | volumes: 15 | # Update this to wherever you want VS Code to mount the folder of your project 16 | - ..:/workspaces:cached 17 | 18 | # Uncomment the next four lines if you will use a ptrace-based debugger like C++, Go, and Rust. 19 | # cap_add: 20 | # - SYS_PTRACE 21 | # security_opt: 22 | # - seccomp:unconfined 23 | 24 | # Overrides default command so things don't shut down after the process ends. 25 | command: /bin/sh -c "while sleep 1000; do :; done" 26 | 27 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/python,visualstudiocode 2 | # Edit at https://www.gitignore.io/?templates=python,visualstudiocode 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # pyenv 71 | .python-version 72 | 73 | # pipenv 74 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 75 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 76 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 77 | # install all needed dependencies. 78 | #Pipfile.lock 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | .spyproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | # Mr Developer 94 | .mr.developer.cfg 95 | .project 96 | .pydevproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | .dmypy.json 104 | dmypy.json 105 | 106 | # Pyre type checker 107 | .pyre/ 108 | 109 | ### VisualStudioCode ### 110 | .vscode/* 111 | !.vscode/settings.json 112 | !.vscode/tasks.json 113 | !.vscode/launch.json 114 | !.vscode/extensions.json 115 | 116 | ### VisualStudioCode Patch ### 117 | # Ignore all local history of files 118 | .history 119 | 120 | .code-workspace 121 | 122 | # End of https://www.gitignore.io/api/python,visualstudiocode 123 | 124 | env/ 125 | .venv/ 126 | private_tests/ 127 | .vscode/ 128 | data/ -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://playerlist.astrea.cc/wiki/premium 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" # See documentation for possible values 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "daily" 7 | commit-message: 8 | prefix: "chore" 9 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '18 9 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.github/workflows/mirror.yml: -------------------------------------------------------------------------------- 1 | name: "Mirror Repository" 2 | 3 | on: [push] 4 | 5 | jobs: 6 | mirror: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | with: 11 | fetch-depth: 0 12 | - uses: yesolutions/mirror-action@master 13 | with: 14 | REMOTE: 'https://codeberg.org/Astrea/RealmsPlayerlistBot.git' 15 | GIT_USERNAME: Astrea 16 | GIT_PASSWORD: '${{ secrets.CODEBERG_PASSWORD }}' -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Pytest 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Set up Python 11 | uses: actions/setup-python@v5 12 | with: 13 | python-version: '3.12' 14 | - name: Install dependencies 15 | run: | 16 | python -m pip install --upgrade pip 17 | pip install -r requirements.txt 18 | - name: Test with pytest 19 | run: | 20 | pip install pytest 21 | python -m pytest tests --doctest-modules --junitxml=junit/test-results.xml 22 | - name: Upload pytest test results 23 | uses: actions/upload-artifact@v4 24 | with: 25 | name: pytest-results 26 | path: junit/test-results.xml 27 | # Use always() to always run this step to publish test results when there are test failures 28 | if: ${{ always() }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.gitignore.io/api/python,visualstudiocode 2 | # Edit at https://www.gitignore.io/?templates=python,visualstudiocode 3 | 4 | ### Python ### 5 | # Byte-compiled / optimized / DLL files 6 | __pycache__/ 7 | *.py[cod] 8 | *$py.class 9 | 10 | # C extensions 11 | *.so 12 | 13 | # Distribution / packaging 14 | .Python 15 | build/ 16 | develop-eggs/ 17 | dist/ 18 | downloads/ 19 | eggs/ 20 | .eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | pip-wheel-metadata/ 28 | share/python-wheels/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | MANIFEST 33 | 34 | # PyInstaller 35 | # Usually these files are written by a python script from a template 36 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 37 | *.manifest 38 | *.spec 39 | 40 | # Installer logs 41 | pip-log.txt 42 | pip-delete-this-directory.txt 43 | 44 | # Unit test / coverage reports 45 | htmlcov/ 46 | .tox/ 47 | .nox/ 48 | .coverage 49 | .coverage.* 50 | .cache 51 | nosetests.xml 52 | coverage.xml 53 | *.cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # pyenv 71 | .python-version 72 | 73 | # pipenv 74 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 75 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 76 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 77 | # install all needed dependencies. 78 | #Pipfile.lock 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | .spyproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | # Mr Developer 94 | .mr.developer.cfg 95 | .project 96 | .pydevproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | .dmypy.json 104 | dmypy.json 105 | 106 | # Pyre type checker 107 | .pyre/ 108 | 109 | ### VisualStudioCode ### 110 | .vscode/* 111 | !.vscode/settings.json 112 | !.vscode/tasks.json 113 | !.vscode/launch.json 114 | !.vscode/extensions.json 115 | 116 | ### VisualStudioCode Patch ### 117 | # Ignore all local history of files 118 | .history 119 | 120 | .code-workspace 121 | 122 | # End of https://www.gitignore.io/api/python,visualstudiocode 123 | 124 | discord.log 125 | debug.log 126 | .env 127 | .venv/ 128 | env/ 129 | config.toml 130 | tokens.json 131 | private_tests/ 132 | data/ 133 | 134 | .vscode/ -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black-pre-commit-mirror 3 | rev: 25.1.0 4 | hooks: 5 | - id: black 6 | args: [--target-version, py312, --preview, --enable-unstable-feature=string_processing] 7 | language_version: python3.12 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v5.0.0 10 | hooks: 11 | - id: trailing-whitespace 12 | - repo: https://github.com/astral-sh/ruff-pre-commit 13 | rev: "v0.11.8" 14 | hooks: 15 | - id: ruff 16 | language_version: python3.12 17 | - repo: https://github.com/Lucas-C/pre-commit-hooks 18 | rev: v1.5.5 19 | hooks: 20 | - id: insert-license 21 | files: \.py$ 22 | args: 23 | - --license-filepath 24 | - license_header.txt 25 | - --comment-style 26 | - "\"\"\"||\"\"\"" 27 | - --allow-past-years 28 | - --use-current-year 29 | - --no-space-in-comment-prefix 30 | 31 | ci: 32 | autofix_commit_msg: "ci: auto fixes from pre-commit.com hooks" 33 | autoupdate_branch: "main" 34 | autoupdate_commit_msg: "ci: pre-commit autoupdate" 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13-alpine 2 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv 3 | 4 | RUN apk add gcc g++ bash musl-dev git libffi-dev openssl 5 | 6 | COPY . /app 7 | WORKDIR /app 8 | 9 | # allows git to work with the directory, making commands like /about better 10 | RUN git config --global --add safe.directory /app 11 | 12 | RUN uv pip install --system -r requirements.txt 13 | 14 | CMD [ "python", "main.py" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Realm Playerlist Bot's Banner. 3 |

4 | 5 |

6 | 7 | Server Setup Guide 8 | 9 | 10 | Support Server 11 | 12 | 13 | Purchase Premium 14 | 15 |

16 | 17 |

18 | A bot that helps out owners of Minecraft: Bedrock Edition Realms by showing various statistics related to player join/leaves. 19 |

20 | 21 | It's: 22 | - 🚀 **Fast:** Under the right (and typical) circumstances, it can send a list of ~300 players in under *2 seconds,* most of which (~1.5 seconds) is spent just sending messages to Discord. 23 | - 📊 **Informative:** The main feature of the bot, the playerlist, can give a detailed log of players on a Realm at a moment's notice. You can also get a breakdown for an individual player to analyze as you wish. 24 | - 👌 **Easy to Use**: Simply add the bot, link your Realm, and you already have join/leave tracking enabled - no need to use your Xbox account for the bot's features. Take a look at the [Server Setup Guide](https://playerlist.astrea.cc/wiki/server_setup.html) for more information. 25 | - 🔓 **Open Source**: The code is available to the public and able to be audited and learned from. Dedicated users can even (try to) self-host the bot, if they wish. 26 | 27 |

28 | For more information, please check the official website. 29 |

30 | 31 | ### Copyright and License Notice 32 | 33 | Copyright 2020-2025 AstreaTSS. 34 | 35 | Unless otherwise stated, all files in this repository are licensed under the GNU Affero General Public License v3.0. A copy of the license is available in the [LICENSE](LICENSE) file. 36 | 37 | The Realms Playerlist Bot is not an official Minecraft product and is not approved by or associated with Mojang or Microsoft. 38 | -------------------------------------------------------------------------------- /common/classes.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import typing 18 | import uuid 19 | from collections.abc import MutableSet 20 | from copy import copy 21 | 22 | import aiohttp 23 | import attrs 24 | import elytra 25 | import humanize 26 | import interactions as ipy 27 | from httpcore._backends import anyio 28 | from httpcore._backends.asyncio import AsyncioBackend 29 | 30 | import common.models as models 31 | import common.playerlist_utils as pl_utils 32 | import common.utils as utils 33 | from common.help_tools import CustomTimeout 34 | 35 | 36 | def valid_channel_check(channel: ipy.GuildChannel) -> ipy.GuildText: 37 | if not isinstance(channel, ipy.MessageableMixin): 38 | raise ipy.errors.BadArgument(f"Cannot send messages in {channel.mention}.") 39 | 40 | perms = channel.permissions 41 | 42 | if not perms: 43 | raise ipy.errors.BadArgument( 44 | f"Cannot resolve permissions for {channel.mention}." 45 | ) 46 | 47 | if ( 48 | ipy.Permissions.VIEW_CHANNEL not in perms 49 | ): # technically pointless, but who knows 50 | raise ipy.errors.BadArgument(f"Cannot read messages in {channel.mention}.") 51 | elif ipy.Permissions.SEND_MESSAGES not in perms: 52 | raise ipy.errors.BadArgument(f"Cannot send messages in {channel.mention}.") 53 | elif ipy.Permissions.EMBED_LINKS not in perms: 54 | raise ipy.errors.BadArgument( 55 | "Cannot send embeds (controlled through `Embed Links`) in" 56 | f" {channel.mention}." 57 | ) 58 | 59 | return channel # type: ignore 60 | 61 | 62 | class ValidChannelConverter(ipy.Converter): 63 | async def convert( 64 | self, _: ipy.InteractionContext, argument: ipy.GuildText 65 | ) -> ipy.GuildText: 66 | channel = valid_channel_check(argument) 67 | 68 | # im 90% sure discord's channel permission property is broken, 69 | # so we'll just try to send a message and see if it errors out 70 | try: 71 | msg = await channel.send(embed=utils.make_embed("Testing...")) 72 | await msg.delete() 73 | except ipy.errors.HTTPException as e: 74 | if isinstance(e, ipy.errors.Forbidden): 75 | if e.text == "Missing Permissions": 76 | raise ipy.errors.BadArgument( 77 | "Cannot send messages and/or send embeds (controlled through" 78 | f" `Embed Links`) in {channel.mention}." 79 | ) from None 80 | elif e.text == "Missing Access": 81 | raise ipy.errors.BadArgument( 82 | f"Cannot read messages in {channel.mention}." 83 | ) from None 84 | else: 85 | raise ipy.errors.BadArgument( 86 | f"Cannot use {channel.mention}. Please check its permissions." 87 | ) from None 88 | 89 | if e.status >= 500: 90 | raise ipy.errors.BadArgument( 91 | "Discord is having issues. Try again later." 92 | ) from None 93 | 94 | raise e 95 | 96 | return channel 97 | 98 | 99 | class _Placeholder: 100 | pass 101 | 102 | 103 | class OrderedSet[T](MutableSet[T]): 104 | def __init__(self, an_iter: typing.Iterable[T] | None = None, /) -> None: 105 | self._dict: dict[T, T] = {} 106 | 107 | if an_iter is not None: 108 | self._dict = {element: element for element in an_iter} 109 | 110 | def __repr__(self) -> str: 111 | if not self: 112 | return f"{self.__class__.__name__}()" 113 | return f"{self.__class__.__name__}({list(self)!r})" 114 | 115 | def __contains__(self, element: T) -> bool: 116 | return self._dict.get(element, _Placeholder) != _Placeholder 117 | 118 | def __len__(self) -> int: 119 | return len(self._dict) 120 | 121 | def __iter__(self) -> typing.Iterator[T]: 122 | return iter(self._dict) 123 | 124 | def add(self, element: T) -> None: 125 | self._dict[element] = element 126 | 127 | def remove(self, element: T) -> None: 128 | self._dict.pop(element) 129 | 130 | def discard(self, element: T) -> None: 131 | self._dict.pop(element, None) 132 | 133 | def pop(self, element: T) -> T: 134 | return self._dict.pop(element) 135 | 136 | def clear(self) -> None: 137 | self._dict.clear() 138 | 139 | def copy(self) -> typing.Self: 140 | return copy(self) 141 | 142 | def intersection(self, *others: typing.Iterable[T]) -> typing.Self: 143 | return self.__class__(e for sub in others for e in sub if e in self) 144 | 145 | def __and__(self, other: typing.Iterable[T]) -> typing.Self: 146 | return self.intersection(other) 147 | 148 | def union(self, *others: typing.Iterable[T]) -> typing.Self: 149 | return self.__class__(self, *(e for sub in others for e in sub)) 150 | 151 | def __or__(self, other: typing.Iterable[T]) -> typing.Self: 152 | return self.union(other) 153 | 154 | 155 | @ipy.utils.define(kw_only=False, auto_detect=True) 156 | class DynamicLeaderboardPaginator: 157 | client: "utils.RealmBotBase" = attrs.field( 158 | repr=False, 159 | ) 160 | """The client to hook listeners into""" 161 | 162 | pages_data: list[tuple[str, int]] = attrs.field(repr=False, kw_only=True) 163 | """The entries for the leaderboard""" 164 | period_str: str = attrs.field(repr=False, kw_only=True) 165 | """The period, represented as a string.""" 166 | timestamp: ipy.Timestamp = attrs.field(repr=False, kw_only=True) 167 | """The timestamp to use for the embeds.""" 168 | nicknames: dict[str, str] = attrs.field(repr=False, kw_only=True) 169 | """The nicknames to use for this leaderboard.""" 170 | 171 | page_index: int = attrs.field(repr=False, kw_only=True, default=0) 172 | """The index of the current page being displayed""" 173 | timeout_interval: int = attrs.field(repr=False, default=120, kw_only=True) 174 | """How long until this paginator disables itself""" 175 | 176 | context: ipy.InteractionContext | None = attrs.field( 177 | default=None, init=False, repr=False 178 | ) 179 | 180 | _uuid: str = attrs.field(repr=False, init=False, factory=uuid.uuid4) 181 | _message: ipy.Message = attrs.field(repr=False, init=False, default=ipy.MISSING) 182 | _timeout_task: CustomTimeout = attrs.field( 183 | repr=False, init=False, default=ipy.MISSING 184 | ) 185 | _author_id: ipy.Snowflake_Type = attrs.field( 186 | repr=False, init=False, default=ipy.MISSING 187 | ) 188 | 189 | def __attrs_post_init__(self) -> None: 190 | self.bot.add_component_callback( 191 | ipy.ComponentCommand( 192 | name=f"Paginator:{self._uuid}", 193 | callback=self._on_button, 194 | listeners=[ 195 | f"{self._uuid}|select", 196 | f"{self._uuid}|first", 197 | f"{self._uuid}|back", 198 | f"{self._uuid}|next", 199 | f"{self._uuid}|last", 200 | ], 201 | ) 202 | ) 203 | 204 | @property 205 | def bot(self) -> "utils.RealmBotBase": 206 | return self.client 207 | 208 | @property 209 | def message(self) -> ipy.Message: 210 | """The message this paginator is currently attached to""" 211 | return self._message 212 | 213 | @property 214 | def author_id(self) -> ipy.Snowflake_Type: 215 | """The ID of the author of the message this paginator is currently attached to""" 216 | return self._author_id 217 | 218 | @property 219 | def last_page_index(self) -> int: 220 | return 0 if len(self.pages_data) == 0 else (len(self.pages_data) - 1) // 20 221 | 222 | def create_components(self, disable: bool = False) -> list[ipy.ActionRow]: 223 | """ 224 | Create the components for the paginator message. 225 | 226 | Args: 227 | disable: Should all the components be disabled? 228 | 229 | Returns: 230 | A list of ActionRows 231 | 232 | """ 233 | lower_index = max(0, min((self.last_page_index + 1) - 25, self.page_index - 12)) 234 | 235 | output: list[ipy.Button | ipy.StringSelectMenu] = [ 236 | ipy.StringSelectMenu( 237 | *( 238 | ipy.StringSelectOption(label=f"Page {i+1}", value=str(i)) 239 | for i in range( 240 | lower_index, 241 | min(self.last_page_index + 1, lower_index + 25), 242 | ) 243 | ), 244 | custom_id=f"{self._uuid}|select", 245 | placeholder=f"Page {self.page_index+1}", 246 | max_values=1, 247 | disabled=disable, 248 | ), 249 | ipy.Button( 250 | style=ipy.ButtonStyle.BLURPLE, 251 | emoji="⏮️", 252 | custom_id=f"{self._uuid}|first", 253 | disabled=disable or self.page_index == 0, 254 | ), 255 | ipy.Button( 256 | style=ipy.ButtonStyle.BLURPLE, 257 | emoji="⬅️", 258 | custom_id=f"{self._uuid}|back", 259 | disabled=disable or self.page_index == 0, 260 | ), 261 | ipy.Button( 262 | style=ipy.ButtonStyle.BLURPLE, 263 | emoji="➡️", 264 | custom_id=f"{self._uuid}|next", 265 | disabled=disable or self.page_index >= self.last_page_index, 266 | ), 267 | ipy.Button( 268 | style=ipy.ButtonStyle.BLURPLE, 269 | emoji="⏭️", 270 | custom_id=f"{self._uuid}|last", 271 | disabled=disable or self.page_index >= self.last_page_index, 272 | ), 273 | ] 274 | return ipy.spread_to_rows(*output) 275 | 276 | async def to_dict(self) -> dict: 277 | """Convert this paginator into a dictionary for sending.""" 278 | page_data = self.pages_data[self.page_index * 20 : (self.page_index * 20) + 20] 279 | 280 | gamertag_map = await pl_utils.get_xuid_to_gamertag_map( 281 | self.bot, [e[0] for e in page_data if e[0] not in self.nicknames] 282 | ) 283 | 284 | leaderboard_builder: list[str] = [] 285 | index = self.page_index * 20 286 | 287 | for xuid, playtime in page_data: 288 | precisedelta = humanize.precisedelta( 289 | playtime, minimum_unit="minutes", format="%0.0f" 290 | ) 291 | 292 | if precisedelta == "1 minutes": # why humanize 293 | precisedelta = "1 minute" 294 | 295 | display = models.display_gamertag( 296 | xuid, gamertag_map[xuid], self.nicknames.get(xuid) 297 | ) 298 | 299 | leaderboard_builder.append(f"**{index+1}\\.** {display} {precisedelta}") 300 | 301 | index += 1 302 | 303 | page = ipy.Embed( 304 | title=f"Leaderboard for the past {self.period_str}", 305 | description="\n".join(leaderboard_builder), 306 | color=self.bot.color, 307 | timestamp=self.timestamp, 308 | ) 309 | page.set_author(name=f"Page {self.page_index+1}/{self.last_page_index+1}") 310 | 311 | return { 312 | "embeds": [page.to_dict()], 313 | "components": [c.to_dict() for c in self.create_components()], 314 | } 315 | 316 | async def send(self, ctx: ipy.BaseContext, **kwargs: typing.Any) -> ipy.Message: 317 | """ 318 | Send this paginator. 319 | 320 | Args: 321 | ctx: The context to send this paginator with 322 | **kwargs: Additional options to pass to `send`. 323 | 324 | Returns: 325 | The resulting message 326 | 327 | """ 328 | if isinstance(ctx, ipy.InteractionContext): 329 | self.context = ctx 330 | 331 | self._message = await ctx.send(**await self.to_dict(), **kwargs) 332 | self._author_id = ctx.author.id 333 | 334 | if self.timeout_interval > 1: 335 | self._timeout_task = CustomTimeout(self) 336 | self.client.create_task(self._timeout_task()) 337 | 338 | return self._message 339 | 340 | async def _on_button( 341 | self, ctx: ipy.ComponentContext, *_: typing.Any, **__: typing.Any 342 | ) -> typing.Optional[ipy.Message]: 343 | if ctx.author.id != self.author_id: 344 | return await ctx.send( 345 | "You are not allowed to use this paginator.", ephemeral=True 346 | ) 347 | if self._timeout_task: 348 | self._timeout_task.ping.set() 349 | match ctx.custom_id.split("|")[1]: 350 | case "first": 351 | self.page_index = 0 352 | case "last": 353 | self.page_index = self.last_page_index 354 | case "next": 355 | if (self.page_index + 1) <= self.last_page_index: 356 | self.page_index += 1 357 | case "back": 358 | if self.page_index >= 1: 359 | self.page_index -= 1 360 | case "select": 361 | self.page_index = int(ctx.values[0]) 362 | 363 | await ctx.edit_origin(**await self.to_dict()) 364 | return None 365 | 366 | 367 | @ipy.utils.define(kw_only=False, auto_detect=True) 368 | class DynamicRealmMembers(DynamicLeaderboardPaginator): 369 | pages_data: list[elytra.Player] = attrs.field(repr=False, kw_only=True) 370 | realm_name: str = attrs.field(repr=False, kw_only=True) 371 | owner_xuid: str = attrs.field(repr=False, kw_only=True) 372 | period_str: str = attrs.field(repr=False, kw_only=True, default="", init=False) 373 | 374 | async def to_dict(self) -> dict: 375 | """Convert this paginator into a dictionary for sending.""" 376 | page_data = self.pages_data[self.page_index * 20 : (self.page_index * 20) + 20] 377 | 378 | gamertag_map = await pl_utils.get_xuid_to_gamertag_map( 379 | self.client, [p.uuid for p in page_data if p.uuid not in self.nicknames] 380 | ) 381 | 382 | str_builder: list[str] = [] 383 | index = self.page_index * 20 384 | 385 | for player in page_data: 386 | xuid = player.uuid 387 | 388 | base_display = models.display_gamertag( 389 | xuid, gamertag_map[xuid], self.nicknames.get(xuid) 390 | ) 391 | 392 | ending = "" 393 | 394 | if xuid == self.owner_xuid: 395 | ending += "**Owner**" 396 | elif player.permission.value == "OPERATOR": 397 | ending += "**Operator**" 398 | elif player.uuid == self.bot.xbox.auth_mgr.xsts_token.xuid: 399 | ending += "**This Bot**" 400 | else: 401 | ending += player.permission.value.title() 402 | 403 | str_builder.append(f"- {base_display} - {ending}") 404 | 405 | index += 1 406 | 407 | page = ipy.Embed( 408 | f"Members of {self.realm_name}", 409 | description="\n".join(str_builder), 410 | color=self.bot.color, 411 | timestamp=self.timestamp, 412 | ) 413 | page.set_author(name=f"Page {self.page_index+1}/{self.last_page_index+1}") 414 | 415 | return { 416 | "embeds": [page.to_dict()], 417 | "components": [c.to_dict() for c in self.create_components()], 418 | } 419 | 420 | 421 | class BetterResponse(aiohttp.ClientResponse): 422 | async def aread(self) -> bytes: 423 | return await self.read() 424 | 425 | def raise_for_status(self) -> None: 426 | # i just dont want the resp to close lol 427 | if not self.ok: 428 | # reason should always be not None for a started response 429 | assert self.reason is not None # noqa: S101 430 | raise aiohttp.ClientResponseError( 431 | self.request_info, 432 | self.history, 433 | status=self.status, 434 | message=self.reason, 435 | headers=self.headers, 436 | ) 437 | 438 | 439 | anyio.AnyIOBackend = AsyncioBackend 440 | -------------------------------------------------------------------------------- /common/device_code.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import asyncio 18 | import datetime 19 | import logging 20 | import os 21 | 22 | import elytra 23 | import interactions as ipy 24 | import orjson 25 | from httpx import HTTPStatusError 26 | 27 | import common.utils as utils 28 | 29 | logger = logging.getLogger("realms_bot") 30 | 31 | 32 | async def handle_flow( 33 | ctx: utils.RealmContext, msg: ipy.Message 34 | ) -> elytra.OAuth2TokenResponse: 35 | async with ctx.bot.session.get( 36 | "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode", 37 | data={ 38 | "client_id": os.environ["XBOX_CLIENT_ID"], 39 | "scope": "Xboxlive.signin", 40 | }, 41 | ) as resp: 42 | resp.raise_for_status() 43 | init_data = await resp.json(loads=orjson.loads) 44 | 45 | success_response: elytra.OAuth2TokenResponse | None = None 46 | 47 | await ctx.edit( 48 | msg, 49 | embeds=utils.make_embed( 50 | f"Please go to {init_data['verification_uri']} and enter code" 51 | f" `{init_data['user_code']}` to authenticate. The bot will periodically" 52 | " check to see if/when the account is authenticated.\n\n*You have 5" 53 | " minutes to authenticate.*" 54 | ), 55 | components=[], 56 | ) 57 | 58 | try: 59 | async with asyncio.timeout(300): 60 | while True: 61 | await asyncio.sleep(init_data["interval"]) 62 | 63 | async with ctx.bot.session.post( 64 | "https://login.microsoftonline.com/consumers/oauth2/v2.0/token", 65 | data={ 66 | "client_id": os.environ["XBOX_CLIENT_ID"], 67 | "grant_type": "urn:ietf:params:oauth:grant-type:device_code", 68 | "device_code": init_data["device_code"], 69 | }, 70 | ) as resp: 71 | resp_json = await resp.json() 72 | if error := resp_json.get("error"): 73 | if error in { 74 | "authorization_declined", 75 | "expired_token", 76 | "bad_verification_code", 77 | }: 78 | break 79 | else: 80 | success_response = elytra.OAuth2TokenResponse.from_data( 81 | resp_json 82 | ) 83 | break 84 | except TimeoutError: 85 | raise utils.CustomCheckFailure("Authentication timed out.") from None 86 | 87 | if success_response is None: 88 | raise utils.CustomCheckFailure("Authentication failed or was cancelled.") 89 | 90 | return success_response 91 | 92 | 93 | async def handle_realms( 94 | ctx: utils.RealmContext, msg: ipy.Message, oauth: elytra.OAuth2TokenResponse 95 | ) -> elytra.FullRealm: 96 | await ctx.edit(msg, embeds=utils.make_embed("Getting Realms data...")) 97 | 98 | try: 99 | user_xbox = await elytra.XboxAPI.from_oauth( 100 | os.environ["XBOX_CLIENT_ID"], os.environ["XBOX_CLIENT_SECRET"], oauth 101 | ) 102 | except HTTPStatusError as e: 103 | if e.response.status_code == 401: 104 | ipy.get_logger().error( 105 | "Forbidden response when logging into Xbox Live:" 106 | f" {(await e.response.aread()).decode()}" 107 | ) 108 | raise utils.CustomCheckFailure( 109 | "Failed to authenticate with Xbox Live. Please try again and join the" 110 | f" support server ({ctx.bot.mention_command('support')}) if this" 111 | " persists." 112 | ) from e 113 | raise 114 | 115 | user_xuid = user_xbox.auth_mgr.xsts_token.xuid 116 | my_xuid = ctx.bot.xbox.auth_mgr.xsts_token.xuid 117 | 118 | user_realms = await elytra.BedrockRealmsAPI.from_oauth( 119 | os.environ["XBOX_CLIENT_ID"], os.environ["XBOX_CLIENT_SECRET"], oauth 120 | ) 121 | realms = await user_realms.fetch_realms() 122 | owned_realms = [ 123 | r for r in realms.servers if r.owner_uuid == user_xuid and not r.expired 124 | ] 125 | 126 | try: 127 | if not owned_realms: 128 | raise utils.CustomCheckFailure( 129 | "You do not own any active Realms. Please create one and try again." 130 | ) 131 | 132 | select_realm = ipy.StringSelectMenu( 133 | *( 134 | ipy.StringSelectOption(label=r.name, value=str(r.id)) 135 | for r in owned_realms 136 | ), 137 | placeholder="Select a Realm", 138 | ) 139 | await ctx.edit( 140 | msg, 141 | embeds=utils.make_embed( 142 | "Select a Realm to add the bot to:\n*You have 5 minutes to complete" 143 | " this.*" 144 | ), 145 | components=[select_realm], 146 | ) 147 | 148 | try: 149 | event = await ctx.bot.wait_for_component(msg, select_realm, timeout=300) 150 | await event.ctx.defer(edit_origin=True) 151 | await ctx.edit( 152 | msg, 153 | embeds=utils.make_embed( 154 | "Adding bot to Realm...\n*You may see the bot adding you as a" 155 | " friend. This is part of the process - it'll unfriend you soon" 156 | " after.*" 157 | ), 158 | components=[], 159 | ) 160 | except TimeoutError: 161 | await ctx.edit(msg, components=[]) 162 | raise utils.CustomCheckFailure("Realm selection timed out.") from None 163 | 164 | realm_id = int(event.ctx.values[0]) 165 | associated_realm = next((r for r in realms.servers if r.id == realm_id), None) 166 | if associated_realm is None: 167 | raise utils.CustomCheckFailure( 168 | "The Realm you selected no longer exists. Please try again." 169 | ) 170 | 171 | # work around potential mojang protections against inviting users to realms 172 | # note: not a bypass, the user has given permission to do this with oauth 173 | try: 174 | await user_xbox.add_friend(xuid=my_xuid) 175 | await ctx.bot.xbox.add_friend(xuid=user_xuid) 176 | except elytra.MicrosoftAPIException as e: 177 | # not too important, but we'll log it 178 | logger.warning("Failed to add %s as friend.", user_xuid, exc_info=e) 179 | 180 | await user_realms.invite_player(realm_id, my_xuid) 181 | await asyncio.sleep(5) 182 | 183 | block_off_time = int( 184 | ( 185 | datetime.datetime.now(datetime.UTC) - datetime.timedelta(minutes=2) 186 | ).timestamp() 187 | ) 188 | pending_invites = await ctx.bot.realms.fetch_pending_invites() 189 | 190 | # yeah, not the best, but we'll make it work 191 | invite = next( 192 | ( 193 | i 194 | for i in pending_invites.invites 195 | if i.world_owner_uuid == user_xuid 196 | and i.world_name == associated_realm.name 197 | and i.date_timestamp >= block_off_time 198 | ), 199 | None, 200 | ) 201 | if invite is None: 202 | raise utils.CustomCheckFailure( 203 | "Failed to send invite to Realm. Please try again." 204 | ) 205 | 206 | await ctx.bot.realms.accept_invite(invite.invitation_id) 207 | 208 | try: 209 | await user_xbox.remove_friend(xuid=my_xuid) 210 | await ctx.bot.xbox.remove_friend(xuid=user_xuid) 211 | except elytra.MicrosoftAPIException as e: 212 | logger.warning("Failed to remove %s as friend.", user_xuid, exc_info=e) 213 | 214 | return associated_realm 215 | finally: 216 | await user_xbox.close() 217 | await user_realms.close() 218 | -------------------------------------------------------------------------------- /common/fuzzy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import typing 18 | 19 | import rapidfuzz 20 | from rapidfuzz import process 21 | 22 | 23 | def extract_from_list[T]( 24 | argument: str, 25 | list_of_items: typing.Collection[T], 26 | processors: typing.Iterable[typing.Callable], 27 | score_cutoff: float = 0.8, 28 | scorers: typing.Iterable[typing.Callable] | None = None, 29 | ) -> list[list[T]]: 30 | """Uses multiple scorers and processors for a good mix of accuracy and fuzzy-ness""" 31 | if scorers is None: 32 | scorers = [rapidfuzz.distance.JaroWinkler.similarity] 33 | combined_list = [] 34 | 35 | for scorer in scorers: 36 | for processor in processors: 37 | if fuzzy_list := process.extract( 38 | argument, 39 | list_of_items, 40 | scorer=scorer, 41 | processor=processor, 42 | score_cutoff=score_cutoff, 43 | ): 44 | combined_entries = [e[0] for e in combined_list] 45 | new_members = [e for e in fuzzy_list if e[0] not in combined_entries] 46 | combined_list.extend(new_members) 47 | 48 | return combined_list 49 | -------------------------------------------------------------------------------- /common/graph_template.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import functools 18 | import typing 19 | from urllib.parse import urlencode 20 | 21 | import orjson 22 | 23 | 24 | @functools.lru_cache(maxsize=128) 25 | def graph_dict( 26 | title: str, 27 | scale_label: str, 28 | bottom_label: str, 29 | labels: tuple[str, ...], 30 | data: tuple[int, ...], 31 | *, 32 | max_value: typing.Optional[int] = 70, 33 | ) -> dict[str, typing.Any]: 34 | config = { 35 | "type": "bar", 36 | "data": { 37 | "datasets": [ 38 | { 39 | "backgroundColor": "#a682e3", 40 | "borderColor": "#92b972", 41 | "borderWidth": 0, 42 | "data": list(data), 43 | "type": "bar", 44 | } 45 | ], 46 | "labels": list(labels), 47 | }, 48 | "options": { 49 | "title": { 50 | "display": True, 51 | "text": title, 52 | }, 53 | "legend": { 54 | "display": False, 55 | }, 56 | "scales": { 57 | "xAxes": [ 58 | { 59 | "id": "X1", 60 | "display": True, 61 | "position": "bottom", 62 | "distribution": "linear", 63 | "scaleLabel": { 64 | "display": True, 65 | "labelString": bottom_label, 66 | }, 67 | } 68 | ], 69 | "yAxes": [ 70 | { 71 | "ticks": { 72 | "display": True, 73 | "fontSize": 12, 74 | "min": 0, 75 | "max": max_value, 76 | }, 77 | "scaleLabel": { 78 | "display": True, 79 | "labelString": scale_label, 80 | }, 81 | } 82 | ], 83 | }, 84 | }, 85 | } 86 | 87 | if not max_value: 88 | config["options"]["scales"]["yAxes"][0]["ticks"].pop("max", None) 89 | 90 | return config 91 | 92 | 93 | @functools.lru_cache(maxsize=128) 94 | def graph_template( 95 | title: str, 96 | scale_label: str, 97 | bottom_label: str, 98 | labels: tuple[str, ...], 99 | data: tuple[int, ...], 100 | *, 101 | width: int = 700, 102 | height: int = 400, 103 | max_value: typing.Optional[int] = 70, 104 | ) -> str: 105 | config = graph_dict( 106 | title, scale_label, bottom_label, labels, data, max_value=max_value 107 | ) 108 | 109 | payload = { 110 | "bkg": "white", 111 | "w": width, 112 | "h": height, 113 | "chart": orjson.dumps(config), 114 | } 115 | 116 | return f"https://quickchart.io/chart?{urlencode(payload)}" 117 | 118 | 119 | @functools.lru_cache(maxsize=128) 120 | def multi_graph_dict( 121 | title: str, 122 | scale_label: str, 123 | bottom_label: str, 124 | labels: tuple[str, ...], 125 | gamertags: typing.Iterable[str], 126 | datas: tuple[tuple[int, ...], ...], 127 | *, 128 | max_value: typing.Optional[int] = 70, 129 | ) -> dict[str, typing.Any]: 130 | config = { 131 | "type": "bar", 132 | "data": { 133 | "datasets": [ 134 | {"label": gamertag, "data": list(data)} 135 | for gamertag, data in zip(gamertags, datas, strict=True) 136 | ], 137 | "labels": list(labels), 138 | }, 139 | "options": { 140 | "title": { 141 | "display": True, 142 | "text": title, 143 | }, 144 | "legend": { 145 | "display": True, 146 | }, 147 | "scales": { 148 | "xAxes": [ 149 | { 150 | "id": "X1", 151 | "display": True, 152 | "position": "bottom", 153 | "distribution": "linear", 154 | "scaleLabel": { 155 | "display": True, 156 | "labelString": bottom_label, 157 | }, 158 | } 159 | ], 160 | "yAxes": [ 161 | { 162 | "ticks": { 163 | "display": True, 164 | "fontSize": 12, 165 | "min": 0, 166 | "max": max_value, 167 | }, 168 | "scaleLabel": { 169 | "display": True, 170 | "labelString": scale_label, 171 | }, 172 | } 173 | ], 174 | }, 175 | }, 176 | } 177 | 178 | if not max_value: 179 | config["options"]["scales"]["yAxes"][0]["ticks"].pop("max", None) 180 | 181 | return config 182 | 183 | 184 | @functools.lru_cache(maxsize=128) 185 | def multi_graph_template( 186 | title: str, 187 | scale_label: str, 188 | bottom_label: str, 189 | labels: tuple[str, ...], 190 | gamertags: typing.Iterable[str], 191 | datas: tuple[tuple[int, ...], ...], 192 | *, 193 | width: int = 700, 194 | height: int = 400, 195 | max_value: typing.Optional[int] = 70, 196 | ) -> str: 197 | config = multi_graph_dict( 198 | title, scale_label, bottom_label, labels, gamertags, datas, max_value=max_value 199 | ) 200 | 201 | payload = { 202 | "bkg": "white", 203 | "w": width, 204 | "h": height, 205 | "chart": orjson.dumps(config), 206 | } 207 | 208 | return f"https://quickchart.io/chart?{urlencode(payload)}" 209 | -------------------------------------------------------------------------------- /common/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import logging 18 | import os 19 | import re 20 | from datetime import UTC, datetime, timedelta 21 | from functools import cached_property 22 | 23 | import orjson 24 | import typing_extensions as typing 25 | from tortoise import Model, fields 26 | from tortoise.contrib.postgres.fields import ArrayField 27 | 28 | logger = logging.getLogger("realms_bot") 29 | 30 | USER_MENTION = re.compile(r"^<@!?[0-9]{15,25}>$") 31 | 32 | 33 | def display_gamertag( 34 | xuid: str, gamertag: str | None = None, nickname: str | None = None 35 | ) -> str: 36 | display = "Unknown User" 37 | if nickname: 38 | # optional check to display user mentions as is if it is one 39 | display = nickname if USER_MENTION.fullmatch(nickname) else f"`{nickname}`" 40 | elif gamertag: 41 | display = f"`{gamertag}`" 42 | elif xuid: 43 | display = f"User with XUID `{xuid}`" 44 | 45 | return display 46 | 47 | 48 | class NotificationChannels(typing.TypedDict, total=False): 49 | realm_offline: int 50 | player_watchlist: int 51 | reoccurring_leaderboard: int 52 | 53 | 54 | class GuildConfig(Model): 55 | guild_id = fields.BigIntField(primary_key=True, source_field="guild_id") 56 | club_id: fields.Field[typing.Optional[str]] = fields.CharField( 57 | max_length=50, null=True, source_field="club_id" 58 | ) 59 | playerlist_chan: fields.Field[typing.Optional[int]] = fields.BigIntField( 60 | source_field="playerlist_chan", null=True 61 | ) 62 | realm_id: fields.Field[typing.Optional[str]] = fields.CharField( 63 | max_length=50, null=True, source_field="realm_id" 64 | ) 65 | live_playerlist = fields.BooleanField(default=False, source_field="live_playerlist") 66 | realm_offline_role: fields.Field[typing.Optional[int]] = fields.BigIntField( 67 | source_field="realm_offline_role", null=True 68 | ) 69 | warning_notifications = fields.BooleanField( 70 | default=True, source_field="warning_notifications" 71 | ) 72 | fetch_devices = fields.BooleanField(default=False, source_field="fetch_devices") 73 | live_online_channel: fields.Field[typing.Optional[str]] = fields.CharField( 74 | max_length=75, null=True, source_field="live_online_channel" 75 | ) 76 | player_watchlist_role: fields.Field[typing.Optional[int]] = fields.BigIntField( 77 | source_field="player_watchlist_role", null=True 78 | ) 79 | player_watchlist: fields.Field[list[str] | None] = ArrayField( 80 | "TEXT", null=True, source_field="player_watchlist" 81 | ) 82 | notification_channels: fields.Field[NotificationChannels] = fields.JSONField( 83 | default="{}", 84 | source_field="notification_channels", 85 | encoder=lambda x: orjson.dumps(x).decode(), 86 | decoder=orjson.loads, 87 | ) 88 | reoccurring_leaderboard: fields.Field[typing.Optional[int]] = fields.IntField( 89 | source_field="reoccurring_leaderboard", null=True 90 | ) 91 | nicknames = fields.JSONField(default="{}", source_field="nicknames") 92 | 93 | premium_code: fields.ForeignKeyNullableRelation["PremiumCode"] = ( 94 | fields.ForeignKeyField( 95 | "models.PremiumCode", 96 | related_name="guilds", 97 | on_delete=fields.SET_NULL, 98 | null=True, 99 | ) 100 | ) 101 | 102 | class Meta: 103 | table = "realmguildconfig" 104 | 105 | @cached_property 106 | def valid_premium(self) -> bool: 107 | return bool(self.premium_code and self.premium_code.valid_code) 108 | 109 | def get_notif_channel(self, type_name: str) -> int: 110 | return self.notification_channels.get(type_name, self.playerlist_chan) 111 | 112 | 113 | EMOJI_DEVICE_NAMES = { 114 | "Android": "android", 115 | "iOS": "ios", 116 | "WindowsOneCore": "windows", 117 | "Win32": "windows", 118 | "XboxOne": "xbox_one", 119 | "Scarlett": "xbox_series", 120 | "Xbox360": "xbox_360", # what? 121 | "Nintendo": "switch", 122 | "PlayStation": "playstation", 123 | } 124 | 125 | 126 | class PlayerSession(Model): 127 | custom_id = fields.UUIDField(primary_key=True, source_field="custom_id") 128 | realm_id = fields.CharField(max_length=50, source_field="realm_id") 129 | xuid = fields.CharField(max_length=50, source_field="xuid") 130 | online = fields.BooleanField(default=False, source_field="online") 131 | last_seen = fields.DatetimeField(source_field="last_seen") 132 | joined_at: fields.Field[typing.Optional[datetime]] = fields.DatetimeField( 133 | null=True, source_field="joined_at" 134 | ) 135 | 136 | gamertag: typing.Optional[str] = None 137 | device: typing.Optional[str] = None 138 | show_left: bool = True 139 | 140 | class Meta: 141 | table = "realmplayersession" 142 | 143 | @property 144 | def device_emoji(self) -> str | None: 145 | if not self.device: 146 | return None 147 | 148 | # case statement, woo! 149 | match self.device: 150 | case "Android": 151 | base_emoji_id = os.environ["ANDROID_EMOJI_ID"] 152 | case "iOS": 153 | base_emoji_id = os.environ["IOS_EMOJI_ID"] 154 | case "WindowsOneCore" | "Win32": 155 | base_emoji_id = os.environ["WINDOWS_EMOJI_ID"] 156 | case "XboxOne" | "Xbox360": 157 | base_emoji_id = os.environ["XBOX_ONE_EMOJI_ID"] 158 | case "Scarlett": 159 | base_emoji_id = os.environ["XBOX_SERIES_EMOJI_ID"] 160 | case "Nintendo": 161 | base_emoji_id = os.environ["SWITCH_EMOJI_ID"] 162 | case "PlayStation": 163 | base_emoji_id = os.environ["PLAYSTATION_EMOJI_ID"] 164 | case _: 165 | logger.info("Unknown device: %s", self.device) 166 | base_emoji_id = os.environ["UNKNOWN_DEVICE_EMOJI_ID"] 167 | 168 | return ( 169 | f"<:{EMOJI_DEVICE_NAMES.get(self.device, self.device.lower().replace(' ', '_'))}:{base_emoji_id}>" 170 | ) 171 | 172 | @property 173 | def realm_xuid_id(self) -> str: 174 | return f"{self.realm_id}-{self.xuid}" 175 | 176 | @property 177 | def resolved(self) -> bool: 178 | return bool(self.gamertag) 179 | 180 | def base_display(self, nickname: str | None = None) -> str: 181 | display = display_gamertag(self.xuid, self.gamertag, nickname) 182 | if self.device_emoji: 183 | display += f" {self.device_emoji}" 184 | return display 185 | 186 | def display(self, nickname: str | None = None) -> str: 187 | notes: list[str] = [] 188 | if self.joined_at: 189 | notes.append(f"joined ") 190 | 191 | if not self.online and self.show_left: 192 | notes.append(f"left ") 193 | 194 | return ( 195 | f"{self.base_display(nickname)}: {', '.join(notes)}" 196 | if notes 197 | else self.base_display(nickname) 198 | ) 199 | 200 | 201 | class PremiumCode(Model): 202 | id = fields.IntField(primary_key=True, source_field="id") 203 | code = fields.CharField(max_length=100, source_field="code") 204 | user_id: fields.Field[typing.Optional[int]] = fields.BigIntField( 205 | source_field="user_id", null=True 206 | ) 207 | uses = fields.IntField(default=0, source_field="uses") 208 | max_uses = fields.IntField(default=2, source_field="max_uses") 209 | customer_id: fields.Field[typing.Optional[str]] = fields.CharField( 210 | max_length=50, null=True, source_field="customer_id" 211 | ) 212 | expires_at: fields.Field[typing.Optional[datetime]] = fields.DatetimeField( 213 | null=True, source_field="expires_at" 214 | ) 215 | 216 | guilds: fields.ReverseRelation["GuildConfig"] 217 | 218 | _valid_code: bool | None = None 219 | 220 | class Meta: 221 | table = "realmpremiumcode" 222 | 223 | @property 224 | def valid_code(self) -> bool: 225 | if self._valid_code is not None: 226 | return self._valid_code 227 | self._valid_code = not self.expires_at or self.expires_at + timedelta( 228 | days=1 229 | ) > datetime.now(UTC) 230 | return self._valid_code 231 | -------------------------------------------------------------------------------- /common/playerlist_events.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import typing 18 | from datetime import datetime 19 | 20 | import attrs 21 | import interactions as ipy 22 | 23 | import common.models as models 24 | import common.playerlist_utils as pl_utils 25 | 26 | 27 | @typing.dataclass_transform( 28 | eq_default=False, 29 | order_default=False, 30 | kw_only_default=False, 31 | field_specifiers=(attrs.field,), 32 | ) 33 | def define[C]() -> typing.Callable[[C], C]: 34 | return attrs.define(eq=False, order=False, hash=False, kw_only=False) # type: ignore 35 | 36 | 37 | @define() 38 | class PlayerlistParseFinish(ipy.events.BaseEvent): 39 | containers: tuple[pl_utils.RealmPlayersContainer, ...] = attrs.field(repr=False) 40 | 41 | 42 | @define() 43 | class PlayerlistEvent(ipy.events.BaseEvent): 44 | realm_id: str = attrs.field(repr=False) 45 | 46 | async def configs(self) -> list[models.GuildConfig]: 47 | return await models.GuildConfig.filter(realm_id=self.realm_id) 48 | 49 | 50 | @define() 51 | class RealmDown(PlayerlistEvent): 52 | disconnected: set[str] = attrs.field(repr=False) 53 | timestamp: datetime = attrs.field(repr=False) 54 | 55 | 56 | @define() 57 | class LivePlayerlistSend(PlayerlistEvent): 58 | joined: set[str] = attrs.field(repr=False) 59 | left: set[str] = attrs.field(repr=False) 60 | timestamp: datetime = attrs.field(repr=False) 61 | realm_down_event: bool = attrs.field(repr=False, default=False, kw_only=True) 62 | 63 | 64 | @define() 65 | class LiveOnlineUpdate(LivePlayerlistSend): 66 | gamertag_mapping: dict[str, str] = attrs.field(repr=False) 67 | config: models.GuildConfig = attrs.field(repr=False) 68 | 69 | @property 70 | def live_online_channel(self) -> str: 71 | return self.config.live_online_channel # type: ignore 72 | 73 | 74 | @define() 75 | class WarnMissingPlayerlist(PlayerlistEvent): 76 | pass 77 | 78 | 79 | @define() 80 | class PlayerWatchlistMatch(PlayerlistEvent): 81 | player_xuid: str = attrs.field(repr=False) 82 | guild_ids: set[int] = attrs.field(repr=False) 83 | 84 | async def configs(self) -> list[models.GuildConfig]: 85 | return await models.GuildConfig.filter( 86 | realm_id=self.realm_id, guild_id__in=list(self.guild_ids) 87 | ) 88 | -------------------------------------------------------------------------------- /common/premium_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | # code modified from https://github.com/brett-patterson/coupon_codes - 2015 Brett Patterson, MIT License 18 | """ 19 | The MIT License (MIT) 20 | 21 | Copyright (c) 2015 Brett Patterson 22 | 23 | Permission is hereby granted, free of charge, to any person obtaining a copy 24 | of this software and associated documentation files (the "Software"), to deal 25 | in the Software without restriction, including without limitation the rights 26 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 27 | copies of the Software, and to permit persons to whom the Software is 28 | furnished to do so, subject to the following conditions: 29 | 30 | The above copyright notice and this permission notice shall be included in 31 | all copies or substantial portions of the Software. 32 | 33 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 34 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 35 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 36 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 37 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 38 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 39 | THE SOFTWARE. 40 | """ 41 | 42 | import asyncio 43 | import codecs 44 | import os 45 | import re 46 | import secrets 47 | import typing 48 | 49 | import interactions as ipy 50 | from Crypto.Cipher import AES 51 | 52 | __all__ = ("bytestring_length_decode", "full_code_generate", "full_code_validate") 53 | 54 | 55 | BAD_WORDS: typing.Final[frozenset[str]] = frozenset( 56 | codecs.decode(w, "rot13") 57 | for w in ( 58 | "SHPX", 59 | "PHAG", 60 | "JNAX", 61 | "JNAT", 62 | "CVFF", 63 | "PBPX", 64 | "FUVG", 65 | "GJNG", 66 | "GVGF", 67 | "SNEG", 68 | "URYY", 69 | "ZHSS", 70 | "QVPX", 71 | "XABO", 72 | "NEFR", 73 | "FUNT", 74 | "GBFF", 75 | "FYHG", 76 | "GHEQ", 77 | "FYNT", 78 | "PENC", 79 | "CBBC", 80 | "OHGG", 81 | "SRPX", 82 | "OBBO", 83 | "WVFZ", 84 | "WVMM", 85 | "CUNG", 86 | ) 87 | ) 88 | 89 | 90 | SYMBOLS: typing.Final[list[str]] = list("0123456789ABCDEFGHJKLMNPQRTUVWXY") 91 | SYMBOLS_LENGTH: typing.Final[int] = len(SYMBOLS) 92 | 93 | SYMBOLS_MAP: typing.Final[dict[str, int]] = {s: i for i, s in enumerate(SYMBOLS)} 94 | 95 | PART_SEP: typing.Final[str] = "-" 96 | 97 | REPLACEMENTS: typing.Final[list[tuple[re.Pattern, str]]] = [ 98 | (re.compile(r"[^0-9A-Z-]+"), ""), 99 | (re.compile(r"O"), "0"), 100 | (re.compile(r"I"), "1"), 101 | (re.compile(r"Z"), "2"), 102 | (re.compile(r"S"), "5"), 103 | ] 104 | 105 | 106 | def has_bad_word(code: str) -> bool: 107 | """Check if a given code contains a bad word.""" 108 | return any(word in code for word in BAD_WORDS) 109 | 110 | 111 | def check_digit(data: str, n: int) -> str: 112 | """Generate the check digit for a code part.""" 113 | for c in data: 114 | n = n * 19 + SYMBOLS_MAP[c] 115 | return SYMBOLS[n % (SYMBOLS_LENGTH - 1)] 116 | 117 | 118 | def rpl_checksum(clamped_max_uses: int, user_id: ipy.Snowflake_Type) -> str: 119 | """Generate the check digit for a full code.""" 120 | user_id = str(user_id) 121 | sum_user_id = sum(ord(c) + int(c) + clamped_max_uses for c in user_id) 122 | return SYMBOLS[SYMBOLS_LENGTH - 1 - (sum_user_id % 11)] 123 | 124 | 125 | def base_code_generate(*, n_parts: int = 3, part_len: int = 4) -> str: 126 | """ 127 | Generate the base part of the code. 128 | 129 | Parameters: 130 | ----------- 131 | 132 | n_parts : int 133 | The number of parts for the code. 134 | 135 | part_len : int 136 | The number of symbols in each part. 137 | 138 | Returns: 139 | -------- 140 | A base code string. 141 | """ 142 | parts = [] 143 | 144 | while not parts or has_bad_word("".join(parts)): 145 | for i in range(n_parts): 146 | part = "".join(secrets.choice(SYMBOLS) for _ in range(part_len - 1)) 147 | part += check_digit(part, i + 1) 148 | parts.append(part) 149 | 150 | return PART_SEP.join(parts) 151 | 152 | 153 | def full_code_generate( 154 | max_uses: int, user_id: typing.Optional[ipy.Snowflake_Type] = None 155 | ) -> str: 156 | clamped_max_uses = max_uses % 11 157 | max_uses_char = SYMBOLS[clamped_max_uses + 11] 158 | check_chara = rpl_checksum(clamped_max_uses, user_id) if user_id else "A" 159 | return f"PL{max_uses_char}{check_chara}-{base_code_generate()}" 160 | 161 | 162 | def base_code_validate(code: str, *, n_parts: int = 3, part_len: int = 4) -> str: 163 | """ 164 | Validate a given code. 165 | 166 | Parameters: 167 | ----------- 168 | code : str 169 | The code to validate. 170 | 171 | n_parts : int 172 | The number of parts for the code. 173 | 174 | part_len : int 175 | The number of symbols in each part. 176 | 177 | Returns: 178 | -------- 179 | A cleaned code if the code is valid, otherwise an empty string. 180 | """ 181 | parts = code.split(PART_SEP) 182 | if len(parts) != n_parts: 183 | return "" 184 | 185 | for i, part in enumerate(parts): 186 | if len(part) != part_len: 187 | return "" 188 | 189 | data = part[:-1] 190 | check = part[-1] 191 | 192 | if check != check_digit(data, i + 1): 193 | return "" 194 | 195 | return code 196 | 197 | 198 | def full_code_validate(code: str, user_id: ipy.Snowflake_Type) -> str: 199 | # handles all the checks for a proper code, which includes the first part before -s 200 | code = code.upper() 201 | for replacement in REPLACEMENTS: 202 | code = replacement[0].sub(replacement[1], code) 203 | 204 | if not code.startswith("PL"): 205 | return "" 206 | 207 | first_part = code.split(PART_SEP, maxsplit=1)[0] 208 | if len(first_part) != 4: 209 | return "" 210 | 211 | max_uses_symbol = first_part[2] 212 | try: 213 | clamped_max_uses = SYMBOLS.index(max_uses_symbol) - 11 214 | except ValueError: 215 | return "" 216 | 217 | if clamped_max_uses < 0 or clamped_max_uses > 10: 218 | return "" 219 | 220 | if first_part[3] == "A": 221 | check_chara = "A" 222 | else: 223 | check_chara = rpl_checksum(clamped_max_uses, user_id) 224 | 225 | if not code.startswith(f"PL{max_uses_symbol}{check_chara}-"): 226 | return "" 227 | 228 | if base_code_validate(code.removeprefix(f"PL{max_uses_symbol}{check_chara}-")): 229 | return code 230 | return "" 231 | 232 | 233 | def bytestring_length_decode(the_input: str) -> int: 234 | the_input = the_input.removeprefix("b'").removesuffix("'") 235 | try: 236 | return len(the_input.encode().decode("unicode_escape")) 237 | except UnicodeDecodeError: 238 | return -1 239 | 240 | 241 | def _encrypt_input(code: str, *, encryption_key: bytes | None = None) -> str: 242 | if not encryption_key: 243 | encryption_key = bytes(os.environ["PREMIUM_ENCRYPTION_KEY"], "utf-8") 244 | 245 | # siv is best when we don't want nonces 246 | # we can't exactly use anything as a nonce since we have no way of obtaining 247 | # info about a code without the code itself - there's no username that a database 248 | # can look up to get the nonce 249 | aes = AES.new(encryption_key, AES.MODE_SIV) 250 | 251 | # the database stores values in keys - furthermore, only the first part of 252 | # the tuple given is actually the key 253 | return str(aes.encrypt_and_digest(bytes(code, "utf-8"))[0]) # type: ignore 254 | 255 | 256 | async def encrypt_input(code: str, *, encryption_key: bytes | None = None) -> str: 257 | # just because this is a technically complex function by design - aes isn't cheap 258 | return await asyncio.to_thread(_encrypt_input, code, encryption_key=encryption_key) 259 | 260 | 261 | if __name__ == "__main__": 262 | encryption_key = input("Enter the encryption key: ") 263 | user_id: str | None = input("Enter the user ID (or press enter to skip): ") 264 | uses = int(input("Enter the max uses: ")) 265 | 266 | if not user_id: 267 | user_id = None 268 | 269 | code = full_code_generate(uses, user_id) 270 | encrypted_code = _encrypt_input(code, encryption_key=bytes(encryption_key, "utf-8")) 271 | 272 | print(f"Code: {code}") # noqa: T201 273 | print(f"Encrypted code: {encrypted_code}") # noqa: T201 274 | -------------------------------------------------------------------------------- /common/realm_stories.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import datetime 18 | import typing 19 | 20 | import elytra 21 | 22 | import common.models as models 23 | import common.utils as utils 24 | 25 | 26 | def get_floored_minute_timestamp( 27 | d: datetime.datetime, 28 | ) -> datetime.datetime: 29 | kwargs: dict[str, typing.Any] = { 30 | "second": 0, 31 | "microsecond": 0, 32 | "tzinfo": datetime.UTC, 33 | } 34 | return d.replace(**kwargs) 35 | 36 | 37 | async def fill_in_data_from_stories( 38 | bot: utils.RealmBotBase, 39 | realm_id: str, 40 | ) -> bool: 41 | close_to_now = get_floored_minute_timestamp(datetime.datetime.now(tz=datetime.UTC)) 42 | 43 | try: 44 | await bot.realms.update_realm_story_settings( 45 | realm_id, player_opt_in="OPT_IN", timeline=True 46 | ) 47 | resp = await bot.realms.fetch_realm_story_player_activity(realm_id) 48 | except elytra.MicrosoftAPIException: 49 | return False 50 | 51 | if not resp.activity: 52 | return False 53 | 54 | player_list: list[models.PlayerSession] = [] 55 | 56 | for xuid, entries in resp.activity.items(): 57 | for entry in entries: 58 | end_floored = get_floored_minute_timestamp(entry.end) 59 | start_floored = get_floored_minute_timestamp(entry.start) 60 | 61 | online = close_to_now <= end_floored 62 | 63 | player_list.append( 64 | models.PlayerSession( 65 | custom_id=bot.uuid_cache[f"{realm_id}-{xuid}"], 66 | realm_id=realm_id, 67 | xuid=xuid, 68 | online=online, 69 | last_seen=close_to_now if online else end_floored, 70 | joined_at=start_floored, 71 | ) 72 | ) 73 | 74 | if online: 75 | bot.online_cache[int(realm_id)].add(xuid) 76 | 77 | if player_list: 78 | await models.PlayerSession.bulk_create(player_list, ignore_conflicts=True) 79 | return True 80 | -------------------------------------------------------------------------------- /common/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import asyncio 18 | import collections 19 | import datetime 20 | import logging 21 | import os 22 | import re 23 | import traceback 24 | import typing 25 | from collections import defaultdict 26 | from pathlib import Path 27 | 28 | import aiohttp 29 | import elytra 30 | import interactions as ipy 31 | import orjson 32 | import sentry_sdk 33 | from interactions.ext import prefixed_commands as prefixed 34 | 35 | from common.models import GuildConfig 36 | 37 | SENTRY_ENABLED = bool(os.environ.get("SENTRY_DSN", False)) # type: ignore 38 | 39 | DEV_GUILD_ID = int(os.environ.get("DEV_GUILD_ID", "0")) 40 | 41 | EXPIRE_GAMERTAGS_AT = int(datetime.timedelta(days=7).total_seconds()) 42 | 43 | logger = logging.getLogger("realms_bot") 44 | 45 | _DEBUG: dict[str, bool] = orjson.loads(os.environ.get("DEBUG", "{}")) 46 | _debug_defaults = { 47 | "HANDLE_MISSING_REALMS": True, 48 | "PROCESS_REALMS": True, 49 | "AUTORUNNER": True, 50 | "ETC_EVENTS": True, 51 | "PRINT_TRACKBACK_FOR_ERRORS": False, 52 | "EVENTUALLY_INVALIDATE": True, 53 | "SECURITY_CHECK": True, 54 | "RUN_MIGRATIONS_AUTOMATICALLY": True, 55 | "VOTEGATING": True, 56 | } 57 | 58 | REOCCURRING_LB_FREQUENCY: dict[int, str] = { 59 | 4: "Every day at 12:00 AM (00:00) UTC", 60 | 1: "Every Sunday at 12:00 AM (00:00) UTC", 61 | 2: "Every other Sunday at 12:00 AM (00:00) UTC", 62 | 3: "The first Sunday of every month at 12:00 AM (00:00) UTC", 63 | } 64 | REOCCURRING_LB_PERIODS: dict[int, str] = { 65 | 1: "24 hours", 66 | 2: "1 week", 67 | 3: "2 weeks", 68 | 4: "30 days", 69 | } 70 | 71 | FORMAT_CODE_REGEX: re.Pattern[str] = re.compile(r"§\S") 72 | 73 | 74 | def FEATURE(feature: str) -> bool: # noqa: N802 75 | return _DEBUG.get(feature, _debug_defaults[feature]) 76 | 77 | 78 | VOTING_ENABLED: bool = bool( 79 | os.environ.get("TOP_GG_TOKEN") or os.environ.get("DBL_TOKEN") 80 | ) 81 | 82 | SHOULD_VOTEGATE: bool = FEATURE("VOTEGATING") and VOTING_ENABLED 83 | 84 | 85 | async def sleep_until(dt: datetime.datetime) -> None: 86 | if dt.tzinfo is None: 87 | dt = dt.astimezone() 88 | now = datetime.datetime.now(datetime.UTC) 89 | time_to_sleep = max((dt - now).total_seconds(), 0) 90 | await asyncio.sleep(time_to_sleep) 91 | 92 | 93 | async def error_handle( 94 | error: Exception, *, ctx: typing.Optional[ipy.BaseContext] = None 95 | ) -> None: 96 | if not isinstance(error, aiohttp.ServerDisconnectedError): 97 | if FEATURE("PRINT_TRACKBACK_FOR_ERRORS") or not SENTRY_ENABLED: 98 | traceback.print_exception(error) 99 | logger.error("An error occured.", exc_info=error) 100 | else: 101 | scope = sentry_sdk.Scope.get_current_scope() 102 | if ctx: 103 | scope.set_context( 104 | type(ctx).__name__, 105 | { 106 | "args": ctx.args, # type: ignore 107 | "kwargs": ctx.kwargs, # type: ignore 108 | "message": ctx.message, 109 | }, 110 | ) 111 | sentry_sdk.capture_exception(error) 112 | if ctx: 113 | if isinstance(ctx, prefixed.PrefixedContext): 114 | await ctx.reply( 115 | "An internal error has occured. The bot owner has been notified." 116 | ) 117 | elif isinstance(ctx, ipy.InteractionContext): 118 | await ctx.send( 119 | content=( 120 | "An internal error has occured. The bot owner has been notified." 121 | ), 122 | ephemeral=ctx.ephemeral, 123 | ) 124 | 125 | 126 | async def msg_to_owner( 127 | bot: "RealmBotBase", content: typing.Any, split: bool = True 128 | ) -> None: 129 | # sends a message to the owner 130 | string = str(content) if split else content 131 | 132 | str_chunks = string_split(string) if split else content 133 | for chunk in str_chunks: 134 | await bot.bot_owner.send(f"{chunk}") 135 | 136 | 137 | def line_split(content: str, split_by: int = 20) -> list[list[str]]: 138 | # splits strings into lists of strings, each with a max length of split_by 139 | content_split = content.splitlines() 140 | return [ 141 | content_split[x : x + split_by] for x in range(0, len(content_split), split_by) 142 | ] 143 | 144 | 145 | def embed_check(embed: ipy.Embed) -> bool: 146 | """ 147 | Checks if an embed is valid, as per Discord's guidelines. 148 | See https://discord.com/developers/docs/resources/channel#embed-limits for details. 149 | """ 150 | if len(embed) > 6000: 151 | return False 152 | 153 | if embed.title and len(embed.title) > 256: 154 | return False 155 | if embed.description and len(embed.description) > 4096: 156 | return False 157 | if embed.author and embed.author.name and len(embed.author.name) > 256: 158 | return False 159 | if embed.footer and embed.footer.text and len(embed.footer.text) > 2048: 160 | return False 161 | if embed.fields: 162 | if len(embed.fields) > 25: 163 | return False 164 | for field in embed.fields: 165 | if field.name and len(field.name) > 1024: 166 | return False 167 | if field.value and len(field.value) > 2048: 168 | return False 169 | 170 | return True 171 | 172 | 173 | def deny_mentions(user: ipy.BaseUser) -> ipy.AllowedMentions: 174 | # generates an AllowedMentions object that only pings the user specified 175 | return ipy.AllowedMentions(users=[user]) 176 | 177 | 178 | def error_format(error: Exception) -> str: 179 | # simple function that formats an exception 180 | return "".join( 181 | traceback.format_exception( # type: ignore 182 | type(error), value=error, tb=error.__traceback__ 183 | ) 184 | ) 185 | 186 | 187 | def string_split(string: str) -> list[str]: 188 | # simple function that splits a string into 1950-character parts 189 | return [string[i : i + 1950] for i in range(0, len(string), 1950)] 190 | 191 | 192 | def file_to_ext(str_path: str, base_path: str) -> str: 193 | # changes a file to an import-like string 194 | str_path = str_path.replace(base_path, "") 195 | str_path = str_path.replace("/", ".") 196 | return str_path.replace(".py", "") 197 | 198 | 199 | def get_all_extensions(str_path: str, folder: str = "exts") -> collections.deque[str]: 200 | # gets all extensions in a folder 201 | ext_files: collections.deque[str] = collections.deque() 202 | loc_split = str_path.split(folder) 203 | base_path = loc_split[0] 204 | 205 | if base_path == str_path: 206 | base_path = base_path.replace("main.py", "") 207 | base_path = base_path.replace("\\", "/") 208 | 209 | if base_path[-1] != "/": 210 | base_path += "/" 211 | 212 | pathlist = Path(f"{base_path}/{folder}").glob("**/*.py") 213 | for path in pathlist: 214 | str_path = str(path.as_posix()) 215 | str_path = file_to_ext(str_path, base_path) 216 | 217 | if str_path != "exts.db_handler": 218 | ext_files.append(str_path) 219 | 220 | return ext_files 221 | 222 | 223 | def toggle_friendly_str(bool_to_convert: bool) -> typing.Literal["on", "off"]: 224 | return "on" if bool_to_convert else "off" 225 | 226 | 227 | def yesno_friendly_str(bool_to_convert: bool) -> typing.Literal["yes", "no"]: 228 | return "yes" if bool_to_convert else "no" 229 | 230 | 231 | def na_friendly_str(obj: typing.Any) -> str: 232 | return str(obj) if obj else "N/A" 233 | 234 | 235 | _bot_color = ipy.Color(int(os.environ.get("BOT_COLOR", "11557887"))) 236 | 237 | 238 | def make_embed(description: str, *, title: str | None = None) -> ipy.Embed: 239 | return ipy.Embed( 240 | title=title, 241 | description=description, 242 | color=_bot_color, 243 | timestamp=ipy.Timestamp.utcnow(), 244 | ) 245 | 246 | 247 | def error_embed_generate(error_msg: str) -> ipy.Embed: 248 | return ipy.Embed( 249 | title="Error", 250 | description=error_msg, 251 | color=ipy.MaterialColors.RED, 252 | timestamp=ipy.Timestamp.utcnow(), 253 | ) 254 | 255 | 256 | def partial_channel( 257 | bot: "RealmBotBase", channel_id: ipy.Snowflake_Type 258 | ) -> ipy.GuildText: 259 | return ipy.GuildText(client=bot, id=ipy.to_snowflake(channel_id), type=ipy.ChannelType.GUILD_TEXT) # type: ignore 260 | 261 | 262 | async def config_info_generate( 263 | ctx: "RealmContext | RealmPrefixedContext", 264 | config: GuildConfig, 265 | *, 266 | diagnostic_info: bool = False, 267 | ) -> ipy.Embed: 268 | embed = ipy.Embed( 269 | color=ctx.bot.color, title="Server Config", timestamp=ipy.Timestamp.now() 270 | ) 271 | 272 | playerlist_channel = ( 273 | f"<#{config.playerlist_chan}>" if config.playerlist_chan else "N/A" 274 | ) 275 | autorunner = toggle_friendly_str(bool(config.realm_id and config.playerlist_chan)) 276 | offline_realm_ping = ( 277 | f"<@&{config.realm_offline_role}>" if config.realm_offline_role else "N/A" 278 | ) 279 | player_watchlist_ping = ( 280 | f"<@&{config.player_watchlist_role}>" if config.player_watchlist_role else "N/A" 281 | ) 282 | 283 | notification_channels = "" 284 | if config.notification_channels: 285 | notification_channels = "__Notification Channels__:\n" 286 | if player_watchlist := config.notification_channels.get("player_watchlist"): 287 | notification_channels += f"Player Watchlist Channel: <#{player_watchlist}>\n" 288 | if realm_offline := config.notification_channels.get("realm_offline"): 289 | notification_channels += f"Realm Offline Channel: <#{realm_offline}>\n" 290 | if reoccurring_leaderboard := config.notification_channels.get( 291 | "reoccurring_leaderboard" 292 | ): 293 | notification_channels += ( 294 | f"Reoccurring Leaderboard Channel: <#{reoccurring_leaderboard}>\n" 295 | ) 296 | 297 | notification_channels = notification_channels.strip() 298 | 299 | realm_name = "N/A" 300 | 301 | if config.realm_id: 302 | try: 303 | realm = await ctx.bot.realms.fetch_realm(config.realm_id) 304 | realm_name = FORMAT_CODE_REGEX.sub("", realm.name) 305 | realm_name = f"`{realm_name}`" 306 | except elytra.MicrosoftAPIException: 307 | realm_name = "Not Found" 308 | 309 | embed.add_field( 310 | "Basic Information", 311 | f"Realm Name: {realm_name}\n\nAutorunner Enabled: {autorunner}\n" 312 | f"Autorunning Playerlist Channel: {playerlist_channel}\nWarning Notifications:" 313 | f" {toggle_friendly_str(config.warning_notifications)}\n\nRealm Offline Role:" 314 | f" {offline_realm_ping}\nPlayer Watchlist Role:" 315 | f" {player_watchlist_ping}\nPeople on Watchlist: See" 316 | f" {ctx.bot.mention_command('watchlist list')}\n\n{notification_channels}" 317 | .strip(), 318 | inline=True, 319 | ) 320 | 321 | if config.premium_code: 322 | live_online_msg = "N/A" 323 | if config.live_online_channel: 324 | live_online_split = config.live_online_channel.split("|") 325 | live_online_msg = f"https://discord.com/channels/{ctx.guild_id}/{live_online_split[0]}/{live_online_split[1]}" 326 | 327 | premium_linked_to = ( 328 | f"<@{config.premium_code.user_id}>" 329 | if config.premium_code and config.premium_code.user_id 330 | else "N/A" 331 | ) 332 | 333 | reoccurring_lb = ( 334 | f"{REOCCURRING_LB_PERIODS[config.reoccurring_leaderboard % 10]} " 335 | f" {REOCCURRING_LB_FREQUENCY[config.reoccurring_leaderboard // 10]}" 336 | if config.reoccurring_leaderboard 337 | else "N/A" 338 | ) 339 | 340 | embed.add_field( 341 | "Premium Information", 342 | f"Premium Active: {yesno_friendly_str(config.valid_premium)}\nLinked To:" 343 | f" {premium_linked_to}\nLive Playerlist:" 344 | f" {toggle_friendly_str(config.live_playerlist)}\nLive Online Message:" 345 | f" {live_online_msg}\nDisplay Device Information:" 346 | f" {toggle_friendly_str(config.fetch_devices)}\nReoccurring Leaderboard:" 347 | f" {reoccurring_lb}", 348 | inline=True, 349 | ) 350 | else: 351 | embed.fields[0].value += "\nPremium Active: no" 352 | 353 | if diagnostic_info: 354 | premium_code_id = str(config.premium_code.id) if config.premium_code else "N/A" 355 | dev_info_str = ( 356 | f"Server ID: {config.guild_id}\nRealm ID:" 357 | f" {na_friendly_str(config.realm_id)}\nClub ID:" 358 | f" {na_friendly_str(config.club_id)}\nAutorunning Playerlist Channel ID:" 359 | f" {na_friendly_str(config.playerlist_chan)}\nRealm Offline Role" 360 | f" ID:{na_friendly_str(config.realm_offline_role)}\nLinked Premium ID:" 361 | f" {premium_code_id}\nPlayer Watchlist XUIDs:" 362 | f" {na_friendly_str(config.player_watchlist)}\nNotification Channels Dict:" 363 | f" {na_friendly_str(config.notification_channels)}\nReoccurring Leaderboard" 364 | f" Value: {na_friendly_str(config.reoccurring_leaderboard)}\n" 365 | ) 366 | if config.premium_code: 367 | expires_at = ( 368 | f"" 369 | if config.premium_code.expires_at 370 | else "N/A" 371 | ) 372 | dev_info_str += ( 373 | "\nUses:" 374 | f" {config.premium_code.uses} used/{config.premium_code.max_uses}\nExpires" 375 | f" At: {expires_at}\nLive Online:" 376 | f" {na_friendly_str(config.live_online_channel)}" 377 | ) 378 | 379 | embed.add_field( 380 | "Diagnostic Information", 381 | dev_info_str, 382 | inline=False, 383 | ) 384 | shard_id = ctx.bot.get_shard_id(config.guild_id) 385 | embed.set_footer(f"Shard ID: {shard_id}") 386 | 387 | return embed 388 | 389 | 390 | class CustomCheckFailure(ipy.errors.BadArgument): 391 | # custom classs for custom prerequisite failures outside of normal command checks 392 | pass 393 | 394 | 395 | if typing.TYPE_CHECKING: 396 | import uuid 397 | 398 | import valkey.asyncio as aiovalkey 399 | 400 | from .classes import OrderedSet 401 | from .help_tools import MiniCommand, PermissionsResolver 402 | 403 | class RealmBotBase(ipy.AutoShardedClient): 404 | prefixed: prefixed.PrefixedManager 405 | 406 | unavailable_guilds: set[int] 407 | bot_owner: ipy.User 408 | color: ipy.Color 409 | init_load: bool 410 | fully_ready: asyncio.Event 411 | pl_sem: asyncio.Semaphore 412 | 413 | session: aiohttp.ClientSession 414 | openxbl_session: aiohttp.ClientSession 415 | xbox: elytra.XboxAPI 416 | realms: elytra.BedrockRealmsAPI 417 | valkey: aiovalkey.Valkey 418 | own_gamertag: str 419 | background_tasks: set[asyncio.Task] 420 | 421 | online_cache: defaultdict[int, set[str]] 422 | slash_perms_cache: defaultdict[int, dict[int, PermissionsResolver]] 423 | mini_commands_per_scope: dict[int, dict[str, MiniCommand]] 424 | live_playerlist_store: defaultdict[str, set[int]] 425 | player_watchlist_store: defaultdict[str, set[int]] 426 | uuid_cache: defaultdict[str, uuid.UUID] 427 | offline_realms: OrderedSet[int] 428 | dropped_offline_realms: set[int] 429 | fetch_devices_for: set[str] 430 | blacklist: set[int] 431 | 432 | def create_task( 433 | self, coro: typing.Coroutine[typing.Any, typing.Any, ipy.const.T] 434 | ) -> asyncio.Task[ipy.const.T]: ... 435 | 436 | else: 437 | 438 | class RealmBotBase(ipy.AutoShardedClient): 439 | pass 440 | 441 | 442 | class RealmContextMixin: 443 | config: typing.Optional[GuildConfig] 444 | guild_id: ipy.Snowflake 445 | 446 | def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: 447 | self.config = None 448 | super().__init__(*args, **kwargs) 449 | 450 | async def fetch_config(self) -> GuildConfig: 451 | """ 452 | Gets the configuration for the context's guild. 453 | 454 | Returns: 455 | GuildConfig: The guild config. 456 | """ 457 | if not self.guild_id: 458 | raise ValueError("No guild ID set.") 459 | 460 | if self.config: 461 | return self.config 462 | 463 | config = await GuildConfig.get_or_none(guild_id=self.guild_id).prefetch_related( 464 | "premium_code" 465 | ) or await GuildConfig.create(guild_id=self.guild_id, notification_channels={}) 466 | self.config = config 467 | return config 468 | 469 | 470 | class RealmInteractionContext(RealmContextMixin, ipy.InteractionContext[RealmBotBase]): 471 | pass 472 | 473 | 474 | class RealmContext(RealmContextMixin, ipy.SlashContext[RealmBotBase]): 475 | pass 476 | 477 | 478 | class RealmComponentContext(RealmContextMixin, ipy.ComponentContext[RealmBotBase]): 479 | pass 480 | 481 | 482 | class RealmContextMenuContext(RealmContextMixin, ipy.ContextMenuContext[RealmBotBase]): 483 | pass 484 | 485 | 486 | class RealmModalContext(RealmContextMixin, ipy.ModalContext[RealmBotBase]): 487 | pass 488 | 489 | 490 | class RealmPrefixedContext(RealmContextMixin, prefixed.PrefixedContext[RealmBotBase]): 491 | @property 492 | def channel(self) -> ipy.GuildText: 493 | """The channel this context was invoked in.""" 494 | return partial_channel(self.bot, self.channel_id) 495 | 496 | 497 | class RealmAutocompleteContext( 498 | RealmContextMixin, ipy.AutocompleteContext[RealmBotBase] 499 | ): 500 | pass 501 | 502 | 503 | async def _global_checks(ctx: RealmContext) -> bool: 504 | if ctx.author_id in ctx.bot.owner_ids: 505 | return True 506 | 507 | if int(ctx.author_id) in ctx.bot.blacklist or ( 508 | ctx.guild_id and int(ctx.guild_id) in ctx.bot.blacklist 509 | ): 510 | return False 511 | 512 | return bool(ctx.bot.fully_ready.is_set()) 513 | 514 | 515 | class Extension(ipy.Extension): 516 | def __new__( 517 | cls, bot: ipy.Client, *_: typing.Any, **kwargs: typing.Any 518 | ) -> ipy.Extension: 519 | instance = super().__new__(cls, bot, **kwargs) 520 | instance.add_ext_check(_global_checks) 521 | return instance 522 | -------------------------------------------------------------------------------- /config_example.toml: -------------------------------------------------------------------------------- 1 | # when using the bot, make sure to rename this to just "config.toml" 2 | 3 | MAIN_TOKEN = "TOKEN" # your discord bot token, of course! 4 | 5 | DEV_GUILD_ID = 11111111111111111 # guild id that you own and that will not be used for a playerlist 6 | BOT_COLOR = 12670688 # a color to color some embeds and all - it's nice for branding. 7 | 8 | DOCKER_MODE = false # whether or not the bot is running in docker. if it is, it'll automatically use the docker valkey and postgres, and you can leave the next two entries blank 9 | DB_URL = "POSTGRESDB_URL" # a url to a postgres database 10 | VALKEY_URL = "VALKEY_URL" # url to a valkey database 11 | 12 | # worth noting for docker: 13 | # - you'll need a .env file with a POSTGRES_PASSWORD entry (POSTGRES_PASSWORD="PASSWORD"). docker limitation, i would squeeze it in config.toml if i could 14 | 15 | # - you can swap out the valkey for any redis 7.2 supporting backend, though i'd suggest keeping the name of the entry in the compose file "redis" because 16 | # the bot relies on that to connect to it 17 | 18 | SENTRY_DSN = "SENTRY_DSN" # the dsn for sentry. optional, you can leave it blank if you don't want to use it 19 | 20 | # these next two are generated by the beginning instructions here: 21 | # https://github.com/Astrea-Stellarium-Labs/elytra-ms#make-an-application 22 | XBOX_CLIENT_ID = "ID" 23 | XBOX_CLIENT_SECRET = "SECRET" 24 | 25 | # you'll also need a token file called "tokens.json" in the root directory of the bot, generated by 26 | # https://github.com/Astrea-Stellarium-Labs/elytra-ms#make-an-application 27 | # the name is currently hardcoded, but i'll make it configurable in the future 28 | 29 | OPENXBL_KEY = "KEY" # register an account at https://xbl.io/ and you'll get a token to use 30 | 31 | # bot list site tokens. leave blank or remove the entries if you don't want to use them 32 | TOP_GG_TOKEN = "" 33 | DBL_TOKEN = "" 34 | 35 | # various emojis used throughout the bot. more or less self explanatory 36 | GREEN_CIRCLE_EMOJI="RAW EMOJI STRING" 37 | GRAY_CIRCLE_EMOJI="RAW EMOJI STRING" 38 | WINDOWS_EMOJI_ID="EMOJI ID" 39 | SWITCH_EMOJI_ID="EMOJI ID" 40 | IOS_EMOJI_ID="EMOJI ID" 41 | ANDROID_EMOJI_ID="EMOJI ID" 42 | XBOX_ONE_EMOJI_ID="EMOJI ID" 43 | XBOX_SERIES_EMOJI_ID="EMOJI ID" 44 | PLAYSTATION_EMOJI_ID="EMOJI ID" 45 | UNKNOWN_DEVICE_EMOJI_ID="EMOJI ID" 46 | 47 | # the key used to encrypt premium codes. make this a very strong and random 64 character code 48 | PREMIUM_ENCRYPTION_KEY = "KEY" -------------------------------------------------------------------------------- /db_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import os 18 | 19 | import rpl_config 20 | 21 | rpl_config.load() 22 | 23 | 24 | TORTOISE_ORM = { 25 | "connections": {"default": os.environ["DB_URL"]}, 26 | "apps": { 27 | "models": { 28 | "models": ["common.models", "aerich.models"], 29 | } 30 | }, 31 | } 32 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: "redis:alpine" 4 | ports: 5 | - 6379:6379 6 | volumes: 7 | - ./data/redis:/var/lib/redis 8 | 9 | db: 10 | image: "postgres:16-alpine" 11 | ports: 12 | - 5432:5432 13 | environment: 14 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 15 | volumes: 16 | - ./data/postgres:/var/lib/postgresql/data 17 | 18 | bot: 19 | build: . 20 | volumes: 21 | - ./:/app 22 | depends_on: 23 | - db 24 | - redis 25 | restart: always -------------------------------------------------------------------------------- /exts/autorunners.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import asyncio 18 | import contextlib 19 | import datetime 20 | import importlib 21 | 22 | import interactions as ipy 23 | from pypika import Order, PostgreSQLQuery, Table 24 | 25 | import common.classes as cclasses 26 | import common.models as models 27 | import common.playerlist_events as pl_events 28 | import common.playerlist_utils as pl_utils 29 | import common.utils as utils 30 | 31 | UPSELLS = [ 32 | ( 33 | "Want minute-to-minute updates on your Realm? Do you want device information" 34 | " for players? Check out Playerlist Premium: /premium info" 35 | ), 36 | ( 37 | "Check out all of the features the bot has:" 38 | " https://playerlist.astrea.cc/wiki/features" 39 | ), 40 | ( 41 | "Do you want a constantly updating live online list? Check out Playerlist" 42 | " Premium: /premium info" 43 | ), 44 | ( 45 | "Having issues with the bot? Check out the FAQ or join the support server:" 46 | " /support" 47 | ), 48 | ] 49 | 50 | if utils.VOTING_ENABLED: 51 | UPSELLS.append( 52 | "If you like the bot, you can vote for it via /vote! Voting helps the bot grow" 53 | " and get more features." 54 | ) 55 | 56 | LENGTH_UPSELLS = len(UPSELLS) 57 | 58 | 59 | def upsell_determiner(dt: datetime.datetime) -> str | None: 60 | if dt.hour % 6 == 0: 61 | total_seconds = int(dt.timestamp()) 62 | x_hour_boundary = total_seconds % (21600 * LENGTH_UPSELLS) 63 | return UPSELLS[x_hour_boundary // 21600] 64 | 65 | return None 66 | 67 | 68 | def period_determiner(period_index: int) -> int: 69 | match period_index: 70 | case 1: 71 | return 1 72 | case 2: 73 | return 7 74 | case 3: 75 | return 14 76 | case 4: 77 | return 30 78 | 79 | raise ValueError("This should never happen.") 80 | 81 | 82 | class Autorunners(utils.Extension): 83 | # the cog that controls the automatic version of the some commands 84 | # this way, we can fix the main command itself without 85 | # resetting the autorun cycle 86 | 87 | def __init__(self, bot: utils.RealmBotBase) -> None: 88 | self.bot: utils.RealmBotBase = bot 89 | self.playerlist_task = self.bot.create_task(self._start_playerlist()) 90 | self.reoccuring_lb_task = self.bot.create_task(self._start_reoccurring_lb()) 91 | self.player_session_delete.start() 92 | 93 | def drop(self) -> None: 94 | self.playerlist_task.cancel() 95 | self.reoccuring_lb_task.cancel() 96 | self.player_session_delete.stop() 97 | super().drop() 98 | 99 | async def _start_playerlist(self) -> None: 100 | await self.bot.fully_ready.wait() 101 | 102 | while True: 103 | try: 104 | # margin of error 105 | now = ipy.Timestamp.utcnow() + datetime.timedelta(milliseconds=1) 106 | next_time = now.replace(minute=59, second=59, microsecond=0) 107 | 108 | # yes, next_time could be in the past, but that's handled by sleep_until 109 | await utils.sleep_until(next_time) 110 | 111 | # wait for the playerlist to finish parsing 112 | with contextlib.suppress(asyncio.TimeoutError): 113 | await self.bot.wait_for(pl_events.PlayerlistParseFinish, timeout=15) 114 | 115 | await self.playerlist_loop(upsell=upsell_determiner(next_time)) 116 | except Exception as e: 117 | if not isinstance(e, asyncio.CancelledError): 118 | await utils.error_handle(e) 119 | else: 120 | return 121 | 122 | async def playerlist_loop( 123 | self, 124 | upsell: str | None, 125 | ) -> None: 126 | """ 127 | A simple way of running the playerlist command every hour in every server the bot is in. 128 | """ 129 | 130 | list_cmd = next( 131 | c for c in self.bot.application_commands if str(c.name) == "playerlist" 132 | ) 133 | 134 | configs = await models.GuildConfig.filter( 135 | guild_id__in=[int(g) for g in self.bot.user._guild_ids], 136 | live_playerlist=False, 137 | realm_id__isnull=False, 138 | playerlist_chan__isnull=False, 139 | ).prefetch_related("premium_code") 140 | if not configs: 141 | return 142 | 143 | realm_ids = {c.realm_id for c in configs} 144 | for config in configs: 145 | if config.fetch_devices and config.valid_premium: 146 | realm_ids.discard(config.realm_id) 147 | 148 | now = ipy.Timestamp.utcnow().replace(second=30) 149 | time_delta = datetime.timedelta(hours=1, minutes=5) 150 | time_ago = now - time_delta 151 | 152 | playersession = Table(models.PlayerSession.Meta.table) 153 | query = ( 154 | PostgreSQLQuery.from_(playersession) 155 | .select(*models.PlayerSession._meta.fields) 156 | .where( 157 | playersession.realm_id.isin(list(realm_ids)) 158 | & ( 159 | playersession.online.eq(True) 160 | | playersession.last_seen.gte(time_ago) 161 | ) 162 | ) 163 | .orderby("xuid", order=Order.asc) 164 | .orderby("last_seen", order=Order.desc) 165 | .distinct_on("xuid") # type: ignore 166 | ) 167 | 168 | player_sessions: list[models.PlayerSession] = await models.PlayerSession.raw( 169 | str(query) 170 | ) # type: ignore 171 | if not player_sessions: 172 | return 173 | 174 | gamertag_map = await pl_utils.get_xuid_to_gamertag_map( 175 | self.bot, [p.xuid for p in player_sessions] 176 | ) 177 | 178 | to_run = [ 179 | self.auto_run_playerlist(list_cmd, config, upsell, gamertag_map) 180 | for config in configs 181 | ] 182 | 183 | # why not use a taskgroup? because if we did, if one task errored, 184 | # the entire thing would stop and we don't want that 185 | output = await asyncio.gather(*to_run, return_exceptions=True) 186 | for message in output: 187 | if isinstance(message, Exception): 188 | await utils.error_handle(message) 189 | 190 | async def auto_run_playerlist( 191 | self, 192 | list_cmd: ipy.InteractionCommand, 193 | config: models.GuildConfig, 194 | upsell: str | None, 195 | gamertag_map: dict[str, str], 196 | ) -> None: 197 | if config.guild_id in self.bot.unavailable_guilds: 198 | return 199 | 200 | # make a fake context to make things easier 201 | a_ctx = utils.RealmPrefixedContext(client=self.bot) 202 | a_ctx.author_id = self.bot.user.id 203 | a_ctx.channel_id = ipy.to_snowflake(config.playerlist_chan) 204 | a_ctx.guild_id = ipy.to_snowflake(config.guild_id) 205 | a_ctx.config = config # type: ignore 206 | 207 | a_ctx.prefix = "" 208 | a_ctx.content_parameters = "" 209 | a_ctx.command = None # type: ignore 210 | a_ctx.args = [] 211 | a_ctx.kwargs = {} 212 | 213 | # take advantage of the fact that users cant really use kwargs for commands 214 | # the ones listed here silence the 'this may take a long time' message 215 | # and also make it so it doesnt go back 12 hours, instead only going one 216 | # and yes, add the upsell info 217 | 218 | try: 219 | await asyncio.wait_for( 220 | list_cmd.callback( 221 | a_ctx, 222 | 1, 223 | autorunner=True, 224 | upsell=upsell, 225 | gamertag_map=gamertag_map, 226 | ), 227 | timeout=60, 228 | ) 229 | except ipy.errors.HTTPException as e: 230 | if e.status < 500: 231 | await pl_utils.eventually_invalidate(self.bot, config) 232 | 233 | async def _start_reoccurring_lb(self) -> None: 234 | await self.bot.fully_ready.wait() 235 | try: 236 | while True: 237 | # margin of error 238 | now = ipy.Timestamp.utcnow() + datetime.timedelta(milliseconds=1) 239 | 240 | tomorrow = now.replace( 241 | hour=0, minute=0, second=0, microsecond=0 242 | ) + datetime.timedelta(days=1) 243 | 244 | if tomorrow.weekday() == 6: 245 | # silly way to have a bitfield that toggles every sunday 246 | bit = self.bot.valkey.bitfield( 247 | "rpl-sunday-bitshift", default_overflow="WRAP" 248 | ) 249 | bit.incrby("u1", "#0", 1) 250 | bit_resp: list[int] = await bit.execute() # [0] or [1] 251 | else: 252 | bit_resp: list[int] = [1] 253 | 254 | await utils.sleep_until(tomorrow) 255 | await self.reoccurring_lb_loop( 256 | tomorrow.weekday() == 6, bit_resp[0] % 2 == 0, tomorrow.day <= 7 257 | ) 258 | 259 | except Exception as e: 260 | if not isinstance(e, asyncio.CancelledError): 261 | await utils.error_handle(e) 262 | 263 | async def reoccurring_lb_loop( 264 | self, sunday: bool, second_sunday: bool, first_sunday_of_month: bool 265 | ) -> None: 266 | lb_command = next( 267 | c for c in self.bot.application_commands if str(c.name) == "leaderboard" 268 | ) 269 | 270 | if not sunday: 271 | configs = await models.GuildConfig.filter( 272 | guild_id__in=[int(g) for g in self.bot.user._guild_ids], 273 | reoccurring_leaderboard__isnull=False, 274 | realm_id__isnull=False, 275 | reoccurring_leaderboard__gte=40, 276 | reoccurring_leaderboard__lt=50, 277 | ).prefetch_related("premium_code") 278 | elif second_sunday and first_sunday_of_month: 279 | configs = await models.GuildConfig.filter( 280 | guild_id__in=[int(g) for g in self.bot.user._guild_ids], 281 | reoccurring_leaderboard__isnull=False, 282 | realm_id__isnull=False, 283 | ).prefetch_related("premium_code") 284 | elif first_sunday_of_month: 285 | configs = await models.GuildConfig.filter( 286 | guild_id__in=[int(g) for g in self.bot.user._guild_ids], 287 | reoccurring_leaderboard__isnull=False, 288 | realm_id__isnull=False, 289 | reoccurring_leaderboard__gte=20, 290 | reoccurring_leaderboard__lt=30, 291 | ).prefetch_related("premium_code") 292 | elif second_sunday: 293 | configs = await models.GuildConfig.filter( 294 | guild_id__in=[int(g) for g in self.bot.user._guild_ids], 295 | reoccurring_leaderboard__isnull=False, 296 | realm_id__isnull=False, 297 | reoccurring_leaderboard__gte=30, 298 | reoccurring_leaderboard__lt=40, 299 | ).prefetch_related("premium_code") 300 | else: 301 | configs = await models.GuildConfig.filter( 302 | guild_id__in=[int(g) for g in self.bot.user._guild_ids], 303 | reoccurring_leaderboard__isnull=False, 304 | realm_id__isnull=False, 305 | reoccurring_leaderboard__gte=20, 306 | reoccurring_leaderboard__lt=40, 307 | ).prefetch_related("premium_code") 308 | 309 | to_run = [self.send_reoccurring_lb(lb_command, config) for config in configs] 310 | output = await asyncio.gather(*to_run, return_exceptions=True) 311 | 312 | for message in output: 313 | if isinstance(message, Exception): 314 | await utils.error_handle(message) 315 | 316 | async def send_reoccurring_lb( 317 | self, 318 | lb_command: ipy.InteractionCommand, 319 | config: models.GuildConfig, 320 | ) -> None: 321 | if config.guild_id in self.bot.unavailable_guilds: 322 | return 323 | 324 | if not config.valid_premium: 325 | await pl_utils.invalidate_premium(self.bot, config) 326 | return 327 | 328 | # make a fake context to make things easier 329 | a_ctx = utils.RealmPrefixedContext(client=self.bot) 330 | a_ctx.author_id = self.bot.user.id 331 | a_ctx.channel_id = ipy.to_snowflake( 332 | config.get_notif_channel("reoccurring_leaderboard") 333 | ) 334 | a_ctx.guild_id = ipy.to_snowflake(config.guild_id) 335 | a_ctx.config = config 336 | 337 | a_ctx.prefix = "" 338 | a_ctx.content_parameters = "" 339 | a_ctx.command = None 340 | a_ctx.args = [] 341 | a_ctx.kwargs = {} 342 | 343 | try: 344 | await asyncio.wait_for( 345 | lb_command.callback( 346 | a_ctx, 347 | period_determiner(config.reoccurring_leaderboard % 10), 348 | autorunner=True, 349 | ), 350 | timeout=180, 351 | ) 352 | except ipy.errors.BadArgument: 353 | return 354 | except ipy.errors.HTTPException as e: 355 | if e.status < 500: 356 | if config.notification_channels.get("reoccurring_leaderboard"): 357 | await pl_utils.eventually_invalidate_reoccurring_lb( 358 | self.bot, config 359 | ) 360 | else: 361 | await pl_utils.eventually_invalidate(self.bot, config) 362 | 363 | @ipy.Task.create( 364 | ipy.OrTrigger(ipy.TimeTrigger(utc=True), ipy.TimeTrigger(hour=12, utc=True)) 365 | ) 366 | async def player_session_delete(self) -> None: 367 | now = datetime.datetime.now(tz=datetime.UTC) 368 | time_back = now - datetime.timedelta(days=31) 369 | 370 | await models.PlayerSession.filter( 371 | online=False, 372 | last_seen__lt=time_back, 373 | ).delete() 374 | 375 | too_far_ago = now - datetime.timedelta(hours=1) 376 | online_for_too_long = await models.PlayerSession.filter( 377 | online=True, last_seen__lt=too_far_ago 378 | ) 379 | 380 | await models.PlayerSession.filter( 381 | online=True, last_seen__lt=too_far_ago 382 | ).update(online=False) 383 | 384 | for session in online_for_too_long: 385 | self.bot.online_cache[int(session.realm_id)].discard(session.xuid) 386 | 387 | 388 | def setup(bot: utils.RealmBotBase) -> None: 389 | importlib.reload(utils) 390 | importlib.reload(pl_utils) 391 | importlib.reload(cclasses) 392 | Autorunners(bot) 393 | -------------------------------------------------------------------------------- /exts/etc_events.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import asyncio 18 | import contextlib 19 | import importlib 20 | import os 21 | 22 | import elytra 23 | import interactions as ipy 24 | import msgspec 25 | 26 | import common.models as models 27 | import common.utils as utils 28 | 29 | 30 | class EtcEvents(utils.Extension): 31 | def __init__(self, bot: utils.RealmBotBase) -> None: 32 | self.bot: utils.RealmBotBase = bot 33 | self.update_tokens.start() 34 | 35 | def drop(self) -> None: 36 | self.update_tokens.stop() 37 | super().drop() 38 | 39 | @ipy.listen("guild_join") 40 | async def on_guild_join(self, event: ipy.events.GuildJoin) -> None: 41 | if not self.bot.is_ready: 42 | return 43 | 44 | if int(event.guild_id) in self.bot.blacklist: 45 | await self.bot.http.leave_guild(event.guild_id) 46 | return 47 | 48 | await models.GuildConfig.get_or_create( 49 | guild_id=int(event.guild_id), 50 | ) 51 | 52 | @ipy.listen("guild_left") 53 | async def on_guild_left(self, event: ipy.events.GuildLeft) -> None: 54 | if not self.bot.is_ready: 55 | return 56 | 57 | if config := await models.GuildConfig.get_or_none(guild_id=int(event.guild_id)): 58 | if ( 59 | config.realm_id 60 | and await models.GuildConfig.filter( 61 | realm_id=config.realm_id, 62 | guild_id__not=int(event.guild_id), 63 | ).count() 64 | == 1 65 | ): 66 | # don't want to keep around entries we no longer need, so delete them 67 | await models.PlayerSession.filter( 68 | realm_id=config.realm_id, 69 | ).delete() 70 | # also attempt to leave the realm cus why not 71 | with contextlib.suppress(elytra.MicrosoftAPIException): 72 | await self.bot.realms.leave_realm(config.realm_id) 73 | 74 | await config.delete() 75 | 76 | def _update_tokens(self) -> None: 77 | with open(os.environ["XAPI_TOKENS_LOCATION"], mode="wb") as f: 78 | f.write(msgspec.json.encode(self.bot.xbox.auth_mgr.oauth)) 79 | 80 | @ipy.Task.create(ipy.IntervalTrigger(hours=6)) 81 | async def update_tokens(self) -> None: 82 | await asyncio.to_thread(self._update_tokens) 83 | 84 | 85 | def setup(bot: utils.RealmBotBase) -> None: 86 | importlib.reload(utils) 87 | EtcEvents(bot) 88 | -------------------------------------------------------------------------------- /exts/general_cmds.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import asyncio 18 | import importlib 19 | import os 20 | import subprocess 21 | import time 22 | 23 | import interactions as ipy 24 | import tansy 25 | from tortoise.expressions import Q 26 | 27 | import common.classes as cclasses 28 | import common.models as models 29 | import common.playerlist_utils as pl_utils 30 | import common.utils as utils 31 | 32 | 33 | class GeneralCMDS(utils.Extension): 34 | def __init__(self, bot: utils.RealmBotBase) -> None: 35 | self.name = "General" 36 | self.bot: utils.RealmBotBase = bot 37 | 38 | self.invite_link = "" 39 | self.bot.create_task(self.async_wait()) 40 | 41 | async def async_wait(self) -> None: 42 | await self.bot.wait_until_ready() 43 | self.invite_link = f"https://discord.com/api/oauth2/authorize?client_id={self.bot.user.id}&permissions=309238025280&scope=applications.commands%20bot" 44 | 45 | def _get_commit_hash(self) -> str | None: 46 | try: 47 | return ( 48 | subprocess.check_output(["git", "rev-parse", "--short", "HEAD"]) 49 | .decode("ascii") 50 | .strip() 51 | ) 52 | except Exception: # screw it 53 | return None 54 | 55 | async def get_commit_hash(self) -> str | None: 56 | return await asyncio.to_thread(self._get_commit_hash) 57 | 58 | @ipy.slash_command( 59 | "ping", 60 | description=( 61 | "Pings the bot. Great way of finding out if the bot's working correctly," 62 | " but has no real use." 63 | ), 64 | ) 65 | async def ping(self, ctx: utils.RealmContext) -> None: 66 | """ 67 | Pings the bot. Great way of finding out if the bot's working correctly, but has no real use. 68 | """ 69 | 70 | start_time = time.perf_counter() 71 | average_ping = round((self.bot.latency * 1000), 2) 72 | shard_id = self.bot.get_shard_id(ctx.guild_id) if ctx.guild_id else 0 73 | shard_ping = round((self.bot.latencies[shard_id] * 1000), 2) 74 | 75 | embed = ipy.Embed( 76 | "Pong!", color=self.bot.color, timestamp=ipy.Timestamp.utcnow() 77 | ) 78 | embed.set_footer(f"Shard ID: {shard_id}") 79 | embed.description = ( 80 | f"Average Ping: `{average_ping}` ms\nShard Ping: `{shard_ping}`" 81 | " ms\nCalculating RTT..." 82 | ) 83 | 84 | await ctx.send(embed=embed) 85 | 86 | end_time = time.perf_counter() 87 | # not really rtt ping but shh 88 | rtt_ping = round(((end_time - start_time) * 1000), 2) 89 | embed.description = ( 90 | f"Average Ping: `{average_ping}` ms\nShard Ping: `{shard_ping}` ms\nRTT" 91 | f" Ping: `{rtt_ping}` ms" 92 | ) 93 | 94 | await ctx.edit(embed=embed) 95 | 96 | @ipy.slash_command( 97 | name="invite", 98 | description="Sends instructions on how to set up and invite the bot.", 99 | ) 100 | async def invite(self, ctx: utils.RealmContext) -> None: 101 | embed = utils.make_embed( 102 | "If you want to invite me to your server, it's a good idea to use the" 103 | " Server Setup Guide. However, if you know what you're doing, you can" 104 | " use the Invite Link instead.", 105 | title="Invite Bot", 106 | ) 107 | components = [ 108 | ipy.Button( 109 | style=ipy.ButtonStyle.URL, 110 | label="Server Setup Guide", 111 | url="https://playerlist.astrea.cc/wiki/server_setup.html", 112 | ), 113 | ipy.Button( 114 | style=ipy.ButtonStyle.URL, 115 | label="Invite Link", 116 | url=self.invite_link, 117 | ), 118 | ] 119 | await ctx.send(embeds=embed, components=components) 120 | 121 | @ipy.slash_command( 122 | "support", description="Gives information about getting support." 123 | ) 124 | async def support(self, ctx: ipy.InteractionContext) -> None: 125 | embed = utils.make_embed( 126 | "Check out the FAQ to see if your question/issue has already been answered." 127 | " If not, feel free to join the support server and ask your question/report" 128 | " your issue there.", 129 | title="Support", 130 | ) 131 | 132 | components = [ 133 | ipy.Button( 134 | style=ipy.ButtonStyle.URL, 135 | label="Read the FAQ", 136 | url="https://playerlist.astrea.cc/wiki/faq.html", 137 | ), 138 | ipy.Button( 139 | style=ipy.ButtonStyle.URL, 140 | label="Join Support Server", 141 | url="https://discord.gg/NSdetwGjpK", 142 | ), 143 | ] 144 | await ctx.send(embeds=embed, components=components) 145 | 146 | @ipy.slash_command("about", description="Gives information about the bot.") 147 | async def about(self, ctx: ipy.InteractionContext) -> None: 148 | msg_list = [ 149 | ( 150 | "Hi! I'm the **Realms Playerlist Bot**, a bot that helps out owners of" 151 | " Minecraft: Bedrock Edition Realms by showing a log of players who" 152 | " have joined and left." 153 | ), 154 | ( 155 | "If you want to use me, go ahead and invite me to your server and take" 156 | f" a look at {self.bot.mention_command('config help')}!" 157 | ), 158 | ( 159 | "*The Realms Playerlist Bot is not an official Minecraft product, and" 160 | " is not approved by or associated with Mojang or Microsoft.*" 161 | ), 162 | ] 163 | 164 | about_embed = ipy.Embed( 165 | title="About", 166 | color=self.bot.color, 167 | description="\n".join(msg_list), 168 | ) 169 | about_embed.set_thumbnail( 170 | ctx.bot.user.display_avatar.url 171 | if ctx.guild_id 172 | else self.bot.user.display_avatar.url 173 | ) 174 | 175 | commit_hash = await self.get_commit_hash() 176 | command_num = len(self.bot.application_commands) + len( 177 | self.bot.prefixed.commands 178 | ) 179 | premium_count = await models.GuildConfig.filter( 180 | Q(premium_code_id__not_isnull=True) 181 | & ( 182 | Q(premium_code__expires_at__isnull=True) 183 | | Q(premium_code__expires_at__gt=ctx.id.created_at) 184 | ) 185 | ).count() 186 | 187 | num_shards = len(self.bot.shards) 188 | shards_str = f"{num_shards} shards" if num_shards != 1 else "1 shard" 189 | 190 | about_embed.add_field( 191 | name="Stats", 192 | value="\n".join( 193 | ( 194 | f"Servers: {self.bot.guild_count} ({shards_str})", 195 | f"Premium Servers: {premium_count}", 196 | f"Commands: {command_num} ", 197 | ( 198 | "Startup Time:" 199 | f" {ipy.Timestamp.fromdatetime(self.bot.start_time).format(ipy.TimestampStyles.RelativeTime)}" 200 | ), 201 | ( 202 | "Commit Hash:" 203 | f" [{commit_hash}](https://github.com/AstreaTSS/RealmsPlayerlistBot/commit/{commit_hash})" 204 | if commit_hash 205 | else "Commit Hash: N/A" 206 | ), 207 | ( 208 | "Interactions.py Version:" 209 | f" [{ipy.__version__}](https://github.com/interactions-py/interactions.py/tree/{ipy.__version__})" 210 | ), 211 | "Made By: [AstreaTSS](https://astrea.cc)", 212 | ) 213 | ), 214 | inline=True, 215 | ) 216 | 217 | links = [ 218 | "Website: [Link](https://playerlist.astrea.cc)", 219 | "FAQ: [Link](https://playerlist.astrea.cc/wiki/faq.html)", 220 | "Support Server: [Link](https://discord.gg/NSdetwGjpK)", 221 | ] 222 | 223 | if os.environ.get("TOP_GG_TOKEN"): 224 | links.append(f"Top.gg Page: [Link](https://top.gg/bot/{self.bot.user.id})") 225 | 226 | links.extend( 227 | ( 228 | "Source Code: [Link](https://github.com/AstreaTSS/RealmsPlayerlistBot)", 229 | ( 230 | "Privacy Policy:" 231 | " [Link](https://playerlist.astrea.cc/legal/privacy_policy.html)" 232 | ), 233 | "Terms of Service: [Link](https://playerlist.astrea.cc/legal/tos.html)", 234 | ) 235 | ) 236 | 237 | about_embed.add_field( 238 | name="Links", 239 | value="\n".join(links), 240 | inline=True, 241 | ) 242 | about_embed.timestamp = ipy.Timestamp.utcnow() 243 | 244 | shard_id = self.bot.get_shard_id(ctx.guild_id) if ctx.guild_id else 0 245 | about_embed.set_footer(f"Shard ID: {shard_id}") 246 | 247 | await ctx.send(embed=about_embed) 248 | 249 | @tansy.slash_command( 250 | "gamertag-from-xuid", 251 | description="Gets the gamertag for a specified XUID.", 252 | dm_permission=False, 253 | ) 254 | @ipy.cooldown(ipy.Buckets.GUILD, 1, 5) 255 | async def gamertag_from_xuid( 256 | self, 257 | ctx: utils.RealmContext, 258 | xuid: str = tansy.Option("The XUID of the player to get."), 259 | ) -> None: 260 | """ 261 | Gets the gamertag for a specified XUID. 262 | 263 | Think of XUIDs as Discord user IDs but for Xbox Live - \ 264 | they are frequently used both in Minecraft and with this bot. 265 | Gamertags are like the user's username in a sense. 266 | 267 | For technical reasons, when using the playerlist, the bot has to do a XUID > gamertag lookup. 268 | This lookup usually works well, but on the rare occasion it does fail, the bot will show \ 269 | the XUID of a player instead of their gamertag to at least make sure something is shown about them. 270 | 271 | This command is useful if the bot fails that lookup and displays the XUID to you. 272 | """ 273 | 274 | try: 275 | if len(xuid) > 64: 276 | raise ValueError() 277 | 278 | valid_xuid = int(xuid) if xuid.isdigit() else int(xuid, 16) 279 | except ValueError: 280 | raise ipy.errors.BadArgument(f'"{xuid}" is not a valid XUID.') from None 281 | 282 | gamertag = await pl_utils.gamertag_from_xuid(self.bot, valid_xuid) 283 | embed = utils.make_embed( 284 | f"`{valid_xuid}`'s gamertag: `{gamertag}`.", 285 | title="Gamertag from XUID", 286 | ) 287 | await ctx.send(embed=embed) 288 | 289 | @tansy.slash_command( 290 | "xuid-from-gamertag", 291 | description="Gets the XUID for a specified gamertag.", 292 | dm_permission=False, 293 | ) 294 | @ipy.cooldown(ipy.Buckets.GUILD, 1, 5) 295 | async def xuid_from_gamertag( 296 | self, 297 | ctx: utils.RealmContext, 298 | gamertag: str = tansy.Option("The gamertag of the player to get."), 299 | ) -> None: 300 | xuid = await pl_utils.xuid_from_gamertag(self.bot, gamertag) 301 | embed = utils.make_embed( 302 | f"`{gamertag}`'s XUID: `{xuid}` (hex: `{int(xuid):0{len(xuid)}X}`).", 303 | title="XUID from gamertag", 304 | ) 305 | await ctx.send(embed=embed) 306 | 307 | 308 | def setup(bot: utils.RealmBotBase) -> None: 309 | importlib.reload(utils) 310 | importlib.reload(pl_utils) 311 | importlib.reload(cclasses) 312 | GeneralCMDS(bot) 313 | -------------------------------------------------------------------------------- /exts/help_cmd.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import importlib 18 | import typing 19 | 20 | import interactions as ipy 21 | import tansy 22 | 23 | import common.fuzzy as fuzzy 24 | import common.help_tools as help_tools 25 | import common.utils as utils 26 | 27 | 28 | class HelpCMD(utils.Extension): 29 | """The cog that handles the help command.""" 30 | 31 | def __init__(self, bot: utils.RealmBotBase) -> None: 32 | self.name = "Help Category" 33 | self.bot: utils.RealmBotBase = bot 34 | 35 | async def _check_wrapper( 36 | self, ctx: ipy.BaseContext, check: typing.Callable 37 | ) -> bool: 38 | """A wrapper to ignore errors by checks.""" 39 | try: 40 | return await check(ctx) 41 | except Exception: 42 | return False 43 | 44 | async def _custom_can_run( 45 | self, ctx: ipy.BaseInteractionContext, cmd: help_tools.MiniCommand 46 | ) -> bool: 47 | """ 48 | Determines if this command can be run, but ignores cooldowns and concurrency. 49 | """ 50 | 51 | slash_cmd = cmd.slash_command 52 | 53 | if not slash_cmd.get_cmd_id(int(ctx.guild_id)): 54 | return False 55 | 56 | if not self.bot.slash_perms_cache.get(int(ctx.guild_id)): 57 | return False 58 | 59 | if not self.bot.slash_perms_cache[int(ctx.guild_id)][ 60 | int(slash_cmd.get_cmd_id(int(ctx.guild_id))) 61 | ].has_permission_ctx(ctx): 62 | return False 63 | 64 | if cmd.subcommands: 65 | return True 66 | 67 | if not slash_cmd.enabled: 68 | return False 69 | 70 | for check in slash_cmd.checks: 71 | if not await self._check_wrapper(ctx, check): 72 | return False 73 | 74 | if slash_cmd.extension and slash_cmd.extension.extension_checks: 75 | for check in slash_cmd.extension.extension_checks: 76 | if not await self._check_wrapper(ctx, check): 77 | return False 78 | 79 | return True 80 | 81 | async def extract_commands( 82 | self, ctx: ipy.AutocompleteContext, argument: typing.Optional[str] 83 | ) -> tuple[str, ...]: 84 | cmds = help_tools.get_mini_commands_for_scope(self.bot, int(ctx.guild_id)) 85 | 86 | runnable_cmds = [v for v in cmds.values() if await self._custom_can_run(ctx, v)] 87 | resolved_names = { 88 | c.resolved_name.lower(): c.resolved_name 89 | for c in sorted(runnable_cmds, key=lambda c: c.resolved_name) 90 | } 91 | 92 | if not argument: 93 | return tuple(resolved_names.values())[:25] 94 | 95 | queried_cmds: list[list[str]] = fuzzy.extract_from_list( 96 | argument=argument.lower(), 97 | list_of_items=tuple(resolved_names.keys()), 98 | processors=[lambda x: x.lower()], 99 | score_cutoff=0.7, 100 | ) 101 | return tuple(resolved_names[c[0]] for c in queried_cmds)[:25] 102 | 103 | async def get_multi_command_embeds( 104 | self, 105 | ctx: utils.RealmContext, 106 | commands: list[help_tools.MiniCommand], 107 | name: str, 108 | description: typing.Optional[str], 109 | ) -> list[ipy.Embed]: 110 | embeds: list[ipy.Embed] = [] 111 | 112 | commands = [c for c in commands if await self._custom_can_run(ctx, c)] 113 | if not commands: 114 | return [] 115 | 116 | chunks = [commands[x : x + 9] for x in range(0, len(commands), 9)] 117 | multiple_embeds = len(chunks) > 1 118 | 119 | for index, chunk in enumerate(chunks): 120 | embed = ipy.Embed(description=description, color=ctx.bot.color) 121 | embed.set_footer(text='Use "/help command" for more info on a command.') 122 | 123 | embed.title = f"{name} - Page {index + 1}" if multiple_embeds else name 124 | for cmd in chunk: 125 | signature = f"{cmd.resolved_name} {cmd.signature}".strip() 126 | embed.add_field( 127 | name=signature, 128 | value=cmd.brief_description, 129 | inline=False, 130 | ) 131 | 132 | embeds.append(embed) 133 | 134 | return embeds 135 | 136 | async def get_ext_cmd_embeds( 137 | self, 138 | ctx: utils.RealmContext, 139 | cmds: dict[str, help_tools.MiniCommand], 140 | ext: ipy.Extension, 141 | ) -> list[ipy.Embed]: 142 | slash_cmds = [ 143 | c 144 | for c in cmds.values() 145 | if c.extension == ext and " " not in c.resolved_name 146 | ] 147 | slash_cmds.sort(key=lambda c: c.resolved_name) 148 | 149 | if not slash_cmds: 150 | return [] 151 | 152 | name = f"{ext.name} Commands" 153 | return await self.get_multi_command_embeds( 154 | ctx, slash_cmds, name, ext.description 155 | ) 156 | 157 | async def get_all_cmd_embeds( 158 | self, 159 | ctx: utils.RealmContext, 160 | cmds: dict[str, help_tools.MiniCommand], 161 | bot: utils.RealmBotBase, 162 | ) -> list[ipy.Embed]: 163 | embeds: list[ipy.Embed] = [] 164 | 165 | for ext in bot.ext.values(): 166 | ext_cmd_embeds = await self.get_ext_cmd_embeds(ctx, cmds, ext) 167 | if ext_cmd_embeds: 168 | embeds.extend(ext_cmd_embeds) 169 | 170 | return embeds 171 | 172 | async def get_command_embeds( 173 | self, ctx: utils.RealmContext, command: help_tools.MiniCommand 174 | ) -> list[ipy.Embed]: 175 | if command.subcommands: 176 | return await self.get_multi_command_embeds( 177 | ctx, command.view_subcommands, command.name, command.description 178 | ) 179 | 180 | signature = f"{command.resolved_name} {command.signature}" 181 | return [ 182 | ipy.Embed( 183 | title=signature, 184 | description=command.description, 185 | color=ctx.bot.color, 186 | ) 187 | ] 188 | 189 | @tansy.slash_command( 190 | name="help", 191 | description="Shows help about the bot or a command.", 192 | dm_permission=False, 193 | ) 194 | async def help_cmd( 195 | self, 196 | ctx: utils.RealmContext, 197 | query: typing.Optional[str] = tansy.Option( 198 | "The command to search for.", 199 | autocomplete=True, 200 | default=None, 201 | ), 202 | ) -> None: 203 | embeds: list[ipy.Embed] = [] 204 | 205 | if not self.bot.slash_perms_cache[int(ctx.guild_id)]: 206 | await help_tools.process_bulk_slash_perms(self.bot, int(ctx.guild_id)) 207 | 208 | cmds = help_tools.get_mini_commands_for_scope(self.bot, int(ctx.guild_id)) 209 | 210 | if not query: 211 | embeds = await self.get_all_cmd_embeds(ctx, cmds, self.bot) 212 | elif (command := cmds.get(query.lower())) and await self._custom_can_run( 213 | ctx, command 214 | ): 215 | embeds = await self.get_command_embeds(ctx, command) 216 | else: 217 | raise ipy.errors.BadArgument(f"No valid command called `{query}` found.") 218 | 219 | if not embeds: 220 | raise ipy.errors.BadArgument(f"No valid command called `{query}` found.") 221 | 222 | if len(embeds) == 1: 223 | # pointless to do a paginator here 224 | await ctx.send(embeds=embeds) 225 | return 226 | 227 | pag = help_tools.HelpPaginator.create_from_embeds(self.bot, *embeds, timeout=60) 228 | await pag.send(ctx) 229 | 230 | @help_cmd.autocomplete("query") 231 | async def query_autocomplete( 232 | self, 233 | ctx: utils.RealmAutocompleteContext, 234 | ) -> None: 235 | query = ctx.kwargs.get("query") 236 | 237 | if not self.bot.slash_perms_cache[int(ctx.guild_id)]: 238 | await help_tools.process_bulk_slash_perms(self.bot, int(ctx.guild_id)) 239 | 240 | commands = await self.extract_commands(ctx, query) 241 | await ctx.send([{"name": c, "value": c} for c in commands]) 242 | 243 | 244 | def setup(bot: utils.RealmBotBase) -> None: 245 | importlib.reload(utils) 246 | importlib.reload(help_tools) 247 | HelpCMD(bot) 248 | -------------------------------------------------------------------------------- /exts/on_cmd_error.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import datetime 18 | import importlib 19 | 20 | import humanize 21 | import interactions as ipy 22 | from interactions.ext import prefixed_commands as prefixed 23 | 24 | import common.utils as utils 25 | 26 | 27 | class OnCMDError(utils.Extension): 28 | def __init__(self, bot: utils.RealmBotBase) -> None: 29 | self.bot: utils.RealmBotBase = bot 30 | 31 | @staticmethod 32 | async def handle_send( 33 | ctx: prefixed.PrefixedContext | ipy.InteractionContext, content: str 34 | ) -> None: 35 | embed = utils.error_embed_generate(content) 36 | if isinstance(ctx, prefixed.PrefixedContext): 37 | await ctx.reply(embeds=embed) 38 | else: 39 | await ctx.send(embeds=embed, ephemeral=ctx.ephemeral) 40 | 41 | @ipy.listen(disable_default_listeners=True) 42 | async def on_command_error( 43 | self, 44 | event: ipy.events.CommandError, 45 | ) -> None: 46 | if not isinstance(event.ctx, prefixed.PrefixedContext | ipy.InteractionContext): 47 | return await utils.error_handle(event.error) 48 | 49 | if isinstance(event.error, ipy.errors.CommandOnCooldown): 50 | delta_wait = datetime.timedelta( 51 | seconds=event.error.cooldown.get_cooldown_time() 52 | ) 53 | await self.handle_send( 54 | event.ctx, 55 | "You're doing that command too fast! " 56 | + "Try again in" 57 | f" `{humanize.precisedelta(delta_wait, format='%0.1f')}`.", 58 | ) 59 | 60 | elif isinstance(event.error, utils.CustomCheckFailure | ipy.errors.BadArgument): 61 | await self.handle_send(event.ctx, str(event.error)) 62 | elif isinstance(event.error, ipy.errors.CommandCheckFailure): 63 | if event.ctx.guild_id: 64 | await self.handle_send( 65 | event.ctx, 66 | "You do not have the proper permissions to use that command.", 67 | ) 68 | else: 69 | await utils.error_handle(event.error, ctx=event.ctx) 70 | 71 | @ipy.listen(ipy.events.ModalError, disable_default_listeners=True) 72 | async def on_modal_error(self, event: ipy.events.ModalError) -> None: 73 | await self.on_command_error.callback(self, event) 74 | 75 | @ipy.listen(ipy.events.ComponentError, disable_default_listeners=True) 76 | async def on_component_error(self, event: ipy.events.ComponentError) -> None: 77 | await self.on_command_error.callback(self, event) 78 | 79 | 80 | def setup(bot: utils.RealmBotBase) -> None: 81 | importlib.reload(utils) 82 | OnCMDError(bot) 83 | -------------------------------------------------------------------------------- /exts/owner_cmds.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import asyncio 18 | import collections 19 | import contextlib 20 | import importlib 21 | import io 22 | import os 23 | import platform 24 | import textwrap 25 | import traceback 26 | import typing 27 | 28 | import aiohttp 29 | import interactions as ipy 30 | import orjson 31 | import tansy 32 | from interactions.ext import paginators 33 | from interactions.ext import prefixed_commands as prefixed 34 | from interactions.ext.debug_extension.utils import debug_embed, get_cache_state 35 | 36 | import common.realm_stories as realm_stories 37 | import common.utils as utils 38 | from common.models import GuildConfig 39 | 40 | DEV_GUILD_ID = int(os.environ["DEV_GUILD_ID"]) 41 | 42 | 43 | class OwnerCMDs(utils.Extension): 44 | def __init__(self, bot: utils.RealmBotBase) -> None: 45 | self.bot: utils.RealmBotBase = bot 46 | self.name = "Owner" 47 | 48 | self.set_extension_error(self.ext_error) 49 | self.add_ext_check(ipy.is_owner()) 50 | 51 | @tansy.slash_command( 52 | name="view-guild", 53 | description="Displays a guild's config. Can only be used by the bot's owner.", 54 | scopes=[DEV_GUILD_ID], 55 | default_member_permissions=ipy.Permissions.ADMINISTRATOR, 56 | ) 57 | async def view_guild( 58 | self, 59 | ctx: utils.RealmContext, 60 | guild_id: str = tansy.Option("The guild to view."), 61 | ) -> None: 62 | config = await GuildConfig.get( 63 | guild_id=int(guild_id), 64 | ).prefetch_related("premium_code") 65 | 66 | guild_data = await self.bot.http.get_guild(guild_id) 67 | guild_name = guild_data["name"] 68 | 69 | embed = await utils.config_info_generate(ctx, config, diagnostic_info=True) 70 | embed.title = f"Server Config for {guild_name}" 71 | await ctx.send(embeds=embed) 72 | 73 | @tansy.slash_command( 74 | name="add-guild", 75 | description=( 76 | "Adds a guild to the bot's configs. Can only be used by the bot's owner." 77 | ), 78 | scopes=[DEV_GUILD_ID], 79 | default_member_permissions=ipy.Permissions.ADMINISTRATOR, 80 | ) 81 | async def add_guild( 82 | self, 83 | ctx: utils.RealmContext, 84 | guild_id: str = tansy.Option("The guild ID for the guild to add."), 85 | club_id: typing.Optional[str] = tansy.Option( 86 | "The club ID for the Realm.", default=None 87 | ), 88 | realm_id: typing.Optional[str] = tansy.Option( 89 | "The Realm ID for the Realm.", default=None 90 | ), 91 | playerlist_chan: typing.Optional[str] = tansy.Option( 92 | "The playerlist channel ID for this guild.", default=None 93 | ), 94 | ) -> None: 95 | data: dict[str, typing.Any] = {"guild_id": int(guild_id)} 96 | 97 | if club_id: 98 | data["club_id"] = club_id 99 | if realm_id: 100 | data["realm_id"] = realm_id 101 | await realm_stories.fill_in_data_from_stories(self.bot, realm_id) 102 | if playerlist_chan: 103 | data["playerlist_chan"] = int(playerlist_chan) 104 | 105 | await GuildConfig.create(**data) 106 | await ctx.send("Done!") 107 | 108 | @tansy.slash_command( 109 | name="edit-guild", 110 | description=( 111 | "Edits a guild in the bot's configs. Can only be used by the bot's owner." 112 | ), 113 | scopes=[DEV_GUILD_ID], 114 | default_member_permissions=ipy.Permissions.ADMINISTRATOR, 115 | ) 116 | async def edit_guild( 117 | self, 118 | ctx: utils.RealmContext, 119 | guild_id: str = tansy.Option("The guild to edit."), 120 | club_id: typing.Optional[str] = tansy.Option( 121 | "The club ID for the Realm.", default=None 122 | ), 123 | realm_id: typing.Optional[str] = tansy.Option( 124 | "The Realm ID for the Realm.", default=None 125 | ), 126 | playerlist_chan: typing.Optional[str] = tansy.Option( 127 | "The playerlist channel ID for this guild.", default=None 128 | ), 129 | ) -> None: 130 | config = await GuildConfig.get(guild_id=int(guild_id)) 131 | 132 | if realm_id: 133 | config.realm_id = realm_id if realm_id != "None" else None 134 | await realm_stories.fill_in_data_from_stories(self.bot, realm_id) 135 | if club_id: 136 | config.club_id = club_id if club_id != "None" else None 137 | if playerlist_chan: 138 | config.playerlist_chan = ( 139 | int(playerlist_chan) if playerlist_chan != "None" else None 140 | ) 141 | 142 | await config.save() 143 | await ctx.send("Done!") 144 | 145 | @tansy.slash_command( 146 | name="remove-guild", 147 | description=( 148 | "Removes a guild from the bot's configs. Can only be used by the bot's" 149 | " owner." 150 | ), 151 | scopes=[DEV_GUILD_ID], 152 | default_member_permissions=ipy.Permissions.ADMINISTRATOR, 153 | ) 154 | async def remove_guild( 155 | self, 156 | ctx: utils.RealmContext, 157 | guild_id: str = tansy.Option("The guild ID for the guild to remove."), 158 | ) -> None: 159 | await GuildConfig.filter(guild_id=int(guild_id)).delete() 160 | await ctx.send("Deleted!") 161 | 162 | @prefixed.prefixed_command(aliases=["jsk"]) 163 | async def debug(self, ctx: prefixed.PrefixedContext) -> None: 164 | """Get basic information about the bot.""" 165 | uptime = ipy.Timestamp.fromdatetime(self.bot.start_time) 166 | e = debug_embed("General") 167 | e.set_thumbnail(self.bot.user.avatar.url) 168 | e.add_field("Operating System", platform.platform()) 169 | 170 | e.add_field( 171 | "Version Info", 172 | f"interactions.py@{ipy.__version__} | Py@{ipy.__py_version__}", 173 | ) 174 | 175 | e.add_field("Start Time", f"{uptime.format(ipy.TimestampStyles.RelativeTime)}") 176 | 177 | if privileged_intents := [ 178 | i.name for i in self.bot.intents if i in ipy.Intents.PRIVILEGED 179 | ]: 180 | e.add_field("Privileged Intents", " | ".join(privileged_intents)) 181 | 182 | e.add_field("Loaded Extensions", ", ".join(self.bot.ext)) 183 | 184 | e.add_field("Guilds", str(self.bot.guild_count)) 185 | 186 | await ctx.reply(embeds=[e]) 187 | 188 | @debug.subcommand(aliases=["cache"]) 189 | async def cache_info(self, ctx: prefixed.PrefixedContext) -> None: 190 | """Get information about the current cache state.""" 191 | e = debug_embed("Cache") 192 | 193 | e.description = f"```prolog\n{get_cache_state(self.bot)}\n```" 194 | await ctx.reply(embeds=[e]) 195 | 196 | @debug.subcommand() 197 | async def shutdown(self, ctx: prefixed.PrefixedContext) -> None: 198 | """Shuts down the bot.""" 199 | await ctx.reply("Shutting down 😴") 200 | await self.bot.stop() 201 | 202 | @debug.subcommand() 203 | async def reload(self, ctx: prefixed.PrefixedContext, *, module: str) -> None: 204 | """Regrows an extension.""" 205 | self.bot.reload_extension(module) 206 | self.bot.slash_perms_cache = collections.defaultdict(dict) 207 | self.bot.mini_commands_per_scope = {} 208 | await ctx.reply(f"Reloaded `{module}`.") 209 | 210 | @debug.subcommand() 211 | async def load(self, ctx: prefixed.PrefixedContext, *, module: str) -> None: 212 | """Grows a scale.""" 213 | self.bot.load_extension(module) 214 | self.bot.slash_perms_cache = collections.defaultdict(dict) 215 | self.bot.mini_commands_per_scope = {} 216 | await ctx.reply(f"Loaded `{module}`.") 217 | 218 | @debug.subcommand() 219 | async def unload(self, ctx: prefixed.PrefixedContext, *, module: str) -> None: 220 | """Sheds a scale.""" 221 | self.bot.unload_extension(module) 222 | self.bot.slash_perms_cache = collections.defaultdict(dict) 223 | self.bot.mini_commands_per_scope = {} 224 | await ctx.reply(f"Unloaded `{module}`.") 225 | 226 | @prefixed.prefixed_command(aliases=["reloadallextensions"]) 227 | async def reload_all_extensions(self, ctx: prefixed.PrefixedContext) -> None: 228 | for ext in (e.extension_name for e in self.bot.ext.copy().values()): 229 | self.bot.reload_extension(ext) 230 | await ctx.reply("All extensions reloaded!") 231 | 232 | @reload.error 233 | @load.error 234 | @unload.error 235 | async def extension_error( 236 | self, error: Exception, ctx: prefixed.PrefixedContext, *_: typing.Any 237 | ) -> ipy.Message | None: 238 | if isinstance(error, ipy.errors.CommandCheckFailure): 239 | return await ctx.reply( 240 | "You do not have permission to execute this command." 241 | ) 242 | await utils.error_handle(error, ctx=ctx) 243 | return None 244 | 245 | @debug.subcommand(aliases=["python", "exc"]) 246 | async def exec(self, ctx: prefixed.PrefixedContext, *, body: str) -> ipy.Message: 247 | """Direct evaluation of Python code.""" 248 | await ctx.channel.trigger_typing() 249 | env = { 250 | "bot": self.bot, 251 | "ctx": ctx, 252 | "channel": ctx.channel, 253 | "author": ctx.author, 254 | "message": ctx.message, 255 | } | globals() 256 | 257 | body = ( 258 | "\n".join(body.split("\n")[1:-1]) 259 | if body.startswith("```") and body.endswith("```") 260 | else body.strip("` \n") 261 | ) 262 | 263 | stdout = io.StringIO() 264 | 265 | to_compile = f"async def func():\n{textwrap.indent(body, ' ')}" 266 | try: 267 | exec(to_compile, env) # noqa: S102 268 | except SyntaxError: 269 | return await ctx.reply(f"```py\n{traceback.format_exc()}\n```") 270 | 271 | func = env["func"] 272 | try: 273 | with contextlib.redirect_stdout(stdout): 274 | ret = await func() 275 | except Exception: 276 | await ctx.message.add_reaction("❌") 277 | raise 278 | else: 279 | return await self.handle_exec_result(ctx, ret, stdout.getvalue()) 280 | 281 | async def handle_exec_result( 282 | self, ctx: prefixed.PrefixedContext, result: typing.Any, value: typing.Any 283 | ) -> ipy.Message: 284 | if result is None: 285 | result = value or "No Output!" 286 | 287 | await ctx.message.add_reaction("✅") 288 | 289 | if isinstance(result, ipy.Message): 290 | try: 291 | e = debug_embed( 292 | "Exec", timestamp=result.created_at, url=result.jump_url 293 | ) 294 | e.description = result.content 295 | e.set_author( 296 | result.author.tag, 297 | icon_url=result.author.display_avatar.url, 298 | ) 299 | e.add_field( 300 | "\u200b", f"[Jump To]({result.jump_url})\n{result.channel.mention}" 301 | ) 302 | 303 | return await ctx.message.reply(embeds=e) 304 | except Exception: 305 | return await ctx.message.reply(result.jump_url) 306 | 307 | if isinstance(result, ipy.Embed): 308 | return await ctx.message.reply(embeds=result) 309 | 310 | if isinstance(result, ipy.File): 311 | return await ctx.message.reply(file=result) 312 | 313 | if isinstance(result, paginators.Paginator): 314 | return await result.reply(ctx) 315 | 316 | if hasattr(result, "__iter__"): 317 | l_result = list(result) 318 | if all(isinstance(r, ipy.Embed) for r in result): 319 | paginator = paginators.Paginator.create_from_embeds(self.bot, *l_result) 320 | return await paginator.reply(ctx) 321 | 322 | if not isinstance(result, str): 323 | result = repr(result) 324 | 325 | # prevent token leak 326 | result = result.replace(self.bot.http.token, "[REDACTED TOKEN]") 327 | 328 | if len(result) <= 2000: 329 | return await ctx.message.reply(f"```py\n{result}```") 330 | 331 | paginator = paginators.Paginator.create_from_string( 332 | self.bot, result, prefix="```py", suffix="```", page_size=4000 333 | ) 334 | return await paginator.reply(ctx) 335 | 336 | @debug.subcommand() 337 | async def shell(self, ctx: prefixed.PrefixedContext, *, cmd: str) -> ipy.Message: 338 | """Executes statements in the system shell.""" 339 | async with ctx.channel.typing: 340 | process = await asyncio.create_subprocess_shell( 341 | cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT 342 | ) 343 | 344 | output, _ = await process.communicate() 345 | output_str = output.decode("utf-8") 346 | output_str += f"\nReturn code {process.returncode}" 347 | 348 | if len(output_str) <= 2000: 349 | return await ctx.message.reply(f"```sh\n{output_str}```") 350 | 351 | paginator = paginators.Paginator.create_from_string( 352 | self.bot, output_str, prefix="```sh", suffix="```", page_size=4000 353 | ) 354 | return await paginator.reply(ctx) 355 | 356 | @debug.subcommand() 357 | async def git( 358 | self, ctx: prefixed.PrefixedContext, *, cmd: typing.Optional[str] = None 359 | ) -> None: 360 | """Shortcut for 'debug shell git'. Invokes the system shell.""" 361 | await self.shell.callback(ctx, cmd=f"git {cmd}" if cmd else "git") 362 | 363 | @debug.subcommand() 364 | async def pip( 365 | self, ctx: prefixed.PrefixedContext, *, cmd: typing.Optional[str] = None 366 | ) -> None: 367 | """Shortcut for 'debug shell pip'. Invokes the system shell.""" 368 | await self.shell.callback(ctx, cmd=f"pip {cmd}" if cmd else "pip") 369 | 370 | @debug.subcommand(aliases=["sync-interactions", "sync-cmds", "sync_cmds", "sync"]) 371 | async def sync_interactions( 372 | self, ctx: prefixed.PrefixedContext, scope: int = 0 373 | ) -> None: 374 | """ 375 | Synchronizes all interaction commands with Discord. 376 | 377 | Should not be used lightly. 378 | """ 379 | # syncing interactions in inherently intensive and 380 | # has a high risk of running into the ratelimit 381 | # while this is fine for a small bot where it's unlikely 382 | # to even matter, for big bots, running into this ratelimit 383 | # can cause havoc on other functions 384 | 385 | # we only sync to the global scope to make delete_commands 386 | # a lot better in the ratelimiting department, but i 387 | # would still advise caution to any self-hosters, and would 388 | # only suggest using this when necessary 389 | 390 | async with ctx.channel.typing: 391 | await self.bot.synchronise_interactions( 392 | scopes=[scope], delete_commands=True 393 | ) 394 | self.bot.slash_perms_cache = collections.defaultdict(dict) 395 | self.bot.mini_commands_per_scope = {} 396 | 397 | await ctx.reply("Done!") 398 | 399 | @debug.subcommand(aliases=["sync-dbl-cmds", "sync-dbl", "sync-dbl-commands"]) 400 | async def sync_dbl_commands(self, ctx: prefixed.PrefixedContext) -> None: 401 | if not os.environ.get("DBL_TOKEN"): 402 | raise ipy.errors.BadArgument("DBL_TOKEN is not set.") 403 | 404 | async with ctx.channel.typing: 405 | data = await self.bot.http.get_application_commands(self.bot.user.id, 0) 406 | async with aiohttp.ClientSession( 407 | headers={"Authorization": os.environ["DBL_TOKEN"]} 408 | ) as session: 409 | async with session.post( 410 | f"https://discordbotlist.com/api/v1/bots/{self.bot.user.id}/commands", 411 | json=data, 412 | ) as r: 413 | r.raise_for_status() 414 | 415 | await ctx.reply("Done!") 416 | 417 | @debug.subcommand(aliases=["bl"]) 418 | async def blacklist(self, ctx: utils.RealmPrefixedContext) -> None: 419 | await ctx.reply(str(ctx.bot.blacklist)) 420 | 421 | @blacklist.subcommand(name="add") 422 | async def bl_add( 423 | self, ctx: utils.RealmPrefixedContext, snowflake: ipy.SnowflakeObject 424 | ) -> None: 425 | if int(snowflake.id) in ctx.bot.blacklist: 426 | raise ipy.errors.BadArgument("This entry is already in the blacklist.") 427 | ctx.bot.blacklist.add(int(snowflake.id)) 428 | await ctx.bot.valkey.set("rpl-blacklist", orjson.dumps(list(ctx.bot.blacklist))) 429 | await ctx.reply("Done!") 430 | 431 | @debug.subcommand(aliases=["trigger-autorunning-playerlist", "trigger-autorunner"]) 432 | async def trigger_autorunning_playerlist( 433 | self, ctx: utils.RealmPrefixedContext 434 | ) -> None: 435 | async with ctx.channel.typing: 436 | await self.bot.ext["Autorunners"].playerlist_loop(None) 437 | await ctx.reply("Done!") 438 | 439 | @debug.subcommand( 440 | aliases=["trigger-reoccurring-leaderboard", "trigger-reoccurring-lb"] 441 | ) 442 | async def trigger_reoccurring_leaderboard( 443 | self, 444 | ctx: utils.RealmPrefixedContext, 445 | sunday: bool, 446 | second_sunday: bool, 447 | first_sunday_of_month: bool, 448 | ) -> None: 449 | async with ctx.channel.typing: 450 | await self.bot.ext["Autorunners"].reoccurring_lb_loop( 451 | sunday, second_sunday, first_sunday_of_month 452 | ) 453 | await ctx.reply("Done!") 454 | 455 | @blacklist.subcommand(name="remove", aliases=["delete"]) 456 | async def bl_remove( 457 | self, ctx: utils.RealmPrefixedContext, snowflake: ipy.SnowflakeObject 458 | ) -> None: 459 | if int(snowflake.id) not in ctx.bot.blacklist: 460 | raise ipy.errors.BadArgument("This entry is not in the blacklist.") 461 | ctx.bot.blacklist.discard(int(snowflake.id)) 462 | await ctx.bot.valkey.set("rpl-blacklist", orjson.dumps(list(ctx.bot.blacklist))) 463 | await ctx.reply("Done!") 464 | 465 | async def ext_error( 466 | self, 467 | error: Exception, 468 | ctx: ipy.BaseContext, 469 | *_: typing.Any, 470 | **__: typing.Any, 471 | ) -> None: 472 | if isinstance(ctx, prefixed.PrefixedContext): 473 | ctx.send = ctx.message.reply # type: ignore 474 | 475 | if isinstance(error, ipy.errors.CommandCheckFailure): 476 | if hasattr(ctx, "send"): 477 | await ctx.send("Nice try.") 478 | return 479 | 480 | error_str = utils.error_format(error) 481 | chunks = utils.line_split(error_str) 482 | 483 | for i in range(len(chunks)): 484 | chunks[i][0] = f"```py\n{chunks[i][0]}" 485 | chunks[i][len(chunks[i]) - 1] += "\n```" 486 | 487 | final_chunks = ["\n".join(chunk) for chunk in chunks] 488 | if ctx and hasattr(ctx, "message") and hasattr(ctx.message, "jump_url"): 489 | final_chunks.insert(0, f"Error on: {ctx.message.jump_url}") 490 | 491 | to_send = final_chunks 492 | split = False 493 | 494 | await utils.msg_to_owner(self.bot, to_send, split) 495 | 496 | if hasattr(ctx, "send"): 497 | await ctx.send("An error occured. Please check your DMs.") 498 | 499 | 500 | def setup(bot: utils.RealmBotBase) -> None: 501 | importlib.reload(utils) 502 | importlib.reload(realm_stories) 503 | OwnerCMDs(bot) 504 | -------------------------------------------------------------------------------- /exts/pl_event_handling.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import contextlib 18 | import importlib 19 | import logging 20 | import os 21 | import typing 22 | 23 | import elytra 24 | import interactions as ipy 25 | from tortoise.transactions import in_transaction 26 | 27 | import common.models as models 28 | import common.playerlist_events as pl_events 29 | import common.playerlist_utils as pl_utils 30 | import common.utils as utils 31 | 32 | logger = logging.getLogger("realms_bot") 33 | 34 | 35 | class PlayerlistEventHandling(utils.Extension): 36 | def __init__(self, bot: utils.RealmBotBase) -> None: 37 | self.bot: utils.RealmBotBase = bot 38 | self.name = "Playerlist Event Handling" 39 | 40 | @ipy.listen("playerlist_parse_finish", is_default_listener=True) 41 | async def on_playerlist_finish( 42 | self, event: pl_events.PlayerlistParseFinish 43 | ) -> None: 44 | async with in_transaction(): 45 | for container in event.containers: 46 | await models.PlayerSession.bulk_create( 47 | container.player_sessions, 48 | on_conflict=("custom_id",), 49 | update_fields=container.fields, 50 | ) 51 | 52 | @ipy.listen("live_playerlist_send", is_default_listener=True) 53 | async def on_live_playerlist_send( 54 | self, event: pl_events.LivePlayerlistSend 55 | ) -> None: 56 | player_sessions = [ 57 | models.PlayerSession( 58 | custom_id=self.bot.uuid_cache[f"{event.realm_id}-{p}"], 59 | realm_id=event.realm_id, 60 | xuid=p, 61 | online=True, 62 | joined_at=event.timestamp, 63 | last_seen=event.timestamp, 64 | show_left=False, 65 | ) 66 | for p in event.joined 67 | ] 68 | player_sessions.extend( 69 | models.PlayerSession( 70 | custom_id=self.bot.uuid_cache[f"{event.realm_id}-{p}"], 71 | realm_id=event.realm_id, 72 | xuid=p, 73 | online=False, 74 | last_seen=event.timestamp, 75 | show_left=False, 76 | ) 77 | for p in event.left 78 | ) 79 | 80 | bypass_cache_for = set() 81 | if event.realm_id in self.bot.fetch_devices_for: 82 | bypass_cache_for.update(p.xuid for p in player_sessions if p.online) 83 | 84 | players = await pl_utils.fill_in_gamertags_for_sessions( 85 | self.bot, 86 | player_sessions, 87 | bypass_cache_for=bypass_cache_for, 88 | ) 89 | 90 | base_embed = ipy.Embed( 91 | color=ipy.RoleColors.DARK_GREY, 92 | timestamp=ipy.Timestamp.fromdatetime(event.timestamp), 93 | ) 94 | base_embed.set_footer( 95 | f"{len(self.bot.online_cache[int(event.realm_id)])} players online" 96 | ) 97 | 98 | for guild_id in self.bot.live_playerlist_store[event.realm_id].copy(): 99 | config = await models.GuildConfig.get_or_none( 100 | guild_id=guild_id 101 | ).prefetch_related("premium_code") 102 | 103 | if not config: 104 | self.bot.live_playerlist_store[event.realm_id].discard(guild_id) 105 | continue 106 | 107 | if not config.valid_premium: 108 | await pl_utils.invalidate_premium(self.bot, config) 109 | continue 110 | 111 | if not config.live_playerlist: 112 | self.bot.live_playerlist_store[event.realm_id].discard(guild_id) 113 | continue 114 | 115 | if not config.playerlist_chan: 116 | config.live_playerlist = False 117 | self.bot.live_playerlist_store[event.realm_id].discard(guild_id) 118 | await config.save() 119 | continue 120 | 121 | if guild_id in self.bot.unavailable_guilds: 122 | continue 123 | 124 | gamertag_mapping = { 125 | p.xuid: p.base_display(config.nicknames.get(p.xuid)) for p in players 126 | } 127 | full_gamertag_mapping = { 128 | p.xuid: p.display(config.nicknames.get(p.xuid)) for p in players 129 | } 130 | 131 | if config.live_online_channel: 132 | self.bot.dispatch( 133 | pl_events.LiveOnlineUpdate( 134 | event.realm_id, 135 | event.joined, 136 | event.left, 137 | event.timestamp, 138 | full_gamertag_mapping, 139 | config, 140 | realm_down_event=event.realm_down_event, 141 | ) 142 | ) 143 | 144 | embed = ipy.Embed.from_dict(base_embed.to_dict()) 145 | 146 | if event.joined: 147 | embed.add_field( 148 | name=f"{os.environ['GREEN_CIRCLE_EMOJI']} Joined", 149 | value="\n".join( 150 | sorted( 151 | (gamertag_mapping[p] for p in event.joined), 152 | key=lambda x: x.lower(), 153 | ) 154 | ), 155 | ) 156 | if event.left: 157 | embed.add_field( 158 | name=f"{os.environ['GRAY_CIRCLE_EMOJI']} Left", 159 | value="\n".join( 160 | sorted( 161 | (gamertag_mapping[p] for p in event.left), 162 | key=lambda x: x.lower(), 163 | ) 164 | ), 165 | ) 166 | 167 | try: 168 | chan = utils.partial_channel(self.bot, config.playerlist_chan) 169 | await chan.send(embeds=embed) 170 | except ValueError: 171 | continue 172 | except ipy.errors.HTTPException as e: 173 | if e.status < 500: 174 | await pl_utils.eventually_invalidate(self.bot, config) 175 | continue 176 | 177 | @ipy.listen("live_online_update", is_default_listener=True) 178 | async def on_live_online_update(self, event: pl_events.LiveOnlineUpdate) -> None: 179 | xuid_str: str | None = await self.bot.valkey.hget( 180 | event.live_online_channel, "xuids" 181 | ) 182 | gamertag_str: str | None = await self.bot.valkey.hget( 183 | event.live_online_channel, "gamertags" 184 | ) 185 | 186 | if typing.TYPE_CHECKING: 187 | # a lie, but a harmless one and one needed to make typehints properly work 188 | assert isinstance(xuid_str, str) 189 | assert isinstance(gamertag_str, str) 190 | 191 | xuids_init: list[str] = xuid_str.split(",") if xuid_str else [] 192 | gamertags: list[str] = gamertag_str.splitlines() if gamertag_str else [] 193 | 194 | event.gamertag_mapping.update(dict(zip(xuids_init, gamertags, strict=True))) 195 | reverse_gamertag_map = {v: k for k, v in event.gamertag_mapping.items()} 196 | 197 | xuids: set[str] = set(xuids_init) 198 | xuids = xuids.union(event.joined).difference(event.left) 199 | 200 | gamertag_list = sorted( 201 | (event.gamertag_mapping[xuid] for xuid in xuids), key=lambda g: g.lower() 202 | ) 203 | xuid_list = [reverse_gamertag_map[g] for g in gamertag_list] 204 | 205 | new_gamertag_str = "\n".join(gamertag_list) 206 | 207 | await self.bot.valkey.hset( 208 | event.live_online_channel, "xuids", ",".join(xuid_list) 209 | ) 210 | await self.bot.valkey.hset( 211 | event.live_online_channel, "gamertags", new_gamertag_str 212 | ) 213 | 214 | if event.realm_down_event: 215 | new_gamertag_str = f"{os.environ['GRAY_CIRCLE_EMOJI']} *Realm is offline.*" 216 | 217 | embed = ipy.Embed( 218 | title=f"{len(xuids)}/10 people online", 219 | description=new_gamertag_str or "*No players online.*", 220 | color=self.bot.color, 221 | timestamp=event.timestamp, # type: ignore 222 | ) 223 | embed.set_footer("As of") 224 | 225 | chan_id, msg_id = event.live_online_channel.split("|") 226 | 227 | fake_msg = ipy.Message(client=self.bot, id=int(msg_id), channel_id=int(chan_id)) # type: ignore 228 | 229 | try: 230 | await fake_msg.edit(embed=embed) 231 | except ipy.errors.HTTPException as e: 232 | if e.status < 500: 233 | await pl_utils.eventually_invalidate_live_online(self.bot, event.config) 234 | 235 | @ipy.listen("realm_down", is_default_listener=True) 236 | async def realm_down(self, event: pl_events.RealmDown) -> None: 237 | # live playerlists are time sensitive, get them out first 238 | if self.bot.live_playerlist_store[event.realm_id]: 239 | self.bot.dispatch( 240 | pl_events.LivePlayerlistSend( 241 | event.realm_id, 242 | set(), 243 | event.disconnected, 244 | event.timestamp, 245 | realm_down_event=True, 246 | ) 247 | ) 248 | 249 | # these, meanwhile, aren't 250 | for config in await event.configs(): 251 | if not config.playerlist_chan or not config.realm_offline_role: 252 | continue 253 | 254 | if config.guild_id in self.bot.unavailable_guilds: 255 | continue 256 | 257 | role_mention = f"<@&{config.realm_offline_role}>" 258 | 259 | embed = ipy.Embed( 260 | title="Realm Offline", 261 | description=( 262 | "The bot has detected that the Realm has gone offline (or that all" 263 | " users have left the Realm)." 264 | ), 265 | timestamp=ipy.Timestamp.fromdatetime(event.timestamp), 266 | color=ipy.RoleColors.YELLOW, 267 | ) 268 | 269 | try: 270 | chan = utils.partial_channel( 271 | self.bot, 272 | config.get_notif_channel("realm_offline"), 273 | ) 274 | 275 | await chan.send( 276 | role_mention, 277 | embeds=embed, 278 | allowed_mentions=ipy.AllowedMentions.all(), 279 | ) 280 | except (ipy.errors.HTTPException, ValueError): 281 | if config.notification_channels.get("realm_offline"): 282 | await pl_utils.eventually_invalidate_realm_offline(self.bot, config) 283 | else: 284 | await pl_utils.eventually_invalidate(self.bot, config) 285 | continue 286 | 287 | @ipy.listen("warn_missing_playerlist", is_default_listener=True) 288 | async def warning_missing_playerlist( 289 | self, event: pl_events.WarnMissingPlayerlist 290 | ) -> None: 291 | no_playerlist_chan: list[bool] = [] 292 | 293 | for config in await event.configs(): 294 | if not config.playerlist_chan: 295 | if config.realm_id and config.live_playerlist: 296 | self.bot.live_playerlist_store[config.realm_id].discard( 297 | config.guild_id 298 | ) 299 | 300 | if config.realm_id: 301 | self.bot.offline_realms.discard(int(config.realm_id)) 302 | 303 | config.realm_id = None 304 | config.club_id = None 305 | config.live_playerlist = False 306 | config.fetch_devices = False 307 | 308 | await config.save() 309 | 310 | no_playerlist_chan.append(True) 311 | continue 312 | 313 | no_playerlist_chan.append(False) 314 | 315 | if not config.warning_notifications: 316 | continue 317 | 318 | if config.guild_id in self.bot.unavailable_guilds: 319 | continue 320 | 321 | logger.info("Warning %s for missing Realm.", config.guild_id) 322 | 323 | await pl_utils.eventually_invalidate(self.bot, config, limit=7) 324 | 325 | if not config.playerlist_chan: 326 | continue 327 | 328 | chan = utils.partial_channel(self.bot, config.playerlist_chan) 329 | 330 | with contextlib.suppress(ipy.errors.HTTPException): 331 | content = ( 332 | "I have been unable to get any information about your Realm for" 333 | " the last 24 hours. This could be because the Realm has been" 334 | " turned off or because it's inactive, but if it hasn't, make sure" 335 | f" you haven't banned or kicked `{self.bot.own_gamertag}`. If you" 336 | " have, please unban the account if needed and run" 337 | f" {self.bot.mention_command('config link-realm')} again to fix" 338 | " it.\n\nAlternatively:\n- If you want to disable the autorunning" 339 | " playerlist entirely, you can use" 340 | f" {self.bot.mention_command('config autorunning-playerlist-channel')} to" 341 | " do so.\n- If you want to disable this warning, you can use" 342 | f" {self.bot.mention_command('config realm-warning')} to do so." 343 | " *Note that these warnings are often useful, so disabling is not" 344 | " recommended unless you expect your Realm to be inactive for days" 345 | " on end.*\n\nThe bot will automatically disable the autorunning" 346 | " playerlist and related settings after 7 days of not getting" 347 | " information from your Realm." 348 | ) 349 | await chan.send(content=content) 350 | 351 | if all(no_playerlist_chan) or not no_playerlist_chan: 352 | self.bot.live_playerlist_store.pop(event.realm_id, None) 353 | self.bot.fetch_devices_for.discard(event.realm_id) 354 | self.bot.offline_realms.discard(int(event.realm_id)) 355 | 356 | # we don't want to stop the whole thing, but as of right now i would 357 | # like to know what happens with invalid stuff 358 | try: 359 | await self.bot.realms.leave_realm(event.realm_id) 360 | except elytra.MicrosoftAPIException as e: 361 | # might be an invalid id somehow? who knows 362 | if e.resp.status_code == 404: 363 | logger.warning("Could not leave Realm with ID %s.", event.realm_id) 364 | else: 365 | raise 366 | 367 | @ipy.listen(pl_events.PlayerWatchlistMatch, is_default_listener=True) 368 | async def watchlist_notify(self, event: pl_events.PlayerWatchlistMatch) -> None: 369 | for config in await event.configs(): 370 | if not config.playerlist_chan or not config.player_watchlist: 371 | self.bot.player_watchlist_store[ 372 | f"{event.realm_id}-{event.player_xuid}" 373 | ].discard(config.guild_id) 374 | config.player_watchlist = None 375 | await config.save() 376 | continue 377 | 378 | try: 379 | chan = utils.partial_channel( 380 | self.bot, 381 | config.get_notif_channel("player_watchlist"), 382 | ) 383 | 384 | gamertag = None 385 | 386 | if event.player_xuid not in config.nicknames: 387 | with contextlib.suppress(ipy.errors.BadArgument): 388 | gamertag = await pl_utils.gamertag_from_xuid( 389 | self.bot, event.player_xuid 390 | ) 391 | 392 | display = models.display_gamertag( 393 | event.player_xuid, gamertag, config.nicknames.get(event.player_xuid) 394 | ) 395 | 396 | content = "" 397 | if config.player_watchlist_role: 398 | content = f"<@&{config.player_watchlist_role}>, " 399 | 400 | content += f"{display} joined the Realm!" 401 | 402 | await chan.send( 403 | content, 404 | allowed_mentions=ipy.AllowedMentions.all(), 405 | ) 406 | except (ipy.errors.HTTPException, ValueError): 407 | if config.notification_channels.get("player_watchlist"): 408 | await pl_utils.eventually_invalidate_watchlist(self.bot, config) 409 | else: 410 | await pl_utils.eventually_invalidate(self.bot, config) 411 | continue 412 | 413 | 414 | def setup(bot: utils.RealmBotBase) -> None: 415 | importlib.reload(utils) 416 | importlib.reload(pl_events) 417 | importlib.reload(pl_utils) 418 | PlayerlistEventHandling(bot) 419 | -------------------------------------------------------------------------------- /exts/voting.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import importlib 18 | import os 19 | import typing 20 | 21 | import aiohttp 22 | import attrs 23 | import interactions as ipy 24 | 25 | import common.utils as utils 26 | 27 | 28 | @attrs.define(kw_only=True) 29 | class VoteHandler: 30 | name: str = attrs.field() 31 | base_url: str = attrs.field() 32 | headers: dict[str, str] = attrs.field() 33 | data_url: str = attrs.field() 34 | data_callback: typing.Callable[[int], dict[str, typing.Any]] = attrs.field() 35 | vote_url: typing.Optional[str] = attrs.field() 36 | 37 | 38 | class Voting(utils.Extension): 39 | def __init__(self, bot: utils.RealmBotBase) -> None: 40 | self.bot: utils.RealmBotBase = bot 41 | self.name = "Voting" 42 | 43 | self.shard_count = 0 44 | self.bot.create_task(self.re_shard_count()) 45 | 46 | self.handlers: list[VoteHandler] = [] 47 | 48 | if os.environ.get("TOP_GG_TOKEN"): 49 | self.handlers.append( 50 | VoteHandler( 51 | name="Top.gg", 52 | base_url="https://top.gg/api", 53 | headers={"Authorization": os.environ["TOP_GG_TOKEN"]}, 54 | data_url="/bots/{bot_id}/stats", 55 | data_callback=lambda guild_count: { 56 | "server_count": guild_count, 57 | "shard_count": self.shard_count, 58 | }, 59 | vote_url="https://top.gg/bot/{bot_id}/vote **(prefered)**", 60 | ) 61 | ) 62 | 63 | if os.environ.get("DBL_TOKEN"): 64 | self.handlers.append( 65 | VoteHandler( 66 | name="Discord Bot List", 67 | base_url="https://discordbotlist.com/api/v1", 68 | headers={"Authorization": os.environ["DBL_TOKEN"]}, 69 | data_url="/bots/{bot_id}/stats", 70 | data_callback=lambda guild_count: {"guilds": guild_count}, 71 | vote_url=( 72 | "https://discordbotlist.com/bots/realms-playerlist-bot/upvote" 73 | ), 74 | ) 75 | ) 76 | 77 | if not self.handlers: 78 | raise ValueError("No voting handlers were configured.") 79 | 80 | self.autopost_guild_count.start() 81 | 82 | async def re_shard_count(self) -> None: 83 | await self.bot.wait_until_ready() 84 | self.shard_count = len(self.bot.shards) 85 | 86 | def drop(self) -> None: 87 | self.autopost_guild_count.stop() 88 | super().drop() 89 | 90 | @ipy.Task.create(ipy.IntervalTrigger(minutes=30)) 91 | async def autopost_guild_count(self) -> None: 92 | server_count = self.bot.guild_count 93 | 94 | for handler in self.handlers: 95 | async with self.bot.session.post( 96 | f"{handler.base_url}{handler.data_url.format(bot_id=self.bot.user.id)}", 97 | json=handler.data_callback(server_count), 98 | headers=handler.headers, 99 | ) as r: 100 | try: 101 | r.raise_for_status() 102 | except aiohttp.ClientResponseError as e: 103 | await utils.error_handle(e) 104 | 105 | @ipy.slash_command( 106 | name="vote", 107 | description="Vote for the bot.", 108 | ) 109 | async def vote(self, ctx: utils.RealmContext) -> None: 110 | website_votes: list[str] = [ 111 | f"**{handler.name}** - {handler.vote_url.format(bot_id=self.bot.user.id)}" 112 | for handler in self.handlers 113 | if handler.vote_url 114 | ] 115 | await ctx.send( 116 | embeds=ipy.Embed( 117 | title="Vote for the bot", 118 | description="\n".join(website_votes), 119 | color=self.bot.color, 120 | timestamp=ctx.id.created_at, 121 | ) 122 | ) 123 | 124 | 125 | def setup(bot: utils.RealmBotBase) -> None: 126 | importlib.reload(utils) 127 | Voting(bot) 128 | -------------------------------------------------------------------------------- /license_header.txt: -------------------------------------------------------------------------------- 1 | Copyright 2020-2025 AstreaTSS. 2 | This file is part of the Realms Playerlist Bot. 3 | 4 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 5 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 6 | either version 3 of the License, or (at your option) any later version. 7 | 8 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 9 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 10 | PURPOSE. See the GNU Affero General Public License for more details. 11 | 12 | You should have received a copy of the GNU Affero General Public License along with the Realms 13 | Playerlist Bot. If not, see . -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import asyncio 18 | import contextlib 19 | import datetime 20 | import functools 21 | import logging 22 | import os 23 | import typing 24 | import uuid 25 | from collections import defaultdict 26 | 27 | import rpl_config 28 | 29 | rpl_config.load() 30 | 31 | logger = logging.getLogger("realms_bot") 32 | logger.setLevel(logging.INFO) 33 | handler = logging.FileHandler( 34 | filename=os.environ["LOG_FILE_PATH"], encoding="utf-8", mode="a" 35 | ) 36 | handler.setFormatter( 37 | logging.Formatter("%(asctime)s:%(levelname)s:%(name)s: %(message)s") 38 | ) 39 | logger.addHandler(handler) 40 | 41 | ipy_logger = logging.getLogger("interactions") 42 | ipy_logger.setLevel(logging.INFO) 43 | ipy_logger.addHandler(handler) 44 | 45 | import aiohttp 46 | import elytra 47 | import interactions as ipy 48 | import orjson 49 | import sentry_sdk 50 | import valkey.asyncio as aiovalkey 51 | from interactions.api.events.processors import Processor 52 | from interactions.api.gateway.state import ConnectionState 53 | from interactions.ext import prefixed_commands as prefixed 54 | from tortoise import Tortoise 55 | from tortoise.expressions import Q 56 | 57 | import common.classes as cclasses 58 | import common.help_tools as help_tools 59 | import common.models as models 60 | import common.utils as utils 61 | import db_settings 62 | 63 | if typing.TYPE_CHECKING: 64 | import discord_typings 65 | 66 | 67 | def default_sentry_filter( 68 | event: dict[str, typing.Any], hint: dict[str, typing.Any] 69 | ) -> typing.Optional[dict[str, typing.Any]]: 70 | if "log_record" in hint: 71 | record: logging.LogRecord = hint["log_record"] 72 | if "interactions" in record.name or "realms_bot" in record.name: 73 | # there are some logging messages that are not worth sending to sentry 74 | if ": 403" in record.message: 75 | return None 76 | if ": 404" in record.message: 77 | return None 78 | if record.message.startswith("Ignoring exception in "): 79 | return None 80 | if record.message.startswith("Unsupported channel type for "): 81 | # please shut up 82 | return None 83 | 84 | if "exc_info" in hint: 85 | exc_type, exc_value, tb = hint["exc_info"] 86 | if isinstance(exc_value, KeyboardInterrupt): 87 | # We don't need to report a ctrl+c 88 | return None 89 | return event 90 | 91 | 92 | class MyHookedTask(ipy.Task): 93 | def on_error_sentry_hook(self: ipy.Task, error: Exception) -> None: 94 | scope = sentry_sdk.Scope.get_current_scope() 95 | 96 | if isinstance(self.callback, functools.partial): 97 | scope.set_tag("task", self.callback.func.__name__) 98 | else: 99 | scope.set_tag("task", self.callback.__name__) 100 | 101 | scope.set_tag("iteration", self.iteration) 102 | sentry_sdk.capture_exception(error) 103 | 104 | 105 | # im so sorry 106 | if not utils.FEATURE("PRINT_TRACKBACK_FOR_ERRORS") and utils.SENTRY_ENABLED: 107 | ipy.Task.on_error_sentry_hook = MyHookedTask.on_error_sentry_hook 108 | sentry_sdk.init(dsn=os.environ["SENTRY_DSN"], before_send=default_sentry_filter) 109 | 110 | 111 | # ipy used to implement this, but strayed away from it 112 | # im adding it back in just in case 113 | async def basic_guild_check(ctx: ipy.SlashContext) -> bool: 114 | return True if ctx.command.dm_permission else ctx.guild_id is not None 115 | 116 | 117 | class RealmsPlayerlistBot(utils.RealmBotBase): 118 | @ipy.listen("startup") 119 | async def on_startup(self) -> None: 120 | # frankly, this event isn't needed anymore, 121 | # but too many things depend on fully_ready being set for me to remove it 122 | self.fully_ready.set() 123 | 124 | @ipy.listen("ready") 125 | async def on_ready(self) -> None: 126 | # dms bot owner on every ready noting if the bot is coming up or reconnecting 127 | utcnow = ipy.Timestamp.utcnow() 128 | time_format = f"" 129 | 130 | connect_msg = ( 131 | f"Logged in at {time_format}!" 132 | if self.init_load is True 133 | else f"Reconnected at {time_format}!" 134 | ) 135 | 136 | if not self.bot_owner: 137 | self.bot_owner = self.owner # type: ignore 138 | if not self.bot_owner: 139 | self.bot_owner = await self.fetch_user(self.app.owner_id) # type: ignore 140 | 141 | await self.bot_owner.send(connect_msg) 142 | 143 | self.init_load = False 144 | 145 | # good time to change splash text too 146 | activity = ipy.Activity( 147 | name="Splash Text", 148 | type=ipy.ActivityType.CUSTOM, 149 | state="Watching Realms | playerlist.astrea.cc", 150 | ) 151 | await self.change_presence(activity=activity) 152 | 153 | @ipy.listen("resume") 154 | async def on_resume_func(self) -> None: 155 | activity = ipy.Activity( 156 | name="Splash Text", 157 | type=ipy.ActivityType.CUSTOM, 158 | state="Watching Realms | playerlist.astrea.cc", 159 | ) 160 | await self.change_presence(activity=activity) 161 | 162 | # technically, this is in ipy itself now, but its easier for my purposes to do this 163 | @ipy.listen("raw_application_command_permissions_update") 164 | async def i_like_my_events_very_raw( 165 | self, event: ipy.events.RawGatewayEvent 166 | ) -> None: 167 | data: discord_typings.GuildApplicationCommandPermissionData = event.data # type: ignore 168 | 169 | guild_id = int(data["guild_id"]) 170 | 171 | if not self.slash_perms_cache[guild_id]: 172 | # process slash command permissions for this guild for the help command 173 | await help_tools.process_bulk_slash_perms(self, guild_id) 174 | return 175 | 176 | cmds = help_tools.get_commands_for_scope_by_ids(self, guild_id) 177 | if cmd := cmds.get(int(data["id"])): 178 | self.slash_perms_cache[guild_id][ 179 | int(data["id"]) 180 | ] = help_tools.PermissionsResolver( 181 | cmd.default_member_permissions, guild_id, data["permissions"] # type: ignore 182 | ) 183 | 184 | @ipy.listen(is_default_listener=True) 185 | async def on_error(self, event: ipy.events.Error) -> None: 186 | await utils.error_handle(event.error) 187 | 188 | @ipy.listen(ipy.events.ShardDisconnect) 189 | async def shard_disconnect(self, event: ipy.events.ShardDisconnect) -> None: 190 | # this usually means disconnect with an error, which is very unusual 191 | # thus, we should log this and attempt to restart 192 | try: 193 | await self.wait_for(ipy.events.Disconnect, timeout=1) 194 | except TimeoutError: 195 | return 196 | 197 | await self.bot_owner.send( 198 | f"Shard {event.shard_id} disconnected due to an error. Attempting restart." 199 | ) 200 | await asyncio.sleep(5) 201 | 202 | self._connection_states[event.shard_id] = ConnectionState( 203 | self, self.intents, event.shard_id 204 | ) 205 | self.create_task(self._connection_states[event.shard_id].start()) 206 | 207 | # guild related stuff so that no caching of guilds is even attempted 208 | # this code is cursed, im aware 209 | 210 | @ipy.listen() 211 | async def _on_websocket_ready(self, event: ipy.events.RawGatewayEvent) -> None: 212 | connection_data = event.data 213 | expected_guilds = {int(guild["id"]) for guild in connection_data["guilds"]} 214 | self.unavailable_guilds |= expected_guilds 215 | await super()._on_websocket_ready(self, event) 216 | 217 | @Processor.define() 218 | async def _on_raw_guild_create(self, event: "ipy.events.RawGatewayEvent") -> None: 219 | guild_id: int = int(event.data["id"]) 220 | new_guild = guild_id not in self.user._guild_ids 221 | self.unavailable_guilds.discard(guild_id) 222 | 223 | if new_guild: 224 | self.user._guild_ids.add(guild_id) 225 | self.dispatch(ipy.events.GuildJoin(guild_id)) 226 | else: 227 | self.dispatch(ipy.events.GuildAvailable(guild_id)) 228 | 229 | @Processor.define() 230 | async def _on_raw_guild_update(self, _: "ipy.events.RawGatewayEvent") -> None: 231 | # yes, this is funny, but we never use guild updates and it would only add 232 | # to our cache 233 | return 234 | 235 | @Processor.define() 236 | async def _on_raw_guild_delete(self, event: "ipy.events.RawGatewayEvent") -> None: 237 | guild_id: int = int(event.data["id"]) 238 | 239 | if event.data.get("unavailable", False): 240 | self.unavailable_guilds.add(guild_id) 241 | self.dispatch(ipy.events.GuildUnavailable(guild_id)) 242 | else: 243 | self.user._guild_ids.discard(guild_id) 244 | self.dispatch(ipy.events.GuildLeft(guild_id, None)) # type: ignore 245 | 246 | @Processor.define() 247 | async def _on_raw_message_create(self, event: "ipy.events.RawGatewayEvent") -> None: 248 | # needs to be custom defined otherwise it will try to cache the guild 249 | msg = self.cache.place_message_data(event.data) 250 | if not msg._guild_id and event.data.get("guild_id"): 251 | msg._guild_id = event.data["guild_id"] 252 | 253 | if not self.cache.get_channel(msg._channel_id): 254 | self.cache.channel_cache[ipy.to_snowflake(msg._channel_id)] = ( 255 | utils.partial_channel(self, msg._channel_id) 256 | ) 257 | 258 | self.dispatch(ipy.events.MessageCreate(msg)) 259 | 260 | def create_task(self, coro: typing.Coroutine) -> asyncio.Task: 261 | # see the "important" note below for why we do this (to prevent early gc) 262 | # https://docs.python.org/3/library/asyncio-task.html#asyncio.create_task 263 | task = asyncio.create_task(coro) 264 | self.background_tasks.add(task) 265 | task.add_done_callback(self.background_tasks.discard) 266 | return task 267 | 268 | def load_extension( 269 | self, name: str, package: str | None = None, **load_kwargs: typing.Any 270 | ) -> None: 271 | super().load_extension(name, package, **load_kwargs) 272 | 273 | # ipy forgets to do this lol 274 | if not self.sync_ext and self._ready.is_set(): 275 | self.create_task(self._cache_interactions(warn_missing=False)) 276 | 277 | def add_interaction(self, command: ipy.InteractionCommand) -> bool: 278 | result = super().add_interaction(command) 279 | if result and self.enforce_interaction_perms: 280 | command.checks.append(basic_guild_check) 281 | return result 282 | 283 | async def stop(self) -> None: 284 | await bot.openxbl_session.close() 285 | await bot.session.close() 286 | await bot.xbox.close() 287 | await bot.realms.close() 288 | await Tortoise.close_connections() 289 | await bot.valkey.aclose(close_connection_pool=True) 290 | 291 | return await super().stop() 292 | 293 | 294 | intents = ipy.Intents.new( 295 | guilds=True, 296 | messages=True, 297 | ) 298 | mentions = ipy.AllowedMentions.all() 299 | 300 | bot = RealmsPlayerlistBot( 301 | activity=ipy.Activity( 302 | name="Status", type=ipy.ActivityType.CUSTOM, state="Loading..." 303 | ), 304 | status=ipy.Status.IDLE, 305 | sync_interactions=False, # big bots really shouldn't have this on 306 | sync_ext=False, 307 | allowed_mentions=mentions, 308 | intents=intents, 309 | interaction_context=utils.RealmInteractionContext, 310 | slash_context=utils.RealmContext, 311 | component_context=utils.RealmComponentContext, 312 | modal_context=utils.RealmModalContext, 313 | context_menu_context=utils.RealmContextMenuContext, 314 | autocomplete_context=utils.RealmAutocompleteContext, 315 | auto_defer=ipy.AutoDefer(enabled=True, time_until_defer=0), 316 | # we do not need many of these 317 | message_cache=ipy.utils.TTLCache(10, 10, 50), 318 | role_cache=ipy.utils.TTLCache(60, 100, 200), 319 | channel_cache=ipy.utils.TTLCache(60, 200, 400), 320 | user_cache=ipy.utils.TTLCache(60, 200, 400), 321 | member_cache=ipy.utils.TTLCache(60, 200, 400), 322 | # do not need at all 323 | voice_state_cache=ipy.utils.NullCache(), 324 | user_guilds=ipy.utils.NullCache(), 325 | guild_cache=ipy.utils.NullCache(), 326 | dm_channels=ipy.utils.NullCache(), 327 | logger=logger, 328 | ) 329 | prefixed.setup(bot, prefixed_context=utils.RealmPrefixedContext) 330 | bot.guild_event_timeout = ( 331 | -1 332 | ) # internal variable to control how long to wait for each guild object, but we dont care for them 333 | bot.unavailable_guilds = set() 334 | bot.init_load = True 335 | bot.bot_owner = None # type: ignore 336 | bot.color = ipy.Color(int(os.environ["BOT_COLOR"])) # c156e0, aka 12670688 337 | bot.online_cache = defaultdict(set) 338 | bot.slash_perms_cache = defaultdict(dict) 339 | bot.live_playerlist_store = defaultdict(set) 340 | bot.player_watchlist_store = defaultdict(set) 341 | bot.uuid_cache = defaultdict(uuid.uuid4) 342 | bot.mini_commands_per_scope = {} 343 | bot.offline_realms = cclasses.OrderedSet() 344 | bot.dropped_offline_realms = set() 345 | bot.fetch_devices_for = set() 346 | bot.background_tasks = set() 347 | bot.blacklist = set() 348 | 349 | 350 | async def start() -> None: 351 | await Tortoise.init(db_settings.TORTOISE_ORM) 352 | 353 | bot.valkey = aiovalkey.Valkey.from_url( 354 | os.environ["VALKEY_URL"], 355 | decode_responses=True, 356 | ) 357 | 358 | if blacklist_raw := await bot.valkey.get("rpl-blacklist"): 359 | bot.blacklist = set(orjson.loads(blacklist_raw)) 360 | else: 361 | bot.blacklist = set() 362 | await bot.valkey.set("rpl-blacklist", orjson.dumps([])) 363 | 364 | # mark players as offline if they were online more than 5 minutes ago 365 | five_minutes_ago = ipy.Timestamp.utcnow() - datetime.timedelta(minutes=5) 366 | 367 | num_updated = await models.PlayerSession.filter( 368 | online=True, last_seen__lt=five_minutes_ago 369 | ).update(online=False) 370 | if num_updated > 0: 371 | async for config in models.GuildConfig.filter( 372 | live_online_channel__not_isnull=True 373 | ): 374 | # if we have a live online channel, we need to reset it 375 | await bot.valkey.hset(config.live_online_channel, "xuids", "") 376 | await bot.valkey.hset(config.live_online_channel, "gamertags", "") 377 | 378 | # add all online players to the online cache 379 | async for player in models.PlayerSession.filter(online=True): 380 | bot.uuid_cache[player.realm_xuid_id] = player.custom_id 381 | bot.online_cache[int(player.realm_id)].add(player.xuid) 382 | 383 | if utils.FEATURE("HANDLE_MISSING_REALMS"): 384 | async for realm_id in bot.valkey.scan_iter("missing-realm-*"): 385 | bot.offline_realms.add(int(realm_id.removeprefix("missing-realm-"))) 386 | 387 | async for config in models.GuildConfig.filter( 388 | playerlist_chan__not_isnull=True, 389 | realm_id__not_isnull=True, 390 | player_watchlist__not_isnull=True, 391 | ): 392 | # add all player watchlist players to the player watchlist store 393 | for player_xuid in config.player_watchlist: 394 | bot.player_watchlist_store[f"{config.realm_id}-{player_xuid}"].add( 395 | config.guild_id 396 | ) 397 | 398 | # add info for who has premium features on and has valid premium 399 | async for config in models.GuildConfig.filter( 400 | Q(premium_code__id__not_isnull=True) 401 | & Q( 402 | Q(premium_code__expires_at__isnull=True) 403 | | Q( 404 | premium_code__expires_at__gt=ipy.Timestamp.utcnow() 405 | - datetime.timedelta(days=1) 406 | ) 407 | ) 408 | & Q(realm_id__not_isnull=True) 409 | ).prefetch_related("premium_code"): 410 | if config.playerlist_chan and config.live_playerlist: 411 | bot.live_playerlist_store[config.realm_id].add(config.guild_id) # type: ignore 412 | if config.fetch_devices: 413 | bot.fetch_devices_for.add(config.realm_id) 414 | 415 | bot.fully_ready = asyncio.Event() 416 | bot.pl_sem = asyncio.Semaphore(12) # TODO: maybe increase this? 417 | 418 | bot.xbox = await elytra.XboxAPI.from_file( 419 | os.environ["XBOX_CLIENT_ID"], 420 | os.environ["XBOX_CLIENT_SECRET"], 421 | os.environ["XAPI_TOKENS_LOCATION"], 422 | ) 423 | bot.realms = await elytra.BedrockRealmsAPI.from_file( 424 | os.environ["XBOX_CLIENT_ID"], 425 | os.environ["XBOX_CLIENT_SECRET"], 426 | os.environ["XAPI_TOKENS_LOCATION"], 427 | ) 428 | bot.realms.BASE_URL = ( 429 | "https://bedrock.frontendlegacy.realms.minecraft-services.net/" 430 | ) 431 | bot.own_gamertag = bot.xbox.auth_mgr.xsts_token.gamertag 432 | 433 | headers = { 434 | "X-Authorization": os.environ["OPENXBL_KEY"], 435 | "Accept": "application/json", 436 | "Accept-Language": "en-US", 437 | } 438 | bot.openxbl_session = aiohttp.ClientSession( 439 | headers=headers, 440 | response_class=cclasses.BetterResponse, 441 | json_serialize=lambda x: orjson.dumps(x).decode(), 442 | ) 443 | bot.session = aiohttp.ClientSession( 444 | response_class=cclasses.BetterResponse, 445 | json_serialize=lambda x: orjson.dumps(x).decode(), 446 | ) 447 | 448 | ext_list = utils.get_all_extensions(os.environ["DIRECTORY_OF_BOT"]) 449 | for ext in ext_list: 450 | # skip loading voting ext if token doesn't exist 451 | if "voting" in ext and not utils.VOTING_ENABLED: 452 | continue 453 | 454 | if not utils.FEATURE("AUTORUNNER") and "autorun" in ext: 455 | continue 456 | 457 | if not utils.FEATURE("ETC_EVENTS") and "etc" in ext: 458 | continue 459 | 460 | try: 461 | bot.load_extension(ext) 462 | except ipy.errors.ExtensionLoadException: 463 | raise 464 | 465 | with contextlib.suppress(asyncio.CancelledError): 466 | await bot.astart(os.environ["MAIN_TOKEN"]) 467 | 468 | 469 | if __name__ == "__main__": 470 | run_method = asyncio.run 471 | 472 | # use uvloop if possible 473 | with contextlib.suppress(ImportError): 474 | import uvloop # type: ignore 475 | 476 | run_method = uvloop.run 477 | 478 | if os.environ.get("DOCKER_MODE") == "True" and utils.FEATURE( 479 | "RUN_MIGRATIONS_AUTOMATICALLY" 480 | ): 481 | import subprocess 482 | import sys 483 | 484 | subprocess.run( 485 | [sys.executable, "-m", "aerich", "upgrade"], 486 | check=True, 487 | ) 488 | 489 | run_method(start()) 490 | -------------------------------------------------------------------------------- /migrations/models/5_20250506001949_None.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | from tortoise import BaseDBAsyncClient 18 | 19 | 20 | async def upgrade(_: BaseDBAsyncClient) -> str: 21 | return """ 22 | CREATE TABLE IF NOT EXISTS "aerich" ( 23 | "id" SERIAL NOT NULL PRIMARY KEY, 24 | "version" VARCHAR(255) NOT NULL, 25 | "app" VARCHAR(100) NOT NULL, 26 | "content" JSONB NOT NULL 27 | );""" 28 | 29 | 30 | async def downgrade(_: BaseDBAsyncClient) -> str: 31 | return """ 32 | """ 33 | -------------------------------------------------------------------------------- /migrations/models/6_20250506005003_create_dbs.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | from tortoise import BaseDBAsyncClient 18 | 19 | 20 | async def upgrade(_: BaseDBAsyncClient) -> str: 21 | return """ 22 | CREATE TABLE IF NOT EXISTS "realmpremiumcode" ( 23 | "id" SERIAL NOT NULL PRIMARY KEY, 24 | "code" VARCHAR(100) NOT NULL, 25 | "user_id" INT NOT NULL, 26 | "uses" INT NOT NULL DEFAULT 0, 27 | "max_uses" INT NOT NULL DEFAULT 2, 28 | "customer_id" VARCHAR(50), 29 | "expires_at" TIMESTAMPTZ 30 | ); 31 | CREATE TABLE IF NOT EXISTS "realmguildconfig" ( 32 | "guild_id" SERIAL NOT NULL PRIMARY KEY, 33 | "club_id" VARCHAR(50), 34 | "playerlist_chan" INT NOT NULL, 35 | "realm_id" VARCHAR(50), 36 | "live_playerlist" BOOL NOT NULL DEFAULT False, 37 | "realm_offline_role" INT NOT NULL, 38 | "warning_notifications" BOOL NOT NULL DEFAULT True, 39 | "fetch_devices" BOOL NOT NULL DEFAULT False, 40 | "live_online_channel" VARCHAR(75), 41 | "player_watchlist_role" INT NOT NULL, 42 | "player_watchlist" TEXT[], 43 | "notification_channels" JSONB NOT NULL, 44 | "reoccurring_leaderboard" INT NOT NULL, 45 | "nicknames" JSONB NOT NULL, 46 | "premium_code_id" INT REFERENCES "realmpremiumcode" ("id") ON DELETE SET NULL 47 | ); 48 | CREATE TABLE IF NOT EXISTS "realmplayersession" ( 49 | "custom_id" UUID NOT NULL PRIMARY KEY, 50 | "realm_id" VARCHAR(50) NOT NULL, 51 | "xuid" VARCHAR(50) NOT NULL, 52 | "online" BOOL NOT NULL DEFAULT False, 53 | "last_seen" TIMESTAMPTZ NOT NULL, 54 | "joined_at" TIMESTAMPTZ 55 | ); 56 | """ 57 | 58 | 59 | async def downgrade(_: BaseDBAsyncClient) -> str: 60 | return """ 61 | DROP TABLE IF EXISTS "realmguildconfig"; 62 | DROP TABLE IF EXISTS "realmplayersession"; 63 | DROP TABLE IF EXISTS "realmpremiumcode";""" 64 | -------------------------------------------------------------------------------- /migrations/models/7_20250506115832_adjust_columns.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | from tortoise import BaseDBAsyncClient 18 | 19 | 20 | async def upgrade(_: BaseDBAsyncClient) -> str: 21 | return """ 22 | ALTER TABLE "realmguildconfig" ALTER COLUMN "guild_id" TYPE BIGINT USING "guild_id"::BIGINT; 23 | ALTER TABLE "realmguildconfig" ALTER COLUMN "reoccurring_leaderboard" DROP NOT NULL; 24 | ALTER TABLE "realmguildconfig" ALTER COLUMN "playerlist_chan" TYPE BIGINT USING "playerlist_chan"::BIGINT; 25 | ALTER TABLE "realmguildconfig" ALTER COLUMN "realm_offline_role" DROP NOT NULL; 26 | ALTER TABLE "realmguildconfig" ALTER COLUMN "realm_offline_role" TYPE BIGINT USING "realm_offline_role"::BIGINT; 27 | ALTER TABLE "realmguildconfig" ALTER COLUMN "player_watchlist_role" DROP NOT NULL; 28 | ALTER TABLE "realmguildconfig" ALTER COLUMN "player_watchlist_role" TYPE BIGINT USING "player_watchlist_role"::BIGINT; 29 | ALTER TABLE "realmpremiumcode" ALTER COLUMN "user_id" DROP NOT NULL; 30 | ALTER TABLE "realmpremiumcode" ALTER COLUMN "user_id" TYPE BIGINT USING "user_id"::BIGINT;""" 31 | 32 | 33 | async def downgrade(_: BaseDBAsyncClient) -> str: 34 | return """ 35 | ALTER TABLE "realmguildconfig" ALTER COLUMN "guild_id" TYPE INT USING "guild_id"::INT; 36 | ALTER TABLE "realmguildconfig" ALTER COLUMN "reoccurring_leaderboard" SET NOT NULL; 37 | ALTER TABLE "realmguildconfig" ALTER COLUMN "playerlist_chan" TYPE INT USING "playerlist_chan"::INT; 38 | ALTER TABLE "realmguildconfig" ALTER COLUMN "realm_offline_role" TYPE INT USING "realm_offline_role"::INT; 39 | ALTER TABLE "realmguildconfig" ALTER COLUMN "realm_offline_role" SET NOT NULL; 40 | ALTER TABLE "realmguildconfig" ALTER COLUMN "player_watchlist_role" TYPE INT USING "player_watchlist_role"::INT; 41 | ALTER TABLE "realmguildconfig" ALTER COLUMN "player_watchlist_role" SET NOT NULL; 42 | ALTER TABLE "realmpremiumcode" ALTER COLUMN "user_id" TYPE INT USING "user_id"::INT; 43 | ALTER TABLE "realmpremiumcode" ALTER COLUMN "user_id" SET NOT NULL;""" 44 | -------------------------------------------------------------------------------- /migrations/models/8_20250507205909_playerlist_chan_null.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | from tortoise import BaseDBAsyncClient 18 | 19 | 20 | async def upgrade(db: BaseDBAsyncClient) -> str: 21 | return """ 22 | ALTER TABLE "realmguildconfig" ALTER COLUMN "playerlist_chan" DROP NOT NULL;""" 23 | 24 | 25 | async def downgrade(db: BaseDBAsyncClient) -> str: 26 | return """ 27 | ALTER TABLE "realmguildconfig" ALTER COLUMN "playerlist_chan" SET NOT NULL;""" 28 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.setuptools] 2 | packages = [] 3 | 4 | [tool.ruff] 5 | line-length = 88 6 | select = [ 7 | "E", 8 | "F", 9 | "UP", 10 | "N", 11 | "YTT", 12 | "ANN", 13 | "S", 14 | "B", 15 | "A", 16 | "C4", 17 | "T20", 18 | "RET", 19 | "SIM", 20 | "I", 21 | "ASYNC", 22 | "N", 23 | "UP", 24 | "DTZ", 25 | "G", 26 | "PIE", 27 | "TCH", 28 | "ARG", 29 | "RUF", 30 | ] 31 | ignore = [ 32 | "E501", 33 | "E402", 34 | "B003", 35 | "ANN101", 36 | "ANN102", 37 | "RET506", 38 | "ANN401", 39 | "B008", 40 | "N806", 41 | "A003", 42 | "N818", 43 | "UP007", 44 | "SIM118", 45 | "RET502", 46 | "RET503", 47 | "SIM114", 48 | "S603", 49 | "S607", 50 | "SIM117", 51 | ] 52 | exclude = [ 53 | ".bzr", 54 | ".direnv", 55 | ".eggs", 56 | ".git", 57 | ".hg", 58 | ".mypy_cache", 59 | ".nox", 60 | ".pants.d", 61 | ".ruff_cache", 62 | ".svn", 63 | ".tox", 64 | ".venv", 65 | "__pypackages__", 66 | "_build", 67 | "buck-out", 68 | "build", 69 | "dist", 70 | "node_modules", 71 | "venv", 72 | "migrations" 73 | ] 74 | dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 75 | target-version = "py312" 76 | fix = true 77 | 78 | [tool.ruff.mccabe] 79 | max-complexity = 10 80 | 81 | [tool.ruff.lint.per-file-ignores] 82 | "tests/**.py" = [ 83 | "S101", 84 | ] 85 | 86 | [tool.aerich] 87 | tortoise_orm = "db_settings.TORTOISE_ORM" 88 | location = "./migrations" 89 | src_folder = "./." 90 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp==3.11.18 2 | discord-py-interactions[speedup]==5.14.0 3 | tansy==0.9.2 4 | humanize==4.12.3 5 | aiodns==3.4.0 6 | attrs==25.3.0 7 | pydantic==2.11.4 8 | git+https://github.com/MarkusSintonen/httpcore.git@e724565270603a72d11f6d3bfc09671ffcfe36f2 9 | httpx[http2]==0.28.1 10 | tortoise-orm[asyncpg]==0.25.0 11 | aerich==0.9.0 12 | pypika==0.48.9 13 | valkey[libvalkey]==6.1.0 14 | orjson==3.10.18 15 | sentry-sdk==2.28.0 16 | rapidfuzz==3.13.0 17 | pycryptodome==3.22.0 18 | msgspec==0.19.0 19 | elytra-ms==0.7.3 20 | python-dotenv==1.1.0 21 | uvloop==0.21.0; platform_system == "Linux" 22 | -------------------------------------------------------------------------------- /rpl_config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import os 18 | import tomllib 19 | from pathlib import Path 20 | 21 | import orjson 22 | from dotenv import load_dotenv 23 | 24 | IS_LOADED = False 25 | 26 | 27 | def is_loaded() -> bool: 28 | return IS_LOADED 29 | 30 | 31 | def set_loaded() -> None: 32 | global IS_LOADED 33 | IS_LOADED = True 34 | 35 | 36 | def load() -> None: 37 | if is_loaded(): 38 | return 39 | 40 | load_dotenv() 41 | 42 | # load the config file into environment variables 43 | # this allows an easy way to access these variables from any file 44 | # we allow the user to set a configuration location via an already-set 45 | # env var if they wish, but it'll default to config.toml in the running 46 | # directory 47 | CONFIG_LOCATION = os.environ.get("CONFIG_LOCATION", "config.toml") 48 | with open(CONFIG_LOCATION, "rb") as f: 49 | toml_dict = tomllib.load(f) 50 | for key, value in toml_dict.items(): 51 | if key == "DEBUG": 52 | os.environ[key] = orjson.dumps(value).decode() 53 | else: 54 | os.environ[key] = str(value) 55 | 56 | if os.environ.get("DOCKER_MODE", "False") == "True": 57 | os.environ["DB_URL"] = ( 58 | f"postgres://postgres:{os.environ['POSTGRES_PASSWORD']}@db:5432/postgres" 59 | ) 60 | os.environ["VALKEY_URL"] = "redis://redis:6379?protocol=3" 61 | 62 | if not os.environ.get("VALKEY_URL") and os.environ.get("REDIS_URL"): 63 | os.environ["VALKEY_URL"] = os.environ["REDIS_URL"] 64 | 65 | file_location = Path(__file__).parent.absolute().as_posix() 66 | os.environ["DIRECTORY_OF_BOT"] = file_location 67 | os.environ["LOG_FILE_PATH"] = f"{file_location}/discord.log" 68 | os.environ["XAPI_TOKENS_LOCATION"] = f"{file_location}/tokens.json" 69 | 70 | set_loaded() 71 | -------------------------------------------------------------------------------- /tests/common/test_stats_utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | 17 | import stats_utils_models 18 | 19 | import common.stats_utils as stats_utils 20 | 21 | 22 | def test_get_minutes_per_day() -> None: 23 | results = stats_utils.get_minutes_per_day(stats_utils_models.TEST_DATETIMES) 24 | assert results == stats_utils_models.MINUTES_PER_DAY_RESULTS 25 | 26 | 27 | def test_get_minutes_per_hour() -> None: 28 | results = stats_utils.get_minutes_per_hour(stats_utils_models.TEST_DATETIMES) 29 | assert results == stats_utils_models.MINUTES_PER_HOUR_RESULTS 30 | 31 | 32 | def test_timespan_minutes_per_hour() -> None: 33 | results = stats_utils.timespan_minutes_per_hour(stats_utils_models.TEST_DATETIMES) 34 | assert results == stats_utils_models.TIMESPAN_MINUTES_PER_HOUR_RESULTS 35 | 36 | 37 | def test_timespan_minutes_per_day_of_the_week() -> None: 38 | results = stats_utils.timespan_minutes_per_day_of_the_week( 39 | stats_utils_models.TEST_DATETIMES 40 | ) 41 | assert results == stats_utils_models.TIMESPAN_MINUTES_PER_DAY_OF_THE_WEEK_RESULTS 42 | 43 | 44 | def test_calc_leaderboard() -> None: 45 | results = stats_utils.calc_leaderboard(stats_utils_models.TEST_DATETIMES) 46 | assert results == stats_utils_models.CALC_LEADERBOARD_RESULTS 47 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2020-2025 AstreaTSS. 3 | This file is part of the Realms Playerlist Bot. 4 | 5 | The Realms Playerlist Bot is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free Software Foundation, 7 | either version 3 of the License, or (at your option) any later version. 8 | 9 | The Realms Playerlist Bot is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR 11 | PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along with the Realms 14 | Playerlist Bot. If not, see . 15 | """ 16 | --------------------------------------------------------------------------------