All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 31s
- /getgamers: column-aligned monospace table (username, ratings, period). - Per-mode stats (rating/wins/losses/draws): column-aligned block, dropped the meaningless multi-game average accuracy row. - format_stats_response/format_period_notification now wrap the whole message in a single markdown code block instead of alternating plain-text headers and separately-fenced tables. - Rating row gets an emoji prefix to match the other three rows, so Telegram's monospace rendering keeps all values in the same column.
431 lines
20 KiB
Python
431 lines
20 KiB
Python
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
|
||
<pre> 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
|
||
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}"
|