π A fun Telegram bot that crowns a random friend as the day's entertainer based on weighted randomization. All hail the royal jester! πͺ
Court Jester Selector Bot is a Telegram group bot that picks a "jester of the day" from the group's members using a per-player weighted random draw. It is aimed at friend-group chats that want a playful daily ritual without anyone picking by hand.
Each Telegram group the bot joins becomes a Group row; every user the bot observes in that group is tracked as a Player with an integer weight; and every /crown_the_jester invocation produces one Draw row. A unique constraint on (group_id, draw_date) guarantees at most one jester per group per calendar day, where the day rolls over at midnight in the IANA timezone set by DRAW_TIMEZONE (default UTC). Groups must be approved (by a configurable set of admin Telegram user IDs) before any interactive command is honored.
The bot is async-first (python-telegram-bot 22 + SQLModel over asyncpg), observes itself via Sentry, and is shipped as a Docker image that runs Alembic migrations on every start.
- Weighted random daily draw, idempotent per group per day via a
UniqueConstraint("group_id", "draw_date")on thedrawtable. - Group approval workflow driven by
TG_BOT_ADMIN_RIGHTS_CHAT_MEMBER_STATUSandTG_BOT_ADMIN_RIGHTS_USER_IDS, with per-admin approval prompts persisted on thegrouprow. - Per-player weight tuning by group admins, clamped at startup and write time into
[MIN_WEIGHT, MAX_WEIGHT]. - Default commands
/crown_the_jester,/court_leaderboard, and/my_jester_statsβ all renamable via environment variables. - Every bot-visible message is an env-overridable template with validated
{username}/{rank}/{draw_count}/{min_players}placeholders. - Sentry integration with bot-token scrubbing and structured audit logging in
observability.py. - Schema managed by Alembic; migrations are applied on container start by
docker-entrypoint.sh. - Fully containerized deployment (Dockerfile +
docker-compose.yml) with an Ansible skeleton underplaybooks/androles/.
- Python 3.13.13 or newer (
requires-python = ">=3.13.13"in pyproject.toml). - PostgreSQL 18 (the reference stack pins
postgres:18.3-trixiein docker-compose.yml). - Docker and Docker Compose for the supported deployment workflow.
uvfor local development (the project is locked withuv.lock).- A Telegram bot token from @BotFather.
This package is not published to PyPI. Use one of the two supported workflows below.
git clone git@github.com:j-about/Court-Jester-Selector-Bot.git
cd Court-Jester-Selector-Bot
cp .env.example .env
# edit .env and fill in the Required section (token + Postgres credentials)
docker compose up -d --builddocker compose up builds the bot image from the Dockerfile (nonroot uid:gid 999:999, uv sync --locked), starts a healthchecked Postgres, and on every bot start runs alembic upgrade head before python main.py begins long-polling.
git clone git@github.com:j-about/Court-Jester-Selector-Bot.git
cd Court-Jester-Selector-Bot
uv sync # installs runtime deps and the `dev` group
cp .env.example .env # fill in the Required section; point POSTGRES_HOST at your DB
uv run alembic upgrade head
uv run court-jester-bot # equivalent to: uv run python main.pyThe court-jester-bot console script is declared in pyproject.toml and maps to main:main.
-
Create and configure the bot with @BotFather. Message @BotFather and run
/newbotto obtain a token forTG_BOT_TOKEN. Then, on the same bot, run/setprivacyand choose Disable so the bot receives every message posted in the groups it joins. Privacy mode must be off for the auto-enrollment model described in the Overview to work β with privacy on, the bot would only see commands and @mentions and could never register silent members asPlayerrows. -
Configure the environment. Copy
.env.exampleto.envand fill in at minimumTG_BOT_TOKEN,POSTGRES_DB,POSTGRES_USER, andPOSTGRES_PASSWORD. To bypass approval for yourself, put your numeric Telegram user id intoTG_BOT_ADMIN_RIGHTS_USER_IDS. LeavingTG_BOT_ADMIN_RIGHTS_USER_IDSempty disables the approval workflow entirely and auto-approves every group the bot is added to β convenient for single-operator or private deployments, but unsafe on a bot that can be added to arbitrary groups. Every Telegram user ID you put intoTG_BOT_ADMIN_RIGHTS_USER_IDSmust have privately messaged the bot at least once (open the bot's chat and tap Start) before the bot is added to its first group; Telegram refuses direct messages from a bot to a user who has never initiated the conversation, and the group-approval prompt would be silently dropped for that admin (see the swallowedTelegramErrorinhandlers/lifecycle.py). -
Start the stack.
docker compose up -d --build
-
Add the bot to a Telegram group. On join, the bot posts an approval request to each configured admin. Once any admin approves, the group becomes interactive.
-
Crown a jester. In the approved group, any member sends:
/crown_the_jesterExpected reply (default template):
πͺ By royal decree, @alice is hereby appointed as today's Royal Entertainer! The throne awaits your foolery! π
Sending /crown_the_jester a second time on the same calendar day returns the already-crowned player rather than re-rolling.
The three default commands below are registered for approved groups only. Every name and message below is configurable; the values shown are the shipping defaults.
/crown_the_jester
Picks one Player at random, weighted by each player's weight field (default 3, range [MIN_WEIGHT, MAX_WEIGHT]). The group must have at least MIN_PLAYERS (default 10) registered members; otherwise the bot replies with NOT_ENOUGH_PLAYERS_MESSAGE. The result is written to the draw table; re-issuing the command the same day is a no-op that echoes the existing winner.
/court_leaderboard
/my_jester_stats
/court_leaderboard prints a paginated list of players ordered by their all-time draw count, using LEADERBOARD_INTRO_MESSAGE, LEADERBOARD_RANK_MESSAGE, and LEADERBOARD_OUTRO_MESSAGE. /my_jester_stats shows the caller's own draw count and rank, falling back to PERSONAL_STATS_NO_PICKED_PLAYER_MESSAGE when they have never been crowned.
Group admins (as defined by TG_BOT_ADMIN_RIGHTS_CHAT_MEMBER_STATUS, and anyone listed in TG_BOT_ADMIN_RIGHTS_USER_IDS) interact with the admin handler registered by handlers/admin.py to adjust individual players' weight values. The bot validates every new weight against [MIN_WEIGHT, MAX_WEIGHT] and also clamps all stored weights back into that range at startup (see clamp_player_weights in queries.py).
All configuration flows through the Settings class in config.py, which is a pydantic-settings model that loads variables from the process environment and from a .env file at the working directory. Unknown variables are ignored. A complete, commented template lives in .env.example.
| Name | Type | Default | Description |
|---|---|---|---|
TG_BOT_TOKEN |
string | β | Telegram bot API token from @BotFather. |
POSTGRES_DB |
string | β | Database name. |
POSTGRES_USER |
string | β | Database user. |
POSTGRES_PASSWORD |
secret string | β | Database password. |
| Name | Type | Default | Description |
|---|---|---|---|
POSTGRES_HOST |
string | db |
Hostname of the Postgres server (defaults to the compose service name). |
POSTGRES_PORT |
int | 5432 |
Postgres port. |
| Name | Type | Default | Description |
|---|---|---|---|
TG_BOT_ADMIN_RIGHTS_CHAT_MEMBER_STATUS |
CSV of creator|administrator |
creator,administrator |
Which Telegram chat-member statuses confer admin powers inside a group. |
TG_BOT_ADMIN_RIGHTS_USER_IDS |
CSV of positive ints | empty | Telegram user IDs that are always treated as bot admins and receive per-group approval prompts. If left empty, every newly-joined group is auto-approved (no approval workflow runs); set this to at least one trusted Telegram user ID to keep human gating in place. Each listed admin must have opened a private chat with the bot and pressed /start before the bot is added to any group β otherwise Telegram will refuse the DM and that admin will not receive the approval prompt. |
| Name | Type | Default | Description |
|---|---|---|---|
MIN_WEIGHT |
int (β₯ 0) | 1 |
Lower bound for a player's draw weight. |
MAX_WEIGHT |
int (β₯ MIN_WEIGHT) |
5 |
Upper bound for a player's draw weight. |
DEFAULT_WEIGHT |
int (within [MIN_WEIGHT, MAX_WEIGHT]) |
3 |
Weight assigned to newly observed players. |
GROUPS_PER_PAGE |
int (β₯ 1) | 5 |
Page size for the admin group listing. |
PLAYERS_PER_PAGE |
int (β₯ 1) | 5 |
Page size for the admin player listing. |
MIN_PLAYERS |
int (β₯ 2) | 10 |
Minimum player count before draws/leaderboard/stats are allowed. |
Each command name must match ^[a-z][a-z0-9_]{0,31}$.
| Name | Type | Default | Description |
|---|---|---|---|
PICK_PLAYER_COMMAND |
string | crown_the_jester |
Slash command that performs the daily draw. |
PICK_PLAYER_COMMAND_DESCRIPTION |
string | Crown today's jester. |
Help text shown by Telegram. |
SHOW_LEADERBOARD_COMMAND |
string | court_leaderboard |
Slash command that prints the leaderboard. |
SHOW_LEADERBOARD_COMMAND_DESCRIPTION |
string | View the court rankings. |
Help text shown by Telegram. |
SHOW_PERSONAL_STATS_COMMAND |
string | my_jester_stats |
Slash command that prints the caller's stats. |
SHOW_PERSONAL_STATS_COMMAND_DESCRIPTION |
string | Check your jester stats. |
Help text shown by Telegram. |
All templates default to the values in config.py and support the placeholders shown below. Placeholders marked required are validated at load time; omitting them raises a ValueError before the bot starts.
| Name | Required placeholders | Optional placeholders |
|---|---|---|
NON_APPROVED_GROUP_MESSAGE |
β | β |
NOT_ENOUGH_PLAYERS_MESSAGE |
β | {min_players} |
PICK_PLAYER_PICKED_PLAYER_MESSAGE |
{username} |
β |
LEADERBOARD_INTRO_MESSAGE |
β | β |
LEADERBOARD_RANK_MESSAGE |
{rank}, {username} |
{draw_count} |
LEADERBOARD_OUTRO_MESSAGE |
β | β |
LEADERBOARD_NOT_ENOUGH_PICKED_PLAYERS_MESSAGE |
β | β |
PERSONAL_STATS_MESSAGE |
{draw_count} |
{username}, {rank} |
PERSONAL_STATS_NO_PICKED_PLAYER_MESSAGE |
β | {username} |
| Name | Type | Default | Description |
|---|---|---|---|
DRAW_TIMEZONE |
IANA tz name | UTC |
Timezone whose midnight defines the rollover between one draw_date and the next. Any name accepted by zoneinfo.ZoneInfo works (e.g. Europe/Paris, America/New_York); invalid names fail fast at startup. The configured zone is also emitted on every draw.execution audit record alongside the UTC instant of the decision. |
| Name | Type | Default | Description |
|---|---|---|---|
SENTRY_DSN |
string | (unset) | If set, errors and audit events are reported via the Sentry SDK. The bot token is scrubbed from outgoing events by observability.py. |
Local development uses uv end-to-end. uv sync installs both the runtime dependencies and the dev dependency group (pytest, pytest-asyncio, pytest-cov, ruff, ty).
uv sync # install runtime + dev deps
uv run ruff check . # lint
uv run ruff format . # format
uv run ty check # type-check (see scope in pyproject.toml)
uv run pytest # run the suite (coverage is on by default)
uv run alembic revision --autogenerate -m "describe it" # create a new migration
uv run alembic upgrade head # apply migrations to the configured DBRuff is configured with line-length = 120, target-version = "py313", and rule set E, F, W, I, UP, B, SIM, RUF (see pyproject.toml). ty is Astral's type checker and is currently in alpha (0.0.31); its configured scope is the top-level modules plus the handlers/ package.
The suite lives under tests/ and is discovered by pytest via testpaths = ["tests"]. asyncio_mode = "auto" is set globally, so async def test_* functions need no decorator. Coverage is wired into pytest's default addopts, so uv run pytest already enforces the 85 % floor over config, models, database, queries, observability, handlers, and utils:
uv run pytest # terminal coverage + 85% gate
uv run pytest --cov-report=html -- tests/some_file # HTML report at htmlcov/index.htmlThe suite covers the settings model, handlers, queries, and the observability module. main.py and the Alembic migration folder are explicitly omitted from coverage β see [tool.coverage.run] in pyproject.toml.
The shipping deployment path is Docker Compose. The Dockerfile uses the ghcr.io/astral-sh/uv:python3.13-trixie-slim base image, runs as a dedicated nonroot user, and installs a locked, pre-compiled bytecode environment via uv sync --locked. On container start, docker-entrypoint.sh runs alembic upgrade head before execing the configured CMD (python main.py).
An Ansible skeleton is present for server provisioning β see inventory.yaml, playbooks/cjsb_deployment.yaml, and the role under roles/cjsb_deployment/. The inventory reads HOST_IP, HOST_USER, and HOST_SSH_PRIVATE_KEY_FILE from the environment, mirroring the Deployment section of .env.example.
Released under the MIT License, Β© 2026 Jonathan About.