├── .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 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
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 |
--------------------------------------------------------------------------------