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 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'
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\)\.'
)
'_Движок: 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,31 +326,19 @@ async def cancel_sub(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text('Нет активного процесса подписки\.')
async def my_players(update: Update, context: ContextTypes.DEFAULT_TYPE):
user = update.effective_user
players = tracker.get_user_subs(user.id)
if not players:
await update.message.reply_text('У вас нет активных подписок\.\n'
'/addplayer — добавить игрока',
parse_mode=ParseMode.MARKDOWN_V2)
return
sep = '' * 22
lines = [f'*Ваши игроки \\({len(players)}\\):*']
for p in players:
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.append(sep)
lines.append(f'👤 *{safe_name}*')
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('⏳ _Ожидаю турнир_')
lines.append(loc['waiting'])
else:
for t in tours:
if t['name'] is None:
@ -330,32 +347,49 @@ async def my_players(update: Update, context: ContextTypes.DEFAULT_TYPE):
pts = _md_escape(f'{t["points"]:.1f}')
rd = t['round_done']
lines.append(f'📌 {safe_tour}')
lines.append(f' {pts} очк\\., тур {rd}')
lines.append(f' {pts} {loc["points_label"]}, {loc["round_label"]} {rd}')
lines.append(f'🗑 /removeplayer {fide_id}')
lines.append(sep)
await update.message.reply_text('\n'.join(lines),
parse_mode=ParseMode.MARKDOWN_V2)
keyboard = InlineKeyboardMarkup([[
InlineKeyboardButton('🗑 Удалить', callback_data=f'remove:{fide_id}')
]])
return '\n'.join(lines), keyboard
async def remove_player(update: Update, context: ContextTypes.DEFAULT_TYPE):
async def my_players(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 — посмотреть список',
loc = _get_locale(user.language_code or '')
players = tracker.get_user_subs(user.id)
if not players:
await update.message.reply_text(loc['no_players'],
parse_mode=ParseMode.MARKDOWN_V2)
return
fide_id = int(parts[1])
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:
text, keyboard = _player_card(p, loc)
await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard)
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(