[
  {
    "path": ".dockerignore",
    "content": "repo_images/\nredis.example.conf\nsettings.example.toml\n.git/\n.idea/\n.vscode/\n__pycache__\n*.pyc"
  },
  {
    "path": ".gitignore",
    "content": ".idea/\n/venv/\n/.venv/\n.vscode/\n*.ipynb\ndocker-compose.yml\n.env\n/redis.conf\n/settings.toml\n"
  },
  {
    "path": ".python-version",
    "content": "3.11\n"
  },
  {
    "path": "Dockerfile",
    "content": "## Build stage\nFROM ghcr.io/astral-sh/uv:bookworm-slim AS builder\nENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy\nENV UV_PYTHON_INSTALL_DIR=/python UV_PYTHON_PREFERENCE=only-managed\nRUN uv python install 3.11\nWORKDIR /app\nCOPY pyproject.toml uv.lock ./\nRUN --mount=type=cache,target=/root/.cache/uv \\\n    uv sync --frozen --no-install-project --no-dev --no-editable\nCOPY ./bot /app/bot\nRUN --mount=type=cache,target=/root/.cache/uv \\\n    uv sync --frozen --no-dev --no-editable\n\n\n## Final stage\nFROM gcr.io/distroless/python3-debian12:nonroot\nCOPY --from=builder --chown=nonroot:nonroot /python /python\nWORKDIR /app\nCOPY --from=builder --chown=nonroot:nonroot /app/ /app\n\nENV PYTHONPATH=\"/app/.venv/lib/python3.11/site-packages:$PYTHONPATH\"\nCMD [\"-m\", \"bot\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2020-present Aleksandr (aka MasterGroosha on GitHub)\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "[<img src=\"https://img.shields.io/badge/Telegram-%40DifichentoBot-blue\">](https://t.me/DifichentoBot) (Ru)\n\n> 🇷🇺 README на русском доступен [здесь](README.ru.md)\n\n# Telegram Virtual Casino\n\nIn October 2020 Telegram team released [yet another update](https://telegram.org/blog/pinned-messages-locations-playlists) \nwith slot machine dice. Here it is:\n\n![slot machine dice](repo_images/slot_machine.png)\n\nAccording to [Dice type documentation](https://core.telegram.org/bots/api#dice) in Bot API, slot machine \nemits values 1 to 64. In [dice_check.py](bot/dice_check.py) file you can find all the logic regarding \nmatching dice integer value with visual three-icons representation. There is also a test bot [@DifichentoBot](https://t.me/difichentobot) \nin Russian to test how it works.  \nDice are generated on Telegram server-side, you your bot cannot affect the result.\n\n## Technology\n\n* [aiogram](https://github.com/aiogram/aiogram) — asyncio Telegram Bot API framework;\n* [redis](https://redis.io) — persistent data storage (persistency enabled separately);\n* [cachetools](https://cachetools.readthedocs.io/en/stable) — for anti-flood throttling mechanism;\n* [Docker](https://www.docker.com) and [Docker-Compose](https://docs.docker.com/compose) — quickly deploy bot in containers.\n* Systemd\n\n## Installation\n\nCopy `settings.example.toml` file to `settings.toml`, open and edit it.  \nTo change bot's language, overwrite `bot/locale/current` directory contents with a chosen language. For example, you want \nto use English language in your bot. Then, in `bot/locale/current` create a directory named `en` and place your `.ftl` \nfiles inside. Check [this directory](bot/locales/example) for samples. \nPlease note that only one language can be active at a time.\n\nFinally, run the bot with `docker-compose --profile \"all\" up -d` command.\n\nAlternative way: you can use Systemd services, there is also an [example](casino-bot.example.service) available.\n\n## Credits to\n\n* [@Tishka17](https://t.me/Tishka17) for initial inspiration\n* [@svinerus](https://t.me/svinerus) for compact dice combination check (f6f42a841d3c1778f0e32)\n\n\n## Note on versioning\n\nFor most of my Telegram bots, I plan to use Calendar Versioning with the following rules:\n\n* Versions should look like `vAAAA.BB.C`, where:\n* * `vAAAA` is the letter \"v\" followed by the 4-digit year of release, e.g., `v2025`.\n* * `BB` is the 2-digit month number, e.g., `06` for June.\n* * `C` is the release number for that month, not zero-padded, e.g., 1 for the first release in June.\nFor example, the first release to use the new versioning schema will be tagged as `v2025.06.1`.\n\nThis scheme makes it easier to understand which Bot API features might be supported in a given release and which are definitely not."
  },
  {
    "path": "README.ru.md",
    "content": "[<img src=\"https://img.shields.io/badge/Telegram-%40DifichentoBot-blue\">](https://t.me/DifichentoBot)\n\n# Виртуальное казино в Telegram\n\nВ конце октября 2020 года команда Telegram выпустила [очередное обновление](https://telegram.org/blog/pinned-messages-locations-playlists/ru?ln=a) \nмессенджера с поддержкой дайса игрового автомата. Вот он:\n\n![игровой автомат](repo_images/slot_machine.png)\n\nСогласно [документации на тип Dice](https://core.telegram.org/bots/api#dice) в Bot API, слот-машина \nможет принимать значения от 1 до 64 включительно. В файле [dice_check.py](bot/dice_check.py) вы найдёте функции \nдля сопоставления значения дайса с тройкой выпавших элементов игрового автомата. \nДля демонстрации создан бот [@DifichentoBot](https://t.me/difichentobot) с ведением счёта на виртуальные очки.  \nВажным отличием от «традиционного» казино является невозможность влиять \nна выпадающие комбинации, т.к. итоговое значение генерируется на стороне Telegram.\n\n## Технологии\n\n* [aiogram](https://github.com/aiogram/aiogram) — работа с Telegram Bot API;\n* [redis](https://redis.io) — персистентное хранение данных (персистентность включается отдельно);\n* [cachetools](https://cachetools.readthedocs.io/en/stable) — реализация троттлинга для борьбы с флудом;\n* [Docker](https://www.docker.com) и [Docker-Compose](https://docs.docker.com/compose) — быстрое разворачивание бота в изолированном контейнере.\n* Systemd\n\n## Установка\n\nСкопируйте файл `settings.example.toml` под именем `settings.toml`, откройте и отредактируйте.  \nДля смены языка в каталоге `bot/locale/current` создайте каталог с названием нужного языка (например, `ru`), \nа внутрь положите вашие `.ftl`-файлы. Примеры есть в [этом каталоге](bot/locales/example). \nУчтите, что язык бота определяется именно файлами в каталоге `current`, язык пользователя не учитывается.\n\nНаконец, запустите бота командой `docker-compose up --profile \"all\" -d`. \n\nАльтернативный вариант: используйте Systemd, пример службы тоже есть в [репозитории](casino-bot.example.service).\n\n## Благодарности\n\n* [@Tishka17](https://t.me/Tishka17) за изначальный вектор направления\n* [@svinerus](https://t.me/svinerus) за компактную реализацию определения выпавшей комбинации (f6f42a841d3c1778f0e32)\n\n\n## Примечание об именовании релизов\n\nДля большинства своих телеграм-ботов, я планирую использовать [Calendar Versioning](https://calver.org) по следующим правилам:\n\n* Номер версии (релиза) должен иметь формат `vAAAA.BB.C`, где:\n* * `vAAAA` это латинская буква v, после которой идет номер года релиза, например, `v2025`.\n* * `BB` это двузначный номер месяца, например, `06` для июня.\n* * `C` это порядковый номер релиза в месяце (без zero-padding'а), например, 1 для первого релиза в июне.\nПроще говоря, первый релиз, который будет следовать новому формату, будет иметь вид `v2025.06.1`.\n\nПри таком подходе становится гораздо проще понять, какие фичи Bot API могут поддерживаться ботом, а какие точно нет.\n"
  },
  {
    "path": "bot/__init__.py",
    "content": ""
  },
  {
    "path": "bot/__main__.py",
    "content": "import asyncio\n\nimport structlog\nfrom aiogram import Bot, Dispatcher, F\nfrom aiogram.client.default import DefaultBotProperties\nfrom aiogram.enums import ParseMode\nfrom aiogram.fsm.storage.memory import MemoryStorage\nfrom aiogram.fsm.storage.redis import RedisStorage\nfrom structlog.typing import FilteringBoundLogger\n\nfrom bot.config_reader import LogConfig, get_config, BotConfig, FSMMode, RedisConfig, GameConfig\nfrom bot.fluent_loader import get_fluent_localization\nfrom bot.handlers import default_commands, spin\nfrom bot.logs import get_structlog_config\nfrom bot.middlewares.throttling import ThrottlingMiddleware\nfrom bot.ui_commands import set_bot_commands\n\n\nasync def main():\n    log_config = get_config(model=LogConfig, root_key=\"logs\")\n    structlog.configure(**get_structlog_config(log_config))\n\n    bot_config = get_config(model=BotConfig, root_key=\"bot\")\n    bot = Bot(\n        token=bot_config.token.get_secret_value(),\n        default=DefaultBotProperties(\n            parse_mode=ParseMode.HTML\n        )\n    )\n\n    if bot_config.fsm_mode == FSMMode.REDIS:\n        redis_config = get_config(model=RedisConfig, root_key=\"redis\")\n        storage = RedisStorage.from_url(\n            url=str(redis_config.dsn),\n            connection_kwargs={\"decode_responses\": True},\n        )\n    else:\n        storage = MemoryStorage()\n\n    # Loading localization for bot\n    l10n = get_fluent_localization()\n\n    game_config = get_config(model=GameConfig, root_key=\"game_config\")\n\n    # Creating dispatcher with some dependencies\n    dp = Dispatcher(\n        storage=storage,\n        l10n=l10n,\n        game_config=game_config,\n    )\n    # Make bot work only in PM (one-on-one chats) with bot\n    dp.message.filter(F.chat.type == \"private\")\n\n    # Register routers with handlers\n    dp.include_router(default_commands.router)\n    dp.include_router(spin.router)\n\n    # Register throttling middleware\n    dp.message.middleware(\n        ThrottlingMiddleware(game_config.throttle_time_spin, game_config.throttle_time_other)\n    )\n\n    # Set bot commands in the UI\n    await set_bot_commands(bot, l10n)\n\n    logger: FilteringBoundLogger = structlog.get_logger()\n    await logger.ainfo(\"Starting polling...\")\n    try:\n        await dp.start_polling(bot)\n    finally:\n        await bot.session.close()\n\n\nif __name__ == '__main__':\n    asyncio.run(main())\n"
  },
  {
    "path": "bot/config_reader.py",
    "content": "from enum import StrEnum, auto\nfrom functools import lru_cache\nfrom os import getenv\nfrom tomllib import load\nfrom typing import Type, TypeVar\n\nfrom pydantic import BaseModel, SecretStr, field_validator, RedisDsn\n\nConfigType = TypeVar(\"ConfigType\", bound=BaseModel)\n\n\nclass LogRenderer(StrEnum):\n    JSON = auto()\n    CONSOLE = auto()\n\n\nclass FSMMode(StrEnum):\n    MEMORY = auto()\n    REDIS = auto()\n\n\nclass BotConfig(BaseModel):\n    token: SecretStr\n    fsm_mode: FSMMode\n\n    @field_validator('fsm_mode', mode=\"before\")\n    @classmethod\n    def fsm_mode_to_lower(cls, v: str):\n        return v.lower()\n\n\nclass LogConfig(BaseModel):\n    project_name: str = \"my project\"\n    show_datetime: bool\n    datetime_format: str\n    show_debug_logs: bool\n    time_in_utc: bool\n    use_colors_in_console: bool\n    renderer: LogRenderer\n    allow_third_party_logs: bool\n\n    @field_validator('renderer', mode=\"before\")\n    @classmethod\n    def log_renderer_to_lower(cls, v: str):\n        return v.lower()\n\n\nclass RedisConfig(BaseModel):\n    dsn: RedisDsn\n\n\nclass GameConfig(BaseModel):\n    starting_points: int\n    send_gameover_sticker: bool\n    throttle_time_spin: int\n    throttle_time_other: int\n\n@lru_cache\ndef parse_config_file() -> dict:\n    # Проверяем наличие переменной окружения, которая переопределяет путь к конфигу\n    file_path = getenv(\"CONFIG_FILE_PATH\")\n    if file_path is None:\n        error = \"Could not find settings file\"\n        raise ValueError(error)\n    # Читаем сам файл, пытаемся его распарсить как TOML\n    with open(file_path, \"rb\") as file:\n        config_data = load(file)\n    return config_data\n\n\n@lru_cache\ndef get_config(model: Type[ConfigType], root_key: str) -> ConfigType:\n    config_dict = parse_config_file()\n    if root_key not in config_dict:\n        error = f\"Key {root_key} not found\"\n        raise ValueError(error)\n    return model.model_validate(config_dict[root_key])\n"
  },
  {
    "path": "bot/dice_check.py",
    "content": "# Source: https://gist.github.com/MasterGroosha/963c0a82df348419788065ab229094ac\n\nfrom functools import lru_cache\nfrom typing import List\n\nfrom fluent.runtime import FluentLocalization\n\n\n@lru_cache(maxsize=64)\ndef get_score_change(dice_value: int) -> int:\n    \"\"\"\n    Checks for the winning combination\n\n    :param dice_value: dice value (1-64)\n    :return: user score change (integer)\n    \"\"\"\n\n    # three-of-a-kind (except 777)\n    if dice_value in (1, 22, 43):\n        return 7\n    # starting with two 7's (again, except 777)\n    elif dice_value in (16, 32, 48):\n        return 5\n    # jackpot (777)\n    elif dice_value == 64:\n        return 10\n    else:\n        return -1\n\n\ndef get_combo_parts(dice_value: int) -> List[str]:\n    \"\"\"\n    Returns exact icons from dice (bar, grapes, lemon, seven).\n    Do not edit these values, since they are subject to be translated\n    by outer code.\n    :param dice_value: dice value (1-64)\n    :return: list of icons' texts\n    \"\"\"\n\n    # Alternative way (credits to t.me/svinerus):\n    #   return [casino[(dice_value - 1) // i % 4]for i in (1, 4, 16)]\n\n    # Do not edit these values; they are actually translation keys\n    #           0       1         2        3\n    values = [\"bar\", \"grapes\", \"lemon\", \"seven\"]\n\n    dice_value -= 1\n    result = []\n    for _ in range(3):\n        result.append(values[dice_value % 4])\n        dice_value //= 4\n    return result\n\n\n@lru_cache(maxsize=64)\ndef get_combo_text(dice_value: int, l10n: FluentLocalization) -> str:\n    \"\"\"\n    Returns localized string with dice result\n    :param dice_value: dice value (1-64)\n    :param l10n: Fluent localization object\n    :return: string with localized result\n    \"\"\"\n    parts: list[str] = get_combo_parts(dice_value)\n    for i in range(len(parts)):\n        parts[i] = l10n.format_value(parts[i])\n    return \", \".join(parts)\n"
  },
  {
    "path": "bot/filters/__init__.py",
    "content": "from .spin_text_filter import SpinTextFilter\n\n__all__ = [\n    \"SpinTextFilter\"\n]\n"
  },
  {
    "path": "bot/filters/spin_text_filter.py",
    "content": "from aiogram.filters import BaseFilter\nfrom aiogram.types import Message\nfrom fluent.runtime import FluentLocalization\n\n\nclass SpinTextFilter(BaseFilter):\n    async def __call__(self, message: Message, l10n: FluentLocalization) -> bool:\n        return message.text == l10n.format_value(\"spin-button-text\")\n"
  },
  {
    "path": "bot/fluent_loader.py",
    "content": "from pathlib import Path\n\nfrom fluent.runtime import FluentLocalization, FluentResourceLoader\n\n\ndef get_fluent_localization() -> FluentLocalization:\n    # Access \"locale/current\" dir\n    real_locale_dir = Path(__file__).parent.joinpath(\"locale\", \"current\")\n    # Find all subdirectories.\n    real_languages_subdirs: list = [d for d in real_locale_dir.iterdir() if d.is_dir()]\n    # There must be at least one subdirectory inside. Ideally – only one.\n    if len(real_languages_subdirs) == 0:\n        raise RuntimeError(\"No languages found in the 'current' directory.\")\n    # Select the first subdir if there are multiple subdirectories inside.\n    selected_language_dir = real_languages_subdirs[0]\n\n    # Find all .ftl files inside the selected language directory\n    ftl_files = [f.name for f in selected_language_dir.iterdir() if f.is_file() and f.suffix == \".ftl\"]\n    if len(ftl_files) == 0:\n        raise RuntimeError(f\"No .ftl files found in the {selected_language_dir.name} directory.\")\n\n    # Now form Fluent-compatible path and create Fluent objects.\n    locale_dir = real_locale_dir.joinpath(\"{locale}\")\n    loader = FluentResourceLoader(str(locale_dir.absolute()))\n    return FluentLocalization([selected_language_dir.name], ftl_files, loader)\n"
  },
  {
    "path": "bot/handlers/__init__.py",
    "content": ""
  },
  {
    "path": "bot/handlers/default_commands.py",
    "content": "from aiogram import Router\nfrom aiogram.filters import Command\nfrom aiogram.fsm.context import FSMContext\nfrom aiogram.types import Message, ReplyKeyboardRemove\nfrom fluent.runtime import FluentLocalization\n\nfrom bot.config_reader import GameConfig\nfrom bot.keyboards import get_spin_keyboard\n\nflags = {\"throttling_key\": \"default\"}\nrouter = Router()\n\n\n@router.message(Command(\"start\"), flags=flags)\nasync def cmd_start(\n        message: Message,\n        state: FSMContext,\n        l10n: FluentLocalization,\n        game_config: GameConfig,\n):\n    start_text = l10n.format_value(\"start-text\", {\"points\": game_config.starting_points})\n\n    await state.update_data(score=game_config.starting_points)\n    await message.answer(start_text, reply_markup=get_spin_keyboard(l10n))\n\n\n@router.message(Command(\"stop\"), flags=flags)\nasync def cmd_stop(message: Message, l10n: FluentLocalization):\n    await message.answer(\n        l10n.format_value(\"stop-text\"),\n        reply_markup=ReplyKeyboardRemove()\n    )\n\n\n@router.message(Command(\"help\"), flags=flags)\nasync def cmd_help(message: Message, l10n: FluentLocalization):\n    await message.answer(\n        l10n.format_value(\"help-text\"),\n        disable_web_page_preview=True\n    )\n"
  },
  {
    "path": "bot/handlers/spin.py",
    "content": "from asyncio import sleep\nfrom contextlib import suppress\n\nfrom aiogram import Router\nfrom aiogram.enums.dice_emoji import DiceEmoji\nfrom aiogram.exceptions import TelegramBadRequest\nfrom aiogram.filters import Command\nfrom aiogram.fsm.context import FSMContext\nfrom aiogram.types import Message\nfrom fluent.runtime import FluentLocalization\n\nfrom bot.config_reader import GameConfig\nfrom bot.dice_check import get_combo_text, get_score_change\nfrom bot.filters import SpinTextFilter\nfrom bot.keyboards import get_spin_keyboard\n\nflags = {\"throttling_key\": \"spin\"}\nrouter = Router()\n\n\n@router.message(Command(\"spin\"), flags=flags)\n@router.message(SpinTextFilter(), flags=flags)\nasync def cmd_spin(\n        message: Message,\n        state: FSMContext,\n        l10n: FluentLocalization,\n        game_config: GameConfig,\n):\n    # Get current score\n    user_data = await state.get_data()\n    user_score = user_data.get(\"score\", game_config.starting_points)\n\n    if user_score == 0:\n        if game_config.send_gameover_sticker:\n            # In case sticker file_id is invalid or missing\n            with suppress(TelegramBadRequest):\n                await message.answer_sticker(l10n.format_value(\"zero-balance-sticker\"))\n        await message.answer(l10n.format_value(\"zero-balance\"))\n        return\n\n    # Send dice to user\n    msg = await message.answer_dice(emoji=DiceEmoji.SLOT_MACHINE, reply_markup=get_spin_keyboard(l10n))\n\n    # Check whether user won or not\n    score_change = get_score_change(msg.dice.value)\n\n    if score_change < 0:\n        win_or_lose_text = l10n.format_value(\"spin-fail\")\n    else:\n        win_or_lose_text = l10n.format_value(\"spin-success\", {\"score-value\": score_change})\n\n    # Updating score in FSM data\n    new_score = user_score + score_change\n    await state.update_data(score=new_score)\n\n    # This delay is roughly equivalent of animation duration\n    # of slot machine. Depending on dice value,\n    # animation duration is different, but approx. 2 seconds\n    await sleep(2.0)\n    await msg.reply(\n        l10n.format_value(\n            \"after-spin\",\n            {\n                \"combo_text\": get_combo_text(msg.dice.value, l10n),\n                \"dice_value\": msg.dice.value,\n                \"result_text\": win_or_lose_text,\n                \"score-value\": new_score\n            }\n        )\n    )\n"
  },
  {
    "path": "bot/keyboards.py",
    "content": "from functools import cache\n\nfrom aiogram.types import ReplyKeyboardMarkup, KeyboardButton\nfrom fluent.runtime import FluentLocalization\n\n\n@cache\ndef get_spin_keyboard(l10n: FluentLocalization):\n    keyboard = [\n        [KeyboardButton(text=l10n.format_value(\"spin-button-text\"))]\n    ]\n    return ReplyKeyboardMarkup(keyboard=keyboard, resize_keyboard=True)\n"
  },
  {
    "path": "bot/locale/current/README.md",
    "content": "Place your locale directory with ftl-files here."
  },
  {
    "path": "bot/locale/example/README.md",
    "content": "> 🇷🇺 README на русском доступен [здесь](README.ru.md)\n\nIn this directory you can find sample localization files for EN and RU languages.\n\nTo use them in your bot, create a new directory with language code, e.g. \"en\" in \"locales/current\" folder \n(e.g. \"bot/locales/current/en\"). Then put your .ftl files in that directory (e.g. \"strings.ftl\"). \n"
  },
  {
    "path": "bot/locale/example/README.ru.md",
    "content": "В этом каталоге располагаются образцы файлов локализации для английского и русского языков.\n\nЧтобы использовать бота на нужном языке, создайте в каталоге \"locales/current\" папку с любым удобным названием \nлокализации, например, \"ru\". Затем положите туда ваши `.ftl`-файлы (или скопируйте пример из текущего каталога).  \nУ вас должен получиться путь `bot/locales/current/ru` с `ftl`-файлами внутри."
  },
  {
    "path": "bot/locale/example/en/strings.ftl",
    "content": "start-text =\n    <b>Welcome to our virtual casino!</b>\n    From the start, you have { $points } points. Every spin costs 1 point. Possible winning combinations are:\n\n    3-of-a-kind (except \"7\") — 7 points\n    7️⃣7️⃣▫️ — 5 points (square = anything)\n    7️⃣7️⃣7️⃣ — 10 points\n\n    <b>Disclaimer</b>: this bot was made solely for entertainment purposes, your data may be lost any time!\n    There are no paid options in this bot.\n\n    Remove keyboard — /stop\n    Show keyboard if missing — /spin\n\nhelp-text =\n    Telegram slot machine has 4 icons: BAR, grapes, lemon and number 7. In total, there are 64 combinations.\n    Decoding of dice value is described <a href='https://github.com/MasterGroosha/telegram-casino-bot/blob/aiogram3/bot/dice_check.py'>here</a>.\n\n    Source code of the bot is available on <a href='https://github.com/MasterGroosha/telegram-casino-bot'>GitHub</a>\n\nstop-text = Keyboard removed. To start from scratch, press /start, to get keyboard and continue: /spin\n\nbar = BAR\ngrapes = grapes\nlemon = lemon\nseven = seven\n\nspin-button-text = 🎰 Try it!\n\nspin-fail = You lost the bet.\nspin-success =\n    You won {$score_change ->\n         [one] {$score_change} point\n        *[many] {$score_change} points\n    }!\n\nafter-spin =\n    Your combination: { $combo_text } (№{ $dice_value }).\n    { $result_text } New score: <b>{ $new_score }</b>.\n\nzero-balance =\n    Your balance is zero. Accept your fate and get back to your business, or press /start to start over. To remove keyboard, press /stop.\n\n# If you don't want to send sticker when balance is zero, disable feature in bot configuration\nzero-balance-sticker = CAACAgIAAxkBAAEWXv5lAUAm76JOjvehtp18Gxb3if0eVQAC-hEAAknF8EuBzj23_M8x3jAE\n\nmenu-start = Restart Casino\nmenu-spin = Show keyboard and make a spin\nmenu-stop = Remove keyboard\nmenu-help = Information about this bot\n"
  },
  {
    "path": "bot/locale/example/ru/strings.ftl",
    "content": "start-text =\n    <b>Добро пожаловать в наше виртуальное казино!</b>\n    У вас на старте { $points } очков. Каждая попытка стоит 1 очко, а за выигрышные комбинации вы получите:\n\n    3 одинаковых символа (кроме семёрки) — 7 очков\n    7️⃣7️⃣▫️ — 5 очков (квадрат = что угодно)\n    7️⃣7️⃣7️⃣ — 10 очков\n\n    <b>Внимание</b>: бот предназначен исключительно для демонстрации, и ваши данные могут быть сброшены в любой момент!\n    Помните: лудомания — это болезнь, и никаких платных опций в боте нет.\n\n    Убрать клавиатуру — /stop\n    Показать клавиатуру, если пропала — /spin\n\nhelp-text =\n    В казино доступно 4 элемента: BAR, виноград, лимон и цифра семь. Комбинаций, соответственно, 64.\n    Для распознавания комбинации используется четверичная система, а пример кода для получения комбинации по значению от Bot API можно увидеть <a href='https://gist.github.com/MasterGroosha/963c0a82df348419788065ab229094ac'>здесь</a>.\n\n    Исходный код бота доступен на <a href='https://github.com/MasterGroosha/telegram-casino-bot'>GitHub</a> и на <a href='https://git.groosha.space/shared/telegram-casino-bot'>GitLab</a>.\"\n\nstop-text = Клавиатура удалена. Начать заново: /start, вернуть клавиатуру и продолжить: /spin\n\nbar = BAR\ngrapes = виноград\nlemon = лимон\nseven = семь\n\nspin-button-text = 🎰 Испытать удачу!\n\nscore-points = {$score-value ->\n    [one] {$score-value} очко\n    [few] {$score-value} очка\n   *[many] {$score-value} очков\n}\n\nspin-fail =\n    К сожалению, вы не выиграли.\n\nspin-success =\n    <b>Вы выиграли {score-points}!</b>\n\nafter-spin =\n    Ваша комбинация: {$combo_text} (№{$dice_value}).\n    {$result_text}\n    У вас осталось {score-points}.\n\nzero-balance =\n    Ваш баланс равен нулю. Вы можете смириться с судьбой и продолжить жить своей жизнью, а можете нажать /start, чтобы начать всё заново. Или /stop, чтобы просто убрать клавиатуру.\n\n# Если не хотите использовать стикер, укажите это в конфиге\nzero-balance-sticker = CAACAgIAAxkBAAEFGxpfqmqG-MltYIj4zjmFl1eCBfvhZwACuwIAAuPwEwwS3zJY4LIw9B4E\n\nmenu-start = Перезапустить казино\nmenu-spin = Показать клавиатуру и сделать бросок\nmenu-stop = Убрать клавиатуру\nmenu-help = Справочная информация\n"
  },
  {
    "path": "bot/logs.py",
    "content": "import logging\nfrom json import dumps\nfrom sys import stdout\n\nimport structlog\nfrom structlog import WriteLoggerFactory\nfrom structlog.typing import WrappedLogger, EventDict\n\nfrom bot.config_reader import LogConfig, LogRenderer\n\n\nclass ProjectNameProcessor:\n    def __init__(self, project_name: str):\n        self.project_name = project_name\n\n    def __call__(\n        self, logger: WrappedLogger, name: str, event_dict: EventDict\n    ) -> EventDict:\n        event_dict[\"project_name\"] = self.project_name\n        return event_dict\n\n\ndef get_structlog_config(log_config: LogConfig) -> dict:\n    if log_config.show_debug_logs is True:\n        min_level = logging.DEBUG\n    else:\n        min_level = logging.INFO\n\n    if log_config.allow_third_party_logs:\n        # Create handler for stdlib logging\n        standard_handler = logging.StreamHandler(stream=stdout)\n        standard_handler.setFormatter(\n            structlog.stdlib.ProcessorFormatter(\n                processors=get_processors(log_config)\n            )\n        )\n\n        # Configure root logger to use this handler\n        standard_logger = logging.getLogger()\n        standard_logger.addHandler(standard_handler)\n        standard_logger.setLevel(logging.DEBUG if log_config.show_debug_logs else logging.INFO)\n\n\n    return {\n        \"processors\": get_processors(log_config),\n        \"cache_logger_on_first_use\": True,\n        \"wrapper_class\": structlog.make_filtering_bound_logger(min_level),\n        \"logger_factory\": WriteLoggerFactory()\n    }\n\n\ndef get_processors(log_config: LogConfig) -> list:\n    def custom_json_serializer(data, *args, **kwargs):\n        result = dict()\n\n        # Set keys in specific order\n        for key in (\"level\", \"event\"):\n            if key in data:\n                result[key] = data.pop(key)\n\n        # Clean up non-native structlog logs:\n        if \"_from_structlog\" in data:\n            data.pop(\"_from_structlog\")\n            data.pop(\"_record\")\n\n        # Add all other fields\n        result.update(**data)\n        return dumps(result, default=str)\n\n    processors = list()\n    if log_config.show_datetime is True:\n        processors.append(structlog.processors.TimeStamper(\n            fmt=log_config.datetime_format,\n            utc=log_config.time_in_utc\n            )\n        )\n\n    processors.append(structlog.processors.add_log_level)\n    processors.append(ProjectNameProcessor(log_config.project_name))\n\n    if log_config.renderer == LogRenderer.JSON:\n        processors.append(structlog.processors.format_exc_info)\n        processors.append(structlog.processors.JSONRenderer(serializer=custom_json_serializer))\n    else:\n        processors.append(structlog.dev.ConsoleRenderer(\n            colors=log_config.use_colors_in_console,\n            pad_level=False\n        ))\n    return processors\n"
  },
  {
    "path": "bot/middlewares/__init__.py",
    "content": ""
  },
  {
    "path": "bot/middlewares/throttling.py",
    "content": "from typing import Any, Awaitable, Callable, Dict\n\nfrom aiogram import BaseMiddleware\nfrom aiogram.dispatcher.flags import get_flag\nfrom aiogram.types import Message\nfrom cachetools import TTLCache\n\n\n\nclass ThrottlingMiddleware(BaseMiddleware):\n    def __init__(self, throttle_time_spin: int, throttle_time_other: int):\n        self.caches = {\n            \"spin\": TTLCache(maxsize=10_000, ttl=throttle_time_spin),\n            \"default\": TTLCache(maxsize=10_000, ttl=throttle_time_other)\n        }\n\n    async def __call__(\n            self,\n            handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],\n            event: Message,\n            data: Dict[str, Any],\n    ) -> Any:\n        throttling_key = get_flag(data, \"throttling_key\")\n        if throttling_key is not None and throttling_key in self.caches:\n            if event.chat.id in self.caches[throttling_key]:\n                return\n            else:\n                self.caches[throttling_key][event.chat.id] = None\n        return await handler(event, data)\n"
  },
  {
    "path": "bot/ui_commands.py",
    "content": "from aiogram import Bot\nfrom aiogram.types import BotCommand, BotCommandScopeAllPrivateChats\nfrom fluent.runtime import FluentLocalization\n\n\nasync def set_bot_commands(bot: Bot, l10n: FluentLocalization):\n    commands = [\n            BotCommand(command=\"start\", description=l10n.format_value(\"menu-start\")),\n            BotCommand(command=\"spin\", description=l10n.format_value(\"menu-spin\")),\n            BotCommand(command=\"stop\", description=l10n.format_value(\"menu-stop\")),\n            BotCommand(command=\"help\", description=l10n.format_value(\"menu-help\"))\n        ]\n    await bot.set_my_commands(commands=commands, scope=BotCommandScopeAllPrivateChats())\n"
  },
  {
    "path": "casino-bot.example.service",
    "content": "[Unit]\nDescription=Telegram Casino Bot\nAfter=network.target redis.service\n\n[Service]\nType=simple\nWorkingDirectory=/home/user/casino-bot\nExecStart=/home/user/casino-bot/venv/bin/python -m bot\nKillMode=process\nRestart=always\nRestartSec=10\n\n[Install]\nWantedBy=multi-user.target"
  },
  {
    "path": "docker-compose.example.yml",
    "content": "name: \"telegram-casino-bot\"\nservices:\n    bot:\n        build:\n            context: \".\"\n            dockerfile: \"Dockerfile\"\n        profiles:\n            - \"all\"\n        restart: \"always\"\n        stop_signal: SIGINT\n        volumes:\n            # Path to .toml file with settings\n            - \"/home/user/casino-bot/settings.toml:/app/settings.toml\"\n            # Path to localization directory\n            - \"/home/user/casino-bot/locale/example/en:/app/bot/locale/current/en\"\n        environment:\n            - CONFIG_FILE_PATH=/app/settings.toml\n        depends_on:\n            - redis\n\n    redis:\n        profiles:\n            - \"all\"\n            - \"infra\"\n        image: \"redis:8-alpine\"\n        restart: \"always\"\n        volumes:\n            # Path to redis.conf file\n            - \"/home/user/casino-bot/redis.conf:/usr/local/etc/redis/redis.conf\"\n            # Redis volume\n            - \"redis_data:/data\"\n        command: \"redis-server /usr/local/etc/redis/redis.conf\"\n        healthcheck:\n            test: [ \"CMD\", \"redis-cli\",\"ping\" ]\n\nvolumes:\n    redis_data:\n"
  },
  {
    "path": "pyproject.toml",
    "content": "[project]\nname = \"telegram-casino-bot\"\nversion = \"2025.06.1\"\ndescription = \"A simple casino-like bot for Telegram\"\nreadme = \"README.md\"\nrequires-python = \">=3.11\"\ndependencies = [\n    \"aiogram>=3.18.0\",\n    \"async-timeout>=5.0.1\",\n    \"cachetools>=5.5.2\",\n    \"fluent-runtime>=0.4.0\",\n    \"redis>=5.2.1\",\n    \"structlog>=25.1.0\",\n]\n"
  },
  {
    "path": "redis.example.conf",
    "content": "port 6379\nsave 600 1\ndbfilename redis_dump.rdb"
  },
  {
    "path": "settings.example.toml",
    "content": "[bot]\n# Bot token. Obtain one from https://t.me/botfather\ntoken = \"1234567890:AaBbCcDdEeFfGrOoShALlMmNnOoPpQqRrSs\"\n# Where to store users' data. Options: \"memory\", \"redis\".\n# Memory storage gets wiped upon bot restart, Redis uses persistency.\nfsm_mode = \"redis\"\n\n\n[redis]\n# In case you select \"Redis\" fsm_mode, specify connection string here\ndsn = \"redis://user:pass@host:port\"\n\n\n[logs]\n# Project name. Used in logs, useful when aggregating logs from different bots.\nproject_name = \"casino_bot\"\n# If true, logs will include date and time of the event\nshow_datetime = true\n# Datetime format in logs\ndatetime_format = \"%Y-%m-%d %H:%M:%S\"\n# If true, logs with DEBUG level will be shown. Otherwise, only INFO+\nshow_debug_logs = false\n# If true, logs will use UTC time instead of server's local time\ntime_in_utc = false\n# How to show logs. Options: \"console\", \"json\"\nrenderer = \"json\"\n# If true, \"console\" renderer will use colors when rendering logs\nuse_colors_in_console = false\n# If true, logs from other libraries (e.g. aiogram) will be shown\nallow_third_party_logs = true\n\n\n[game_config]\n# Starting points for all new games\nstarting_points = 50\n# Whether to send a special \"game over\" sticker or not. Specify sticker's file id in localization file\nsend_gameover_sticker = true\n# Throttling time for spins. Casino dice animation is variable, on average ~2 seconds long\nthrottle_time_spin = 2\n# Throttling time for all other actions.\nthrottle_time_other = 1"
  }
]