diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2da36ce --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Telegram-боты chessCalc +CLIENT_BOT_TOKEN=8620149115:AAFVZvdD_Sh9SAtBObWCcn5piijGtDK3-Dw +ADMIN_BOT_TOKEN=8637175624:AAFT-_xkPKM1gIAkrEQ71LdaDnDCwAEX858 + +# Путь к bbpPairings (опционально, по умолчанию /usr/local/bin/bbpPairings.exe) +BBP_PATH=/usr/local/bin/bbpPairings.exe + +# Путь к БД статистики +STATS_DB=/app/data/tournaments.db diff --git a/.gitignore b/.gitignore index b63d8e7..400b925 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ *.pyc .DS_Store +.env +bots/tournaments.db diff --git a/Dockerfile b/Dockerfile index 7bb46e9..6867937 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,27 +2,21 @@ FROM python:3.11-slim WORKDIR /app -# Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* -# Install Python dependencies COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -# Copy bbpPairings binary (FIDE Swiss engine) COPY bbpPairings.exe /usr/local/bin/bbpPairings.exe RUN chmod +x /usr/local/bin/bbpPairings.exe -# Set path for bbpPairings wrapper ENV BBP_PATH=/usr/local/bin/bbpPairings.exe -# Copy application code COPY swiss_calc/ /app/swiss_calc/ +COPY bots/ /app/bots/ -# Create entry point -RUN python3 -c "from swiss_calc import parser; print('✅ Module loaded')" +RUN mkdir -p /app/data -ENTRYPOINT ["python3", "-m", "swiss_calc"] -CMD ["--help"] +RUN python3 -c "from swiss_calc import parser; print('Module loaded')" diff --git a/bots/__init__.py b/bots/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bots/admin_bot.py b/bots/admin_bot.py new file mode 100644 index 0000000..e9e39d5 --- /dev/null +++ b/bots/admin_bot.py @@ -0,0 +1,58 @@ +""" +Админский Telegram-бот chessCalc. +Показывает статистику использования клиентского бота. +""" +import os +import sys +from telegram import Update +from telegram.ext import Application, CommandHandler, ContextTypes +from telegram.constants import ParseMode + +from bots.stats import get_stats, record_user + +TOKEN = os.environ.get('ADMIN_BOT_TOKEN', '') + +START_MSG = ( + '🔧 chessCalc — админский бот\n\n' + 'Доступные команды:\n' + '/stat — статистика использования клиентского бота\n' + '/start — эта справка' +) + + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): + user = update.effective_user + record_user(user.id, user.username or '', user.first_name or '', + user.last_name or '') + await update.message.reply_text(START_MSG, parse_mode=ParseMode.HTML) + + +async def stat(update: Update, context: ContextTypes.DEFAULT_TYPE): + user = update.effective_user + record_user(user.id, user.username or '', user.first_name or '', + user.last_name or '') + stats = get_stats() + text = ( + '📊 Статистика chessCalc\n\n' + f'👤 Уникальных пользователей: {stats["unique_users"]}\n' + f'🔢 Всего запросов: {stats["total_requests"]}\n' + f'📅 За сегодня: {stats["today_requests"]}' + ) + await update.message.reply_text(text, parse_mode=ParseMode.HTML) + + +def main(): + if not TOKEN: + print('ADMIN_BOT_TOKEN not set', file=sys.stderr) + sys.exit(1) + + app = Application.builder().token(TOKEN).build() + app.add_handler(CommandHandler('start', start)) + app.add_handler(CommandHandler('stat', stat)) + + print('Admin bot started', file=sys.stderr) + app.run_polling() + + +if __name__ == '__main__': + main() diff --git a/bots/client_bot.py b/bots/client_bot.py new file mode 100644 index 0000000..4622154 --- /dev/null +++ b/bots/client_bot.py @@ -0,0 +1,223 @@ +""" +Клиентский Telegram-бот chessCalc. +Принимает ссылку на турнир chess-results.com, рассчитывает жеребьёвку +следующего тура и выводит пары. +""" +import os +import re +import sys +import asyncio +from telegram import Update +from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from telegram.constants import ParseMode + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from swiss_calc.parser import fetch_tournament +from swiss_calc.swiss import calculate_next_round +from swiss_calc.display import format_pairings_table +from bots.stats import record_user, record_request + +TOKEN = os.environ.get('CLIENT_BOT_TOKEN', '') + +URL_PATTERN = re.compile(r'https?://(?:[\w-]+\.)?chess-results\.com/\S+') +START_MSG = ( + '♟️ chessCalc — жеребьёвка следующего тура\n\n' + 'Я рассчитываю пары следующего тура шахматного турнира ' + 'до официальной публикации на chess-results.com.\n\n' + 'Просто пришли мне ссылку на турнир — и я покажу, ' + 'кто с кем играет в следующем туре.\n\n' + 'Пример ссылки:\n' + 'https://chess-results.com/tnr1393124.aspx?lan=11\n\n' + 'Работает для швейцарских турниров. Движок: FIDE Dutch System (bbpPairings).' +) + + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): + user = update.effective_user + record_user(user.id, user.username or '', user.first_name or '', + user.last_name or '') + await update.message.reply_text(START_MSG, parse_mode=ParseMode.HTML) + + +async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE): + user = update.effective_user + text = update.message.text.strip() + record_user(user.id, user.username or '', user.first_name or '', + user.last_name or '') + + match = URL_PATTERN.search(text) + if not match: + await update.message.reply_text( + '❌ Не нашёл ссылку на chess-results.com в сообщении.\n\n' + 'Пришли ссылку вида:\n' + 'https://chess-results.com/tnr1393124.aspx?lan=11', + parse_mode=ParseMode.HTML) + return + + url = match.group(0) + msg = await update.message.reply_text('⏳ Загружаю турнир...') + + try: + tournament = fetch_tournament(url) + except Exception as e: + await msg.edit_text(f'❌ Ошибка загрузки: {e}') + record_request(user.id, url, '', False, str(e)) + return + + if tournament['current_round'] >= tournament['num_rounds']: + await msg.edit_text('🏁 Турнир уже завершён. Жеребьёвки не будет.') + record_request(user.id, url, tournament.get('name', ''), + True) + return + + status_parts = [ + f'✅ {tournament["name"]}', + f'📅 Сыграно туров: {tournament["current_round"]} из {tournament["num_rounds"]}', + f'👥 Участников: {len(tournament["standings"])}', + ] + await msg.edit_text('\n'.join(status_parts), parse_mode=ParseMode.HTML) + + await asyncio.sleep(0.5) # дать прочитать + + # Check if next round pairings already published on site + base_url = re.sub(r'[&?]art=\d+', '', url) + base_url = re.sub(r'[&?]rd=\d+', '', base_url) + base_url = re.sub(r'[&?]turdet=\w+', '', base_url) + base_url = re.sub(r'[&?]SNode=\w+', '', base_url) + next_rd = tournament['current_round'] + 1 + + from swiss_calc.parser import fetch_url as _fetch_url, parse_round_pairings + try: + rd_html = _fetch_url(f'{base_url}&art=2&rd={next_rd}&turdet=YES') + rd_games = parse_round_pairings(rd_html, next_rd) + # Check if any game has a result (means round already played) + played = any(g.get('white_score') is not None for g in rd_games if g) + if played: + await update.message.reply_text( + f'⚠️ Тур {next_rd} уже сыгран — на сайте есть результаты.', + parse_mode=ParseMode.HTML) + record_request(user.id, url, tournament.get('name', ''), True) + return + # Check if pairings are published (unplayed games exist) + if rd_games: + await update.message.reply_text( + f'⚠️ Жеребьёвка тура {next_rd} уже опубликована на chess-results.com.\n' + 'Я всё равно посчитаю, но пары могут незначительно отличаться ' + '(разные редакции правил FIDE).', + parse_mode=ParseMode.HTML) + except Exception: + pass # art=2 page not accessible — pairings likely not published yet + + # Calculate next round + calc_msg = await update.message.reply_text( + f'🧮 Считаю жеребьёвку тура {next_rd}...') + + try: + result = calculate_next_round(tournament) + except Exception as e: + await calc_msg.edit_text(f'❌ Ошибка расчёта: {e}') + record_request(user.id, url, tournament.get('name', ''), False, str(e)) + return + + output = format_pairings(result, tournament['name']) + + # Telegram has 4096 char limit + if len(output) > 4000: + chunks = _chunk_output(output, 3800) + await calc_msg.edit_text(chunks[0], parse_mode=ParseMode.HTML, + disable_web_page_preview=True) + for chunk in chunks[1:]: + await update.message.reply_text(chunk, parse_mode=ParseMode.HTML, + disable_web_page_preview=True) + else: + await calc_msg.edit_text(output, parse_mode=ParseMode.HTML, + disable_web_page_preview=True) + + record_request(user.id, url, tournament.get('name', ''), True) + + +def format_pairings(pairings_data: dict, tournament_name: str) -> str: + pairings = pairings_data['pairings'] + rnd = pairings_data['round'] + + lines = [ + f'{_escape_html(tournament_name)}', + f'📋 Тур {rnd} — пары', + '', + ] + + board = 1 + for pairing in pairings: + if len(pairing) != 3: + continue + p1, p2, color = pairing + + if color == 'bye': + lines.append(f' {board}. {_escape_html(p1.name)} — BYE') + board += 1 + continue + + if color == 'w': + white, black = p1, p2 + else: + white, black = p2, p1 + + w_name = _escape_html(white.name) + b_name = _escape_html(black.name) + w_pts = white.points + b_pts = black.points + + lines.append( + f' {board}. {w_name} — {b_name} ' + f'({w_pts}/{b_pts})' + ) + board += 1 + + lines.append('') + source = pairings_data.get('source', 'algorithm') + if source == 'bbp_pairings_fide_2025': + lines.append('FIDE 2025 (bbpPairings)') + elif source == 'swiss_algorithm_simplified': + lines.append('Упрощённый Swiss (bbpPairings недоступен)') + + return '\n'.join(lines) + + +def _escape_html(text: str) -> str: + return text.replace('&', '&').replace('<', '<').replace('>', '>') + + +def _chunk_output(text: str, size: int) -> list: + lines = text.split('\n') + chunks = [] + current = [] + current_len = 0 + for line in lines: + if current_len + len(line) > size and current: + chunks.append('\n'.join(current)) + current = [line] + current_len = len(line) + else: + current.append(line) + current_len += len(line) + if current: + chunks.append('\n'.join(current)) + return chunks + + +def main(): + if not TOKEN: + print('CLIENT_BOT_TOKEN not set', file=sys.stderr) + sys.exit(1) + + app = Application.builder().token(TOKEN).build() + app.add_handler(CommandHandler('start', start)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url)) + + print('Client bot started', file=sys.stderr) + app.run_polling() + + +if __name__ == '__main__': + main() diff --git a/bots/stats.py b/bots/stats.py new file mode 100644 index 0000000..2672335 --- /dev/null +++ b/bots/stats.py @@ -0,0 +1,95 @@ +""" +Статистика использования ботов (SQLite). +""" +import os +import sqlite3 +from datetime import datetime, date +from pathlib import Path + +DB_PATH = os.environ.get('STATS_DB', '/app/data/tournaments.db') + + +def _get_conn(): + p = Path(DB_PATH) + try: + p.parent.mkdir(parents=True, exist_ok=True) + except PermissionError: + # Fallback: use tempdir or cwd (local testing) + fallback = Path(__file__).parent.parent / 'data' / 'tournaments.db' + fallback.parent.mkdir(parents=True, exist_ok=True) + return _get_conn_at(fallback) + return _get_conn_at(p) + + +def _get_conn_at(db_path: Path): + conn = sqlite3.connect(str(db_path)) + conn.execute('PRAGMA journal_mode=WAL') + conn.execute(''' + CREATE TABLE IF NOT EXISTS users ( + user_id INTEGER PRIMARY KEY, + username TEXT, + first_name TEXT, + last_name TEXT, + first_seen TEXT DEFAULT (datetime('now')), + last_seen TEXT DEFAULT (datetime('now')) + ) + ''') + conn.execute(''' + CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + url TEXT, + tournament_name TEXT, + timestamp TEXT DEFAULT (datetime('now')), + success INTEGER DEFAULT 1, + error TEXT + ) + ''') + conn.commit() + return conn + + +def record_user(user_id: int, username: str = '', first_name: str = '', + last_name: str = ''): + conn = _get_conn() + now = datetime.utcnow().isoformat() + existing = conn.execute('SELECT user_id FROM users WHERE user_id = ?', + (user_id,)).fetchone() + if existing: + conn.execute('UPDATE users SET username=?, first_name=?, ' + 'last_name=?, last_seen=? WHERE user_id=?', + (username, first_name, last_name, now, user_id)) + else: + conn.execute('INSERT INTO users (user_id, username, first_name, ' + 'last_name, first_seen, last_seen) VALUES (?,?,?,?,?,?)', + (user_id, username, first_name, last_name, now, now)) + conn.commit() + conn.close() + + +def record_request(user_id: int, url: str = '', tournament_name: str = '', + success: bool = True, error: str = ''): + conn = _get_conn() + conn.execute('INSERT INTO requests (user_id, url, tournament_name, ' + 'timestamp, success, error) VALUES (?,?,?,datetime(\'now\'),?,?)', + (user_id, url, tournament_name, 1 if success else 0, error)) + conn.commit() + conn.close() + + +def get_stats() -> dict: + conn = _get_conn() + unique_users = conn.execute( + 'SELECT COUNT(*) FROM users').fetchone()[0] + total_requests = conn.execute( + 'SELECT COUNT(*) FROM requests').fetchone()[0] + today = date.today().isoformat() + today_requests = conn.execute( + "SELECT COUNT(*) FROM requests WHERE date(timestamp) = ?", + (today,)).fetchone()[0] + conn.close() + return { + 'unique_users': unique_users, + 'total_requests': total_requests, + 'today_requests': today_requests, + } diff --git a/docker-compose.yml b/docker-compose.yml index a7312ab..1197291 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,28 @@ --- services: - chess-calc: + client-bot: build: . image: chess-calc - container_name: chess-calc - command: ["--help"] - # Example usage with a URL: - # docker compose run --rm chess-calc 'https://chess-results.com/tnr1393124.aspx?lan=11&art=2&rd=3&turdet=YES' + container_name: chess-calc-client-bot + restart: unless-stopped + env_file: .env + environment: + - STATS_DB=/app/data/tournaments.db + volumes: + - bot-data:/app/data + command: ["python3", "-m", "bots.client_bot"] + + admin-bot: + build: . + image: chess-calc + container_name: chess-calc-admin-bot + restart: unless-stopped + env_file: .env + environment: + - STATS_DB=/app/data/tournaments.db + volumes: + - bot-data:/app/data + command: ["python3", "-m", "bots.admin_bot"] + +volumes: + bot-data: diff --git a/requirements.txt b/requirements.txt index f91844a..81e114a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ requests==2.32.3 beautifulsoup4==4.12.3 rich==13.7.1 +python-telegram-bot[job-queue]==21.11