Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# EditorConfig helps maintain consistent coding styles
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4

[*.rs]
indent_size = 4

[*.md]
max_line_length = off
trim_trailing_whitespace = false
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Telegram Dice Bot configuration
BOT_TOKEN=your_bot_token_here
PORT=5000
# MODE can be: polling or webhook (webhook not yet enabled)
MODE=polling
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
push:
branches: [ main, master ]
pull_request:

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@v1
with:
toolchain: stable
components: rustfmt, clippy

- name: Cache Cargo
uses: Swatinem/rust-cache@v2

- name: Format
run: cargo fmt --all -- --check

- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings

- name: Tests
run: cargo test --all-features --workspace --verbose
20 changes: 20 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
repos:
- repo: local
hooks:
- id: cargo-fmt
name: cargo fmt
entry: bash -c 'cargo fmt --all'
language: system
pass_filenames: false

- id: cargo-clippy
name: cargo clippy
entry: bash -c 'cargo clippy --all-targets --all-features -- -D warnings'
language: system
pass_filenames: false

- id: cargo-test
name: cargo test
entry: bash -c 'cargo test --all-features --workspace'
language: system
pass_filenames: false
9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ version = "0.1.0"
edition = "2021"

[dependencies]
teloxide = { version = "0.12", features = ["macros", "webhooks"] }
teloxide = { version = "0.12", default-features = false, features = ["macros", "webhooks", "rustls"] }
tokio = { version = "1.0", features = ["full"] }
rand = "0.8"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
env_logger = "0.10"
axum = "0.7"
url = "2.4"

[dev-dependencies]
proptest = "1"
pretty_assertions = "1"

[dependencies.dotenvy]
version = "0.15"
32 changes: 32 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# syntax=docker/dockerfile:1

ARG RUST_VERSION=1.79
ARG APP_NAME=telegram-dice-bot

FROM rust:${RUST_VERSION}-slim AS builder
WORKDIR /app

# Cache dependencies
COPY Cargo.toml Cargo.lock ./
RUN mkdir -p src && echo "fn main(){}" > src/main.rs && \
cargo build --release && \
rm -rf src

# Build application
COPY . .
RUN cargo build --release --bin ${APP_NAME}

FROM debian:bookworm-slim AS runtime
ENV RUST_LOG=info
ENV PORT=5000

# Create non-root user
RUN useradd -u 10001 -m appuser

WORKDIR /app
COPY --from=builder /app/target/release/${APP_NAME} /usr/local/bin/${APP_NAME}

EXPOSE 5000
USER appuser

CMD ["/usr/local/bin/telegram-dice-bot"]
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025 The Telegram Dice Bot Authors

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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ cd telegram-dice-bot

2. Создайте бота у @BotFather в Telegram и получите токен

3. Установите переменную окружения:
3. Создайте файл `.env` на основе `.env.example` или установите переменную окружения:
```bash
export BOT_TOKEN="ваш_токен_бота"
```
Expand Down
40 changes: 20 additions & 20 deletions plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@
Краткий, практичный план по внедрению тестов, CI/CD, контейнеризации, наблюдаемости и улучшений функционала. Выполняем по фазам, каждая фаза даёт самодостаточную ценность.

### Фаза 1 — Базовое качество (тесты, стиль, лицензия)
- [ ] Тесты ядра игры (`src/game.rs`):
- [ ] Unit-тесты для `check_even_odd`, `check_high_low`, `check_exact_number`, `check_guess_one`, `dice_emoji`
- [ ] Property-тесты (crate `proptest`) для диапазона 1..=6 и свойств чётности/диапазонов
- [x] Тесты ядра игры (`src/game.rs`):
- [x] Unit-тесты для `check_even_odd`, `check_high_low`, `check_exact_number`, `check_guess_one`, `dice_emoji`
- [x] Property-тесты (crate `proptest`) для диапазона 1..=6 и свойств чётности/диапазонов
- [ ] Интеграционные тесты (`tests/`): сценарии `/start`, `/help`, `/play` и callback’и c моком Telegram API
- [ ] Лицензия: добавить файл `LICENSE` (MIT), чтобы совпадал с README
- [ ] Единый стиль и инструменты разработчика:
- [ ] `rustfmt.toml` (форматирование) и `.editorconfig`
- [ ] Предкоммит-хуки (например, `pre-commit`) с `cargo fmt`, `cargo clippy`, `cargo test`
- [ ] Конфигурация окружения:
- [ ] Добавить `dotenvy` и автозагрузка `.env` в dev-режиме
- [ ] Шаблон `.env.example` (переменные: `BOT_TOKEN`, `PORT=5000`, `MODE=polling|webhook`)
- [x] Лицензия: добавить файл `LICENSE` (MIT), чтобы совпадал с README
- [x] Единый стиль и инструменты разработчика:
- [x] `rustfmt.toml` (форматирование) и `.editorconfig`
- [x] Предкоммит-хуки (например, `pre-commit`) с `cargo fmt`, `cargo clippy`, `cargo test`
- [x] Конфигурация окружения:
- [x] Добавить `dotenvy` и автозагрузка `.env` в dev-режиме
- [x] Шаблон `.env.example` (переменные: `BOT_TOKEN`, `PORT=5000`, `MODE=polling|webhook`)

Примеры команд:

Expand All @@ -24,18 +24,18 @@ cargo fmt && cargo clippy -D warnings && cargo test
```

### Фаза 2 — CI/CD
- [ ] GitHub Actions: `.github/workflows/ci.yml`
- [ ] Установка toolchain, кеш Cargo
- [ ] Шаги: `fmt --check`, `clippy -D warnings`, `test --all`
- [x] GitHub Actions: `.github/workflows/ci.yml`
- [x] Установка toolchain, кеш Cargo
- [x] Шаги: `fmt --check`, `clippy -D warnings`, `test --all`
- [ ] Безопасность: `cargo-audit`, `cargo-deny`
- [ ] Автообновления: `dependabot.yml` (Cargo + GitHub Actions)
- [ ] Релизы (опционально): `cargo-release` и публикация GitHub Releases по тегу

### Фаза 3 — Контейнеризация и поставка
- [ ] `Dockerfile` (multi-stage):
- [ ] Сборка релизного бинаря
- [ ] Runtime-образ минимальный, запуск под non-root
- [ ] По возможности `rustls` вместо OpenSSL для упрощения доставки
- [x] `Dockerfile` (multi-stage):
- [x] Сборка релизного бинаря
- [x] Runtime-образ минимальный, запуск под non-root
- [x] По возможности `rustls` вместо OpenSSL для упрощения доставки
- [ ] `docker-compose.yml` для локального запуска
- [ ] Публикация образа в GHCR из CI (по тегу/мэйнам)

Expand Down Expand Up @@ -82,6 +82,6 @@ docker run --rm -e BOT_TOKEN=... -e PORT=5000 -p 5000:5000 ghcr.io/<org>/telegra
- [ ] Документация обновлена: README разделы по запуску (локально/Docker), переменные окружения

### Следующие шаги (я могу сделать сразу)
- [ ] Добавить unit-тесты для `src/game.rs`
- [ ] Создать `.github/workflows/ci.yml` (fmt, clippy, test)
- [ ] Добавить `Dockerfile` и `.env.example`
- [x] Добавить unit-тесты для `src/game.rs`
- [x] Создать `.github/workflows/ci.yml` (fmt, clippy, test)
- [x] Добавить `Dockerfile` и `.env.example`
1 change: 1 addition & 0 deletions rustfmt.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
edition = "2021"
4 changes: 2 additions & 2 deletions src/bot.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
use log::{error, info};
use teloxide::{
dispatching::UpdateHandler,
prelude::*,
types::{InlineKeyboardButton, InlineKeyboardMarkup, ParseMode},
utils::command::BotCommands,
dispatching::UpdateHandler,
RequestError,
};
use log::{info, error};

use crate::game::DiceGame;
use crate::state::{EvenOddChoice, HighLowChoice, GuessOneChoice};
Expand Down
89 changes: 88 additions & 1 deletion src/game.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use rand::Rng;
use crate::state::{EvenOddChoice, HighLowChoice, GuessOneChoice};
use rand::Rng;

/// Структура для управления игровой логикой
pub struct DiceGame;
Expand Down Expand Up @@ -83,3 +83,90 @@ impl DiceGame {
messages[index]
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::state::{EvenOddChoice, HighLowChoice, GuessOneChoice};
use pretty_assertions::assert_eq;

#[test]
fn test_check_even_odd_basic() {
assert!(DiceGame::check_even_odd(2, EvenOddChoice::Even));
assert!(!DiceGame::check_even_odd(2, EvenOddChoice::Odd));
assert!(DiceGame::check_even_odd(5, EvenOddChoice::Odd));
assert!(!DiceGame::check_even_odd(5, EvenOddChoice::Even));
}

#[test]
fn test_check_high_low_basic() {
assert!(DiceGame::check_high_low(1, HighLowChoice::Low));
assert!(DiceGame::check_high_low(3, HighLowChoice::Low));
assert!(!DiceGame::check_high_low(3, HighLowChoice::High));
assert!(DiceGame::check_high_low(4, HighLowChoice::High));
assert!(DiceGame::check_high_low(6, HighLowChoice::High));
assert!(!DiceGame::check_high_low(4, HighLowChoice::Low));
}

#[test]
fn test_check_exact_number_basic() {
assert!(DiceGame::check_exact_number(4, 4));
assert!(!DiceGame::check_exact_number(1, 6));
}

#[test]
fn test_check_guess_one_basic() {
assert!(DiceGame::check_guess_one(1, GuessOneChoice::Yes));
assert!(!DiceGame::check_guess_one(1, GuessOneChoice::No));
assert!(DiceGame::check_guess_one(3, GuessOneChoice::No));
assert!(!DiceGame::check_guess_one(3, GuessOneChoice::Yes));
}

#[test]
fn test_dice_emoji_mapping() {
assert_eq!(DiceGame::dice_emoji(1), "⚀");
assert_eq!(DiceGame::dice_emoji(2), "⚁");
assert_eq!(DiceGame::dice_emoji(3), "⚂");
assert_eq!(DiceGame::dice_emoji(4), "⚃");
assert_eq!(DiceGame::dice_emoji(5), "⚄");
assert_eq!(DiceGame::dice_emoji(6), "⚅");
assert_eq!(DiceGame::dice_emoji(0), "🎲");
assert_eq!(DiceGame::dice_emoji(7), "🎲");
}

#[test]
fn test_roll_dice_range() {
for _ in 0..1000 {
let value = DiceGame::roll_dice();
assert!((1..=6).contains(&value), "roll_dice produced {}", value);
}
}

mod properties {
use super::*;
use proptest::prelude::*;

proptest! {
#[test]
fn even_odd_property(dice_result in 1u8..=6u8) {
let is_even = dice_result % 2 == 0;
prop_assert_eq!(DiceGame::check_even_odd(dice_result, EvenOddChoice::Even), is_even);
prop_assert_eq!(DiceGame::check_even_odd(dice_result, EvenOddChoice::Odd), !is_even);
}

#[test]
fn high_low_property(dice_result in 1u8..=6u8) {
let is_high = dice_result >= 4;
prop_assert_eq!(DiceGame::check_high_low(dice_result, HighLowChoice::High), is_high);
prop_assert_eq!(DiceGame::check_high_low(dice_result, HighLowChoice::Low), !is_high);
}

#[test]
fn exact_number_property(dice_result in 1u8..=6u8) {
prop_assert!(DiceGame::check_exact_number(dice_result, dice_result));
let other = if dice_result == 6 { 1 } else { dice_result + 1 };
prop_assert!(!DiceGame::check_exact_number(dice_result, other));
}
}
}
}
15 changes: 7 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,7 @@
use teloxide::prelude::*;
use log::{info, error};
use axum::{
http::StatusCode,
response::Html,
routing::get,
Router,
};
use axum::{http::StatusCode, response::Html, routing::get, Router};
use log::{error, info};
use std::net::SocketAddr;
use teloxide::prelude::*;

mod bot;
mod game;
Expand All @@ -19,6 +14,10 @@ async fn main() {
// Инициализация логгера
env_logger::init();

// Автозагрузка переменных окружения из .env в dev-режиме
// Не паникуем, если файла нет (prod окружение)
let _ = dotenvy::dotenv();

info!("Запуск Telegram бота для игры в кубики");

// Получение токена бота из переменных окружения
Expand Down
Loading