feat: inline remove button in /myplayers, localized no-players message
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 14s

- /myplayers: each player card gets inline 🗑 button — tap to remove instantly
- removal via CallbackQueryHandler (remove:<fide_id>), 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 <noreply@anthropic.com>
This commit is contained in:
vrubel 2026-06-20 23:04:32 +00:00
parent 293c1d90c8
commit 39f1f7e857

View file

@ -7,8 +7,8 @@ import os
import re import re
import sys import sys
import asyncio import asyncio
from telegram import Update from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes from telegram.ext import Application, CommandHandler, MessageHandler, CallbackQueryHandler, filters, ContextTypes
from telegram.constants import ParseMode from telegram.constants import ParseMode
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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+') URL_PATTERN = re.compile(r'https?://(?:[\w-]+\.)?chess-results\.com/\S+')
START_MSG = ( START_MSG = {
'*♟️ chessCalc — жеребьёвка следующего тура*\n\n' 'ru': (
'Я рассчитываю пары следующего тура шахматного турнира ' '♟️ *chessCalc* — жеребьёвка следующего тура\n\n'
'о официальной публикации_ на chess\-results\.com\.\n\n' 'Рассчитываю пары _до официальной публикации_ на chess\-results\.com\.\n'
'Просто пришли мне ссылку на турнир — и я покажу, ' 'Слежу за игроками: как только тур завершён — присылаю результат и жеребьёвку следующего\.\n\n'
'кто с кем играет в следующем туре\.\n\n' '*Команды:*\n'
'Пример ссылки:\n' '/addplayer — подписаться на игрока по FIDE ID\n'
'`https://chess\-results\.com/tnr1393124\.aspx?lan=11`\n\n' '/myplayers — список отслеживаемых игроков\n'
'Работает для швейцарских турниров\. Движок: FIDE Dutch System \(bbpPairings\)\.' '/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 = { LOCALE = {
'ru': { 'ru': {
@ -43,6 +61,11 @@ LOCALE = {
'bye': '— BYE —', 'bye': '— BYE —',
'footer_bbp': '\n_FIDE 2025 \(bbpPairings\)_', 'footer_bbp': '\n_FIDE 2025 \(bbpPairings\)_',
'footer_fallback': '\n_Упрощённый Swiss \(bbpPairings недоступен\)_', 'footer_fallback': '\n_Упрощённый Swiss \(bbpPairings недоступен\)_',
'no_players': 'У вас нет отслеживаемых игроков\.\n/addplayer — добавить игрока',
'my_players_title': 'Ваши игроки',
'waiting': '⏳ _Ожидаю турнир_',
'round_label': 'тур',
'points_label': 'очк\\.',
}, },
'en': { 'en': {
'player': 'Player', 'player': 'Player',
@ -53,6 +76,11 @@ LOCALE = {
'bye': '— BYE —', 'bye': '— BYE —',
'footer_bbp': '\n_FIDE 2025 \(bbpPairings\)_', 'footer_bbp': '\n_FIDE 2025 \(bbpPairings\)_',
'footer_fallback': '\n_Simplified Swiss \(bbpPairings unavailable\)_', '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 user = update.effective_user
record_user(user.id, user.username or '', user.first_name or '', record_user(user.id, user.username or '', user.first_name or '',
user.last_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): 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('Нет активного процесса подписки\.') 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): async def my_players(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user user = update.effective_user
loc = _get_locale(user.language_code or '')
players = tracker.get_user_subs(user.id) players = tracker.get_user_subs(user.id)
if not players: if not players:
await update.message.reply_text('У вас нет активных подписок\.\n' await update.message.reply_text(loc['no_players'],
'/addplayer — добавить игрока',
parse_mode=ParseMode.MARKDOWN_V2) parse_mode=ParseMode.MARKDOWN_V2)
return return
sep = '' * 22 header = f'*{_md_escape(loc["my_players_title"])} \\({len(players)}\\):*'
lines = [f'*Ваши игроки \\({len(players)}\\):*'] await update.message.reply_text(header, parse_mode=ParseMode.MARKDOWN_V2)
for p in players: for p in players:
safe_name = _md_escape(p['player_name']) text, keyboard = _player_card(p, loc)
fed = p['fed'] or '' await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN_V2,
rating = p['rating'] or '' reply_markup=keyboard)
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)
async def remove_player(update: Update, context: ContextTypes.DEFAULT_TYPE): async def remove_player_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user query = update.callback_query
parts = update.message.text.strip().split() await query.answer()
if len(parts) < 2 or not parts[1].isdigit(): user = query.from_user
await update.message.reply_text( loc = _get_locale(user.language_code or '')
'Использование: `/removeplayer FIDE_ID`\n'
'/myplayers — посмотреть список', fide_id = int(query.data.split(':')[1])
parse_mode=ParseMode.MARKDOWN_V2)
return
fide_id = int(parts[1])
ok = tracker.remove_player_subscriptions(fide_id, user.id) ok = tracker.remove_player_subscriptions(fide_id, user.id)
if ok: if ok:
await update.message.reply_text('✅ Игрок удалён из подписок\.', # Strike through the card text and remove the button
parse_mode=ParseMode.MARKDOWN_V2) struck = query.message.text.replace('👤', '')
await query.edit_message_text(struck, reply_markup=None)
else: else:
await update.message.reply_text('Игрок не найден\. /myplayers — список', await query.edit_message_text(query.message.text + '\n\n_уже удалён_',
parse_mode=ParseMode.MARKDOWN_V2) parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=None)
def _extract_fide_id(text: str) -> int | 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('addplayer', add_player))
app.add_handler(CommandHandler('cancel', cancel_sub)) app.add_handler(CommandHandler('cancel', cancel_sub))
app.add_handler(CommandHandler('myplayers', my_players)) 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.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_url))
app.job_queue.run_repeating( app.job_queue.run_repeating(