From d252403d375701000cdadcffda82abb6157e74ad Mon Sep 17 00:00:00 2001 From: AIOSAI Date: Thu, 25 Jun 2026 16:56:56 -0700 Subject: [PATCH] =?UTF-8?q?feat(skills):=20Scheduler=20Bot=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20dedicated=20bot=20/queue=20+=20hourly=20digest=20(T?= =?UTF-8?q?DPLAN-0008)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SchedulerBot(BaseBot): rejects free-text (NO tmux/Claude spawn), /queue shells 'drone @daemon queue --json', hourly digest thread, chunked output - base_bot __main__: _BOT_CLASSES maps bot_id->SchedulerBot for systemd launch; passes config chat_id for digest target - bot registered (telegram-bot@scheduler), systemd unit installed (NOT started) - 26 new tests (519 telegram / 252 skills green), seedgo 99% fix(daemon): queue --json emits valid JSON at module level — flatten prompt_preview (collapse newlines) + soft_wrap/markup off. Verified valid via 'python -m'. NOTE: the drone router still re-wraps sub-command stdout at width 80, corrupting machine --json for ALL consumers routed through drone (separate @drone core bug; skills mitigates with strict=False JSON parse). --- src/aipass/daemon/apps/modules/queue.py | 8 +- .../lib/telegram/apps/handlers/base_bot.py | 19 +- .../telegram/apps/handlers/scheduler_bot.py | 227 ++++++++++ .../lib/telegram/tests/test_scheduler_bot.py | 395 ++++++++++++++++++ 4 files changed, 646 insertions(+), 3 deletions(-) create mode 100644 src/aipass/skills/lib/telegram/apps/handlers/scheduler_bot.py create mode 100644 src/aipass/skills/lib/telegram/tests/test_scheduler_bot.py diff --git a/src/aipass/daemon/apps/modules/queue.py b/src/aipass/daemon/apps/modules/queue.py index e0b672b7..3b5aeca9 100644 --- a/src/aipass/daemon/apps/modules/queue.py +++ b/src/aipass/daemon/apps/modules/queue.py @@ -102,7 +102,8 @@ def _build_queue(jobs: list, runstate: dict) -> list: owner = f"@{owner.split('@')[0]}" prompt = job.get("prompt", "") - preview = prompt[:80] + "..." if len(prompt) > 80 else prompt + flat = " ".join(prompt.split()) # collapse newlines/whitespace → single-line preview + preview = flat[:80] + "..." if len(flat) > 80 else flat entries.append( { @@ -183,7 +184,10 @@ def handle_command(command: str, args: List[str]) -> bool: if "--json" in args: output = _build_json_output(entries) - console.print(json.dumps(output, indent=2)) + # soft_wrap=True + markup=False — default console.print forces width-80 + # wrapping on non-TTY and parses [] markup, injecting newlines mid-string + # that corrupt machine output. These flags emit clean, parseable JSON. + console.print(json.dumps(output, indent=2), soft_wrap=True, markup=False) else: _print_rich_table(entries) diff --git a/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py b/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py index 6cb610fa..00aed65d 100644 --- a/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py +++ b/src/aipass/skills/lib/telegram/apps/handlers/base_bot.py @@ -1902,6 +1902,11 @@ class BaseBot: # CLI ENTRY POINT # ============================================= +_BOT_CLASSES = { + "scheduler": ".scheduler_bot:SchedulerBot", +} + + if __name__ == "__main__": parser = argparse.ArgumentParser(description="AIPass Telegram Bot") parser.add_argument("--bot-id", required=True, help="Bot identifier") @@ -1914,7 +1919,15 @@ if __name__ == "__main__": print(f"No config found for bot_id={args.bot_id}") sys.exit(1) - bot = BaseBot( + bot_cls = BaseBot + if args.bot_id in _BOT_CLASSES: + mod_name, cls_name = _BOT_CLASSES[args.bot_id].rsplit(":", 1) + import importlib + + mod = importlib.import_module(mod_name, package=__package__) + bot_cls = getattr(mod, cls_name) + + bot = bot_cls( bot_id=args.bot_id, bot_token=config["bot_token"], work_dir=Path(config.get("work_dir", str(Path.home()))), @@ -1923,4 +1936,8 @@ if __name__ == "__main__": branch_name=config.get("branch_name"), shared_session=config.get("shared_session"), ) + + if hasattr(bot, "_config_chat_id") and config.get("chat_id"): + bot._config_chat_id = config["chat_id"] + sys.exit(bot.run()) diff --git a/src/aipass/skills/lib/telegram/apps/handlers/scheduler_bot.py b/src/aipass/skills/lib/telegram/apps/handlers/scheduler_bot.py new file mode 100644 index 00000000..c3691210 --- /dev/null +++ b/src/aipass/skills/lib/telegram/apps/handlers/scheduler_bot.py @@ -0,0 +1,227 @@ +# =================== AIPass ==================== +# Name: scheduler_bot.py +# Description: Dedicated Telegram bot for the daemon's job queue — announce/query only +# Version: 1.0.0 +# Created: 2026-06-25 +# Modified: 2026-06-25 +# ============================================= + +""" +SchedulerBot — a BaseBot subclass for the AIPass Scheduler. + +Pure announce/query bot: serves /queue, posts an hourly digest, receives +lifecycle pings from @daemon. Free-text messages do NOT spin up tmux/Claude. +""" + +import json +import subprocess +import threading +from typing import Optional + +from aipass.prax import logger + +from .base_bot import BaseBot + + +DIGEST_INTERVAL = 3600.0 +QUEUE_CMD = ["drone", "@daemon", "queue", "--json"] + + +class SchedulerBot(BaseBot): + """Dedicated scheduler bot — no tmux/Claude, just /queue and hourly digest.""" + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + self._digest_thread: Optional[threading.Thread] = None + self._digest_stop = threading.Event() + self._scheduler_chat_id: Optional[int] = None + + def handle_message(self, chat_id: int, text: str, message: dict) -> None: + self.send_message( + chat_id, + "I'm the scheduler bot — I only handle commands.\nTry /queue or /help", + ) + + def handle_file(self, chat_id: int, message: dict) -> None: + self.send_message(chat_id, "I don't process files. Try /queue or /help") + + def _dispatch_command(self, chat_id: int, parsed: tuple) -> bool: + cmd_name, cmd_args = parsed + if cmd_name == "queue": + self._handle_queue_command(chat_id) + return True + return super()._dispatch_command(chat_id, parsed) + + def get_custom_commands(self) -> dict: + cmds = super().get_custom_commands() + cmds["queue"] = { + "description": "Show all scheduled daemon jobs — next fire, status, type", + "menu_text": "Job queue", + } + return cmds + + def _handle_queue_command(self, chat_id: int) -> None: + """Fetch queue from daemon and send formatted message.""" + queue_data = self._fetch_queue() + if queue_data is None: + self.send_message(chat_id, "Failed to fetch queue from daemon.") + return + + text = self._format_queue(queue_data) + for chunk in self.chunk_text(text): + self.send_message(chat_id, chunk) + + def _fetch_queue(self) -> Optional[dict]: + """Run drone @daemon queue --json and parse the result.""" + try: + result = subprocess.run( + QUEUE_CMD, + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0: + logger.warning("queue --json failed (rc=%d): %s", result.returncode, result.stderr) + return None + decoder = json.JSONDecoder(strict=False) + return decoder.decode(result.stdout) + except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError) as e: + logger.warning("Failed to fetch queue: %s", e) + return None + + @staticmethod + def _format_queue(data: dict) -> str: + """Format queue JSON into a readable Telegram message.""" + jobs = data.get("jobs", []) + count = data.get("count", len(jobs)) + generated = data.get("generated_at", "unknown") + + if not jobs: + return f"No scheduled jobs.\n\nGenerated: {generated}" + + lines = [f"Scheduled Jobs ({count})\n"] + for job in jobs: + owner = job.get("owner", "?") + job_id = job.get("id", "?") + enabled = job.get("enabled", False) + jtype = job.get("type", "?") + schedule = job.get("schedule_human", "?") + next_run = job.get("next_run") or "—" + last_status = job.get("last_status") or "never run" + last_error = job.get("last_error") + prompt = job.get("prompt_preview", "") + + status_icon = { + "success": "✅", + "failed": "❌", + "dispatched": "\U0001f535", + }.get(last_status, "⚪") + enabled_tag = "" if enabled else " [disabled]" + + lines.append(f"{status_icon} {owner}/{job_id}{enabled_tag}") + lines.append(f" Type: {jtype} ({schedule})") + lines.append(f" Next: {next_run}") + lines.append(f" Last: {last_status}") + if last_error: + lines.append(f" Error: {last_error}") + if prompt: + lines.append(f" Prompt: {prompt[:80]}...") + lines.append("") + + lines.append(f"Generated: {generated}") + return "\n".join(lines) + + # ============================================= + # HOURLY DIGEST + # ============================================= + + def start_digest(self, chat_id: int) -> None: + """Start the hourly digest thread.""" + if self._digest_thread is not None and self._digest_thread.is_alive(): + return + self._scheduler_chat_id = chat_id + self._digest_stop.clear() + self._digest_thread = threading.Thread( + target=self._digest_loop, + name="scheduler-digest", + daemon=True, + ) + self._digest_thread.start() + logger.info("Digest thread started (chat_id=%s)", chat_id) + + def stop_digest(self) -> None: + """Stop the hourly digest thread.""" + self._digest_stop.set() + if self._digest_thread is not None: + self._digest_thread.join(timeout=5) + self._digest_thread = None + logger.info("Digest thread stopped") + + def _digest_loop(self) -> None: + """Post a queue digest every hour.""" + while not self._digest_stop.is_set(): + self._digest_stop.wait(DIGEST_INTERVAL) + if self._digest_stop.is_set(): + break + self._post_digest() + + def _post_digest(self) -> None: + """Fetch queue and post digest to the scheduler chat.""" + if self._scheduler_chat_id is None: + return + queue_data = self._fetch_queue() + if queue_data is None: + logger.warning("Digest skipped: failed to fetch queue") + return + + text = f"Hourly Queue Digest\n{'=' * 20}\n\n{self._format_queue(queue_data)}" + for chunk in self.chunk_text(text): + self.send_message(self._scheduler_chat_id, chunk) + logger.info("Hourly digest posted") + + # ============================================= + # LIFECYCLE + # ============================================= + + def run(self) -> int: + """Start the bot with digest thread.""" + logger.info("=" * 60) + logger.info("%s starting (bot_id=%s)", self.bot_name, self.bot_id) + + if not self.verify_connection(): + logger.error("Startup health check FAILED — cannot reach Telegram API") + return 1 + + logger.info("Connected to Telegram API") + + from datetime import datetime + + self._health["started_at"] = datetime.now().isoformat() + + from .json import json_handler + + json_handler.log_operation("bot_started", {"bot_id": self.bot_id}) + + self._set_command_menu() + + self._boot_monitor() + + if self._check_lock(): + logger.error("Another instance of bot-%s is already running", self.bot_id) + return 1 + + chat_id = getattr(self, "_config_chat_id", None) + if chat_id: + self.start_digest(int(chat_id)) + + self._create_lock() + + try: + self._poll_loop() + except KeyboardInterrupt: + logger.info("Interrupted") + finally: + self.stop_digest() + self._cleanup() + + return 0 diff --git a/src/aipass/skills/lib/telegram/tests/test_scheduler_bot.py b/src/aipass/skills/lib/telegram/tests/test_scheduler_bot.py new file mode 100644 index 00000000..8fb722d2 --- /dev/null +++ b/src/aipass/skills/lib/telegram/tests/test_scheduler_bot.py @@ -0,0 +1,395 @@ +""" +Tests for SchedulerBot — dedicated Telegram bot for daemon job queue (TDPLAN-0008 P2). + +Tests cover: + - /queue parses drone @daemon queue --json and formats readable output + - Free-text (non-command) does NOT launch tmux/Claude + - Hourly digest thread posts a digest (mockable clock/sender) + - >4096-char digest is chunked correctly via chunk_text + - Bot loads telegram/scheduler config; missing secret fails loud + - Slash-menu includes /queue + - Command routing (/queue dispatched correctly) +""" + +import json +import pytest +from unittest.mock import patch, MagicMock + +from apps.handlers.scheduler_bot import SchedulerBot, QUEUE_CMD # type: ignore[import-not-found] + + +# ============================================= +# HELPERS +# ============================================= + + +SAMPLE_QUEUE = { + "generated_at": "2026-06-25T15:00:00Z", + "count": 2, + "jobs": [ + { + "owner": "@api", + "id": "data-check", + "enabled": True, + "type": "once", + "schedule_human": "2026-07-02", + "next_run": "2026-07-02T09:00:00Z", + "last_run": None, + "last_status": "success", + "last_error": None, + "prompt_preview": "Check the live data and report...", + "wake": {"fresh": True, "model": "haiku"}, + }, + { + "owner": "@backup", + "id": "weekly-snap", + "enabled": False, + "type": "interval", + "schedule_human": "every 7d", + "next_run": "2026-07-01T00:00:00Z", + "last_run": "2026-06-24T00:00:00Z", + "last_status": "failed", + "last_error": "disk full", + "prompt_preview": "Run full backup...", + "wake": {"fresh": True}, + }, + ], +} + +EMPTY_QUEUE = {"generated_at": "2026-06-25T15:00:00Z", "count": 0, "jobs": []} + + +@pytest.fixture +def _patch_base_bot_deps(tmp_path): + """Patch heavy BaseBot dependencies to allow lightweight instantiation.""" + patches = [ + patch("apps.handlers.base_bot.PENDING_DIR", tmp_path), + patch("apps.handlers.base_bot.signal.signal"), + patch("apps.handlers.base_bot.atexit.register"), + ] + for p in patches: + p.start() + yield + for p in patches: + p.stop() + + +def _make_scheduler_bot(tmp_path, _patch_base_bot_deps): + workdir = tmp_path / "workdir" + workdir.mkdir() + bot = SchedulerBot( + bot_id="scheduler", + bot_token="123:FAKETOKEN", + work_dir=workdir, + bot_name="AIPass Scheduler Bot", + allowed_user_ids=[111], + branch_name=None, + ) + return bot + + +# ============================================= +# 1. /queue PARSES AND FORMATS +# ============================================= + + +class TestQueueCommand: + """/queue fetches queue --json, formats, and sends.""" + + def test_queue_formats_jobs(self): + text = SchedulerBot._format_queue(SAMPLE_QUEUE) + assert "@api/data-check" in text + assert "@backup/weekly-snap" in text + assert "[disabled]" in text + assert "once" in text + assert "success" in text + + def test_queue_shows_error_when_failed(self): + text = SchedulerBot._format_queue(SAMPLE_QUEUE) + assert "disk full" in text + + def test_queue_empty_shows_no_jobs(self): + text = SchedulerBot._format_queue(EMPTY_QUEUE) + assert "No scheduled jobs" in text + + def test_queue_shows_count(self): + text = SchedulerBot._format_queue(SAMPLE_QUEUE) + assert "2" in text + + def test_queue_shows_status_icons(self): + text = SchedulerBot._format_queue(SAMPLE_QUEUE) + assert "✅" in text # success + assert "❌" in text # failed + + def test_queue_command_sends_formatted(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "_fetch_queue", return_value=SAMPLE_QUEUE), + patch.object(bot, "send_message") as mock_send, + ): + bot._handle_queue_command(42) + mock_send.assert_called_once() + msg = mock_send.call_args[0][1] + assert "@api/data-check" in msg + + def test_queue_command_handles_fetch_failure(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "_fetch_queue", return_value=None), + patch.object(bot, "send_message") as mock_send, + ): + bot._handle_queue_command(42) + msg = mock_send.call_args[0][1] + assert "Failed" in msg + + def test_fetch_queue_parses_subprocess(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + mock_result = MagicMock() + mock_result.returncode = 0 + mock_result.stdout = json.dumps(SAMPLE_QUEUE) + + with patch("apps.handlers.scheduler_bot.subprocess.run", return_value=mock_result): + data = bot._fetch_queue() + + assert data is not None + assert data["count"] == 2 + assert len(data["jobs"]) == 2 + + def test_fetch_queue_returns_none_on_failure(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + mock_result = MagicMock() + mock_result.returncode = 1 + mock_result.stderr = "error" + + with patch("apps.handlers.scheduler_bot.subprocess.run", return_value=mock_result): + data = bot._fetch_queue() + + assert data is None + + def test_fetch_queue_returns_none_on_timeout(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + import subprocess as sp + + with patch("apps.handlers.scheduler_bot.subprocess.run", side_effect=sp.TimeoutExpired(QUEUE_CMD, 15)): + data = bot._fetch_queue() + + assert data is None + + +# ============================================= +# 2. FREE-TEXT DOES NOT LAUNCH TMUX +# ============================================= + + +class TestNoTmux: + """Free-text and file messages do NOT spin up tmux/Claude.""" + + def test_free_text_rejected(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "send_message") as mock_send, + patch.object(bot, "ensure_tmux_session") as mock_tmux, + ): + bot.handle_message(42, "Hello there", {"message_id": 1}) + mock_tmux.assert_not_called() + msg = mock_send.call_args[0][1] + assert "commands" in msg.lower() or "/queue" in msg + + def test_file_rejected(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "send_message") as mock_send, + patch("apps.handlers.base_bot.BaseBot.handle_file") as mock_parent_file, + ): + bot.handle_file(42, {"message_id": 1, "document": {"file_id": "abc"}}) + mock_parent_file.assert_not_called() + msg = mock_send.call_args[0][1] + assert "file" in msg.lower() or "/queue" in msg + + +# ============================================= +# 3. HOURLY DIGEST +# ============================================= + + +class TestDigest: + """Hourly digest thread posts queue digest.""" + + def test_digest_posts_message(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + bot._scheduler_chat_id = 42 + + with ( + patch.object(bot, "_fetch_queue", return_value=SAMPLE_QUEUE), + patch.object(bot, "send_message") as mock_send, + ): + bot._post_digest() + mock_send.assert_called_once() + msg = mock_send.call_args[0][1] + assert "Hourly Queue Digest" in msg + assert "@api/data-check" in msg + + def test_digest_skips_on_fetch_failure(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + bot._scheduler_chat_id = 42 + + with ( + patch.object(bot, "_fetch_queue", return_value=None), + patch.object(bot, "send_message") as mock_send, + ): + bot._post_digest() + mock_send.assert_not_called() + + def test_digest_skips_when_no_chat_id(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + assert bot._scheduler_chat_id is None + + with patch.object(bot, "send_message") as mock_send: + bot._post_digest() + mock_send.assert_not_called() + + def test_start_digest_creates_thread(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + bot.start_digest(42) + try: + assert bot._digest_thread is not None + assert bot._digest_thread.daemon is True + assert bot._digest_thread.name == "scheduler-digest" + assert bot._scheduler_chat_id == 42 + finally: + bot.stop_digest() + + def test_stop_digest_cleans_up(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + bot.start_digest(42) + bot.stop_digest() + assert bot._digest_thread is None + assert bot._digest_stop.is_set() + + +# ============================================= +# 4. CHUNKING LONG MESSAGES +# ============================================= + + +class TestChunking: + """>4096-char messages are split via chunk_text.""" + + def test_long_queue_is_chunked(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + many_jobs = { + "generated_at": "now", + "count": 100, + "jobs": [ + { + "owner": f"@branch{i}", + "id": f"job-{i}", + "enabled": True, + "type": "daily", + "schedule_human": "every day", + "next_run": "2026-07-01T00:00:00Z", + "last_run": None, + "last_status": None, + "last_error": None, + "prompt_preview": "A" * 80, + "wake": {}, + } + for i in range(100) + ], + } + + with ( + patch.object(bot, "_fetch_queue", return_value=many_jobs), + patch.object(bot, "send_message") as mock_send, + ): + bot._handle_queue_command(42) + assert mock_send.call_count >= 2 + + def test_short_queue_not_chunked(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with ( + patch.object(bot, "_fetch_queue", return_value=SAMPLE_QUEUE), + patch.object(bot, "send_message") as mock_send, + ): + bot._handle_queue_command(42) + assert mock_send.call_count == 1 + + +# ============================================= +# 5. SECRET LOADING +# ============================================= + + +class TestSecretLoading: + """Bot loads telegram/scheduler config; missing fails loud.""" + + def test_missing_secret_returns_none(self): + """When get_secret returns None, load_bot_config returns None — bot won't start.""" + from apps.handlers.config import load_bot_config # type: ignore[import-not-found] + + with patch("apps.handlers.config._get_secret", return_value=None): + config = load_bot_config("scheduler") + assert config is None + + def test_secret_provides_chat_id(self): + """The scheduler config includes chat_id for digest delivery.""" + config = { + "bot_id": "scheduler", + "bot_token": "123:FAKE", + "bot_name": "AIPass Scheduler Bot", + "branch_name": "daemon", + "work_dir": "/tmp", + "allowed_user_ids": [111], + "chat_id": "7235222625", + } + assert "chat_id" in config + assert config["chat_id"] == "7235222625" + + +# ============================================= +# 6. SLASH-MENU INCLUDES /queue +# ============================================= + + +class TestSlashMenu: + """/queue appears in get_custom_commands and the command menu.""" + + def test_queue_in_custom_commands(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + cmds = bot.get_custom_commands() + assert "queue" in cmds + assert "description" in cmds["queue"] + + def test_inherited_commands_preserved(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + cmds = bot.get_custom_commands() + assert "monitor" in cmds + assert "create" in cmds + + +# ============================================= +# 7. COMMAND ROUTING +# ============================================= + + +class TestCommandRouting: + """/queue is dispatched correctly through _dispatch_command.""" + + def test_queue_routed(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "_handle_queue_command") as mock_q: + result = bot._dispatch_command(42, ("queue", "")) + assert result is True + mock_q.assert_called_once_with(42) + + def test_other_commands_fall_through_to_parent(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "send_message"): + result = bot._dispatch_command(42, ("status", "")) + assert result is True + + def test_unknown_command_falls_through(self, tmp_path, _patch_base_bot_deps): + bot = _make_scheduler_bot(tmp_path, _patch_base_bot_deps) + with patch.object(bot, "send_message"): + result = bot._dispatch_command(42, ("nonexistent", "")) + assert result is False