Перевёл Telegram-ботов на MarkdownV2 с таблицами в code-блоках
- client_bot.py: вывод пар в моноширинной таблице () с учётом ширины кириллицы и обрезкой длинных имён - admin_bot.py: /stat в MarkdownV2 - Оба бота: все сообщения в ParseMode.MARKDOWN_V2
This commit is contained in:
parent
5c4b2536e7
commit
35fb54a887
2 changed files with 169 additions and 100 deletions
|
|
@ -13,10 +13,10 @@ from bots.stats import get_stats, record_user
|
||||||
TOKEN = os.environ.get('ADMIN_BOT_TOKEN', '')
|
TOKEN = os.environ.get('ADMIN_BOT_TOKEN', '')
|
||||||
|
|
||||||
START_MSG = (
|
START_MSG = (
|
||||||
'<b>🔧 chessCalc — админский бот</b>\n\n'
|
r'*🔧 chessCalc — админский бот*\n\n'
|
||||||
'Доступные команды:\n'
|
r'Доступные команды:\n'
|
||||||
'/stat — статистика использования клиентского бота\n'
|
r'/stat — статистика использования клиентского бота\n'
|
||||||
'/start — эта справка'
|
r'/start — эта справка'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@ 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.HTML)
|
await update.message.reply_text(START_MSG, parse_mode=ParseMode.MARKDOWN_V2)
|
||||||
|
|
||||||
|
|
||||||
async def stat(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
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 '')
|
user.last_name or '')
|
||||||
stats = get_stats()
|
stats = get_stats()
|
||||||
text = (
|
text = (
|
||||||
'<b>📊 Статистика chessCalc</b>\n\n'
|
r'*📊 Статистика chessCalc*\n\n'
|
||||||
f'👤 Уникальных пользователей: <b>{stats["unique_users"]}</b>\n'
|
rf'👤 Уникальных пользователей: *{stats["unique_users"]}*\n'
|
||||||
f'🔢 Всего запросов: <b>{stats["total_requests"]}</b>\n'
|
rf'🔢 Всего запросов: *{stats["total_requests"]}*\n'
|
||||||
f'📅 За сегодня: <b>{stats["today_requests"]}</b>'
|
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():
|
def main():
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Клиентский Telegram-бот chessCalc.
|
Клиентский Telegram-бот chessCalc.
|
||||||
Принимает ссылку на турнир chess-results.com, рассчитывает жеребьёвку
|
Принимает ссылку на турнир chess-results.com, рассчитывает жеребьёвку
|
||||||
следующего тура и выводит пары.
|
следующего тура и выводит пары в таблице (MarkdownV2).
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import re
|
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.parser import fetch_tournament
|
||||||
from swiss_calc.swiss import calculate_next_round
|
from swiss_calc.swiss import calculate_next_round
|
||||||
from swiss_calc.display import format_pairings_table
|
|
||||||
from bots.stats import record_user, record_request
|
from bots.stats import record_user, record_request
|
||||||
|
|
||||||
TOKEN = os.environ.get('CLIENT_BOT_TOKEN', '')
|
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 = (
|
||||||
'<b>♟️ chessCalc — жеребьёвка следующего тура</b>\n\n'
|
r'*♟️ chessCalc — жеребьёвка следующего тура*\n\n'
|
||||||
'Я рассчитываю пары следующего тура шахматного турнира '
|
r'Я рассчитываю пары следующего тура шахматного турнира '
|
||||||
'<i>до официальной публикации</i> на chess-results.com.\n\n'
|
r'_до официальной публикации_ на chess\-results\.com\.\n\n'
|
||||||
'Просто пришли мне ссылку на турнир — и я покажу, '
|
r'Просто пришли мне ссылку на турнир — и я покажу, '
|
||||||
'кто с кем играет в следующем туре.\n\n'
|
r'кто с кем играет в следующем туре\.\n\n'
|
||||||
'Пример ссылки:\n'
|
r'Пример ссылки:\n'
|
||||||
'<code>https://chess-results.com/tnr1393124.aspx?lan=11</code>\n\n'
|
r'`https://chess\-results\.com/tnr1393124\.aspx?lan=11`\n\n'
|
||||||
'Работает для швейцарских турниров. Движок: FIDE Dutch System (bbpPairings).'
|
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):
|
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.HTML)
|
await update.message.reply_text(START_MSG, parse_mode=ParseMode.MARKDOWN_V2)
|
||||||
|
|
||||||
|
|
||||||
async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
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)
|
match = URL_PATTERN.search(text)
|
||||||
if not match:
|
if not match:
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
'❌ Не нашёл ссылку на chess-results.com в сообщении.\n\n'
|
'❌ Не нашёл ссылку на chess\\-results\\.com в сообщении\\.\n\n'
|
||||||
'Пришли ссылку вида:\n'
|
'Пришли ссылку вида:\n'
|
||||||
'<code>https://chess-results.com/tnr1393124.aspx?lan=11</code>',
|
'`https://chess\\-results\\.com/tnr1393124\\.aspx?lan=11`',
|
||||||
parse_mode=ParseMode.HTML)
|
parse_mode=ParseMode.MARKDOWN_V2)
|
||||||
return
|
return
|
||||||
|
|
||||||
url = match.group(0)
|
url = match.group(0)
|
||||||
msg = await update.message.reply_text('⏳ Загружаю турнир...')
|
msg = await update.message.reply_text('⏳ Загружаю турнир\.\.\.',
|
||||||
|
parse_mode=ParseMode.MARKDOWN_V2)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
tournament = fetch_tournament(url)
|
tournament = fetch_tournament(url)
|
||||||
except Exception as e:
|
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))
|
record_request(user.id, url, '', False, str(e))
|
||||||
return
|
return
|
||||||
|
|
||||||
if tournament['current_round'] >= tournament['num_rounds']:
|
if tournament['current_round'] >= tournament['num_rounds']:
|
||||||
await msg.edit_text('🏁 Турнир уже завершён. Жеребьёвки не будет.')
|
await msg.edit_text('🏁 Турнир уже завершён. Жеребьёвки не будет.',
|
||||||
record_request(user.id, url, tournament.get('name', ''),
|
parse_mode=ParseMode.MARKDOWN_V2)
|
||||||
True)
|
record_request(user.id, url, tournament.get('name', ''), True)
|
||||||
return
|
return
|
||||||
|
|
||||||
status_parts = [
|
status_parts = [
|
||||||
f'✅ <b>{tournament["name"]}</b>',
|
f'✅ *{_md_escape(tournament["name"])}*',
|
||||||
f'📅 Сыграно туров: {tournament["current_round"]} из {tournament["num_rounds"]}',
|
f'📅 Сыграно туров: {tournament["current_round"]} из {tournament["num_rounds"]}',
|
||||||
f'👥 Участников: {len(tournament["standings"])}',
|
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
|
# Check if next round pairings already published on site
|
||||||
base_url = re.sub(r'[&?]art=\d+', '', url)
|
base_url = re.sub(r'[&?]art=\d+', '', url)
|
||||||
|
|
@ -91,32 +206,32 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
try:
|
try:
|
||||||
rd_html = _fetch_url(f'{base_url}&art=2&rd={next_rd}&turdet=YES')
|
rd_html = _fetch_url(f'{base_url}&art=2&rd={next_rd}&turdet=YES')
|
||||||
rd_games = parse_round_pairings(rd_html, next_rd)
|
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)
|
played = any(g.get('white_score') is not None for g in rd_games if g)
|
||||||
if played:
|
if played:
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
f'⚠️ Тур {next_rd} уже сыгран — на сайте есть результаты.',
|
f'⚠️ Тур {next_rd} уже сыгран — на сайте есть результаты\\.',
|
||||||
parse_mode=ParseMode.HTML)
|
parse_mode=ParseMode.MARKDOWN_V2)
|
||||||
record_request(user.id, url, tournament.get('name', ''), True)
|
record_request(user.id, url, tournament.get('name', ''), True)
|
||||||
return
|
return
|
||||||
# Check if pairings are published (unplayed games exist)
|
|
||||||
if rd_games:
|
if rd_games:
|
||||||
await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
f'⚠️ Жеребьёвка тура {next_rd} уже опубликована на chess-results.com.\n'
|
f'⚠️ Жеребьёвка тура {next_rd} уже опубликована на chess\\-results\\.com\\.\n'
|
||||||
'Я всё равно посчитаю, но пары могут незначительно отличаться '
|
'Я всё равно посчитаю, но пары могут незначительно отличаться '
|
||||||
'(разные редакции правил FIDE).',
|
'\\(разные редакции правил FIDE\\)\\.',
|
||||||
parse_mode=ParseMode.HTML)
|
parse_mode=ParseMode.MARKDOWN_V2)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # art=2 page not accessible — pairings likely not published yet
|
pass
|
||||||
|
|
||||||
# Calculate next round
|
# Calculate next round
|
||||||
calc_msg = await update.message.reply_text(
|
calc_msg = await update.message.reply_text(
|
||||||
f'🧮 Считаю жеребьёвку тура {next_rd}...')
|
f'🧮 Считаю жеребьёвку тура {next_rd}\\.\\.\\.',
|
||||||
|
parse_mode=ParseMode.MARKDOWN_V2)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = calculate_next_round(tournament)
|
result = calculate_next_round(tournament)
|
||||||
except Exception as e:
|
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))
|
record_request(user.id, url, tournament.get('name', ''), False, str(e))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -125,82 +240,36 @@ async def handle_url(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
# Telegram has 4096 char limit
|
# Telegram has 4096 char limit
|
||||||
if len(output) > 4000:
|
if len(output) > 4000:
|
||||||
chunks = _chunk_output(output, 3800)
|
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)
|
disable_web_page_preview=True)
|
||||||
for chunk in chunks[1:]:
|
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)
|
disable_web_page_preview=True)
|
||||||
else:
|
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)
|
disable_web_page_preview=True)
|
||||||
|
|
||||||
record_request(user.id, url, tournament.get('name', ''), 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'<b>{_escape_html(tournament_name)}</b>',
|
|
||||||
f'📋 <b>Тур {rnd}</b> — пары',
|
|
||||||
'',
|
|
||||||
]
|
|
||||||
|
|
||||||
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'<i>({w_pts}/{b_pts})</i>'
|
|
||||||
)
|
|
||||||
board += 1
|
|
||||||
|
|
||||||
lines.append('')
|
|
||||||
source = pairings_data.get('source', 'algorithm')
|
|
||||||
if source == 'bbp_pairings_fide_2025':
|
|
||||||
lines.append('<i>FIDE 2025 (bbpPairings)</i>')
|
|
||||||
elif source == 'swiss_algorithm_simplified':
|
|
||||||
lines.append('<i>Упрощённый Swiss (bbpPairings недоступен)</i>')
|
|
||||||
|
|
||||||
return '\n'.join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _escape_html(text: str) -> str:
|
|
||||||
return text.replace('&', '&').replace('<', '<').replace('>', '>')
|
|
||||||
|
|
||||||
|
|
||||||
def _chunk_output(text: str, size: int) -> list:
|
def _chunk_output(text: str, size: int) -> list:
|
||||||
lines = text.split('\n')
|
"""Split output at ``` boundaries: keep code blocks intact."""
|
||||||
chunks = []
|
chunks = []
|
||||||
|
in_code = False
|
||||||
current = []
|
current = []
|
||||||
current_len = 0
|
current_len = 0
|
||||||
for line in lines:
|
for line in text.split('\n'):
|
||||||
if current_len + len(line) > size and current:
|
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))
|
chunks.append('\n'.join(current))
|
||||||
current = [line]
|
current = [line]
|
||||||
current_len = len(line)
|
current_len = len(line)
|
||||||
else:
|
else:
|
||||||
current.append(line)
|
current.append(line)
|
||||||
current_len += len(line)
|
current_len += len(line) + 1
|
||||||
|
if is_fence:
|
||||||
|
in_code = not in_code
|
||||||
if current:
|
if current:
|
||||||
chunks.append('\n'.join(current))
|
chunks.append('\n'.join(current))
|
||||||
return chunks
|
return chunks
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue