Подписка на игроков: /addplayer, /myplayers, /removeplayer, фоновая проверка результатов и жеребьёвки
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 11s

This commit is contained in:
vrubel 2026-06-19 18:35:12 +00:00
parent cfd410d5fe
commit 7ebe1aa66e
2 changed files with 519 additions and 0 deletions

View file

@ -16,6 +16,7 @@ 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 bots.stats import record_user, record_request
from bots import tracker
TOKEN = os.environ.get('CLIENT_BOT_TOKEN', '')
@ -228,6 +229,107 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(START_MSG, parse_mode=ParseMode.MARKDOWN_V2)
async def add_player(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 '')
context.user_data['awaiting_fide_id'] = True
await update.message.reply_text(
'Пришли мне FIDE ID игрока \(число\) или ссылку на профиль FIDE:\n'
r'`https://ratings\.fide\.com/profile/1503014`\n'
'\n'
'/cancel — отмена',
parse_mode=ParseMode.MARKDOWN_V2)
async def cancel_sub(update: Update, context: ContextTypes.DEFAULT_TYPE):
had_fide = context.user_data.pop('awaiting_fide_id', None)
had_pending = context.user_data.pop('pending_fide', None)
if had_fide or had_pending:
await update.message.reply_text('❎ Подписка отменена\.')
else:
await update.message.reply_text('Нет активного процесса подписки\.')
async def my_players(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
subs = tracker.get_user_subs(user.id)
if not subs:
await update.message.reply_text('У вас нет активных подписок\.\n'
'/addplayer — добавить игрока')
return
lines = ['*Ваши подписки:*']
for s in subs:
safe_name = _md_escape(s['player_name'])
safe_tour = _md_escape(s['tournament_name'] or '')
lines.append(
f' {s["id"]}\\. {safe_name} \({s["rating"]}, {s["fed"]}\) '
f'{safe_tour}{s["last_points"]:.1f} очк\\.')
lines.append('\n/removeplayer <номер> — удалить подписку')
await update.message.reply_text('\n'.join(lines),
parse_mode=ParseMode.MARKDOWN_V2)
async def remove_player(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
parts = update.message.text.strip().split()
if len(parts) < 2 or not parts[1].isdigit():
await update.message.reply_text(
'Использование: `/removeplayer <номер>`\n'
'/myplayers — посмотреть список с номерами',
parse_mode=ParseMode.MARKDOWN_V2)
return
sub_id = int(parts[1])
ok = tracker.remove_subscription(sub_id, user.id)
if ok:
await update.message.reply_text('✅ Подписка удалена\.')
else:
await update.message.reply_text('Подписка не найдена\. /myplayers — список')
def _extract_fide_id(text: str) -> int | None:
"""Extract FIDE ID from plain number or profile URL."""
m = re.search(r'profile/(\d+)', text)
if m:
return int(m.group(1))
# Try plain number (between 6-8 digits)
for token in text.split():
if token.isdigit() and 100000 <= int(token) <= 9999999999:
return int(token)
return None
async def _lookup_and_confirm(update: Update, context, fide_id: int):
user = update.effective_user
lang_code = user.language_code or ''
try:
player = tracker.fetch_fide_player(fide_id)
except Exception as e:
await update.message.reply_text(
f'Не удалось найти игрока: {_md_escape(str(e))}\n'
'Попробуй другой ID или /cancel',
parse_mode=ParseMode.MARKDOWN_V2)
context.user_data['awaiting_fide_id'] = True
return
safe_name = _md_escape(player['name'])
title_str = f', {player["title"]}' if player.get('title') else ''
msg = (
f'✅ *{safe_name}*{title_str}\n'
f'{"🇫 " if player["fed"] else ""}{player["fed"]} \\| Рейтинг: {player["rating"]}\n\n'
'Теперь отправь ссылку на турнир chess\\-results\\.com, '
'где играет этот игрок\.\n'
'/cancel — отмена'
)
context.user_data['pending_fide'] = {
'player': player,
'lang': lang_code,
}
await update.message.reply_text(msg, parse_mode=ParseMode.MARKDOWN_V2)
def _apply_round_results(tournament, games, rd):
standings = tournament['standings']
sno_to_standing = {s['starting_sno']: s for s in standings if s.get('starting_sno')}
@ -301,12 +403,91 @@ def _apply_round_results(tournament, games, rd):
tournament['current_round'] = rd
async def _subscribe_to_tournament(update: Update, context, url: str, pending: dict):
user = update.effective_user
player = pending['player']
lang_code = pending['lang']
msg = await update.message.reply_text('⏳ Проверяю турнир\.\.\.',
parse_mode=ParseMode.MARKDOWN_V2)
try:
tournament = fetch_tournament(url)
except Exception as e:
await msg.edit_text(f'❌ Ошибка загрузки турнира: {_md_escape(str(e))}',
parse_mode=ParseMode.MARKDOWN_V2)
context.user_data['pending_fide'] = pending
return
p = tracker.find_player(tournament, player['name'])
if p is None:
safe_name = _md_escape(player['name'])
await msg.edit_text(
f'❌ *{safe_name}* не найден в этом турнире\\.\n'
'Отправь другой URL или /cancel',
parse_mode=ParseMode.MARKDOWN_V2)
context.user_data['pending_fide'] = pending
return
p_sno = p.get('starting_sno', 0)
results_count = len(p.get('results', []))
points = p.get('points', 0.0)
tracker.add_subscription(
user.id, player, url,
tournament.get('name', ''),
p_sno, results_count, points, lang_code)
safe_name = _md_escape(player['name'])
safe_tour = _md_escape(tournament.get('name', ''))
await msg.edit_text(
f'✅ *{safe_name}* найден в турнире\\.\n'
f'📅 *{safe_tour}*\n'
f'Ст\\.№ {p_sno}, {points:.1f} очк\\. — отслеживаю\!\n\n'
'Я буду присылать результаты игрока и жеребьёвку следующих туров\.\n'
'/myplayers — список подписок',
parse_mode=ParseMode.MARKDOWN_V2)
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 '')
# State machine: waiting for FIDE ID → tournament URL → regular pairings
# Step 1: user just called /addplayer — waiting for FIDE ID
if context.user_data.pop('awaiting_fide_id', None):
fide_id = _extract_fide_id(text)
if fide_id is None:
await update.message.reply_text(
'Не удалось распознать FIDE ID\. Отправь число или ссылку вида\n'
r'`https://ratings\.fide\.com/profile/1503014`' '\n'
'/cancel — отмена',
parse_mode=ParseMode.MARKDOWN_V2)
context.user_data['awaiting_fide_id'] = True
return
await _lookup_and_confirm(update, context, fide_id)
return
# Step 2: player confirmed — waiting for tournament URL
pending = context.user_data.get('pending_fide')
if pending:
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`\n\n'
'/cancel — отмена',
parse_mode=ParseMode.MARKDOWN_V2)
return
url = match.group(0)
context.user_data.pop('pending_fide', None)
await _subscribe_to_tournament(update, context, url, pending)
return
# Step 3: regular pairings request
match = URL_PATTERN.search(text)
if not match:
await update.message.reply_text(
@ -441,8 +622,15 @@ def main():
app = Application.builder().token(TOKEN).build()
app.add_handler(CommandHandler('start', start))
app.add_handler(CommandHandler('addplayer', add_player))
app.add_handler(CommandHandler('cancel', cancel_sub))
app.add_handler(CommandHandler('myplayers', my_players))
app.add_handler(CommandHandler('removeplayer', remove_player))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url))
app.job_queue.run_repeating(
tracker.check_all_subscriptions, interval=300, first=10)
print('Client bot started', file=sys.stderr)
app.run_polling()