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