ChessCalcNextTour/bots/client_bot.py
Roman Vrubel 5c4b2536e7 Добавлены Telegram-боты (клиентский + админский)
- Клиентский бот: принимает ссылку на chess-results.com,
  проверяет наличие результатов/жеребьёвки, считает пары
  следующего тура, выводит в HTML-формате
- Админский бот: /stat — уникальные пользователи,
  всего запросов, запросов за сегодня
- Статистика через SQLite (volume bot-data)
- Оба бота в docker-compose с автоперезапуском
- .env.example с тестовыми токенами
- .env в .gitignore
2026-06-14 20:39:19 +00:00

223 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Клиентский 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 = (
'<b>♟️ chessCalc — жеребьёвка следующего тура</b>\n\n'
'Я рассчитываю пары следующего тура шахматного турнира '
'<i>до официальной публикации</i> на chess-results.com.\n\n'
'Просто пришли мне ссылку на турнир — и я покажу, '
'кто с кем играет в следующем туре.\n\n'
'Пример ссылки:\n'
'<code>https://chess-results.com/tnr1393124.aspx?lan=11</code>\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'
'<code>https://chess-results.com/tnr1393124.aspx?lan=11</code>',
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'✅ <b>{tournament["name"]}</b>',
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'<b>{_escape_html(tournament_name)}</b>',
f'📋 <b>Тур {rnd}</b> — пары',
'',
]
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'<i>({w_pts}/{b_pts})</i>'
)
board += 1
lines.append('')
source = pairings_data.get('source', 'algorithm')
if source == 'bbp_pairings_fide_2025':
lines.append('<i>FIDE 2025 (bbpPairings)</i>')
elif source == 'swiss_algorithm_simplified':
lines.append('<i>Упрощённый Swiss (bbpPairings недоступен)</i>')
return '\n'.join(lines)
def _escape_html(text: str) -> str:
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
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()