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/src/bot/plugins/builtin/dm.py b/src/bot/plugins/builtin/dm.py index e6d0c75..5884c62 100644 --- a/src/bot/plugins/builtin/dm.py +++ b/src/bot/plugins/builtin/dm.py @@ -11,7 +11,16 @@ import logging -from bot.dispatch import AppState, HandlerSpec, has_text, is_private_chat +from aiogram.types import Update + +from bot.dispatch import ( + AppState, + HandlerSpec, + command_filter, + has_text, + is_command_message, + is_private_chat, +) from bot.handlers.dm import handle_dm logger = logging.getLogger(__name__) @@ -19,14 +28,34 @@ # --- Individual registrar function --- +def _dm_check(state: AppState): + """Build the DM-flow predicate. + + The flow owns free-text DMs and ``/start`` deep links. Every other + command belongs to its command handler: without the carve-out, a + ``/status`` DM (whose plugin registers after ``dm`` in group 0) + would be swallowed here by first-match-wins and never reach + ``handle_status``. + """ + is_start = command_filter("start", state) + + def _check(update: Update) -> bool: + return ( + is_private_chat(update) + and has_text(update) + and not (is_command_message(update) and not is_start(update)) + ) + + return _check + + def register_dm(state: AppState) -> list[HandlerSpec]: """Register DM handler spec.""" - _ = state # no state needed beyond the signature spec = HandlerSpec( plugin_name="dm", group=0, update_kinds=("message", "edited_message"), - check=lambda update: is_private_chat(update) and has_text(update), + check=_dm_check(state), callback=handle_dm, label="dm_handler", ) diff --git a/tests/test_dispatch.py b/tests/test_dispatch.py index e279622..68dbb6f 100644 --- a/tests/test_dispatch.py +++ b/tests/test_dispatch.py @@ -674,3 +674,66 @@ async def test_no_handlers_registered_logged(self, bot, caplog): with caplog.at_level(logging.INFO, logger="bot.dispatch"): await dispatch_update(_make_update(), bot, state) assert "dispatch: update_id=1 no handlers registered" in caplog.text + + +class TestDmCommandCarveOut: + """The DM unrestriction flow must not swallow admin commands. + + Regression: ``dm`` registers before ``status`` in group 0, and its old + check (private + text) matched ``/status`` DMs first, so ``handle_dm`` + answered with the unrestriction message and ``handle_status`` never ran. + The DM flow owns free text and /start deep links only. + """ + + def _dm_update(self, text, entities=None): + return Update( + update_id=1, + message=Message( + message_id=1, + date=datetime.now(), + chat=Chat(id=123, type="private"), + from_user=User(id=1, is_bot=False, first_name="T"), + text=text, + entities=entities or [], + ), + ) + + def _command_update(self, text): + first = text.split()[0] + return self._dm_update( + text, + entities=[MessageEntity(type="bot_command", offset=0, length=len(first))], + ) + + def _specs(self): + from bot.plugins.builtin.dm import register_dm + from bot.plugins.builtin.status import register_status + state = AppState() + state.bot_username = "TestBot" + return register_dm(state)[0], register_status(state)[0] + + def test_status_command_reaches_status_not_dm(self): + dm_spec, status_spec = self._specs() + update = self._command_update("/status") + assert dm_spec.check(update) is False + assert status_spec.check(update) is True + + def test_other_commands_not_swallowed_by_dm(self): + dm_spec, _ = self._specs() + update = self._command_update("/trusted") + assert dm_spec.check(update) is False + + def test_start_deep_link_still_goes_to_dm(self): + dm_spec, _ = self._specs() + update = self._command_update("/start verify_-100123") + assert dm_spec.check(update) is True + + def test_plain_start_still_goes_to_dm(self): + dm_spec, _ = self._specs() + update = self._command_update("/start") + assert dm_spec.check(update) is True + + def test_free_text_still_goes_to_dm(self): + dm_spec, _ = self._specs() + update = self._dm_update("halo bot") + assert dm_spec.check(update) is True diff --git a/tests/test_properties.py b/tests/test_properties.py index a152803..0550b72 100644 --- a/tests/test_properties.py +++ b/tests/test_properties.py @@ -10,7 +10,7 @@ import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, assume, given, settings from hypothesis import strategies as st from bot.constants import format_hours_display, format_threshold_display @@ -141,6 +141,9 @@ def test_empty_name_falls_back_to_user_id(self, name: str, uid: int) -> None: @settings(max_examples=200) def test_with_username_format(self, name: str, uid: int, username: str | None) -> None: """If username given, output contains ' (@)' suffix when truthy.""" + # The sentinel "(@" can also come from the name itself (the strategy + # allows punctuation), which says nothing about the username suffix. + assume("(@" not in name) result = _format_person_with_username(name, username, uid) if username: assert f"(@{username})" in result 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