Skip to content

Commit 389cbc6

Browse files
Feature: Customizable texts via Project Fluent (#9)
* Updated all dependencies to their actual versions * Updated pydantic to v2 along with config * Added Project Fluent library and its loader * Updates field validators * Added SpinTextFilter to handle localized spin text * Make localized spin keyboard * Cache created keyboard, since it's always the same until restart * Pass config through dispatcher * Use localized texts for handlers * Greatly speed up getting dice combo text * Optionally send zero balance sticker (easter egg?) * Added more config parameters * Deleted const.py file * Use throttle time from config * Updated LICENSE * Translated dice_check.py to English * Localizable bot commands * More comments in spin.py * Added sample localization files for RU and EN * Hardcode sleep after slot machine spin * Updated env_example * Added README.md in English, updated README.ru.md in Russian * Removed .gitlab-ci.yml * Updated docker-compose.example.yml
1 parent 09ef66c commit 389cbc6

23 files changed

+402
-211
lines changed

.gitlab-ci.yml

Lines changed: 0 additions & 40 deletions
This file was deleted.

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2020-2022 Groosha
3+
Copyright (c) 2020-present Aleksandr (aka MasterGroosha on GitHub)
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,37 @@
1-
[<img src="https://img.shields.io/badge/Telegram-%40DifichentoBot-blue">](https://t.me/DifichentoBot)
1+
[<img src="https://img.shields.io/badge/Telegram-%40DifichentoBot-blue">](https://t.me/DifichentoBot) (Ru)
22

3-
# Виртуальное казино в Telegram
3+
> 🇷🇺 README на русском доступен [здесь](README.ru.md)
44
5-
В конце октября 2020 года команда Telegram выпустила [очередное обновление](https://telegram.org/blog/pinned-messages-locations-playlists/ru?ln=a)
6-
мессенджера с поддержкой дайса игрового автомата. Вот он:
5+
# Telegram Virtual Casino
76

8-
![игровой автомат](repo_images/slot_machine.png)
7+
In October 2020 Telegram team released [yet another update](https://telegram.org/blog/pinned-messages-locations-playlists)
8+
with slot machine dice. Here it is:
99

10-
Согласно [документации на тип Dice](https://core.telegram.org/bots/api#dice) в Bot API, слот-машина
11-
может принимать значения от 1 до 64 включительно. В файле `casino.py` вы найдёте функции для сопоставления значения дайса
12-
с тройкой выпавших элементов игрового автомата. Для демонстрации создан бот [@DifichentoBot](https://t.me/difichentobot) с
13-
ведением счёта на виртуальные очки, начиная с 50.
14-
Важным отличием от «традиционного» казино является невозможность влиять
15-
на выпадающие комбинации, т.к. итоговое значение генерируется на стороне Telegram.
10+
![slot machine dice](repo_images/slot_machine.png)
1611

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

19-
* [aiogram](https://github.com/aiogram/aiogram) — работа с Telegram Bot API;
20-
* [redis](https://redis.io) — персистентное хранение данных;
21-
* [cachetools](https://cachetools.readthedocs.io/en/stable) — реализация троттлинга для борьбы с флудом;
22-
* [Docker](https://www.docker.com) и [Docker-Compose](https://docs.docker.com/compose) — быстрое разворачивание бота \
23-
в изолированном контейнере.
18+
## Technology
19+
20+
* [aiogram](https://github.com/aiogram/aiogram) — asyncio Telegram Bot API framework;
21+
* [redis](https://redis.io) — persistent data storage (persistency enabled separately);
22+
* [cachetools](https://cachetools.readthedocs.io/en/stable) — for anti-flood throttling mechanism;
23+
* [Docker](https://www.docker.com) and [Docker-Compose](https://docs.docker.com/compose) — quickly deploy bot in containers.
2424
* Systemd
2525

26-
## Установка
26+
## Installation
2727

28-
Скопируйте файл `env_example` как `.env` (с точкой в начале), откройте и отредактируйте содержимое. Создайте каталоги
29-
`redis_data` и `redis_config`, в последний подложите свой конфиг `redis.conf` (в репозитории есть пример). \
30-
Запустите бота командой `docker-compose up -d`.
28+
Copy `env_example` file to `.env` (with leading dot), open and edit it. Create `redis_data` and `redis_config`
29+
directories, put `redis.conf` file into the latter (there is [example](redis.example.conf) in this repo).
30+
Run the bot with `docker-compose up -d` command.
3131

32-
Альтернативный вариант: используйте Systemd, пример службы тоже есть в репозитории.
32+
Alternative way: you can use Systemd services, there is also an [example](casino-bot.example.service) available.
3333

34-
## Благодарности
34+
## Credits to
3535

36-
* [@Tishka17](https://t.me/Tishka17) за изначальный вектор направления
37-
* [@svinerus](https://t.me/svinerus) за компактную реализацию определения выпавшей комбинации (f6f42a841d3c1778f0e32)
36+
* [@Tishka17](https://t.me/Tishka17) for initial inspiration
37+
* [@svinerus](https://t.me/svinerus) for compact dice combination check (f6f42a841d3c1778f0e32)

README.ru.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[<img src="https://img.shields.io/badge/Telegram-%40DifichentoBot-blue">](https://t.me/DifichentoBot)
2+
3+
# Виртуальное казино в Telegram
4+
5+
В конце октября 2020 года команда Telegram выпустила [очередное обновление](https://telegram.org/blog/pinned-messages-locations-playlists/ru?ln=a)
6+
мессенджера с поддержкой дайса игрового автомата. Вот он:
7+
8+
![игровой автомат](repo_images/slot_machine.png)
9+
10+
Согласно [документации на тип Dice](https://core.telegram.org/bots/api#dice) в Bot API, слот-машина
11+
может принимать значения от 1 до 64 включительно. В файле [dice_check.py](bot/dice_check.py) вы найдёте функции
12+
для сопоставления значения дайса с тройкой выпавших элементов игрового автомата.
13+
Для демонстрации создан бот [@DifichentoBot](https://t.me/difichentobot) с ведением счёта на виртуальные очки.
14+
Важным отличием от «традиционного» казино является невозможность влиять
15+
на выпадающие комбинации, т.к. итоговое значение генерируется на стороне Telegram.
16+
17+
## Технологии
18+
19+
* [aiogram](https://github.com/aiogram/aiogram) — работа с Telegram Bot API;
20+
* [redis](https://redis.io) — персистентное хранение данных (персистентность включается отдельно);
21+
* [cachetools](https://cachetools.readthedocs.io/en/stable) — реализация троттлинга для борьбы с флудом;
22+
* [Docker](https://www.docker.com) и [Docker-Compose](https://docs.docker.com/compose) — быстрое разворачивание бота в изолированном контейнере.
23+
* Systemd
24+
25+
## Установка
26+
27+
Скопируйте файл `env_example` как `.env` (с точкой в начале), откройте и отредактируйте содержимое. Создайте каталоги
28+
`redis_data` и `redis_config`, в последний подложите свой конфиг `redis.conf`
29+
(в репозитории есть [пример](redis.example.conf)). Запустите бота командой `docker-compose up -d`.
30+
31+
Альтернативный вариант: используйте Systemd, пример службы тоже есть в [репозитории](casino-bot.example.service).
32+
33+
## Благодарности
34+
35+
* [@Tishka17](https://t.me/Tishka17) за изначальный вектор направления
36+
* [@svinerus](https://t.me/svinerus) за компактную реализацию определения выпавшей комбинации (f6f42a841d3c1778f0e32)

bot/__main__.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,16 @@
55
from aiogram.fsm.storage.memory import MemoryStorage
66
from aiogram.fsm.storage.redis import RedisStorage
77

8-
from bot.config_reader import config
8+
from bot.config_reader import Settings
9+
from bot.fluent_loader import get_fluent_localization
910
from bot.handlers import default_commands, spin
1011
from bot.middlewares.throttling import ThrottlingMiddleware
1112
from bot.ui_commands import set_bot_commands
1213

1314

1415
async def main():
1516
logging.basicConfig(level=logging.WARNING)
17+
config = Settings()
1618

1719
bot = Bot(config.bot_token.get_secret_value(), parse_mode="HTML")
1820

@@ -25,8 +27,11 @@ async def main():
2527
else:
2628
storage = MemoryStorage()
2729

30+
# Loading localization for bot
31+
l10n = get_fluent_localization(config.bot_language)
32+
2833
# Создание диспетчера
29-
dp = Dispatcher(storage=storage)
34+
dp = Dispatcher(storage=storage, l10n=l10n, config=config)
3035
# Принудительно настраиваем фильтр на работу только в чатах один-на-один с ботом
3136
dp.message.filter(F.chat.type == "private")
3237

@@ -35,10 +40,10 @@ async def main():
3540
dp.include_router(spin.router)
3641

3742
# Регистрация мидлвари для троттлинга
38-
dp.message.middleware(ThrottlingMiddleware())
43+
dp.message.middleware(ThrottlingMiddleware(config.throttle_time_spin, config.throttle_time_other))
3944

40-
# Установка команд в интерфейсе
41-
await set_bot_commands(bot)
45+
# Set bot commands in the UI
46+
await set_bot_commands(bot, l10n)
4247

4348
try:
4449
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())

bot/config_reader.py

Lines changed: 24 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,31 @@
1+
from enum import Enum
12
from typing import Optional
23

3-
from pydantic import BaseSettings, validator, SecretStr, RedisDsn
4+
from pydantic import field_validator, SecretStr, RedisDsn, FieldValidationInfo
5+
from pydantic_settings import BaseSettings, SettingsConfigDict
46

57

6-
class Settings(BaseSettings):
7-
bot_token: SecretStr
8-
fsm_mode: str
9-
redis: Optional[RedisDsn]
8+
class FSMMode(str, Enum):
9+
MEMORY = "memory"
10+
REDIS = "redis"
1011

11-
@validator("fsm_mode")
12-
def fsm_type_check(cls, v):
13-
if v not in ("memory", "redis"):
14-
raise ValueError("Incorrect fsm_mode. Must be one of: memory, redis")
15-
return v
1612

17-
@validator("redis")
18-
def skip_validating_redis(cls, v, values):
19-
if values["fsm_mode"] == "redis" and v is None:
20-
raise ValueError("Redis config is missing, though fsm_type is 'redis'")
13+
class Settings(BaseSettings):
14+
bot_token: SecretStr
15+
fsm_mode: FSMMode
16+
redis: Optional[RedisDsn] = None
17+
bot_language: str
18+
starting_points: int = 50
19+
send_gameover_sticker: bool = False
20+
throttle_time_spin: int = 2
21+
throttle_time_other: int = 1
22+
23+
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8')
24+
25+
@field_validator("redis", mode="after")
26+
@classmethod
27+
def skip_validating_redis(cls, v: Optional[RedisDsn], info: FieldValidationInfo):
28+
if info.data.get("fsm_mode") == FSMMode.REDIS and v is None:
29+
err = 'FSM Mode is set to "Redis", but Redis DNS is missing!'
30+
raise ValueError(err)
2131
return v
22-
23-
class Config:
24-
env_file = '.env'
25-
env_file_encoding = 'utf-8'
26-
27-
28-
config = Settings()

bot/const.py

Lines changed: 0 additions & 5 deletions
This file was deleted.

bot/dice_check.py

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,48 @@
11
# Source: https://gist.github.com/MasterGroosha/963c0a82df348419788065ab229094ac
22

3-
from typing import List, Tuple
3+
from functools import lru_cache
4+
from typing import List
45

6+
from fluent.runtime import FluentLocalization
57

8+
9+
@lru_cache(maxsize=64)
610
def get_score_change(dice_value: int) -> int:
711
"""
8-
Проверка на выигрышную комбинацию
12+
Checks for the winning combination
913
10-
:param dice_value: значение дайса (число)
11-
:return: изменение счёта игрока (число)
14+
:param dice_value: dice value (1-64)
15+
:return: user score change (integer)
1216
"""
1317

14-
# Совпадающие значения (кроме 777)
18+
# three-of-a-kind (except 777)
1519
if dice_value in (1, 22, 43):
1620
return 7
17-
# Начинающиеся с двух семёрок (опять же, не учитываем 777)
21+
# starting with two 7's (again, except 777)
1822
elif dice_value in (16, 32, 48):
1923
return 5
20-
# Джекпот (три семёрки)
24+
# jackpot (777)
2125
elif dice_value == 64:
2226
return 10
2327
else:
2428
return -1
2529

2630

27-
def get_combo_text(dice_value: int) -> List[str]:
31+
def get_combo_parts(dice_value: int) -> List[str]:
2832
"""
29-
Возвращает то, что было на конкретном дайсе-казино
30-
:param dice_value: значение дайса (число)
31-
:return: массив строк, содержащий все выпавшие элементы в виде текста
32-
33-
Альтернативный вариант (ещё раз спасибо t.me/svinerus):
34-
return [casino[(dice_value - 1) // i % 4]for i in (1, 4, 16)]
33+
Returns exact icons from dice (bar, grapes, lemon, seven).
34+
Do not edit these values, since they are subject to be translated
35+
by outer code.
36+
:param dice_value: dice value (1-64)
37+
:return: list of icons' texts
3538
"""
39+
40+
# Alternative way (credits to t.me/svinerus):
41+
# return [casino[(dice_value - 1) // i % 4]for i in (1, 4, 16)]
42+
43+
# Do not edit these values; they are actually translation keys
3644
# 0 1 2 3
37-
values = ["BAR", "виноград", "лимон", "семь"]
45+
values = ["bar", "grapes", "lemon", "seven"]
3846

3947
dice_value -= 1
4048
result = []
@@ -44,14 +52,15 @@ def get_combo_text(dice_value: int) -> List[str]:
4452
return result
4553

4654

47-
def get_combo_data(dice_value: int) -> Tuple[int, str]:
55+
@lru_cache(maxsize=64)
56+
def get_combo_text(dice_value: int, l10n: FluentLocalization) -> str:
4857
"""
49-
Возвращает все необходимые для показа информации о комбинации данные
50-
51-
:param dice_value: значение дайса (число)
52-
:return: Пара ("изменение счёта", "список выпавших элементов")
58+
Returns localized string with dice result
59+
:param dice_value: dice value (1-64)
60+
:param l10n: Fluent localization object
61+
:return: string with localized result
5362
"""
54-
return (
55-
get_score_change(dice_value),
56-
', '.join(get_combo_text(dice_value))
57-
)
63+
parts: list[str] = get_combo_parts(dice_value)
64+
for i in range(len(parts)):
65+
parts[i] = l10n.format_value(parts[i])
66+
return ", ".join(parts)

bot/filters/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .spin_text_filter import SpinTextFilter
2+
3+
__all__ = [
4+
"SpinTextFilter"
5+
]

bot/filters/spin_text_filter.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from aiogram.filters import BaseFilter
2+
from aiogram.types import Message
3+
from fluent.runtime import FluentLocalization
4+
5+
6+
class SpinTextFilter(BaseFilter):
7+
async def __call__(self, message: Message, l10n: FluentLocalization) -> bool:
8+
return message.text == l10n.format_value("spin-button-text")

bot/fluent_loader.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from pathlib import Path
2+
3+
from fluent.runtime import FluentLocalization, FluentResourceLoader
4+
5+
6+
def get_fluent_localization(language: str) -> FluentLocalization:
7+
"""
8+
Loads FTL files for chosen language
9+
:param language: language name, as passed from configuration outside
10+
:return: FluentLocalization object with loaded FTL files for chosen language
11+
"""
12+
13+
# Check "locales" directory on the same level as this file
14+
locales_dir = Path(__file__).parent.joinpath("locales")
15+
if not locales_dir.exists():
16+
err = '"locales" directory does not exist'
17+
raise FileNotFoundError(err)
18+
if not locales_dir.is_dir():
19+
err = '"locales" is not a directory'
20+
raise NotADirectoryError(err)
21+
22+
locales_dir = locales_dir.absolute()
23+
locale_dir_found = False
24+
for directory in Path.iterdir(locales_dir):
25+
if directory.stem == language:
26+
locale_dir_found = True
27+
break
28+
if not locale_dir_found:
29+
err = f'Directory for "{language}" locale not found'
30+
raise FileNotFoundError(err)
31+
32+
locale_files = list()
33+
for file in Path.iterdir(Path.joinpath(locales_dir, language)):
34+
if file.suffix == ".ftl":
35+
locale_files.append(str(file.absolute()))
36+
l10n_loader = FluentResourceLoader(str(Path.joinpath(locales_dir, "{locale}")))
37+
38+
return FluentLocalization([language], locale_files, l10n_loader)

0 commit comments

Comments
 (0)