diff --git a/bots/admin_bot.py b/bots/admin_bot.py
index e9e39d5..1a0677a 100644
--- a/bots/admin_bot.py
+++ b/bots/admin_bot.py
@@ -13,10 +13,10 @@ from bots.stats import get_stats, record_user
TOKEN = os.environ.get('ADMIN_BOT_TOKEN', '')
START_MSG = (
- '🔧 chessCalc — админский бот\n\n'
- 'Доступные команды:\n'
- '/stat — статистика использования клиентского бота\n'
- '/start — эта справка'
+ r'*🔧 chessCalc — админский бот*\n\n'
+ r'Доступные команды:\n'
+ r'/stat — статистика использования клиентского бота\n'
+ r'/start — эта справка'
)
@@ -24,7 +24,7 @@ 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)
+ await update.message.reply_text(START_MSG, parse_mode=ParseMode.MARKDOWN_V2)
async def stat(update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -33,12 +33,12 @@ async def stat(update: Update, context: ContextTypes.DEFAULT_TYPE):
user.last_name or '')
stats = get_stats()
text = (
- '📊 Статистика chessCalc\n\n'
- f'👤 Уникальных пользователей: {stats["unique_users"]}\n'
- f'🔢 Всего запросов: {stats["total_requests"]}\n'
- f'📅 За сегодня: {stats["today_requests"]}'
+ r'*📊 Статистика chessCalc*\n\n'
+ rf'👤 Уникальных пользователей: *{stats["unique_users"]}*\n'
+ rf'🔢 Всего запросов: *{stats["total_requests"]}*\n'
+ rf'📅 За сегодня: *{stats["today_requests"]}*'
)
- await update.message.reply_text(text, parse_mode=ParseMode.HTML)
+ await update.message.reply_text(text, parse_mode=ParseMode.MARKDOWN_V2)
def main():
diff --git a/bots/client_bot.py b/bots/client_bot.py
index 4622154..1b85da6 100644
--- a/bots/client_bot.py
+++ b/bots/client_bot.py
@@ -1,7 +1,7 @@
"""
Клиентский Telegram-бот chessCalc.
Принимает ссылку на турнир chess-results.com, рассчитывает жеребьёвку
-следующего тура и выводит пары.
+следующего тура и выводит пары в таблице (MarkdownV2).
"""
import os
import re
@@ -15,29 +15,142 @@ 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 = (
- '♟️ 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).'
+ r'*♟️ chessCalc — жеребьёвка следующего тура*\n\n'
+ r'Я рассчитываю пары следующего тура шахматного турнира '
+ r'_до официальной публикации_ на chess\-results\.com\.\n\n'
+ r'Просто пришли мне ссылку на турнир — и я покажу, '
+ r'кто с кем играет в следующем туре\.\n\n'
+ r'Пример ссылки:\n'
+ r'`https://chess\-results\.com/tnr1393124\.aspx?lan=11`\n\n'
+ r'Работает для швейцарских турниров\. Движок: FIDE Dutch System \(bbpPairings\)\.'
)
+def _md_escape(text: str) -> str:
+ escape_chars = r'_*[]()~`>#+-=|{}.!'
+ result = []
+ for ch in text:
+ if ch in escape_chars:
+ result.append('\\' + ch)
+ else:
+ result.append(ch)
+ return ''.join(result)
+
+
+def _display_width(s: str) -> int:
+ w = 0
+ for ch in s:
+ cp = ord(ch)
+ # Cyrillic, CJK, etc — typically 2-wide in monospace
+ if (0x0400 <= cp <= 0x04FF or 0x0500 <= cp <= 0x052F or
+ 0x2E80 <= cp <= 0x9FFF or 0xF900 <= cp <= 0xFAFF or
+ 0xFE30 <= cp <= 0xFE4F or cp == 0x2116):
+ w += 2
+ else:
+ w += 1
+ return w
+
+
+def _pad_to_width(s: str, width: int) -> str:
+ cur = _display_width(s)
+ if cur >= width:
+ return s
+ return s + ' ' * (width - cur)
+
+
+def _trim_to_width(s: str, width: int) -> str:
+ result = []
+ cur = 0
+ for ch in s:
+ cw = 2 if _display_width(ch) == 2 else 1
+ if cur + cw > width:
+ break
+ result.append(ch)
+ cur += cw
+ return ''.join(result)
+
+
+def _make_table_header(cols: list, widths: list) -> str:
+ parts = [_pad_to_width(c, w) for c, w in zip(cols, widths)]
+ line = ' │ '.join(parts)
+ sep = '─┼─'.join('─' * w for w in widths)
+ return line + '\n' + sep
+
+
+def _make_table_row(cols: list, widths: list) -> str:
+ parts = [_pad_to_width(c, w) for c, w in zip(cols, widths)]
+ return ' │ '.join(parts)
+
+
+def format_pairings(pairings_data: dict, tournament_name: str) -> str:
+ pairings = pairings_data['pairings']
+ rnd = pairings_data['round']
+
+ max_name = 28
+ cols = ['#', 'Белые', 'О', 'Чёрные', 'О']
+ widths = [5, max_name, 5, max_name, 5]
+
+ header_line = f'*{_md_escape(tournament_name)}*\n📋 *Тур {rnd}* — пары\n'
+
+ table_lines = []
+ table_lines.append('```')
+ table_lines.append(_make_table_header(cols, widths))
+
+ board = 1
+ for pairing in pairings:
+ if len(pairing) != 3:
+ continue
+ p1, p2, color = pairing
+
+ if color == 'bye':
+ bye_name = _trim_to_width(p1.name, max_name)
+ table_lines.append(
+ _make_table_row(
+ [str(board), bye_name, str(p1.points), '— BYE —', ''],
+ widths))
+ board += 1
+ continue
+
+ if color == 'w':
+ white, black = p1, p2
+ else:
+ white, black = p2, p1
+
+ w_name = _trim_to_width(white.name, max_name)
+ b_name = _trim_to_width(black.name, max_name)
+ w_pts = f'{white.points:.1f}'
+ b_pts = f'{black.points:.1f}'
+
+ table_lines.append(
+ _make_table_row(
+ [str(board), w_name, w_pts, b_name, b_pts],
+ widths))
+ board += 1
+
+ table_lines.append('```')
+
+ footer = ''
+ source = pairings_data.get('source', 'algorithm')
+ if source == 'bbp_pairings_fide_2025':
+ footer = '\n_FIDE 2025 \(bbpPairings\)_'
+ elif source == 'swiss_algorithm_simplified':
+ footer = '\n_Упрощённый Swiss \(bbpPairings недоступен\)_'
+
+ return header_line + '\n'.join(table_lines) + footer
+
+
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)
+ await update.message.reply_text(START_MSG, parse_mode=ParseMode.MARKDOWN_V2)
async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
@@ -49,36 +162,38 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
match = URL_PATTERN.search(text)
if not match:
await update.message.reply_text(
- '❌ Не нашёл ссылку на chess-results.com в сообщении.\n\n'
+ '❌ Не нашёл ссылку на chess\\-results\\.com в сообщении\\.\n\n'
'Пришли ссылку вида:\n'
- 'https://chess-results.com/tnr1393124.aspx?lan=11',
- parse_mode=ParseMode.HTML)
+ '`https://chess\\-results\\.com/tnr1393124\\.aspx?lan=11`',
+ parse_mode=ParseMode.MARKDOWN_V2)
return
url = match.group(0)
- msg = await update.message.reply_text('⏳ Загружаю турнир...')
+ 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'❌ Ошибка загрузки: {e}')
+ await msg.edit_text(f'❌ Ошибка загрузки: {_md_escape(str(e))}',
+ parse_mode=ParseMode.MARKDOWN_V2)
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)
+ await msg.edit_text('🏁 Турнир уже завершён. Жеребьёвки не будет.',
+ parse_mode=ParseMode.MARKDOWN_V2)
+ record_request(user.id, url, tournament.get('name', ''), True)
return
status_parts = [
- f'✅ {tournament["name"]}',
+ f'✅ *{_md_escape(tournament["name"])}*',
f'📅 Сыграно туров: {tournament["current_round"]} из {tournament["num_rounds"]}',
f'👥 Участников: {len(tournament["standings"])}',
]
- await msg.edit_text('\n'.join(status_parts), parse_mode=ParseMode.HTML)
+ await msg.edit_text('\n'.join(status_parts), parse_mode=ParseMode.MARKDOWN_V2)
- await asyncio.sleep(0.5) # дать прочитать
+ await asyncio.sleep(0.5)
# Check if next round pairings already published on site
base_url = re.sub(r'[&?]art=\d+', '', url)
@@ -91,32 +206,32 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
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)
+ f'⚠️ Тур {next_rd} уже сыгран — на сайте есть результаты\\.',
+ parse_mode=ParseMode.MARKDOWN_V2)
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'
+ f'⚠️ Жеребьёвка тура {next_rd} уже опубликована на chess\\-results\\.com\\.\n'
'Я всё равно посчитаю, но пары могут незначительно отличаться '
- '(разные редакции правил FIDE).',
- parse_mode=ParseMode.HTML)
+ '\\(разные редакции правил FIDE\\)\\.',
+ parse_mode=ParseMode.MARKDOWN_V2)
except Exception:
- pass # art=2 page not accessible — pairings likely not published yet
+ pass
# Calculate next round
calc_msg = await update.message.reply_text(
- f'🧮 Считаю жеребьёвку тура {next_rd}...')
+ f'🧮 Считаю жеребьёвку тура {next_rd}\\.\\.\\.',
+ parse_mode=ParseMode.MARKDOWN_V2)
try:
result = calculate_next_round(tournament)
except Exception as e:
- await calc_msg.edit_text(f'❌ Ошибка расчёта: {e}')
+ await calc_msg.edit_text(f'❌ Ошибка расчёта: {_md_escape(str(e))}',
+ parse_mode=ParseMode.MARKDOWN_V2)
record_request(user.id, url, tournament.get('name', ''), False, str(e))
return
@@ -125,82 +240,36 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
# 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,
+ await calc_msg.edit_text(chunks[0], parse_mode=ParseMode.MARKDOWN_V2,
disable_web_page_preview=True)
for chunk in chunks[1:]:
- await update.message.reply_text(chunk, parse_mode=ParseMode.HTML,
+ await update.message.reply_text(chunk, parse_mode=ParseMode.MARKDOWN_V2,
disable_web_page_preview=True)
else:
- await calc_msg.edit_text(output, parse_mode=ParseMode.HTML,
+ await calc_msg.edit_text(output, parse_mode=ParseMode.MARKDOWN_V2,
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'{_escape_html(tournament_name)}',
- f'📋 Тур {rnd} — пары',
- '',
- ]
-
- 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'({w_pts}/{b_pts})'
- )
- board += 1
-
- lines.append('')
- source = pairings_data.get('source', 'algorithm')
- if source == 'bbp_pairings_fide_2025':
- lines.append('FIDE 2025 (bbpPairings)')
- elif source == 'swiss_algorithm_simplified':
- lines.append('Упрощённый Swiss (bbpPairings недоступен)')
-
- 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')
+ """Split output at ``` boundaries: keep code blocks intact."""
chunks = []
+ in_code = False
current = []
current_len = 0
- for line in lines:
- if current_len + len(line) > size and current:
+ for line in text.split('\n'):
+ is_fence = line.strip().startswith('```')
+ new_len = current_len + len(line) + 1
+ if new_len > size and current and not in_code:
chunks.append('\n'.join(current))
current = [line]
current_len = len(line)
else:
current.append(line)
- current_len += len(line)
+ current_len += len(line) + 1
+ if is_fence:
+ in_code = not in_code
if current:
chunks.append('\n'.join(current))
return chunks