from typing import Dict, Any, Optional from datetime import datetime import html 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_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)+color | rating(tracked) | vs | 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. A small circle right after the tracked player's name shows which color they played (⚪ white / ⚫ black). 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) tracked_is_white = row.get('tracked_is_white') is not False color_marker = "⚪" if tracked_is_white else "⚫" if tracked_is_white: 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') else: 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') name_field = f"{tracked_name[:NAME_WIDTH]}{color_marker}" 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) prepared.append((circle, ta, name_field, tr_str, or_str, oa)) acc_width = max(max(len(p[1]), len(p[5])) for p in prepared) name_width = NAME_WIDTH + 1 # +1 for the color marker glued to the name rating_width = max(max(len(p[3]), len(p[4])) for p in prepared) lines = [] for circle, ta, name_field, tr_str, or_str, oa in prepared: lines.append( f"{circle} {ta:>{acc_width}} {name_field:<{name_width}} {tr_str:>{rating_width}} " f"- {or_str:>{rating_width}} {oa:>{acc_width}}" ) return "\n".join(lines) @staticmethod def _format_mode_stats_block(rating, wins, losses, draws, lang: str) -> str: """ Column-aligned monospace block for one game type's rating/wins/losses/draws, shown under the header line. Caller is expected to wrap the result in a Markdown ``` code block ``` for guaranteed monospace rendering. No accuracy row here: an average accuracy across several games isn't a meaningful stat (unlike the per-game accuracy shown in the game-rows table). Every label carries exactly one emoji prefix — Telegram's monospace font renders emoji wider than plain text of the same length() count, so a label with no emoji (unlike the other three) would visibly end up one column off despite matching character counts. """ rows = [ (t('stat_label_rating', lang), str(rating)), (t('stat_label_wins', lang), str(wins)), (t('stat_label_losses', lang), str(losses)), (t('stat_label_draws', lang), str(draws)), ] label_width = max(len(label) for label, _ in rows) value_width = max(len(value) for _, value in rows) return "\n".join(f"{label:<{label_width}} {value:>{value_width}}" for label, value in rows) @staticmethod def format_gamers_table(gamers_data: list) -> str: """ Column-aligned monospace table for /getgamers: username, then bullet/blitz/rapid ratings, then the periodic-notification period suffix (e.g. "· 60m"). Column widths are computed from the actual data so every column lines up character-for-character. Caller is expected to wrap the result in an HTML
block so Telegram renders it with a monospace font (username can't be
bolded there — pre/code entities can't contain other entities).
"""
if not gamers_data:
return ""
rows = []
for g in gamers_data:
username = html.escape(str(g['username']))
bullet = str(g['bullet'])
blitz = str(g['blitz'])
rapid = str(g['rapid'])
period = (g.get('period') or '').strip()
rows.append((username, bullet, blitz, rapid, period))
name_width = max(len(r[0]) for r in rows)
bullet_width = max(len(r[1]) for r in rows)
blitz_width = max(len(r[2]) for r in rows)
rapid_width = max(len(r[3]) for r in rows)
lines = []
for username, bullet, blitz, rapid, period in rows:
line = (
f"{username:<{name_width}} "
f"⚡{bullet:>{bullet_width}} 🔥{blitz:>{blitz_width}} 🐇{rapid:>{rapid_width}}"
)
if period:
line += f" {period}"
lines.append(line)
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()
games_text += t('game_type_header', lang,
emoji=emoji,
game_type=game_type_name,
games_count=games_count,
rating_change=rating_change_str
)
games_text += StatsFormatter._format_mode_stats_block(
rating, wins, losses, draws, lang
) + "\n\n"
# 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 += StatsFormatter._format_game_rows_block(rows) + "\n\n"
# Whole body goes into a single monospace code block so the message doesn't
# alternate between plain-text headers and separately-fenced tables.
body = t('stats_title', lang, username=username, date_range=date_range)
body += task_text
body += games_text.rstrip()
if rows_text:
body += "\n\n" + rows_text.rstrip()
return "```\n" + body + "\n```"
@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=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()
# Точность в шапку не выводим — она всегда дублируется построчным
# разбором партий ниже (периодическая проверка — короткий период).
result += t('game_type_header', lang,
emoji=emoji,
game_type=game_type_name,
games_count=games_count,
rating_change=rating_change_str
)
result += StatsFormatter._format_mode_stats_block(rating, wins, losses, draws, lang) + "\n\n"
if game_type in ('blitz', 'rapid', 'classical'):
rows = game_data.get('games') or []
if rows:
rows_text += t('game_rows_heading', lang, emoji=emoji, game_type=game_type_name)
rows_text += StatsFormatter._format_game_rows_block(rows) + "\n\n"
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)
# Whole body goes into a single monospace code block so the message doesn't
# alternate between plain-text headers and separately-fenced tables.
return "```\n" + result.rstrip() + "\n```"
@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
if games_count >= 1000:
header = f"📈 {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\nStart of these 1000 games: {earliest}"
else:
header = f"📈 {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\nPeriod: {start_str}–{end_str}"
# Collect per-mode rows
rows = []
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
# Strip the variation selector some emoji include (e.g. bullet/classical) —
# it's invisible but counts as an extra character, which would throw off
# the column padding below even though it doesn't add any visual width.
emoji = StatsFormatter._get_game_type_emoji(mode).replace('️', '')
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 = mode_stats.get('rating')
accuracy_str = "-"
if mode in ("blitz", "rapid", "classical"):
accuracy_str = StatsFormatter._format_accuracy(mode_stats.get('accuracy'))
rows.append((
f"{emoji} {mode.title()}",
str(games_played),
StatsFormatter._format_rating_change(rating_change),
str(rating) if rating is not None else "—",
str(wins),
str(losses),
str(draws),
accuracy_str,
))
if not rows:
return f"{header}\n\n📭 No data"
label_w = max(len(r[0]) for r in rows)
games_w = max(len(r[1]) for r in rows)
change_w = max(len(r[2]) for r in rows)
rating_w = max(len(r[3]) for r in rows)
wins_w = max(len(r[4]) for r in rows)
losses_w = max(len(r[5]) for r in rows)
draws_w = max(len(r[6]) for r in rows)
acc_w = max((len(r[7]) for r in rows), default=0)
lines = []
for label, games, change, rating, wins, losses, draws, acc in rows:
line = (
f"{label:<{label_w}} {games:>{games_w}} {change:<{change_w}} "
f"R{rating:>{rating_w}} ✅{wins:>{wins_w}} ❌{losses:>{losses_w}} 🤝{draws:>{draws_w}}"
)
if acc_w:
line += f" 🎯{acc:>{acc_w}}"
lines.append(line)
body = "\n".join(lines)
return f"{header}\n\n```\n{body}\n```"