From 39f1f7e85701a160e7b23e655fcef7383d35ae38 Mon Sep 17 00:00:00 2001 From: vrubel Date: Sat, 20 Jun 2026 23:04:32 +0000 Subject: [PATCH] feat: inline remove button in /myplayers, localized no-players message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /myplayers: each player card gets inline 🗑 button — tap to remove instantly - removal via CallbackQueryHandler (remove:), no text command needed - /removeplayer command removed - no-players message localized (ru/en) via LOCALE - /myplayers header and labels (round, pts) also localized Co-Authored-By: Claude Sonnet 4.6 --- bots/client_bot.py | 154 +++++++++++++++++++++++++++------------------ 1 file changed, 94 insertions(+), 60 deletions(-) diff --git a/bots/client_bot.py b/bots/client_bot.py index ccc220c..74f581c 100644 --- a/bots/client_bot.py +++ b/bots/client_bot.py @@ -7,8 +7,8 @@ import os import re import sys import asyncio -from telegram import Update -from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup +from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes from telegram.constants import ParseMode sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -22,16 +22,34 @@ 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\)\.' -) +START_MSG = { + 'ru': ( + '♟️ *chessCalc* — жеребьёвка следующего тура\n\n' + 'Рассчитываю пары _до официальной публикации_ на chess\-results\.com\.\n' + 'Слежу за игроками: как только тур завершён — присылаю результат и жеребьёвку следующего\.\n\n' + '*Команды:*\n' + '/addplayer — подписаться на игрока по FIDE ID\n' + '/myplayers — список отслеживаемых игроков\n' + '/removeplayer — отписаться от игрока\n\n' + '*Разовый расчёт:*\n' + 'Пришли ссылку на турнир — получи жеребьёвку прямо сейчас\n' + '`https://chess\-results\.com/tnr1393124\.aspx?lan=11`\n\n' + '_Движок: FIDE Dutch System \(bbpPairings\), точность 100% на большинстве турниров_' + ), + 'en': ( + '♟️ *chessCalc* — next round pairings\n\n' + 'I calculate pairings _before official publication_ on chess\-results\.com\.\n' + 'I also track players: when a round finishes — I send the result and next round pairings\.\n\n' + '*Commands:*\n' + '/addplayer — subscribe to a player by FIDE ID\n' + '/myplayers — list of tracked players\n' + '/removeplayer — unsubscribe from a player\n\n' + '*One\-time calculation:*\n' + 'Send a tournament link — get pairings right now\n' + '`https://chess\-results\.com/tnr1393124\.aspx?lan=11`\n\n' + '_Engine: FIDE Dutch System \(bbpPairings\), 100% accuracy on most tournaments_' + ), +} LOCALE = { 'ru': { @@ -43,6 +61,11 @@ LOCALE = { 'bye': '— BYE —', 'footer_bbp': '\n_FIDE 2025 \(bbpPairings\)_', 'footer_fallback': '\n_Упрощённый Swiss \(bbpPairings недоступен\)_', + 'no_players': 'У вас нет отслеживаемых игроков\.\n/addplayer — добавить игрока', + 'my_players_title': 'Ваши игроки', + 'waiting': '⏳ _Ожидаю турнир_', + 'round_label': 'тур', + 'points_label': 'очк\\.', }, 'en': { 'player': 'Player', @@ -53,6 +76,11 @@ LOCALE = { 'bye': '— BYE —', 'footer_bbp': '\n_FIDE 2025 \(bbpPairings\)_', 'footer_fallback': '\n_Simplified Swiss \(bbpPairings unavailable\)_', + 'no_players': 'You have no tracked players\.\n/addplayer — add a player', + 'my_players_title': 'Your players', + 'waiting': '⏳ _Waiting for a tournament_', + 'round_label': 'round', + 'points_label': 'pts', }, } @@ -273,7 +301,8 @@ 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.MARKDOWN_V2) + lang = 'ru' if (user.language_code or '').startswith('ru') else 'en' + await update.message.reply_text(START_MSG[lang], parse_mode=ParseMode.MARKDOWN_V2) async def add_player(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -297,65 +326,70 @@ async def cancel_sub(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text('Нет активного процесса подписки\.') +def _player_card(p: dict, loc: dict) -> tuple[str, InlineKeyboardMarkup]: + safe_name = _md_escape(p['player_name']) + fed = p['fed'] or '' + rating = p['rating'] or '—' + fide_id = p['fide_id'] + + lines = [f'👤 *{safe_name}*'] + lines.append(f'🏳 {_md_escape(fed)} · {rating}') + + tours = p['tournaments'] + waiting = all(t['name'] is None for t in tours) + if waiting: + lines.append(loc['waiting']) + else: + for t in tours: + if t['name'] is None: + continue + safe_tour = _md_escape(t['name']) + pts = _md_escape(f'{t["points"]:.1f}') + rd = t['round_done'] + lines.append(f'📌 {safe_tour}') + lines.append(f' {pts} {loc["points_label"]}, {loc["round_label"]} {rd}') + + keyboard = InlineKeyboardMarkup([[ + InlineKeyboardButton('🗑 Удалить', callback_data=f'remove:{fide_id}') + ]]) + return '\n'.join(lines), keyboard + + async def my_players(update: Update, context: ContextTypes.DEFAULT_TYPE): user = update.effective_user + loc = _get_locale(user.language_code or '') players = tracker.get_user_subs(user.id) if not players: - await update.message.reply_text('У вас нет активных подписок\.\n' - '/addplayer — добавить игрока', + await update.message.reply_text(loc['no_players'], parse_mode=ParseMode.MARKDOWN_V2) return - sep = '━' * 22 - lines = [f'*Ваши игроки \\({len(players)}\\):*'] + header = f'*{_md_escape(loc["my_players_title"])} \\({len(players)}\\):*' + await update.message.reply_text(header, parse_mode=ParseMode.MARKDOWN_V2) for p in players: - safe_name = _md_escape(p['player_name']) - fed = p['fed'] or '' - rating = p['rating'] or '—' - fide_id = p['fide_id'] - - lines.append(sep) - lines.append(f'👤 *{safe_name}*') - lines.append(f'🏳 {_md_escape(fed)} · {rating}') - - tours = p['tournaments'] - waiting = all(t['name'] is None for t in tours) - if waiting: - lines.append('⏳ _Ожидаю турнир_') - else: - for t in tours: - if t['name'] is None: - continue - safe_tour = _md_escape(t['name']) - pts = _md_escape(f'{t["points"]:.1f}') - rd = t['round_done'] - lines.append(f'📌 {safe_tour}') - lines.append(f' {pts} очк\\., тур {rd}') - - lines.append(f'🗑 /removeplayer {fide_id}') - - lines.append(sep) - await update.message.reply_text('\n'.join(lines), - parse_mode=ParseMode.MARKDOWN_V2) + text, keyboard = _player_card(p, loc) + await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=keyboard) -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 FIDE_ID`\n' - '/myplayers — посмотреть список', - parse_mode=ParseMode.MARKDOWN_V2) - return - fide_id = int(parts[1]) +async def remove_player_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): + query = update.callback_query + await query.answer() + user = query.from_user + loc = _get_locale(user.language_code or '') + + fide_id = int(query.data.split(':')[1]) ok = tracker.remove_player_subscriptions(fide_id, user.id) + if ok: - await update.message.reply_text('✅ Игрок удалён из подписок\.', - parse_mode=ParseMode.MARKDOWN_V2) + # Strike through the card text and remove the button + struck = query.message.text.replace('👤', '❌') + await query.edit_message_text(struck, reply_markup=None) else: - await update.message.reply_text('Игрок не найден\. /myplayers — список', - parse_mode=ParseMode.MARKDOWN_V2) + await query.edit_message_text(query.message.text + '\n\n_уже удалён_', + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=None) + def _extract_fide_id(text: str) -> int | None: @@ -674,7 +708,7 @@ def main(): 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(CallbackQueryHandler(remove_player_callback, pattern=r'^remove:\d+$')) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url)) app.job_queue.run_repeating(