LichessStatTgWeb/LichessClientTG_bot/formatters.py
vrubelroman 26cb066515
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 31s
simplify per-game table: tracked player only, no rating diff column
Reworked the per-game row layout to be relative to the tracked player
instead of white/black: the tracked player's name+accuracy+rating are
always shown first, the opponent's rating+accuracy (no name) always
second, regardless of which color each side played in a given game -
this keeps columns aligned across rows even as the tracked player
switches sides. Dropped the rating-change-per-game column added
earlier; it pushed the line past mobile width limits.
2026-07-03 10:08:35 +00:00

395 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from typing import Dict, Any, Optional
from datetime import datetime
from i18n import t
class StatsFormatter:
@staticmethod
def _format_rating_change(rating_change: int) -> str:
"""Format rating change with colored circles"""
if rating_change > 0:
return f"🟢 +{rating_change}"
elif rating_change < 0:
return f"🔴 {rating_change}"
else:
return "⚪ 0"
@staticmethod
def _escape_md(text: str) -> str:
"""Escape literal Markdown-special chars in dynamic text (e.g. usernames) for legacy Telegram Markdown"""
if not text:
return text
for ch in ('_', '*', '`', '['):
text = text.replace(ch, '\\' + ch)
return text
@staticmethod
def _format_accuracy(accuracy: Optional[float]) -> str:
"""Format accuracy percentage, or '-' if not available (game not analyzed)"""
if accuracy is None:
return "-"
return f"{accuracy:.0f}%"
@staticmethod
def _format_result(result: str) -> str:
"""Format a raw '1-0'/'0-1'/'1/2-1/2' result string for display"""
return "½-½" if result == "1/2-1/2" else result
@staticmethod
def _format_row_outcome_circle(row: dict) -> str:
"""
Colored circle for the tracked user's outcome in this row:
🟢 win, 🔴 loss, ⚪ draw (or unknown side).
"""
tracked_is_white = row.get('tracked_is_white')
result = row.get('result', '')
if tracked_is_white is None or result == "1/2-1/2":
return ""
tracked_won = (result == "1-0" and tracked_is_white) or (result == "0-1" and not tracked_is_white)
return "🟢" if tracked_won else "🔴"
@staticmethod
def _format_game_rows_block(rows: list) -> str:
"""
Format a list of individual game rows as a column-aligned, monospace table:
outcome | accuracy(tracked) | name(tracked) | rating(tracked) | result | rating(opponent) | accuracy(opponent)
Only the tracked player is named — the opponent is shown by rating/accuracy
only, keeping the line short enough for mobile screens.
Column widths are computed from the actual data so every column lines up
character-for-character. Caller is expected to wrap the result in a
Markdown ``` code block ``` so Telegram renders it with a monospace font.
"""
if not rows:
return ""
NAME_WIDTH = 7
prepared = []
for row in rows:
circle = StatsFormatter._format_row_outcome_circle(row)
if row.get('tracked_is_white') is False:
tracked_name = row.get('black_name') or '?'
tracked_rating = row.get('black_rating')
tracked_accuracy = row.get('black_accuracy')
opp_rating = row.get('white_rating')
opp_accuracy = row.get('white_accuracy')
else:
tracked_name = row.get('white_name') or '?'
tracked_rating = row.get('white_rating')
tracked_accuracy = row.get('white_accuracy')
opp_rating = row.get('black_rating')
opp_accuracy = row.get('black_accuracy')
tracked_name = tracked_name[:NAME_WIDTH]
tr_str = str(tracked_rating) if tracked_rating is not None else "-"
or_str = str(opp_rating) if opp_rating is not None else "-"
ta = StatsFormatter._format_accuracy(tracked_accuracy)
oa = StatsFormatter._format_accuracy(opp_accuracy)
result = StatsFormatter._format_result(row.get('result', ''))
prepared.append((circle, ta, tracked_name, tr_str, result, or_str, oa))
acc_width = max(max(len(p[1]), len(p[6])) for p in prepared)
name_width = NAME_WIDTH
rating_width = max(max(len(p[3]), len(p[5])) for p in prepared)
result_width = max(len(p[4]) for p in prepared)
lines = []
for circle, ta, tracked_name, tr_str, result, or_str, oa in prepared:
lines.append(
f"{circle} {ta:>{acc_width}} {tracked_name:<{name_width}} {tr_str:>{rating_width}} "
f"{result:^{result_width}} {or_str:>{rating_width}} {oa:>{acc_width}}"
)
return "\n".join(lines)
@staticmethod
def format_stats_response(data: Dict[str, Any], username: str, period: str, lang: str = 'en', accuracy_extra: Optional[Dict[str, Any]] = None) -> str:
"""Format statistics response according to the template"""
if not data or data.get('data') is None:
message = data.get('message', t('no_data', lang)) if data else t('no_data', lang)
# Filter out old "No active player" messages - this functionality is deprecated
if 'No active player' in message or 'Нет активного игрока' in message or 'active player' in message.lower() or 'активного игрока' in message.lower():
return t('no_data', lang)
return f"📭 {message}"
# Extract data from API response
api_data = data.get('data', {})
tasks = api_data.get('tasks', {})
games = api_data.get('games', {})
# Format date range
date_range = StatsFormatter._get_date_range(period, lang)
# Format tasks section
task_text = ""
if tasks and tasks.get('total', 0) > 0:
total_tasks = tasks.get('total', 0)
solved = tasks.get('solved', 0)
unsolved = tasks.get('unsolved', 0)
task_text = t('puzzles_section', lang, total=total_tasks, solved=solved, unsolved=unsolved)
# Supplementary per-mode accuracy (+ per-game rows for today/yesterday), from a parallel
# games-of-period fetch. Purely additive: absent/None just means no accuracy shown.
accuracy_by_mode = (accuracy_extra.get('data') or {}) if accuracy_extra else {}
# Format games section
games_text = ""
if games:
for game_type, game_data in games.items():
if not game_data or game_data.get('games_played', 0) == 0:
continue
# Get game type emoji
emoji = StatsFormatter._get_game_type_emoji(game_type)
games_count = game_data.get('games_played', 0)
rating_change = game_data.get('rating_change', 0)
rating = game_data.get('final_rating', 0)
wins = game_data.get('wins', 0)
losses = game_data.get('losses', 0)
draws = game_data.get('draws', 0)
# Format rating change
rating_change_str = StatsFormatter._format_rating_change(rating_change)
# Get game type name (capitalize first letter)
game_type_name = game_type.title()
# Точность в шапке имеет смысл только там, где нет построчного разбора партий
# ниже (week) — для today/yesterday она была бы избыточна рядом со строками партий.
if game_type in ('blitz', 'rapid', 'classical') and period == "week":
mode_accuracy = (accuracy_by_mode.get(game_type) or {}).get('accuracy')
games_text += t('games_section_with_accuracy', lang,
emoji=emoji,
game_type=game_type_name,
games_count=games_count,
rating_change=rating_change_str,
rating=rating,
wins=wins,
losses=losses,
draws=draws,
accuracy=StatsFormatter._format_accuracy(mode_accuracy)
)
else:
games_text += t('games_section', lang,
emoji=emoji,
game_type=game_type_name,
games_count=games_count,
rating_change=rating_change_str,
rating=rating,
wins=wins,
losses=losses,
draws=draws
)
# Per-game row breakdown (today/yesterday only — week/lastYear stay aggregate-only)
rows_text = ""
if period in ("today", "yesterday"):
for mode in ("blitz", "rapid", "classical"):
rows = (accuracy_by_mode.get(mode) or {}).get('games') or []
if not rows:
continue
emoji = StatsFormatter._get_game_type_emoji(mode)
rows_text += t('game_rows_heading', lang, emoji=emoji, game_type=mode.title())
rows_text += "```\n" + StatsFormatter._format_game_rows_block(rows) + "\n```\n\n"
# Combine all parts
result = t('stats_title', lang, username=StatsFormatter._escape_md(username), date_range=date_range)
result += task_text
result += games_text.rstrip()
if rows_text:
result += "\n\n" + rows_text.rstrip()
return result
@staticmethod
def _get_date_range(period: str, lang: str = 'en') -> str:
"""Get date range string for the period"""
from datetime import datetime, timedelta
today = datetime.now()
if period == "today":
return today.strftime("%d.%m.%Y")
elif period == "yesterday":
yesterday = today - timedelta(days=1)
return yesterday.strftime("%d.%m.%Y")
elif period == "week":
week_ago = today - timedelta(days=7)
return f"{week_ago.strftime('%d.%m.%Y')}{today.strftime('%d.%m.%Y')}"
else:
return today.strftime("%d.%m.%Y")
@staticmethod
def _get_game_type_emoji(game_type: str) -> str:
"""Get emoji for game type"""
emoji_map = {
'bullet': '⚡️',
'blitz': '🔥',
'rapid': '🐇',
'classical': '♟️',
'correspondence': '📮'
}
return emoji_map.get(game_type.lower(), '🎯')
@staticmethod
def format_period_notification(username: str, games_data: Optional[Dict], puzzles_data: Optional[Dict], period_minutes: int, lang: str = 'en') -> str:
"""Format notification for periodic checks"""
from datetime import datetime
# Format period text
if period_minutes == 1:
period_text = t('period_1_minute', lang)
elif period_minutes in [2, 3, 4]:
period_text = t('period_2_3_4_minutes', lang, period=period_minutes)
else:
period_text = t('period_minutes_text', lang, period=period_minutes)
result = t('period_notification_title', lang, username=StatsFormatter._escape_md(username), period_text=period_text)
# Format puzzles first (if available and there's actual activity)
has_puzzles_data = False
if puzzles_data:
# Check puzzles_in_period on top level first (priority)
top_level_puzzles = puzzles_data.get('puzzles_in_period', 0)
# Also check data.total_attempts
if puzzles_data.get('data'):
puzzles_info = puzzles_data['data']
total_puzzles = puzzles_info.get('total_attempts', 0)
solved = puzzles_info.get('solved', 0)
failed = puzzles_info.get('failed', 0)
effective_puzzles = top_level_puzzles if top_level_puzzles > 0 else total_puzzles
# Only show tasks section if there's actual activity (not all zeros)
if effective_puzzles > 0 or solved > 0 or failed > 0:
has_puzzles_data = True
result += t('period_puzzles_section', lang, total=effective_puzzles, solved=solved, failed=failed)
# Format games
has_games_data = False
if games_data and games_data.get('data'):
games_info = games_data['data']
# Check games_count on top level first (priority)
top_level_games_count = games_data.get('games_count', 0)
# Also check data.total.games_played
total_games = games_info.get('total', {}).get('games_played', 0)
# Use top-level games_count if available, otherwise use total.games_played
effective_games_count = top_level_games_count if top_level_games_count > 0 else total_games
# Show details for each game type if there were games
rows_text = ""
if effective_games_count > 0:
for game_type, game_data in games_info.items():
if game_type != 'total' and game_data and game_data.get('games_played', 0) > 0:
has_games_data = True # Only set to True if we actually add game data
emoji = StatsFormatter._get_game_type_emoji(game_type)
games_count = game_data.get('games_played', 0)
rating_change = game_data.get('rating_change', 0)
rating = game_data.get('final_rating', game_data.get('rating', 0))
wins = game_data.get('wins', 0)
losses = game_data.get('losses', 0)
draws = game_data.get('draws', 0)
rating_change_str = StatsFormatter._format_rating_change(rating_change)
game_type_name = game_type.title()
if game_type in ('blitz', 'rapid', 'classical'):
# Точность в шапку не выводим — она всегда дублируется построчным
# разбором партий ниже (периодическая проверка — короткий период).
result += t('period_games_section', lang,
emoji=emoji,
game_type=game_type_name,
games_count=games_count,
rating_change=rating_change_str,
rating=rating,
wins=wins,
losses=losses,
draws=draws
)
rows = game_data.get('games') or []
if rows:
rows_text += t('game_rows_heading', lang, emoji=emoji, game_type=game_type_name)
rows_text += "```\n" + StatsFormatter._format_game_rows_block(rows) + "\n```\n\n"
else:
result += t('period_games_section', lang,
emoji=emoji,
game_type=game_type_name,
games_count=games_count,
rating_change=rating_change_str,
rating=rating,
wins=wins,
losses=losses,
draws=draws
)
if rows_text:
result += rows_text
# If no activity at all
if not has_games_data and not has_puzzles_data:
result += t('no_activity', lang)
return result.rstrip()
@staticmethod
def format_last_year_or_1000(data: Dict[str, Any], username: str, lang: str = 'en') -> str:
"""
Format response for last year or last 1000 games.
Expects GamesOfPeriodResponse-like payload.
"""
if not data:
return "📭 No data"
games_count = data.get('games_count', 0)
period_start = data.get('period_start')
period_end = data.get('period_end')
stats = (data.get('data') or {})
# Title and subheader
escaped_username = StatsFormatter._escape_md(username)
if games_count >= 1000:
header = f"📈 {escaped_username}: last 1000 rated games"
earliest_ts = data.get('earliest_game_ts')
if isinstance(earliest_ts, int):
earliest = datetime.fromtimestamp(earliest_ts).strftime("%d.%m.%Y")
header += f"\n\n\nStart of these 1000 games: {earliest}"
else:
header = f"📈 {escaped_username}: last year (rated), games: {games_count}"
# Use earliest actual game date instead of naive 'year ago'
earliest_ts = data.get('earliest_game_ts', period_start)
if isinstance(earliest_ts, int) and isinstance(period_end, int):
start_str = datetime.fromtimestamp(earliest_ts).strftime("%d.%m.%Y")
end_str = datetime.fromtimestamp(period_end).strftime("%d.%m.%Y")
header += f"\n\n\nPeriod: {start_str}{end_str}"
# Body per mode
lines = []
for mode in ["bullet", "blitz", "rapid", "classical", "correspondence"]:
mode_stats = stats.get(mode)
if not mode_stats:
continue
games_played = mode_stats.get('games_played', 0)
if games_played == 0:
continue
emoji = StatsFormatter._get_game_type_emoji(mode)
wins = mode_stats.get('wins', 0)
losses = mode_stats.get('losses', 0)
draws = mode_stats.get('draws', 0)
rating_change = mode_stats.get('rating_change', 0)
rating_change_str = StatsFormatter._format_rating_change(rating_change)
rating = mode_stats.get('rating')
rating_str = rating if rating is not None else ""
line = f"{emoji} {mode.title()}: {games_played} Δ {rating_change_str} R {rating_str}{wins}{losses} 🤝 {draws}"
if mode in ("blitz", "rapid", "classical"):
line += f" 🎯 {StatsFormatter._format_accuracy(mode_stats.get('accuracy'))}"
lines.append(line)
# Join lines with newlines between each mode
# Between regular modes: one empty line (\n\n)
# Before last mode: two empty lines (\n\n\n)
if len(lines) == 0:
body = ""
elif len(lines) == 1:
body = lines[0]
else:
# All modes except last joined with one empty line
body = "\n\n".join(lines[:-1])
# Add two empty lines before last mode
body += "\n\n\n" + lines[-1]
return f"{header}\n\n{body}"