diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f9e27d9 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7909f5c --- /dev/null +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3587796 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5a07429 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/Cargo.toml b/Cargo.toml index be591fc..45725e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ 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"] } @@ -12,3 +12,10 @@ 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" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..971159e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..323c06a --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index c47de55..de06ed7 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ cd telegram-dice-bot 2. Создайте бота у @BotFather в Telegram и получите токен -3. Установите переменную окружения: +3. Создайте файл `.env` на основе `.env.example` или установите переменную окружения: ```bash export BOT_TOKEN="ваш_токен_бота" ``` diff --git a/plan.md b/plan.md index 7600c00..a6271d8 100644 --- a/plan.md +++ b/plan.md @@ -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`) Примеры команд: @@ -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 (по тегу/мэйнам) @@ -82,6 +82,6 @@ docker run --rm -e BOT_TOKEN=... -e PORT=5000 -p 5000:5000 ghcr.io//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` diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..36c419b --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +edition = "2021" \ No newline at end of file diff --git a/src/bot.rs b/src/bot.rs index 63aaee9..b3c24b2 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -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}; diff --git a/src/game.rs b/src/game.rs index 550659f..748303e 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1,5 +1,5 @@ -use rand::Rng; use crate::state::{EvenOddChoice, HighLowChoice, GuessOneChoice}; +use rand::Rng; /// Структура для управления игровой логикой pub struct DiceGame; @@ -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)); + } + } + } +} diff --git a/src/main.rs b/src/main.rs index bb7eba8..645765c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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; @@ -19,6 +14,10 @@ async fn main() { // Инициализация логгера env_logger::init(); + // Автозагрузка переменных окружения из .env в dev-режиме + // Не паникуем, если файла нет (prod окружение) + let _ = dotenvy::dotenv(); + info!("Запуск Telegram бота для игры в кубики"); // Получение токена бота из переменных окружения