commit d9b97164328076c1290c0e979f298e7ae628ab31 Author: Zikil Date: Wed Dec 18 00:11:17 2024 +0700 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90994d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +venv/ +*.db +__pycache__ +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5687447 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.11-slim + +# Установка рабочей директории +WORKDIR /app + +# Установка зависимостей +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование файлов проекта +COPY bot/ ./bot/ +COPY .env . + +# Создание директории для базы данных +RUN mkdir -p /app/data + +# Запуск бота +CMD ["python", "-m", "bot.main"] \ No newline at end of file diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..0162d3f --- /dev/null +++ b/bot/config.py @@ -0,0 +1,7 @@ +from dotenv import load_dotenv +import os + +load_dotenv() + +BOT_TOKEN = os.getenv('BOT_TOKEN') +WEBAPP_URL = os.getenv('WEBAPP_URL') \ No newline at end of file diff --git a/bot/database.py b/bot/database.py new file mode 100644 index 0000000..d3c87ea --- /dev/null +++ b/bot/database.py @@ -0,0 +1,99 @@ +import sqlite3 +from datetime import datetime +import os + +class Database: + def __init__(self): + self.db_path = '/app/data/nard_dice.db' + self._init_db() + + def _init_db(self): + """Инициализация базы данных""" + if not os.path.exists(self.db_path): + os.makedirs(os.path.dirname(self.db_path), exist_ok=True) + with sqlite3.connect(self.db_path) as conn: + with open('bot/schema.sql', 'r') as f: + conn.executescript(f.read()) + + def _get_connection(self): + """Получение соединения с базой данных""" + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + return conn + + def create_game(self, user_id: int, username: str) -> int: + """Создание новой игры""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO games (user_id, username) + VALUES (?, ?) + ''', (user_id, username)) + conn.commit() + return cursor.lastrowid + + def add_throw(self, game_id: int, dice1: int, dice2: int): + """Добавление броска""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO throws (game_id, dice1, dice2) + VALUES (?, ?, ?) + ''', (game_id, dice1, dice2)) + conn.commit() + + def get_active_game(self, user_id: int): + """Получение активной игры пользователя""" + with self._get_connection() as conn: + cursor = conn.cursor() + game = cursor.execute(''' + SELECT id, user_id, username, start_time + FROM games + WHERE user_id = ? AND status = 'active' + ORDER BY start_time DESC + LIMIT 1 + ''', (user_id,)).fetchone() + + if game: + return dict(game) + return None + + def end_game(self, game_id: int): + """Завершение игры""" + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(''' + UPDATE games + SET status = 'completed', end_time = CURRENT_TIMESTAMP + WHERE id = ? + ''', (game_id,)) + conn.commit() + + def get_statistics(self, user_id: int): + """Получение статистики пользователя""" + with self._get_connection() as conn: + cursor = conn.cursor() + stats = cursor.execute(''' + SELECT + COUNT(*) as total_throws, + AVG(dice1 + dice2) as avg_sum, + COUNT(DISTINCT game_id) as total_games + FROM throws t + JOIN games g ON t.game_id = g.id + WHERE g.user_id = ? + ''', (user_id,)).fetchone() + + return dict(stats) + + def get_game_throws(self, game_id: int): + """Получение всех бросков игры""" + with self._get_connection() as conn: + cursor = conn.cursor() + throws = cursor.execute(''' + SELECT dice1, dice2, throw_time + FROM throws + WHERE game_id = ? + ORDER BY throw_time DESC + ''', (game_id,)).fetchall() + + return [dict(throw) for throw in throws] \ No newline at end of file diff --git a/bot/handlers.py b/bot/handlers.py new file mode 100644 index 0000000..d793b17 --- /dev/null +++ b/bot/handlers.py @@ -0,0 +1,133 @@ +from telegram import Update, WebAppInfo, KeyboardButton, ReplyKeyboardMarkup +from telegram.ext import ContextTypes +from database import Database +import json + +db = Database() + +async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + keyboard = [ + [ + KeyboardButton( + "Открыть счётчик 🎲", + web_app=WebAppInfo(url=context.bot_data['webapp_url']) + ) + ], + [KeyboardButton("Новая игра 🎮"), KeyboardButton("Статистика 📊")], + [KeyboardButton("Завершить игру 🏁")] + ] + + reply_markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True) + + await update.message.reply_text( + "🎲 Добро пожаловать в счётчик кубиков для нард!\n\n" + "Выберите действие:", + reply_markup=reply_markup + ) + +async def new_game_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + user = update.effective_user + + # Проверяем, нет ли уже активной игры + active_game = db.get_active_game(user.id) + if active_game: + await update.message.reply_text( + "❗️ У вас уже есть активная игра. " + "Сначала завершите её командой /end_game" + ) + return + + game_id = db.create_game(user.id, user.username) + context.user_data['current_game'] = game_id + + await update.message.reply_text( + "🎮 Новая игра начата!\n" + "Используйте кнопку «Открыть счётчик» для записи бросков." + ) + +async def handle_webapp_data(update, context): + try: + data = json.loads(update.effective_message.web_app_data.data) + + if data['type'] == 'game_session': + # Получаем активную игру пользователя + game = db.get_active_game(update.effective_user.id) + + if game: + # Записываем все броски + for throw in data['throws']: + db.add_throw( + game['id'], + throw['dice'][0], + throw['dice'][1] + ) + + # Завершаем игру + db.end_game(game['id']) + + # Отправляем сообщение об успешном завершении + await update.message.reply_text( + f"Игра завершена! Записано {len(data['throws'])} бросков.\n" + f"Используйте /statistics для просмотра статистики." + ) + else: + await update.message.reply_text( + "Ошибка: активная игра не найдена.\n" + "Используйте /new_game для начала новой игры." + ) + + except Exception as e: + print(f"Error handling webapp data: {e}") + await update.message.reply_text("Произошла ошибка при обработке данных.") + +async def statistics_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + user_id = update.effective_user.id + stats = db.get_statistics(user_id) + + # Получаем активную игру + active_game = db.get_active_game(user_id) + + response = "📊 Ваша статистика:\n\n" + + if stats['total_throws']: + response += ( + f"Всего игр: {stats['total_games']}\n" + f"Всего бросков: {stats['total_throws']}\n" + f"Средняя сумма: {stats['avg_sum']:.2f}\n" + ) + + if active_game: + game_throws = db.get_game_throws(active_game['id']) + response += f"\nТекущая игра:\n" + response += f"Количество бросков: {len(game_throws)}\n" + if game_throws: + last_throw = game_throws[0] + response += f"Последний бросок: {last_throw['dice1']}-{last_throw['dice2']}" + else: + response += "У вас пока нет записанных бросков." + + await update.message.reply_text(response) + +async def end_game_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + game_id = context.user_data.get('current_game') + if not game_id: + await update.message.reply_text("❌ У вас нет активной игры!") + return + + game_throws = db.get_game_throws(game_id) + db.end_game(game_id) + del context.user_data['current_game'] + + response = "🏁 Игра завершена!\n\n" + if game_throws: + total_throws = len(game_throws) + avg_sum = sum(t['dice1'] + t['dice2'] for t in game_throws) / total_throws + response += ( + f"Статистика игры:\n" + f"Всего бросков: {total_throws}\n" + f"Средняя сумма: {avg_sum:.2f}" + ) + else: + response += "В игре не было записано ни одного броска." + + await update.message.reply_text(response) \ No newline at end of file diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..1e65fab --- /dev/null +++ b/bot/main.py @@ -0,0 +1,143 @@ +from aiogram import Bot, Dispatcher, types, F +from aiogram.filters import Command +from aiogram.types import WebAppInfo, KeyboardButton, ReplyKeyboardMarkup +from database import Database +import json +import asyncio +from config import BOT_TOKEN, WEBAPP_URL +import logging + +# Настройка логирования +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +# Инициализация бота и диспетчера +bot = Bot(token=BOT_TOKEN) +dp = Dispatcher() +db = Database() + +# Упрощенная клавиатура +def get_main_keyboard(): + keyboard = [ + [ + KeyboardButton( + text="Записать броски 🎲", + web_app=WebAppInfo(url=WEBAPP_URL) + ) + ], + [KeyboardButton(text="Статистика 📊")] + ] + return ReplyKeyboardMarkup(keyboard=keyboard, resize_keyboard=True) + +@dp.message(Command("start")) +async def start_command(message: types.Message): + logger.info(f"User {message.from_user.id} ({message.from_user.username}) started the bot") + await message.answer( + "🎲 Добро пожаловать в счётчик кубиков для нард!\n\n" + "Нажмите «Записать броски» чтобы начать новую игру.", + reply_markup=get_main_keyboard() + ) + +# Обновляем функцию подсчета статистики в handle_webapp_data +def calculate_throw_sum(throw): + dice1, dice2 = throw['dice'] + if dice1 == dice2: + return (dice1 + dice2) * 2 + return dice1 + dice2 + +@dp.message(F.web_app_data) +async def handle_webapp_data(message: types.Message): + try: + user = message.from_user + logger.info(f"Received webapp data from user {user.id} ({user.username})") + + data = json.loads(message.web_app_data.data) + + if data['type'] == 'game_session': + # Создаем новую игру + game_id = db.create_game(user.id, user.username) + logger.info(f"Created new game {game_id} for user {user.id}") + + # Записываем все броски + throws = data['throws'] + for throw in throws: + db.add_throw( + game_id, + throw['dice'][0], + throw['dice'][1] + ) + + # Завершаем игру + db.end_game(game_id) + logger.info(f"Game {game_id} completed with {len(throws)} throws") + + # Обновляем подсчет статистики + total_throws = len(throws) + total_sum = sum(calculate_throw_sum(t) for t in throws) + avg_sum = total_sum / total_throws if total_throws > 0 else 0 + doubles = sum(1 for t in throws if t['dice'][0] == t['dice'][1]) + + # Находим максимальный и минимальный броски с учетом дублей + throws_with_sums = [(t, calculate_throw_sum(t)) for t in throws] + max_throw = max(throws_with_sums, key=lambda x: x[1]) + min_throw = min(throws_with_sums, key=lambda x: x[1]) + + # Формируем сообщение + response = "🎯 Игра завершена!\n\n" + response += f"📊 Статистика игры:\n" + response += f"• Всего бросков: {total_throws}\n" + response += f"• Общая сумма: {total_sum}\n" + response += f"• Средняя сумма: {avg_sum:.1f}\n" + response += f"• Дублей выпало: {doubles}\n" + response += f"• Максимальный бросок: {max_throw[0]['dice'][0]}-{max_throw[0]['dice'][1]} (сумма: {max_throw[1]})\n" + response += f"• Минимальный бросок: {min_throw[0]['dice'][0]}-{min_throw[0]['dice'][1]} (сумма: {min_throw[1]})\n\n" + + # Добавляем последние броски с учетом дублей + response += "🎲 Последние броски:\n" + for i, throw in enumerate(throws[:5]): + sum_value = calculate_throw_sum(throw) + is_double = throw['dice'][0] == throw['dice'][1] + response += f"{i+1}. {throw['dice'][0]}-{throw['dice'][1]} {'🎯' if is_double else ''} (сумма: {sum_value})\n" + + if total_throws > 5: + response += f"... и ещё {total_throws - 5} бросков" + + await message.answer(response) + logger.info(f"Sent game statistics to user {user.id}") + + except Exception as e: + logger.error(f"Error handling webapp data: {e}", exc_info=True) + await message.answer("❌ Произошла ошибка при обработке данных.") + +@dp.message(Command("statistics")) +@dp.message(F.text == "Статистика 📊") +async def statistics_command(message: types.Message): + user_id = message.from_user.id + logger.info(f"User {user_id} requested statistics") + + stats = db.get_statistics(user_id) + + response = "📊 Общая статистика:\n\n" + + if stats['total_throws']: + response += ( + f"• Всего игр: {stats['total_games']}\n" + f"• Всего бросков: {stats['total_throws']}\n" + f"• Средняя сумма: {stats['avg_sum']:.1f}\n" + ) + else: + response += "У вас пока нет записанных бросков." + + await message.answer(response) + logger.info(f"Sent statistics to user {user_id}") + +# Запуск бота +async def main(): + logger.info("Starting bot") + await dp.start_polling(bot) + +if __name__ == '__main__': + asyncio.run(main()) \ No newline at end of file diff --git a/bot/schema.sql b/bot/schema.sql new file mode 100644 index 0000000..89de875 --- /dev/null +++ b/bot/schema.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS games ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + username TEXT, + start_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + end_time TIMESTAMP, + status TEXT DEFAULT 'active' +); + +CREATE TABLE IF NOT EXISTS throws ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_id INTEGER, + dice1 INTEGER NOT NULL, + dice2 INTEGER NOT NULL, + throw_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (game_id) REFERENCES games(id) +); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..397b8de --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + nard_bot: + build: . + container_name: nard_bot + restart: unless-stopped + volumes: + - ./data:/app/data # Том для базы данных + - .env:/app/.env # Монтируем .env файл + environment: + - PYTHONUNBUFFERED=1 # Для корректного вывода логов + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8eb1f24 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +python-telegram-bot==20.7 +pymongo==4.6.1 +python-dotenv==1.0.0 +fastapi==0.109.0 +uvicorn==0.27.0 +aiogram==3.0.0 \ No newline at end of file