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 ================================================ [](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 ================================================ [](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 = Welcome to our virtual casino! 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 Disclaimer: 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 here. Source code of the bot is available on GitHub 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: { $new_score }. 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 = Добро пожаловать в наше виртуальное казино! У вас на старте { $points } очков. Каждая попытка стоит 1 очко, а за выигрышные комбинации вы получите: 3 одинаковых символа (кроме семёрки) — 7 очков 7️⃣7️⃣▫️ — 5 очков (квадрат = что угодно) 7️⃣7️⃣7️⃣ — 10 очков Внимание: бот предназначен исключительно для демонстрации, и ваши данные могут быть сброшены в любой момент! Помните: лудомания — это болезнь, и никаких платных опций в боте нет. Убрать клавиатуру — /stop Показать клавиатуру, если пропала — /spin help-text = В казино доступно 4 элемента: BAR, виноград, лимон и цифра семь. Комбинаций, соответственно, 64. Для распознавания комбинации используется четверичная система, а пример кода для получения комбинации по значению от Bot API можно увидеть здесь. Исходный код бота доступен на GitHub и на GitLab." 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 = Вы выиграли {score-points}! 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