From a0ec26bdeee8f0cc0ec3d3208f8754468c975136 Mon Sep 17 00:00:00 2001 From: Tayo Date: Thu, 8 Oct 2026 11:32:56 +0000 Subject: [PATCH 1/2] feat(rich): /status rich table, /trusted 3-column table, WIB dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /status (DM-only admin): render the operational summary as a native rich message — per-group (Grup | Penegakan | Captcha | Probation | Pending | Nonaktif, scoped to the caller's admin groups) plus a key-value section (Uptime, Database, Refresh admin, Auto-restrict). Any send_rich_message failure falls back to the byte-identical Markdown message (verified empirically against the pre-change output). /trusted: drop the standalone User ID column — four columns forced horizontal scrolling. The numeric ID is folded into the User cell as name (@username)
id, keeping it visible and copyable. Markdown fallback untouched. Dates: the /trusted Date column and the /status job timestamps now render in WIB (Asia/Jakarta) instead of UTC, in both the rich output and the Markdown fallback (shared display strings; new constants.WIB single source of truth). /check deliberately stays on Markdown: aiogram 3.31 has no edit-to-rich equivalent, so its callback-query edit path could never go rich and a rich-then-Markdown flip mid-flow would be worse than consistent Markdown. --- AGENTS.md | 7 +- src/bot/constants.py | 9 +- src/bot/handlers/status.py | 219 ++++++++++++++++++++++++++--------- src/bot/handlers/trust.py | 30 +++-- tests/test_status_command.py | 139 ++++++++++++++++------ tests/test_trust_handler.py | 71 ++++++++++-- 6 files changed, 368 insertions(+), 107 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4e9e01b..af2fc90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -215,10 +215,11 @@ group=7 # ai_spam_monitor: Runs LAST, classifier.dev AI spam monitoring (last - Per-group kill switch: `"plugins": {"ai_spam_monitor": false}` / `"plugins": {"ai_spam_callback": false}` in groups.json; no alert chat configured → logfire-only monitoring ### Rich Text (DM Admin Commands) -- Data-dense DM admin outputs use Bot API 10.1+ native rich messages (`context.bot.send_rich_message` with `aiogram.types.InputRichMessage(html=...)`); currently only `/trusted` (native `
` with columns User | User ID | Added by | Date). Same pattern as the mediadownloadbot metadata table -- Rich is the default with a **Markdown fallback**: any `send_rich_message` failure is caught (broad except, logged with `exc_info=True`) and the handler sends the pre-existing Markdown message instead — the Markdown path is never deleted, only demoted to fallback +- Data-dense DM admin outputs use Bot API 10.1+ native rich messages (`context.bot.send_rich_message` with `aiogram.types.InputRichMessage(html=...)`); currently `/trusted` (native `
` with columns User | Added by | Date — the numeric ID is folded into the User cell as `name (@user)
id` since a 4th column forced horizontal scrolling) and `/status` (per-group striped table Grup | Penegakan | Captcha | Probation | Pending | Nonaktif plus a key-value section for uptime/database/job timestamps). Same pattern as the mediadownloadbot metadata table +- Rich is the default with a **Markdown fallback**: any `send_rich_message` failure is caught (broad except, logged with `exc_info=True`) and the handler sends the pre-existing Markdown message instead — the Markdown path is never deleted, only demoted to fallback. `/check` deliberately stays on Markdown: aiogram 3.31 has no edit-to-rich equivalent, so its callback-query edit path could never go rich and a rich-then-Markdown flip mid-flow would be worse than consistent Markdown - Scope is DM-only admin surface: group-facing messages stay on Markdown v1, so member-visible output never changes and old clients are unaffected where it matters -- Rich HTML content is `html.escape()`d (not the Markdown escaper); headings/column labels live in `constants.py` (`TRUST_LIST_RICH_HEADING`, `TRUST_LIST_RICH_COLUMNS`) +- Rich HTML content is `html.escape()`d (not the Markdown escaper); headings/column labels live in `constants.py` (`TRUST_LIST_RICH_*`, `STATUS_RICH_*`) +- All user-facing dates render in WIB (`constants.WIB = ZoneInfo("Asia/Jakarta")`), in both rich and Markdown paths ### Topic Guard Design - Handles both `message` and `edited_message` updates (combined filter) diff --git a/src/bot/constants.py b/src/bot/constants.py index 57ef601..fb0a585 100644 --- a/src/bot/constants.py +++ b/src/bot/constants.py @@ -6,6 +6,10 @@ """ from aiogram.types import ChatPermissions +from zoneinfo import ZoneInfo + +WIB = ZoneInfo("Asia/Jakarta") +"""Western Indonesian Time — display timezone for all user-facing dates.""" # Permissions applied when restricting a user (effectively mutes them) RESTRICTED_PERMISSIONS = ChatPermissions( @@ -294,7 +298,10 @@ def format_hours_display(hours: int) -> str: # Rich-message (Bot API 10.1+) rendering of /trusted for DM admins. # Same data as TRUST_LIST_HEADER, shown as a native striped table. TRUST_LIST_RICH_HEADING = "📋 Trusted Users" -TRUST_LIST_RICH_COLUMNS = ("User", "User ID", "Added by", "Date") +TRUST_LIST_RICH_COLUMNS = ("User", "Added by", "Date") + +STATUS_RICH_HEADING = "📊 Status Bot" +STATUS_RICH_COLUMNS = ("Grup", "Penegakan", "Captcha", "Probation", "Pending", "Nonaktif") TRUST_DM_ONLY_MESSAGE = ( "❌ Perintah ini hanya bisa digunakan di chat pribadi dengan bot." diff --git a/src/bot/handlers/status.py b/src/bot/handlers/status.py index bedd7f7..313f2e2 100644 --- a/src/bot/handlers/status.py +++ b/src/bot/handlers/status.py @@ -10,16 +10,20 @@ from __future__ import annotations +import html import logging import os import time +from dataclasses import dataclass, field +from datetime import datetime -from aiogram.types import Update +from aiogram.types import InputRichMessage, Update from bot.dispatch import AppState, HandlerContext, HandlerSpec, command_filter, effective_chat from bot.services.markdown import escape_markdown from bot.config import get_settings +from bot.constants import STATUS_RICH_COLUMNS, STATUS_RICH_HEADING, WIB from bot.database.service import get_database from bot.group_config import get_group_registry from bot.services.telegram_utils import get_admin_groups @@ -94,19 +98,60 @@ async def handle_status(update: Update, context: HandlerContext) -> None: admin_user_id = update.message.from_user.id admin_group_ids = set(get_admin_groups(context, admin_user_id)) - lines: list[str] = [] + data = _gather_status_data(context, admin_group_ids) + markdown_text = _render_status_markdown(data) + rich_html = _render_status_rich_html(data) + + # Rich table is the default; any failure degrades to the Markdown message. + # Broad except is deliberate: the Markdown path must survive whatever + # broke the rich send (API errors, serialization, client issues). + try: + await context.bot.send_rich_message( + chat_id=update.message.chat.id, + rich_message=InputRichMessage(html=rich_html), + reply_parameters=update.message.as_reply_parameters(), + ) + except Exception: + logger.warning( + "Rich status send failed, falling back to Markdown", + exc_info=True, + ) + await update.message.reply(markdown_text, parse_mode="Markdown") + - # --- Uptime --- +@dataclass +class _GroupStatusRow: + """Per-group status data, scoped to the caller's admin groups.""" + + group_id: int + enforcement: str + captcha_enabled: bool + probation_count: int + pending_count: int + disabled: list[str] = field(default_factory=list) + + +@dataclass +class _StatusData: + """Everything /status displays, gathered once for both renderers.""" + + uptime: str + rows: list[_GroupStatusRow] = field(default_factory=list) + db_size: str = "N/A" + refresh_display: str = "belum pernah" + restrict_display: str = "belum pernah" + + +def _gather_status_data( + context: HandlerContext, admin_group_ids: set[int] +) -> _StatusData: + """Collect /status values once; Markdown and rich renderers share them.""" start = context.state.start_time if start is not None: uptime = _format_uptime(time.monotonic() - start) - lines.append(f"*Uptime:* {uptime}") else: - lines.append("*Uptime:* N/A") + uptime = "N/A" - # --- Per-group summary (scoped to caller's admin groups) --- - lines.append("") - lines.append("*Grup yang kamu admin:*") registry = get_group_registry() effective_map = context.state.plugin_effective_map db = get_database() @@ -114,70 +159,134 @@ async def handle_status(update: Update, context: HandlerContext) -> None: all_probations = db.get_all_new_user_probations() all_pending = db.get_all_pending_captchas() - shown_groups = 0 + rows: list[_GroupStatusRow] = [] for gc in registry.all_groups(): - if gc.group_id not in admin_group_ids: - continue - shown_groups += 1 gid = gc.group_id - enforcement = "Restriksi" if gc.restrict_failed_users else "Peringatan" - captcha = "CAPTCHA" if gc.captcha_enabled else "" - group_parts = [ - f" • `{escape_markdown(str(gid), version=1)}` — _{enforcement}_" - ] - if captcha: - group_parts.append(f" _{captcha}_") - - # Per-group counts - probation_count = sum(1 for p in all_probations if p.group_id == gid) - pending_count = sum(1 for p in all_pending if p.group_id == gid) - group_parts.append( - f"\n Probation: {probation_count}, Captcha: {pending_count}" - ) - + if gid not in admin_group_ids: + continue toggles = effective_map.get(gid, {}) - disabled = [k for k, v in toggles.items() if not v] - if disabled: - disabled_str = ", ".join(sorted(disabled)) - group_parts.append( - f"\n Plugin nonaktif: {escape_markdown(disabled_str, version=1)}" + disabled = sorted(k for k, v in toggles.items() if not v) + rows.append( + _GroupStatusRow( + group_id=gid, + enforcement="Restriksi" if gc.restrict_failed_users else "Peringatan", + captcha_enabled=gc.captcha_enabled, + probation_count=sum(1 for p in all_probations if p.group_id == gid), + pending_count=sum(1 for p in all_pending if p.group_id == gid), + disabled=disabled, ) - lines.append("".join(group_parts)) - - if shown_groups == 0: - lines.append(" (Tidak ada grup yang dipantau)") + ) - # --- Database size --- - lines.append("") - db_path = get_settings().database_path - size_str = _format_filesize(db_path) - lines.append(f"*Database:* {size_str}") + db_size = _format_filesize(get_settings().database_path) - # --- Last jobs --- - lines.append("") - lines.append("*Jadwal terakhir:*") refresh_ts = context.state.data.get("last_admin_refresh") if refresh_ts is not None: - lines.append( - " • Refresh admin: " - f"{time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime(refresh_ts))}" + refresh_display = datetime.fromtimestamp(refresh_ts, tz=WIB).strftime( + "%Y-%m-%d %H:%M:%S WIB" ) else: - lines.append(" • Refresh admin: belum pernah") + refresh_display = "belum pernah" restrict_ts = context.state.data.get("last_auto_restrict") if restrict_ts is not None: - lines.append( - " • Auto-restrict: " - f"{time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime(restrict_ts))}" + restrict_display = datetime.fromtimestamp(restrict_ts, tz=WIB).strftime( + "%Y-%m-%d %H:%M:%S WIB" + ) + else: + restrict_display = "belum pernah" + + return _StatusData( + uptime=uptime, + rows=rows, + db_size=db_size, + refresh_display=refresh_display, + restrict_display=restrict_display, + ) + + +def _render_status_markdown(data: _StatusData) -> str: + """Render /status as the pre-existing Markdown message (fallback path). + + Kept byte-identical to the original output; the rich renderer is the + default and this only runs when the rich send fails. + """ + lines: list[str] = [] + + lines.append(f"*Uptime:* {data.uptime}") + + lines.append("") + lines.append("*Grup yang kamu admin:*") + + if data.rows: + for row in data.rows: + group_parts = [ + f" • `{escape_markdown(str(row.group_id), version=1)}`" + f" — _{row.enforcement}_" + ] + if row.captcha_enabled: + group_parts.append(" _CAPTCHA_") + group_parts.append( + f"\n Probation: {row.probation_count}, Captcha: {row.pending_count}" + ) + if row.disabled: + disabled_str = ", ".join(row.disabled) + group_parts.append( + f"\n Plugin nonaktif: {escape_markdown(disabled_str, version=1)}" + ) + lines.append("".join(group_parts)) + else: + lines.append(" (Tidak ada grup yang dipantau)") + + lines.append("") + lines.append(f"*Database:* {data.db_size}") + + lines.append("") + lines.append("*Jadwal terakhir:*") + lines.append(f" • Refresh admin: {data.refresh_display}") + lines.append(f" • Auto-restrict: {data.restrict_display}") + + return "\n".join(lines) + + +def _render_status_rich_html(data: _StatusData) -> str: + """Render /status as a native rich message (Bot API 10.1+). + + Per-group striped table plus a small key-value section. All cell + content is html-escaped. + """ + parts = [f"{html.escape(STATUS_RICH_HEADING)}"] + + if data.rows: + header = "".join( + f"" for col in STATUS_RICH_COLUMNS + ) + body = "".join( + "" + + "".join( + f"" + for cell in ( + str(row.group_id), + row.enforcement, + "Ya" if row.captcha_enabled else "—", + str(row.probation_count), + str(row.pending_count), + ", ".join(row.disabled) if row.disabled else "—", + ) + ) + + "" + for row in data.rows ) + parts.append(f"
{html.escape(col)}
{html.escape(cell)}
{header}{body}
") else: - lines.append(" • Auto-restrict: belum pernah") + parts.append(f"{html.escape('Tidak ada grup yang dipantau.')}") - await update.message.reply( - "\n".join(lines), - parse_mode="Markdown", + parts.append( + f"Uptime: {html.escape(data.uptime)}
" + f"Database: {html.escape(data.db_size)}
" + f"Refresh admin: {html.escape(data.refresh_display)}
" + f"Auto-restrict: {html.escape(data.restrict_display)}" ) + return "".join(parts) def get_handlers(state: AppState) -> list[HandlerSpec]: diff --git a/src/bot/handlers/trust.py b/src/bot/handlers/trust.py index 2f01939..b38935d 100644 --- a/src/bot/handlers/trust.py +++ b/src/bot/handlers/trust.py @@ -34,6 +34,7 @@ TRUST_USER_ID_INVALID_MESSAGE, TRUST_USER_ID_REQUIRED_MESSAGE, TRUST_USER_NOT_FOUND_MESSAGE, + WIB, ) from bot.database.models import TrustedUserData from bot.database.service import DatabaseService, get_database @@ -90,16 +91,32 @@ def _plain_person(full_name: str, username: str | None, user_id: int) -> str: return display -def _trusted_list_rich_html(rows: list[tuple[str, str, str, str]]) -> str: +def _rich_user_cell(full_name: str, username: str | None, user_id: int) -> str: + """Render the User cell: display line plus the numeric ID below it. + + Returns HTML (already escaped). The standalone "User ID" column was + dropped — four columns forced horizontal scrolling — so the ID is + folded in here, wrapped in to stay visible and copyable. + """ + return ( + f"{html.escape(_plain_person(full_name, username, user_id))}" + f"
{user_id}" + ) + + +def _trusted_list_rich_html(rows: list[tuple[str, str, str]]) -> str: """Render trusted-user rows as a native rich table (Bot API 10.1+). Follows the proven pattern: header row plus - one row per trusted user. All cell content is html-escaped. + one row per trusted user. ``rows`` are (user_cell_html, added_by, date); + the user cell is pre-rendered HTML (see _rich_user_cell); the other + cells are plain text and are html-escaped here. """ header = "".join(f"" for col in TRUST_LIST_RICH_COLUMNS) body = "".join( - "" + "".join(f"" for cell in row) + "" - for row in rows + f"" + f"" + for user_cell, added_by, date in rows ) return ( f"{html.escape(TRUST_LIST_RICH_HEADING)}" @@ -305,7 +322,7 @@ async def handle_trusted_list_command( trusted_at = record.trusted_at if trusted_at.tzinfo is None: trusted_at = trusted_at.replace(tzinfo=UTC) - trusted_at_display = trusted_at.astimezone(UTC).strftime("%Y-%m-%d %H:%M UTC") + trusted_at_display = trusted_at.astimezone(WIB).strftime("%Y-%m-%d %H:%M WIB") user_display = _format_person_with_username( record.user_full_name, record.username, record.user_id @@ -320,8 +337,7 @@ async def handle_trusted_list_command( ) rich_rows.append( ( - _plain_person(record.user_full_name, record.username, record.user_id), - str(record.user_id), + _rich_user_cell(record.user_full_name, record.username, record.user_id), _plain_person( record.admin_full_name, record.admin_username, diff --git a/tests/test_status_command.py b/tests/test_status_command.py index 5a4a1d1..671e26d 100644 --- a/tests/test_status_command.py +++ b/tests/test_status_command.py @@ -63,6 +63,7 @@ def mock_update(): def mock_context(): bot = MagicMock() bot.send_message = AsyncMock() + bot.send_rich_message = AsyncMock() state = AppState() state.admin_ids = [12345] state.group_admin_ids = {-1001: [12345], -1002: [12345]} @@ -74,6 +75,13 @@ def mock_context(): return HandlerContext(bot=bot, state=state, args=[]) +def _rich_html(mock_context): + """Return the html sent via send_rich_message (rich default path).""" + mock_context.bot.send_rich_message.assert_called_once() + _, kwargs = mock_context.bot.send_rich_message.call_args + return kwargs["rich_message"].html + + class TestHandleStatus: async def test_handle_status_non_private_chat_rejected(self, mock_context): @@ -113,7 +121,7 @@ async def test_handle_status_non_admin_rejected(self, mock_context): async def test_handle_status_admin_success( self, mock_update, mock_context, mock_registry, mock_settings, ): - """Admin in private chat gets full status with all sections.""" + """Admin in private chat gets the rich status table by default.""" with ( patch("bot.handlers.status.get_group_registry", return_value=mock_registry), patch("bot.handlers.status.get_settings", return_value=mock_settings), @@ -121,13 +129,15 @@ async def test_handle_status_admin_success( ): await handle_status(mock_update, mock_context) - mock_update.message.reply.assert_called_once() - args, kwargs = mock_update.message.reply.call_args - text = args[0] - assert "*Uptime:*" in text - assert "*Grup yang kamu admin:*" in text - assert "*Database:*" in text - assert "*Jadwal terakhir:*" in text + html = _rich_html(mock_context) + assert "Status Bot" in html + assert "
{html.escape(col)}
{html.escape(cell)}
{user_cell}{html.escape(added_by)}{html.escape(date)}
" in html + assert "Uptime:" in html + assert "Database:" in html + assert "Refresh admin:" in html + assert "Auto-restrict:" in html + # Markdown reply is only the fallback — not used on the happy path. + mock_update.message.reply.assert_not_called() async def test_handle_status_shows_enforcement_mode( self, mock_update, mock_context, mock_registry, mock_settings, @@ -140,14 +150,13 @@ async def test_handle_status_shows_enforcement_mode( ): await handle_status(mock_update, mock_context) - args, _ = mock_update.message.reply.call_args - text = args[0] - assert "Restriksi" in text or "Peringatan" in text + html = _rich_html(mock_context) + assert "Restriksi" in html or "Peringatan" in html async def test_handle_status_shows_per_group_captcha_count( self, mock_update, mock_context, mock_registry, mock_settings, ): - """Per-group pending captcha counts appear in status reply.""" + """Per-group pending captcha counts appear in the rich table.""" db = get_database() db.add_pending_captcha( CaptchaData( @@ -171,14 +180,14 @@ async def test_handle_status_shows_per_group_captcha_count( ): await handle_status(mock_update, mock_context) - args, _ = mock_update.message.reply.call_args - text = args[0] - assert "Captcha: 1" in text + html = _rich_html(mock_context) + assert "Pending" in html + assert "" in html async def test_handle_status_shows_disabled_plugins( self, mock_update, mock_context, mock_registry, mock_settings, ): - """Disabled plugins appear in per-group section.""" + """Disabled plugins appear in the per-group table.""" with ( patch("bot.handlers.status.get_group_registry", return_value=mock_registry), patch("bot.handlers.status.get_settings", return_value=mock_settings), @@ -186,15 +195,13 @@ async def test_handle_status_shows_disabled_plugins( ): await handle_status(mock_update, mock_context) - args, _ = mock_update.message.reply.call_args - text = args[0] - assert "Plugin nonaktif" in text - assert "profile" in text and "monitor" in text + html = _rich_html(mock_context) + assert "profile_monitor" in html async def test_handle_status_shows_last_job_timestamps( self, mock_update, mock_context, mock_registry, mock_settings, ): - """Timestamps for last jobs appear in status reply.""" + """Timestamps for last jobs appear in the key-value section.""" mock_context.state.data["last_admin_refresh"] = time.time() - 60 mock_context.state.data["last_auto_restrict"] = time.time() - 300 @@ -205,16 +212,34 @@ async def test_handle_status_shows_last_job_timestamps( ): await handle_status(mock_update, mock_context) - args, _ = mock_update.message.reply.call_args - text = args[0] - assert "Refresh admin:" in text - assert "Auto-restrict:" in text - assert "belum pernah" not in text + html = _rich_html(mock_context) + assert "Refresh admin:" in html + assert "Auto-restrict:" in html + assert "belum pernah" not in html + + async def test_handle_status_job_timestamps_shown_in_wib( + self, mock_update, mock_context, mock_registry, mock_settings, + ): + """Job timestamps convert from epoch seconds to WIB (+7).""" + # 1728300000 == 2024-10-07 11:20:00 UTC == 18:20:00 WIB. + mock_context.state.data["last_admin_refresh"] = 1728300000.0 + mock_context.state.data["last_auto_restrict"] = 1728300000.0 + + with ( + patch("bot.handlers.status.get_group_registry", return_value=mock_registry), + patch("bot.handlers.status.get_settings", return_value=mock_settings), + patch("bot.handlers.status.get_admin_groups", return_value=[-1001]), + ): + await handle_status(mock_update, mock_context) + + html = _rich_html(mock_context) + assert "2024-10-07 18:20:00 WIB" in html + assert "UTC" not in html async def test_handle_status_no_admin_groups( self, mock_update, mock_context, mock_registry, mock_settings, ): - """Admin with no group admin rights sees empty group list.""" + """Admin with no group admin rights sees the empty-groups line.""" with ( patch("bot.handlers.status.get_group_registry", return_value=mock_registry), patch("bot.handlers.status.get_settings", return_value=mock_settings), @@ -222,9 +247,8 @@ async def test_handle_status_no_admin_groups( ): await handle_status(mock_update, mock_context) - args, _ = mock_update.message.reply.call_args - text = args[0] - assert "Tidak ada grup yang dipantau" in text + html = _rich_html(mock_context) + assert "Tidak ada grup yang dipantau" in html async def test_handle_status_scoped_to_admin_groups_only( self, mock_update, mock_context, mock_registry, mock_settings, @@ -237,7 +261,56 @@ async def test_handle_status_scoped_to_admin_groups_only( ): await handle_status(mock_update, mock_context) - args, _ = mock_update.message.reply.call_args + html = _rich_html(mock_context) + assert "-1001" in html + assert "-1002" not in html + + async def test_handle_status_fallback_to_markdown_on_rich_failure( + self, mock_update, mock_context, mock_registry, mock_settings, + ): + """Rich send failure degrades to the original Markdown message.""" + mock_context.bot.send_rich_message.side_effect = Exception("rich unsupported") + db = get_database() + db.add_pending_captcha( + CaptchaData( + user_id=111, group_id=-1001, + chat_id=-1001, message_id=1, + user_full_name="User1", + ) + ) + mock_context.state.data["last_admin_refresh"] = time.time() - 60 + + with ( + patch("bot.handlers.status.get_group_registry", return_value=mock_registry), + patch("bot.handlers.status.get_settings", return_value=mock_settings), + patch("bot.handlers.status.get_admin_groups", return_value=[-1001, -1002]), + ): + await handle_status(mock_update, mock_context) + + mock_update.message.reply.assert_called_once() + args, kwargs = mock_update.message.reply.call_args text = args[0] - assert "-1001" in text - assert "-1002" not in text + assert kwargs.get("parse_mode") == "Markdown" + assert "*Uptime:*" in text + assert "*Grup yang kamu admin:*" in text + assert "Captcha: 1" in text + assert "Plugin nonaktif" in text + assert "Refresh admin:" in text + assert "Auto-restrict:" in text + assert "belum pernah" in text # auto-restrict never ran + + async def test_handle_status_fallback_empty_groups( + self, mock_update, mock_context, mock_registry, mock_settings, + ): + """Fallback path renders the empty-groups line too.""" + mock_context.bot.send_rich_message.side_effect = Exception("rich unsupported") + + with ( + patch("bot.handlers.status.get_group_registry", return_value=mock_registry), + patch("bot.handlers.status.get_settings", return_value=mock_settings), + patch("bot.handlers.status.get_admin_groups", return_value=[]), + ): + await handle_status(mock_update, mock_context) + + args, _ = mock_update.message.reply.call_args + assert "Tidak ada grup yang dipantau" in args[0] diff --git a/tests/test_trust_handler.py b/tests/test_trust_handler.py index f33aa36..6618803 100644 --- a/tests/test_trust_handler.py +++ b/tests/test_trust_handler.py @@ -1,6 +1,7 @@ """Tests for trusted user command handlers.""" import tempfile +from datetime import UTC, datetime from pathlib import Path from unittest.mock import AsyncMock, MagicMock @@ -349,19 +350,21 @@ async def test_trusted_list_command(self, mock_update, mock_context): rich_html = mock_context.bot.send_rich_message.call_args.kwargs[ "rich_message" ].html - # Native striped table with the four columns. + # Native striped table with three columns (ID folded into User cell). assert "
1
" in rich_html - for header in ("User", "User ID", "Added by", "Date"): + for header in ("User", "Added by", "Date"): assert f"" in rich_html - # Same data the Markdown list used to show. + assert "" not in rich_html + # Same data the Markdown list used to show; the numeric ID sits + # below the display line, wrapped in to stay copyable. assert "Alice Trusted" in rich_html assert "alice_t" in rich_html - assert "8001" in rich_html + assert "8001" in rich_html assert "Admin One" in rich_html assert "Bob Trusted" in rich_html - assert "8002" in rich_html - # Timestamp present and UTC. - assert "UTC" in rich_html + assert "8002" in rich_html + # Timestamp present, converted to WIB. + assert "WIB" in rich_html async def test_trusted_list_command_rich_html_escapes( self, mock_update, mock_context @@ -390,6 +393,23 @@ async def test_trusted_list_command_rich_html_escapes( assert html_module.escape("eve
{header}User ID