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_rating_diff_paren(diff: Optional[int]) -> str: """Format the tracked user's rating change for this game, e.g. '(+5)' / '(-7)' / '(0)'""" diff = diff or 0 if diff > 0: return f"(+{diff})" elif diff < 0: return f"({diff})" else: return "(0)" @staticmethod def _format_game_rows_block(rows: list) -> str: """ Format a list of individual game rows as a column-aligned, monospace table: outcome | rating_diff | accuracy1 | White | rating1 | result | Black | rating2 | accuracy2 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) diff_str = StatsFormatter._format_rating_diff_paren(row.get('tracked_rating_diff')) white = (row.get('white_name') or '?')[:NAME_WIDTH] black = (row.get('black_name') or '?')[:NAME_WIDTH] wr = row.get('white_rating') br = row.get('black_rating') wr_str = str(wr) if wr is not None else "-" br_str = str(br) if br is not None else "-" wa = StatsFormatter._format_accuracy(row.get('white_accuracy')) ba = StatsFormatter._format_accuracy(row.get('black_accuracy')) result = StatsFormatter._format_result(row.get('result', '')) prepared.append((circle, diff_str, wa, white, wr_str, result, black, br_str, ba)) diff_width = max(len(p[1]) for p in prepared) acc_width = max(max(len(p[2]), len(p[8])) for p in prepared) name_width = NAME_WIDTH rating_width = max(max(len(p[4]), len(p[7])) for p in prepared) result_width = max(len(p[5]) for p in prepared) lines = [] for circle, diff_str, wa, white, wr_str, result, black, br_str, ba in prepared: lines.append( f"{circle} {diff_str:>{diff_width}} {wa:>{acc_width}} {white:<{name_width}} {wr_str:>{rating_width}} " f"{result:^{result_width}} {black:<{name_width}} {br_str:>{rating_width}} {ba:>{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}"