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"{html.escape(col)} | " for col in STATUS_RICH_COLUMNS
+ )
+ body = "".join(
+ ""
+ + "".join(
+ f"| {html.escape(cell)} | "
+ 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"")
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"| {html.escape(col)} | " for col in TRUST_LIST_RICH_COLUMNS)
body = "".join(
- "" + "".join(f"| {html.escape(cell)} | " for cell in row) + "
"
- for row in rows
+ f"| {user_cell} | {html.escape(added_by)} | "
+ f"{html.escape(date)} |
"
+ 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 "" 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 "| 1 | " 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 "" in rich_html
- for header in ("User", "User ID", "Added by", "Date"):
+ for header in ("User", "Added by", "Date"):
assert f"| {header} | " in rich_html
- # Same data the Markdown list used to show.
+ assert "User ID | " 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