master 7720d65ee908 cached
32 files
31.1 KB
8.9k tokens
31 symbols
1 requests
Download .txt
Repository: MasterGroosha/telegram-casino-bot
Branch: master
Commit: 7720d65ee908
Files: 32
Total size: 31.1 KB

Directory structure:
gitextract_t9fz8z6y/

├── .dockerignore
├── .gitignore
├── .python-version
├── Dockerfile
├── LICENSE
├── README.md
├── README.ru.md
├── bot/
│   ├── __init__.py
│   ├── __main__.py
│   ├── config_reader.py
│   ├── dice_check.py
│   ├── filters/
│   │   ├── __init__.py
│   │   └── spin_text_filter.py
│   ├── fluent_loader.py
│   ├── handlers/
│   │   ├── __init__.py
│   │   ├── default_commands.py
│   │   └── spin.py
│   ├── keyboards.py
│   ├── locale/
│   │   ├── current/
│   │   │   └── README.md
│   │   └── example/
│   │       ├── README.md
│   │       ├── README.ru.md
│   │       ├── en/
│   │       │   └── strings.ftl
│   │       └── ru/
│   │           └── strings.ftl
│   ├── logs.py
│   ├── middlewares/
│   │   ├── __init__.py
│   │   └── throttling.py
│   └── ui_commands.py
├── casino-bot.example.service
├── docker-compose.example.yml
├── pyproject.toml
├── redis.example.conf
└── settings.example.toml

================================================
FILE CONTENTS
================================================

================================================
FILE: .dockerignore
================================================
repo_images/
redis.example.conf
settings.example.toml
.git/
.idea/
.vscode/
__pycache__
*.pyc

================================================
FILE: .gitignore
================================================
.idea/
/venv/
/.venv/
.vscode/
*.ipynb
docker-compose.yml
.env
/redis.conf
/settings.toml


================================================
FILE: .python-version
================================================
3.11


================================================
FILE: Dockerfile
================================================
## Build stage
FROM ghcr.io/astral-sh/uv:bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
ENV UV_PYTHON_INSTALL_DIR=/python UV_PYTHON_PREFERENCE=only-managed
RUN uv python install 3.11
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-install-project --no-dev --no-editable
COPY ./bot /app/bot
RUN --mount=type=cache,target=/root/.cache/uv \
    uv sync --frozen --no-dev --no-editable


## Final stage
FROM gcr.io/distroless/python3-debian12:nonroot
COPY --from=builder --chown=nonroot:nonroot /python /python
WORKDIR /app
COPY --from=builder --chown=nonroot:nonroot /app/ /app

ENV PYTHONPATH="/app/.venv/lib/python3.11/site-packages:$PYTHONPATH"
CMD ["-m", "bot"]


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2020-present Aleksandr (aka MasterGroosha on GitHub)

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
[<img src="https://img.shields.io/badge/Telegram-%40DifichentoBot-blue">](https://t.me/DifichentoBot) (Ru)

> 🇷🇺 README на русском доступен [здесь](README.ru.md)

# Telegram Virtual Casino

In October 2020 Telegram team released [yet another update](https://telegram.org/blog/pinned-messages-locations-playlists) 
with slot machine dice. Here it is:

![slot machine dice](repo_images/slot_machine.png)

According to [Dice type documentation](https://core.telegram.org/bots/api#dice) in Bot API, slot machine 
emits values 1 to 64. In [dice_check.py](bot/dice_check.py) file you can find all the logic regarding 
matching dice integer value with visual three-icons representation. There is also a test bot [@DifichentoBot](https://t.me/difichentobot) 
in Russian to test how it works.  
Dice are generated on Telegram server-side, you your bot cannot affect the result.

## Technology

* [aiogram](https://github.com/aiogram/aiogram) — asyncio Telegram Bot API framework;
* [redis](https://redis.io) — persistent data storage (persistency enabled separately);
* [cachetools](https://cachetools.readthedocs.io/en/stable) — for anti-flood throttling mechanism;
* [Docker](https://www.docker.com) and [Docker-Compose](https://docs.docker.com/compose) — quickly deploy bot in containers.
* Systemd

## Installation

Copy `settings.example.toml` file to `settings.toml`, open and edit it.  
To change bot's language, overwrite `bot/locale/current` directory contents with a chosen language. For example, you want 
to use English language in your bot. Then, in `bot/locale/current` create a directory named `en` and place your `.ftl` 
files inside. Check [this directory](bot/locales/example) for samples. 
Please note that only one language can be active at a time.

Finally, run the bot with `docker-compose --profile "all" up -d` command.

Alternative way: you can use Systemd services, there is also an [example](casino-bot.example.service) available.

## Credits to

* [@Tishka17](https://t.me/Tishka17) for initial inspiration
* [@svinerus](https://t.me/svinerus) for compact dice combination check (f6f42a841d3c1778f0e32)


## Note on versioning

For most of my Telegram bots, I plan to use Calendar Versioning with the following rules:

* Versions should look like `vAAAA.BB.C`, where:
* * `vAAAA` is the letter "v" followed by the 4-digit year of release, e.g., `v2025`.
* * `BB` is the 2-digit month number, e.g., `06` for June.
* * `C` is the release number for that month, not zero-padded, e.g., 1 for the first release in June.
For example, the first release to use the new versioning schema will be tagged as `v2025.06.1`.

This scheme makes it easier to understand which Bot API features might be supported in a given release and which are definitely not.

================================================
FILE: README.ru.md
================================================
[<img src="https://img.shields.io/badge/Telegram-%40DifichentoBot-blue">](https://t.me/DifichentoBot)

# Виртуальное казино в Telegram

В конце октября 2020 года команда Telegram выпустила [очередное обновление](https://telegram.org/blog/pinned-messages-locations-playlists/ru?ln=a) 
мессенджера с поддержкой дайса игрового автомата. Вот он:

![игровой автомат](repo_images/slot_machine.png)

Согласно [документации на тип Dice](https://core.telegram.org/bots/api#dice) в Bot API, слот-машина 
может принимать значения от 1 до 64 включительно. В файле [dice_check.py](bot/dice_check.py) вы найдёте функции 
для сопоставления значения дайса с тройкой выпавших элементов игрового автомата. 
Для демонстрации создан бот [@DifichentoBot](https://t.me/difichentobot) с ведением счёта на виртуальные очки.  
Важным отличием от «традиционного» казино является невозможность влиять 
на выпадающие комбинации, т.к. итоговое значение генерируется на стороне Telegram.

## Технологии

* [aiogram](https://github.com/aiogram/aiogram) — работа с Telegram Bot API;
* [redis](https://redis.io) — персистентное хранение данных (персистентность включается отдельно);
* [cachetools](https://cachetools.readthedocs.io/en/stable) — реализация троттлинга для борьбы с флудом;
* [Docker](https://www.docker.com) и [Docker-Compose](https://docs.docker.com/compose) — быстрое разворачивание бота в изолированном контейнере.
* Systemd

## Установка

Скопируйте файл `settings.example.toml` под именем `settings.toml`, откройте и отредактируйте.  
Для смены языка в каталоге `bot/locale/current` создайте каталог с названием нужного языка (например, `ru`), 
а внутрь положите вашие `.ftl`-файлы. Примеры есть в [этом каталоге](bot/locales/example). 
Учтите, что язык бота определяется именно файлами в каталоге `current`, язык пользователя не учитывается.

Наконец, запустите бота командой `docker-compose up --profile "all" -d`. 

Альтернативный вариант: используйте Systemd, пример службы тоже есть в [репозитории](casino-bot.example.service).

## Благодарности

* [@Tishka17](https://t.me/Tishka17) за изначальный вектор направления
* [@svinerus](https://t.me/svinerus) за компактную реализацию определения выпавшей комбинации (f6f42a841d3c1778f0e32)


## Примечание об именовании релизов

Для большинства своих телеграм-ботов, я планирую использовать [Calendar Versioning](https://calver.org) по следующим правилам:

* Номер версии (релиза) должен иметь формат `vAAAA.BB.C`, где:
* * `vAAAA` это латинская буква v, после которой идет номер года релиза, например, `v2025`.
* * `BB` это двузначный номер месяца, например, `06` для июня.
* * `C` это порядковый номер релиза в месяце (без zero-padding'а), например, 1 для первого релиза в июне.
Проще говоря, первый релиз, который будет следовать новому формату, будет иметь вид `v2025.06.1`.

При таком подходе становится гораздо проще понять, какие фичи Bot API могут поддерживаться ботом, а какие точно нет.


================================================
FILE: bot/__init__.py
================================================


================================================
FILE: bot/__main__.py
================================================
import asyncio

import structlog
from aiogram import Bot, Dispatcher, F
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.fsm.storage.redis import RedisStorage
from structlog.typing import FilteringBoundLogger

from bot.config_reader import LogConfig, get_config, BotConfig, FSMMode, RedisConfig, GameConfig
from bot.fluent_loader import get_fluent_localization
from bot.handlers import default_commands, spin
from bot.logs import get_structlog_config
from bot.middlewares.throttling import ThrottlingMiddleware
from bot.ui_commands import set_bot_commands


async def main():
    log_config = get_config(model=LogConfig, root_key="logs")
    structlog.configure(**get_structlog_config(log_config))

    bot_config = get_config(model=BotConfig, root_key="bot")
    bot = Bot(
        token=bot_config.token.get_secret_value(),
        default=DefaultBotProperties(
            parse_mode=ParseMode.HTML
        )
    )

    if bot_config.fsm_mode == FSMMode.REDIS:
        redis_config = get_config(model=RedisConfig, root_key="redis")
        storage = RedisStorage.from_url(
            url=str(redis_config.dsn),
            connection_kwargs={"decode_responses": True},
        )
    else:
        storage = MemoryStorage()

    # Loading localization for bot
    l10n = get_fluent_localization()

    game_config = get_config(model=GameConfig, root_key="game_config")

    # Creating dispatcher with some dependencies
    dp = Dispatcher(
        storage=storage,
        l10n=l10n,
        game_config=game_config,
    )
    # Make bot work only in PM (one-on-one chats) with bot
    dp.message.filter(F.chat.type == "private")

    # Register routers with handlers
    dp.include_router(default_commands.router)
    dp.include_router(spin.router)

    # Register throttling middleware
    dp.message.middleware(
        ThrottlingMiddleware(game_config.throttle_time_spin, game_config.throttle_time_other)
    )

    # Set bot commands in the UI
    await set_bot_commands(bot, l10n)

    logger: FilteringBoundLogger = structlog.get_logger()
    await logger.ainfo("Starting polling...")
    try:
        await dp.start_polling(bot)
    finally:
        await bot.session.close()


if __name__ == '__main__':
    asyncio.run(main())


================================================
FILE: bot/config_reader.py
================================================
from enum import StrEnum, auto
from functools import lru_cache
from os import getenv
from tomllib import load
from typing import Type, TypeVar

from pydantic import BaseModel, SecretStr, field_validator, RedisDsn

ConfigType = TypeVar("ConfigType", bound=BaseModel)


class LogRenderer(StrEnum):
    JSON = auto()
    CONSOLE = auto()


class FSMMode(StrEnum):
    MEMORY = auto()
    REDIS = auto()


class BotConfig(BaseModel):
    token: SecretStr
    fsm_mode: FSMMode

    @field_validator('fsm_mode', mode="before")
    @classmethod
    def fsm_mode_to_lower(cls, v: str):
        return v.lower()


class LogConfig(BaseModel):
    project_name: str = "my project"
    show_datetime: bool
    datetime_format: str
    show_debug_logs: bool
    time_in_utc: bool
    use_colors_in_console: bool
    renderer: LogRenderer
    allow_third_party_logs: bool

    @field_validator('renderer', mode="before")
    @classmethod
    def log_renderer_to_lower(cls, v: str):
        return v.lower()


class RedisConfig(BaseModel):
    dsn: RedisDsn


class GameConfig(BaseModel):
    starting_points: int
    send_gameover_sticker: bool
    throttle_time_spin: int
    throttle_time_other: int

@lru_cache
def parse_config_file() -> dict:
    # Проверяем наличие переменной окружения, которая переопределяет путь к конфигу
    file_path = getenv("CONFIG_FILE_PATH")
    if file_path is None:
        error = "Could not find settings file"
        raise ValueError(error)
    # Читаем сам файл, пытаемся его распарсить как TOML
    with open(file_path, "rb") as file:
        config_data = load(file)
    return config_data


@lru_cache
def get_config(model: Type[ConfigType], root_key: str) -> ConfigType:
    config_dict = parse_config_file()
    if root_key not in config_dict:
        error = f"Key {root_key} not found"
        raise ValueError(error)
    return model.model_validate(config_dict[root_key])


================================================
FILE: bot/dice_check.py
================================================
# Source: https://gist.github.com/MasterGroosha/963c0a82df348419788065ab229094ac

from functools import lru_cache
from typing import List

from fluent.runtime import FluentLocalization


@lru_cache(maxsize=64)
def get_score_change(dice_value: int) -> int:
    """
    Checks for the winning combination

    :param dice_value: dice value (1-64)
    :return: user score change (integer)
    """

    # three-of-a-kind (except 777)
    if dice_value in (1, 22, 43):
        return 7
    # starting with two 7's (again, except 777)
    elif dice_value in (16, 32, 48):
        return 5
    # jackpot (777)
    elif dice_value == 64:
        return 10
    else:
        return -1


def get_combo_parts(dice_value: int) -> List[str]:
    """
    Returns exact icons from dice (bar, grapes, lemon, seven).
    Do not edit these values, since they are subject to be translated
    by outer code.
    :param dice_value: dice value (1-64)
    :return: list of icons' texts
    """

    # Alternative way (credits to t.me/svinerus):
    #   return [casino[(dice_value - 1) // i % 4]for i in (1, 4, 16)]

    # Do not edit these values; they are actually translation keys
    #           0       1         2        3
    values = ["bar", "grapes", "lemon", "seven"]

    dice_value -= 1
    result = []
    for _ in range(3):
        result.append(values[dice_value % 4])
        dice_value //= 4
    return result


@lru_cache(maxsize=64)
def get_combo_text(dice_value: int, l10n: FluentLocalization) -> str:
    """
    Returns localized string with dice result
    :param dice_value: dice value (1-64)
    :param l10n: Fluent localization object
    :return: string with localized result
    """
    parts: list[str] = get_combo_parts(dice_value)
    for i in range(len(parts)):
        parts[i] = l10n.format_value(parts[i])
    return ", ".join(parts)


================================================
FILE: bot/filters/__init__.py
================================================
from .spin_text_filter import SpinTextFilter

__all__ = [
    "SpinTextFilter"
]


================================================
FILE: bot/filters/spin_text_filter.py
================================================
from aiogram.filters import BaseFilter
from aiogram.types import Message
from fluent.runtime import FluentLocalization


class SpinTextFilter(BaseFilter):
    async def __call__(self, message: Message, l10n: FluentLocalization) -> bool:
        return message.text == l10n.format_value("spin-button-text")


================================================
FILE: bot/fluent_loader.py
================================================
from pathlib import Path

from fluent.runtime import FluentLocalization, FluentResourceLoader


def get_fluent_localization() -> FluentLocalization:
    # Access "locale/current" dir
    real_locale_dir = Path(__file__).parent.joinpath("locale", "current")
    # Find all subdirectories.
    real_languages_subdirs: list = [d for d in real_locale_dir.iterdir() if d.is_dir()]
    # There must be at least one subdirectory inside. Ideally – only one.
    if len(real_languages_subdirs) == 0:
        raise RuntimeError("No languages found in the 'current' directory.")
    # Select the first subdir if there are multiple subdirectories inside.
    selected_language_dir = real_languages_subdirs[0]

    # Find all .ftl files inside the selected language directory
    ftl_files = [f.name for f in selected_language_dir.iterdir() if f.is_file() and f.suffix == ".ftl"]
    if len(ftl_files) == 0:
        raise RuntimeError(f"No .ftl files found in the {selected_language_dir.name} directory.")

    # Now form Fluent-compatible path and create Fluent objects.
    locale_dir = real_locale_dir.joinpath("{locale}")
    loader = FluentResourceLoader(str(locale_dir.absolute()))
    return FluentLocalization([selected_language_dir.name], ftl_files, loader)


================================================
FILE: bot/handlers/__init__.py
================================================


================================================
FILE: bot/handlers/default_commands.py
================================================
from aiogram import Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, ReplyKeyboardRemove
from fluent.runtime import FluentLocalization

from bot.config_reader import GameConfig
from bot.keyboards import get_spin_keyboard

flags = {"throttling_key": "default"}
router = Router()


@router.message(Command("start"), flags=flags)
async def cmd_start(
        message: Message,
        state: FSMContext,
        l10n: FluentLocalization,
        game_config: GameConfig,
):
    start_text = l10n.format_value("start-text", {"points": game_config.starting_points})

    await state.update_data(score=game_config.starting_points)
    await message.answer(start_text, reply_markup=get_spin_keyboard(l10n))


@router.message(Command("stop"), flags=flags)
async def cmd_stop(message: Message, l10n: FluentLocalization):
    await message.answer(
        l10n.format_value("stop-text"),
        reply_markup=ReplyKeyboardRemove()
    )


@router.message(Command("help"), flags=flags)
async def cmd_help(message: Message, l10n: FluentLocalization):
    await message.answer(
        l10n.format_value("help-text"),
        disable_web_page_preview=True
    )


================================================
FILE: bot/handlers/spin.py
================================================
from asyncio import sleep
from contextlib import suppress

from aiogram import Router
from aiogram.enums.dice_emoji import DiceEmoji
from aiogram.exceptions import TelegramBadRequest
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message
from fluent.runtime import FluentLocalization

from bot.config_reader import GameConfig
from bot.dice_check import get_combo_text, get_score_change
from bot.filters import SpinTextFilter
from bot.keyboards import get_spin_keyboard

flags = {"throttling_key": "spin"}
router = Router()


@router.message(Command("spin"), flags=flags)
@router.message(SpinTextFilter(), flags=flags)
async def cmd_spin(
        message: Message,
        state: FSMContext,
        l10n: FluentLocalization,
        game_config: GameConfig,
):
    # Get current score
    user_data = await state.get_data()
    user_score = user_data.get("score", game_config.starting_points)

    if user_score == 0:
        if game_config.send_gameover_sticker:
            # In case sticker file_id is invalid or missing
            with suppress(TelegramBadRequest):
                await message.answer_sticker(l10n.format_value("zero-balance-sticker"))
        await message.answer(l10n.format_value("zero-balance"))
        return

    # Send dice to user
    msg = await message.answer_dice(emoji=DiceEmoji.SLOT_MACHINE, reply_markup=get_spin_keyboard(l10n))

    # Check whether user won or not
    score_change = get_score_change(msg.dice.value)

    if score_change < 0:
        win_or_lose_text = l10n.format_value("spin-fail")
    else:
        win_or_lose_text = l10n.format_value("spin-success", {"score-value": score_change})

    # Updating score in FSM data
    new_score = user_score + score_change
    await state.update_data(score=new_score)

    # This delay is roughly equivalent of animation duration
    # of slot machine. Depending on dice value,
    # animation duration is different, but approx. 2 seconds
    await sleep(2.0)
    await msg.reply(
        l10n.format_value(
            "after-spin",
            {
                "combo_text": get_combo_text(msg.dice.value, l10n),
                "dice_value": msg.dice.value,
                "result_text": win_or_lose_text,
                "score-value": new_score
            }
        )
    )


================================================
FILE: bot/keyboards.py
================================================
from functools import cache

from aiogram.types import ReplyKeyboardMarkup, KeyboardButton
from fluent.runtime import FluentLocalization


@cache
def get_spin_keyboard(l10n: FluentLocalization):
    keyboard = [
        [KeyboardButton(text=l10n.format_value("spin-button-text"))]
    ]
    return ReplyKeyboardMarkup(keyboard=keyboard, resize_keyboard=True)


================================================
FILE: bot/locale/current/README.md
================================================
Place your locale directory with ftl-files here.

================================================
FILE: bot/locale/example/README.md
================================================
> 🇷🇺 README на русском доступен [здесь](README.ru.md)

In this directory you can find sample localization files for EN and RU languages.

To use them in your bot, create a new directory with language code, e.g. "en" in "locales/current" folder 
(e.g. "bot/locales/current/en"). Then put your .ftl files in that directory (e.g. "strings.ftl"). 


================================================
FILE: bot/locale/example/README.ru.md
================================================
В этом каталоге располагаются образцы файлов локализации для английского и русского языков.

Чтобы использовать бота на нужном языке, создайте в каталоге "locales/current" папку с любым удобным названием 
локализации, например, "ru". Затем положите туда ваши `.ftl`-файлы (или скопируйте пример из текущего каталога).  
У вас должен получиться путь `bot/locales/current/ru` с `ftl`-файлами внутри.

================================================
FILE: bot/locale/example/en/strings.ftl
================================================
start-text =
    <b>Welcome to our virtual casino!</b>
    From the start, you have { $points } points. Every spin costs 1 point. Possible winning combinations are:

    3-of-a-kind (except "7") — 7 points
    7️⃣7️⃣▫️ — 5 points (square = anything)
    7️⃣7️⃣7️⃣ — 10 points

    <b>Disclaimer</b>: this bot was made solely for entertainment purposes, your data may be lost any time!
    There are no paid options in this bot.

    Remove keyboard — /stop
    Show keyboard if missing — /spin

help-text =
    Telegram slot machine has 4 icons: BAR, grapes, lemon and number 7. In total, there are 64 combinations.
    Decoding of dice value is described <a href='https://github.com/MasterGroosha/telegram-casino-bot/blob/aiogram3/bot/dice_check.py'>here</a>.

    Source code of the bot is available on <a href='https://github.com/MasterGroosha/telegram-casino-bot'>GitHub</a>

stop-text = Keyboard removed. To start from scratch, press /start, to get keyboard and continue: /spin

bar = BAR
grapes = grapes
lemon = lemon
seven = seven

spin-button-text = 🎰 Try it!

spin-fail = You lost the bet.
spin-success =
    You won {$score_change ->
         [one] {$score_change} point
        *[many] {$score_change} points
    }!

after-spin =
    Your combination: { $combo_text } (№{ $dice_value }).
    { $result_text } New score: <b>{ $new_score }</b>.

zero-balance =
    Your balance is zero. Accept your fate and get back to your business, or press /start to start over. To remove keyboard, press /stop.

# If you don't want to send sticker when balance is zero, disable feature in bot configuration
zero-balance-sticker = CAACAgIAAxkBAAEWXv5lAUAm76JOjvehtp18Gxb3if0eVQAC-hEAAknF8EuBzj23_M8x3jAE

menu-start = Restart Casino
menu-spin = Show keyboard and make a spin
menu-stop = Remove keyboard
menu-help = Information about this bot


================================================
FILE: bot/locale/example/ru/strings.ftl
================================================
start-text =
    <b>Добро пожаловать в наше виртуальное казино!</b>
    У вас на старте { $points } очков. Каждая попытка стоит 1 очко, а за выигрышные комбинации вы получите:

    3 одинаковых символа (кроме семёрки) — 7 очков
    7️⃣7️⃣▫️ — 5 очков (квадрат = что угодно)
    7️⃣7️⃣7️⃣ — 10 очков

    <b>Внимание</b>: бот предназначен исключительно для демонстрации, и ваши данные могут быть сброшены в любой момент!
    Помните: лудомания — это болезнь, и никаких платных опций в боте нет.

    Убрать клавиатуру — /stop
    Показать клавиатуру, если пропала — /spin

help-text =
    В казино доступно 4 элемента: BAR, виноград, лимон и цифра семь. Комбинаций, соответственно, 64.
    Для распознавания комбинации используется четверичная система, а пример кода для получения комбинации по значению от Bot API можно увидеть <a href='https://gist.github.com/MasterGroosha/963c0a82df348419788065ab229094ac'>здесь</a>.

    Исходный код бота доступен на <a href='https://github.com/MasterGroosha/telegram-casino-bot'>GitHub</a> и на <a href='https://git.groosha.space/shared/telegram-casino-bot'>GitLab</a>."

stop-text = Клавиатура удалена. Начать заново: /start, вернуть клавиатуру и продолжить: /spin

bar = BAR
grapes = виноград
lemon = лимон
seven = семь

spin-button-text = 🎰 Испытать удачу!

score-points = {$score-value ->
    [one] {$score-value} очко
    [few] {$score-value} очка
   *[many] {$score-value} очков
}

spin-fail =
    К сожалению, вы не выиграли.

spin-success =
    <b>Вы выиграли {score-points}!</b>

after-spin =
    Ваша комбинация: {$combo_text} (№{$dice_value}).
    {$result_text}
    У вас осталось {score-points}.

zero-balance =
    Ваш баланс равен нулю. Вы можете смириться с судьбой и продолжить жить своей жизнью, а можете нажать /start, чтобы начать всё заново. Или /stop, чтобы просто убрать клавиатуру.

# Если не хотите использовать стикер, укажите это в конфиге
zero-balance-sticker = CAACAgIAAxkBAAEFGxpfqmqG-MltYIj4zjmFl1eCBfvhZwACuwIAAuPwEwwS3zJY4LIw9B4E

menu-start = Перезапустить казино
menu-spin = Показать клавиатуру и сделать бросок
menu-stop = Убрать клавиатуру
menu-help = Справочная информация


================================================
FILE: bot/logs.py
================================================
import logging
from json import dumps
from sys import stdout

import structlog
from structlog import WriteLoggerFactory
from structlog.typing import WrappedLogger, EventDict

from bot.config_reader import LogConfig, LogRenderer


class ProjectNameProcessor:
    def __init__(self, project_name: str):
        self.project_name = project_name

    def __call__(
        self, logger: WrappedLogger, name: str, event_dict: EventDict
    ) -> EventDict:
        event_dict["project_name"] = self.project_name
        return event_dict


def get_structlog_config(log_config: LogConfig) -> dict:
    if log_config.show_debug_logs is True:
        min_level = logging.DEBUG
    else:
        min_level = logging.INFO

    if log_config.allow_third_party_logs:
        # Create handler for stdlib logging
        standard_handler = logging.StreamHandler(stream=stdout)
        standard_handler.setFormatter(
            structlog.stdlib.ProcessorFormatter(
                processors=get_processors(log_config)
            )
        )

        # Configure root logger to use this handler
        standard_logger = logging.getLogger()
        standard_logger.addHandler(standard_handler)
        standard_logger.setLevel(logging.DEBUG if log_config.show_debug_logs else logging.INFO)


    return {
        "processors": get_processors(log_config),
        "cache_logger_on_first_use": True,
        "wrapper_class": structlog.make_filtering_bound_logger(min_level),
        "logger_factory": WriteLoggerFactory()
    }


def get_processors(log_config: LogConfig) -> list:
    def custom_json_serializer(data, *args, **kwargs):
        result = dict()

        # Set keys in specific order
        for key in ("level", "event"):
            if key in data:
                result[key] = data.pop(key)

        # Clean up non-native structlog logs:
        if "_from_structlog" in data:
            data.pop("_from_structlog")
            data.pop("_record")

        # Add all other fields
        result.update(**data)
        return dumps(result, default=str)

    processors = list()
    if log_config.show_datetime is True:
        processors.append(structlog.processors.TimeStamper(
            fmt=log_config.datetime_format,
            utc=log_config.time_in_utc
            )
        )

    processors.append(structlog.processors.add_log_level)
    processors.append(ProjectNameProcessor(log_config.project_name))

    if log_config.renderer == LogRenderer.JSON:
        processors.append(structlog.processors.format_exc_info)
        processors.append(structlog.processors.JSONRenderer(serializer=custom_json_serializer))
    else:
        processors.append(structlog.dev.ConsoleRenderer(
            colors=log_config.use_colors_in_console,
            pad_level=False
        ))
    return processors


================================================
FILE: bot/middlewares/__init__.py
================================================


================================================
FILE: bot/middlewares/throttling.py
================================================
from typing import Any, Awaitable, Callable, Dict

from aiogram import BaseMiddleware
from aiogram.dispatcher.flags import get_flag
from aiogram.types import Message
from cachetools import TTLCache



class ThrottlingMiddleware(BaseMiddleware):
    def __init__(self, throttle_time_spin: int, throttle_time_other: int):
        self.caches = {
            "spin": TTLCache(maxsize=10_000, ttl=throttle_time_spin),
            "default": TTLCache(maxsize=10_000, ttl=throttle_time_other)
        }

    async def __call__(
            self,
            handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
            event: Message,
            data: Dict[str, Any],
    ) -> Any:
        throttling_key = get_flag(data, "throttling_key")
        if throttling_key is not None and throttling_key in self.caches:
            if event.chat.id in self.caches[throttling_key]:
                return
            else:
                self.caches[throttling_key][event.chat.id] = None
        return await handler(event, data)


================================================
FILE: bot/ui_commands.py
================================================
from aiogram import Bot
from aiogram.types import BotCommand, BotCommandScopeAllPrivateChats
from fluent.runtime import FluentLocalization


async def set_bot_commands(bot: Bot, l10n: FluentLocalization):
    commands = [
            BotCommand(command="start", description=l10n.format_value("menu-start")),
            BotCommand(command="spin", description=l10n.format_value("menu-spin")),
            BotCommand(command="stop", description=l10n.format_value("menu-stop")),
            BotCommand(command="help", description=l10n.format_value("menu-help"))
        ]
    await bot.set_my_commands(commands=commands, scope=BotCommandScopeAllPrivateChats())


================================================
FILE: casino-bot.example.service
================================================
[Unit]
Description=Telegram Casino Bot
After=network.target redis.service

[Service]
Type=simple
WorkingDirectory=/home/user/casino-bot
ExecStart=/home/user/casino-bot/venv/bin/python -m bot
KillMode=process
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

================================================
FILE: docker-compose.example.yml
================================================
name: "telegram-casino-bot"
services:
    bot:
        build:
            context: "."
            dockerfile: "Dockerfile"
        profiles:
            - "all"
        restart: "always"
        stop_signal: SIGINT
        volumes:
            # Path to .toml file with settings
            - "/home/user/casino-bot/settings.toml:/app/settings.toml"
            # Path to localization directory
            - "/home/user/casino-bot/locale/example/en:/app/bot/locale/current/en"
        environment:
            - CONFIG_FILE_PATH=/app/settings.toml
        depends_on:
            - redis

    redis:
        profiles:
            - "all"
            - "infra"
        image: "redis:8-alpine"
        restart: "always"
        volumes:
            # Path to redis.conf file
            - "/home/user/casino-bot/redis.conf:/usr/local/etc/redis/redis.conf"
            # Redis volume
            - "redis_data:/data"
        command: "redis-server /usr/local/etc/redis/redis.conf"
        healthcheck:
            test: [ "CMD", "redis-cli","ping" ]

volumes:
    redis_data:


================================================
FILE: pyproject.toml
================================================
[project]
name = "telegram-casino-bot"
version = "2025.06.1"
description = "A simple casino-like bot for Telegram"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
    "aiogram>=3.18.0",
    "async-timeout>=5.0.1",
    "cachetools>=5.5.2",
    "fluent-runtime>=0.4.0",
    "redis>=5.2.1",
    "structlog>=25.1.0",
]


================================================
FILE: redis.example.conf
================================================
port 6379
save 600 1
dbfilename redis_dump.rdb

================================================
FILE: settings.example.toml
================================================
[bot]
# Bot token. Obtain one from https://t.me/botfather
token = "1234567890:AaBbCcDdEeFfGrOoShALlMmNnOoPpQqRrSs"
# Where to store users' data. Options: "memory", "redis".
# Memory storage gets wiped upon bot restart, Redis uses persistency.
fsm_mode = "redis"


[redis]
# In case you select "Redis" fsm_mode, specify connection string here
dsn = "redis://user:pass@host:port"


[logs]
# Project name. Used in logs, useful when aggregating logs from different bots.
project_name = "casino_bot"
# If true, logs will include date and time of the event
show_datetime = true
# Datetime format in logs
datetime_format = "%Y-%m-%d %H:%M:%S"
# If true, logs with DEBUG level will be shown. Otherwise, only INFO+
show_debug_logs = false
# If true, logs will use UTC time instead of server's local time
time_in_utc = false
# How to show logs. Options: "console", "json"
renderer = "json"
# If true, "console" renderer will use colors when rendering logs
use_colors_in_console = false
# If true, logs from other libraries (e.g. aiogram) will be shown
allow_third_party_logs = true


[game_config]
# Starting points for all new games
starting_points = 50
# Whether to send a special "game over" sticker or not. Specify sticker's file id in localization file
send_gameover_sticker = true
# Throttling time for spins. Casino dice animation is variable, on average ~2 seconds long
throttle_time_spin = 2
# Throttling time for all other actions.
throttle_time_other = 1
Download .txt
gitextract_t9fz8z6y/

├── .dockerignore
├── .gitignore
├── .python-version
├── Dockerfile
├── LICENSE
├── README.md
├── README.ru.md
├── bot/
│   ├── __init__.py
│   ├── __main__.py
│   ├── config_reader.py
│   ├── dice_check.py
│   ├── filters/
│   │   ├── __init__.py
│   │   └── spin_text_filter.py
│   ├── fluent_loader.py
│   ├── handlers/
│   │   ├── __init__.py
│   │   ├── default_commands.py
│   │   └── spin.py
│   ├── keyboards.py
│   ├── locale/
│   │   ├── current/
│   │   │   └── README.md
│   │   └── example/
│   │       ├── README.md
│   │       ├── README.ru.md
│   │       ├── en/
│   │       │   └── strings.ftl
│   │       └── ru/
│   │           └── strings.ftl
│   ├── logs.py
│   ├── middlewares/
│   │   ├── __init__.py
│   │   └── throttling.py
│   └── ui_commands.py
├── casino-bot.example.service
├── docker-compose.example.yml
├── pyproject.toml
├── redis.example.conf
└── settings.example.toml
Download .txt
SYMBOL INDEX (31 symbols across 11 files)

FILE: bot/__main__.py
  function main (line 19) | async def main():

FILE: bot/config_reader.py
  class LogRenderer (line 12) | class LogRenderer(StrEnum):
  class FSMMode (line 17) | class FSMMode(StrEnum):
  class BotConfig (line 22) | class BotConfig(BaseModel):
    method fsm_mode_to_lower (line 28) | def fsm_mode_to_lower(cls, v: str):
  class LogConfig (line 32) | class LogConfig(BaseModel):
    method log_renderer_to_lower (line 44) | def log_renderer_to_lower(cls, v: str):
  class RedisConfig (line 48) | class RedisConfig(BaseModel):
  class GameConfig (line 52) | class GameConfig(BaseModel):
  function parse_config_file (line 59) | def parse_config_file() -> dict:
  function get_config (line 72) | def get_config(model: Type[ConfigType], root_key: str) -> ConfigType:

FILE: bot/dice_check.py
  function get_score_change (line 10) | def get_score_change(dice_value: int) -> int:
  function get_combo_parts (line 31) | def get_combo_parts(dice_value: int) -> List[str]:
  function get_combo_text (line 56) | def get_combo_text(dice_value: int, l10n: FluentLocalization) -> str:

FILE: bot/filters/spin_text_filter.py
  class SpinTextFilter (line 6) | class SpinTextFilter(BaseFilter):
    method __call__ (line 7) | async def __call__(self, message: Message, l10n: FluentLocalization) -...

FILE: bot/fluent_loader.py
  function get_fluent_localization (line 6) | def get_fluent_localization() -> FluentLocalization:

FILE: bot/handlers/default_commands.py
  function cmd_start (line 15) | async def cmd_start(
  function cmd_stop (line 28) | async def cmd_stop(message: Message, l10n: FluentLocalization):
  function cmd_help (line 36) | async def cmd_help(message: Message, l10n: FluentLocalization):

FILE: bot/handlers/spin.py
  function cmd_spin (line 23) | async def cmd_spin(

FILE: bot/keyboards.py
  function get_spin_keyboard (line 8) | def get_spin_keyboard(l10n: FluentLocalization):

FILE: bot/logs.py
  class ProjectNameProcessor (line 12) | class ProjectNameProcessor:
    method __init__ (line 13) | def __init__(self, project_name: str):
    method __call__ (line 16) | def __call__(
  function get_structlog_config (line 23) | def get_structlog_config(log_config: LogConfig) -> dict:
  function get_processors (line 52) | def get_processors(log_config: LogConfig) -> list:

FILE: bot/middlewares/throttling.py
  class ThrottlingMiddleware (line 10) | class ThrottlingMiddleware(BaseMiddleware):
    method __init__ (line 11) | def __init__(self, throttle_time_spin: int, throttle_time_other: int):
    method __call__ (line 17) | async def __call__(

FILE: bot/ui_commands.py
  function set_bot_commands (line 6) | async def set_bot_commands(bot: Bot, l10n: FluentLocalization):
Condensed preview — 32 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (38K chars).
[
  {
    "path": ".dockerignore",
    "chars": 93,
    "preview": "repo_images/\nredis.example.conf\nsettings.example.toml\n.git/\n.idea/\n.vscode/\n__pycache__\n*.pyc"
  },
  {
    "path": ".gitignore",
    "chars": 90,
    "preview": ".idea/\n/venv/\n/.venv/\n.vscode/\n*.ipynb\ndocker-compose.yml\n.env\n/redis.conf\n/settings.toml\n"
  },
  {
    "path": ".python-version",
    "chars": 5,
    "preview": "3.11\n"
  },
  {
    "path": "Dockerfile",
    "chars": 755,
    "preview": "## Build stage\nFROM ghcr.io/astral-sh/uv:bookworm-slim AS builder\nENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy\nENV UV_PYT"
  },
  {
    "path": "LICENSE",
    "chars": 1104,
    "preview": "MIT License\n\nCopyright (c) 2020-present Aleksandr (aka MasterGroosha on GitHub)\n\nPermission is hereby granted, free of c"
  },
  {
    "path": "README.md",
    "chars": 2763,
    "preview": "[<img src=\"https://img.shields.io/badge/Telegram-%40DifichentoBot-blue\">](https://t.me/DifichentoBot) (Ru)\n\n> 🇷🇺 README "
  },
  {
    "path": "README.ru.md",
    "chars": 2935,
    "preview": "[<img src=\"https://img.shields.io/badge/Telegram-%40DifichentoBot-blue\">](https://t.me/DifichentoBot)\n\n# Виртуальное каз"
  },
  {
    "path": "bot/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "bot/__main__.py",
    "chars": 2349,
    "preview": "import asyncio\n\nimport structlog\nfrom aiogram import Bot, Dispatcher, F\nfrom aiogram.client.default import DefaultBotPro"
  },
  {
    "path": "bot/config_reader.py",
    "chars": 1906,
    "preview": "from enum import StrEnum, auto\nfrom functools import lru_cache\nfrom os import getenv\nfrom tomllib import load\nfrom typin"
  },
  {
    "path": "bot/dice_check.py",
    "chars": 1846,
    "preview": "# Source: https://gist.github.com/MasterGroosha/963c0a82df348419788065ab229094ac\n\nfrom functools import lru_cache\nfrom t"
  },
  {
    "path": "bot/filters/__init__.py",
    "chars": 81,
    "preview": "from .spin_text_filter import SpinTextFilter\n\n__all__ = [\n    \"SpinTextFilter\"\n]\n"
  },
  {
    "path": "bot/filters/spin_text_filter.py",
    "chars": 306,
    "preview": "from aiogram.filters import BaseFilter\nfrom aiogram.types import Message\nfrom fluent.runtime import FluentLocalization\n\n"
  },
  {
    "path": "bot/fluent_loader.py",
    "chars": 1254,
    "preview": "from pathlib import Path\n\nfrom fluent.runtime import FluentLocalization, FluentResourceLoader\n\n\ndef get_fluent_localizat"
  },
  {
    "path": "bot/handlers/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "bot/handlers/default_commands.py",
    "chars": 1221,
    "preview": "from aiogram import Router\nfrom aiogram.filters import Command\nfrom aiogram.fsm.context import FSMContext\nfrom aiogram.t"
  },
  {
    "path": "bot/handlers/spin.py",
    "chars": 2335,
    "preview": "from asyncio import sleep\nfrom contextlib import suppress\n\nfrom aiogram import Router\nfrom aiogram.enums.dice_emoji impo"
  },
  {
    "path": "bot/keyboards.py",
    "chars": 359,
    "preview": "from functools import cache\n\nfrom aiogram.types import ReplyKeyboardMarkup, KeyboardButton\nfrom fluent.runtime import Fl"
  },
  {
    "path": "bot/locale/current/README.md",
    "chars": 48,
    "preview": "Place your locale directory with ftl-files here."
  },
  {
    "path": "bot/locale/example/README.md",
    "chars": 344,
    "preview": "> 🇷🇺 README на русском доступен [здесь](README.ru.md)\n\nIn this directory you can find sample localization files for EN a"
  },
  {
    "path": "bot/locale/example/README.ru.md",
    "chars": 397,
    "preview": "В этом каталоге располагаются образцы файлов локализации для английского и русского языков.\n\nЧтобы использовать бота на "
  },
  {
    "path": "bot/locale/example/en/strings.ftl",
    "chars": 1838,
    "preview": "start-text =\n    <b>Welcome to our virtual casino!</b>\n    From the start, you have { $points } points. Every spin costs"
  },
  {
    "path": "bot/locale/example/ru/strings.ftl",
    "chars": 2150,
    "preview": "start-text =\n    <b>Добро пожаловать в наше виртуальное казино!</b>\n    У вас на старте { $points } очков. Каждая попытк"
  },
  {
    "path": "bot/logs.py",
    "chars": 2805,
    "preview": "import logging\nfrom json import dumps\nfrom sys import stdout\n\nimport structlog\nfrom structlog import WriteLoggerFactory\n"
  },
  {
    "path": "bot/middlewares/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "bot/middlewares/throttling.py",
    "chars": 1031,
    "preview": "from typing import Any, Awaitable, Callable, Dict\n\nfrom aiogram import BaseMiddleware\nfrom aiogram.dispatcher.flags impo"
  },
  {
    "path": "bot/ui_commands.py",
    "chars": 658,
    "preview": "from aiogram import Bot\nfrom aiogram.types import BotCommand, BotCommandScopeAllPrivateChats\nfrom fluent.runtime import "
  },
  {
    "path": "casino-bot.example.service",
    "chars": 274,
    "preview": "[Unit]\nDescription=Telegram Casino Bot\nAfter=network.target redis.service\n\n[Service]\nType=simple\nWorkingDirectory=/home/"
  },
  {
    "path": "docker-compose.example.yml",
    "chars": 1075,
    "preview": "name: \"telegram-casino-bot\"\nservices:\n    bot:\n        build:\n            context: \".\"\n            dockerfile: \"Dockerfi"
  },
  {
    "path": "pyproject.toml",
    "chars": 332,
    "preview": "[project]\nname = \"telegram-casino-bot\"\nversion = \"2025.06.1\"\ndescription = \"A simple casino-like bot for Telegram\"\nreadm"
  },
  {
    "path": "redis.example.conf",
    "chars": 46,
    "preview": "port 6379\nsave 600 1\ndbfilename redis_dump.rdb"
  },
  {
    "path": "settings.example.toml",
    "chars": 1455,
    "preview": "[bot]\n# Bot token. Obtain one from https://t.me/botfather\ntoken = \"1234567890:AaBbCcDdEeFfGrOoShALlMmNnOoPpQqRrSs\"\n# Whe"
  }
]

About this extraction

This page contains the full source code of the MasterGroosha/telegram-casino-bot GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 32 files (31.1 KB), approximately 8.9k tokens, and a symbol index with 31 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!