Добавлены Telegram-боты (клиентский + админский)
- Клиентский бот: принимает ссылку на chess-results.com, проверяет наличие результатов/жеребьёвки, считает пары следующего тура, выводит в HTML-формате - Админский бот: /stat — уникальные пользователи, всего запросов, запросов за сегодня - Статистика через SQLite (volume bot-data) - Оба бота в docker-compose с автоперезапуском - .env.example с тестовыми токенами - .env в .gitignore
This commit is contained in:
parent
bbbfe61094
commit
5c4b2536e7
9 changed files with 415 additions and 14 deletions
223
bots/client_bot.py
Normal file
223
bots/client_bot.py
Normal file
|
|
@ -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 = (
|
||||
'<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('&', '&').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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue