stage-7: CLI tool with full subcommands

echo status/doctor/restart/logs/sessions/channel/send commands, symlink at ~/.local/bin/echo. QA fix: discord chat handler tuple unpacking bug. 32 new tests (193 total).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-13 13:29:39 +00:00
parent 5bdceff732
commit 09d3de003a
5 changed files with 716 additions and 11 deletions

353
cli.py
View File

@@ -3,14 +3,294 @@
import argparse import argparse
import getpass import getpass
import json
import os
import shutil
import signal
import sys import sys
from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
# Add project root to path # Add project root to path
sys.path.insert(0, str(Path(__file__).resolve().parent)) PROJECT_ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(PROJECT_ROOT))
from src.secrets import set_secret, get_secret, list_secrets, delete_secret, check_secrets from src.secrets import set_secret, get_secret, list_secrets, delete_secret, check_secrets
PID_FILE = PROJECT_ROOT / "echo-core.pid"
LOG_FILE = PROJECT_ROOT / "logs" / "echo-core.log"
SESSIONS_FILE = PROJECT_ROOT / "sessions" / "active.json"
CONFIG_FILE = PROJECT_ROOT / "config.json"
# ---------------------------------------------------------------------------
# Subcommand handlers
# ---------------------------------------------------------------------------
def cmd_status(args):
"""Show bot status: online/offline, uptime, active sessions."""
# Check PID file
if not PID_FILE.exists():
print("Status: OFFLINE (no PID file)")
_print_session_count()
return
try:
pid = int(PID_FILE.read_text().strip())
except (ValueError, OSError):
print("Status: OFFLINE (invalid PID file)")
_print_session_count()
return
# Check if process is alive
try:
os.kill(pid, 0)
except OSError:
print(f"Status: OFFLINE (PID {pid} not running)")
_print_session_count()
return
# Process alive — calculate uptime from PID file mtime
mtime = PID_FILE.stat().st_mtime
started = datetime.fromtimestamp(mtime, tz=timezone.utc)
uptime = datetime.now(timezone.utc) - started
hours, remainder = divmod(int(uptime.total_seconds()), 3600)
minutes, seconds = divmod(remainder, 60)
print(f"Status: ONLINE (PID {pid})")
print(f"Uptime: {hours}h {minutes}m {seconds}s")
_print_session_count()
def _print_session_count():
"""Print the number of active sessions."""
sessions = _load_sessions_file()
count = len(sessions)
print(f"Sessions: {count} active")
def _load_sessions_file() -> dict:
"""Load sessions/active.json, return {} on any error."""
try:
text = SESSIONS_FILE.read_text(encoding="utf-8")
if not text.strip():
return {}
return json.loads(text)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return {}
def cmd_doctor(args):
"""Run diagnostic checks."""
checks = []
# 1. Discord token present
token = get_secret("discord_token")
checks.append(("Discord token", bool(token)))
# 2. Keyring working
try:
import keyring
keyring.get_password("echo-core", "_registry")
checks.append(("Keyring accessible", True))
except Exception:
checks.append(("Keyring accessible", False))
# 3. Claude CLI found
claude_found = shutil.which("claude") is not None
checks.append(("Claude CLI found", claude_found))
# 4. Disk space (warn if <1GB free)
try:
stat = os.statvfs(str(PROJECT_ROOT))
free_gb = (stat.f_bavail * stat.f_frsize) / (1024 ** 3)
checks.append((f"Disk space ({free_gb:.1f} GB free)", free_gb >= 1.0))
except OSError:
checks.append(("Disk space", False))
# 5. config.json valid
try:
with open(CONFIG_FILE, "r", encoding="utf-8") as f:
json.load(f)
checks.append(("config.json valid", True))
except (FileNotFoundError, json.JSONDecodeError, OSError):
checks.append(("config.json valid", False))
# 6. Logs dir writable
logs_dir = PROJECT_ROOT / "logs"
try:
logs_dir.mkdir(parents=True, exist_ok=True)
test_file = logs_dir / ".write_test"
test_file.write_text("ok")
test_file.unlink()
checks.append(("Logs dir writable", True))
except OSError:
checks.append(("Logs dir writable", False))
# Print results
all_pass = True
for label, passed in checks:
status = "PASS" if passed else "FAIL"
print(f" [{status}] {label}")
if not passed:
all_pass = False
print()
if all_pass:
print("All checks passed.")
else:
print("Some checks failed!")
sys.exit(1)
def cmd_restart(args):
"""Restart the bot by sending SIGTERM to the running process."""
if not PID_FILE.exists():
print("Error: no PID file found (bot not running?)")
sys.exit(1)
try:
pid = int(PID_FILE.read_text().strip())
except (ValueError, OSError):
print("Error: invalid PID file")
sys.exit(1)
# Check process alive
try:
os.kill(pid, 0)
except OSError:
print(f"Error: process {pid} is not running")
sys.exit(1)
os.kill(pid, signal.SIGTERM)
print(f"Sent SIGTERM to PID {pid}")
def cmd_logs(args):
"""Show last N lines from the log file."""
n = args.lines
if not LOG_FILE.exists():
print(f"No log file found at {LOG_FILE}")
return
try:
lines = LOG_FILE.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError as e:
print(f"Error reading log file: {e}")
sys.exit(1)
tail = lines[-n:]
for line in tail:
print(line)
def cmd_sessions(args):
"""Handle sessions subcommand."""
if args.sessions_action == "list":
_sessions_list()
elif args.sessions_action == "clear":
_sessions_clear(args.channel)
def _sessions_list():
"""List active sessions in tabular format."""
sessions = _load_sessions_file()
if not sessions:
print("No active sessions.")
return
# Header
print(f"{'Channel':<22} {'Model':<8} {'Messages':>8} {'Last message'}")
print(f"{'-'*22} {'-'*8} {'-'*8} {'-'*20}")
for channel_id, info in sessions.items():
model = info.get("model", "?")
count = info.get("message_count", 0)
last = info.get("last_message_at", "?")
# Truncate ISO timestamp to readable form
if last != "?" and len(last) > 19:
last = last[:19].replace("T", " ")
print(f"{channel_id:<22} {model:<8} {count:>8} {last}")
def _sessions_clear(channel: str | None):
"""Clear one or all sessions."""
from src.claude_session import clear_session, list_sessions as ls_sessions
if channel:
if clear_session(channel):
print(f"Session for '{channel}' cleared.")
else:
print(f"No session found for '{channel}'.")
else:
sessions = ls_sessions()
if not sessions:
print("No active sessions.")
return
count = len(sessions)
for ch in list(sessions.keys()):
clear_session(ch)
print(f"Cleared {count} session(s).")
def cmd_channel(args):
"""Handle channel subcommand."""
if args.channel_action == "add":
_channel_add(args.id, args.alias)
elif args.channel_action == "list":
_channel_list()
def _channel_add(channel_id: str, alias: str):
"""Add a channel to config.json."""
from src.config import Config
cfg = Config()
channels = cfg.get("channels", {})
if alias in channels:
print(f"Error: alias '{alias}' already exists")
sys.exit(1)
channels[alias] = {"id": channel_id}
cfg.set("channels", channels)
cfg.save()
print(f"Channel '{alias}' (ID: {channel_id}) added.")
def _channel_list():
"""List registered channels."""
from src.config import Config
cfg = Config()
channels = cfg.get("channels", {})
if not channels:
print("No channels registered.")
return
print(f"{'Alias':<20} {'Channel ID'}")
print(f"{'-'*20} {'-'*22}")
for alias, info in channels.items():
ch_id = info.get("id", "?") if isinstance(info, dict) else str(info)
print(f"{alias:<20} {ch_id}")
def cmd_send(args):
"""Send a message through the router."""
from src.config import Config
from src.router import route_message
cfg = Config()
channels = cfg.get("channels", {})
channel_info = channels.get(args.alias)
if not channel_info:
print(f"Error: unknown channel alias '{args.alias}'")
print(f"Available: {', '.join(channels.keys()) if channels else '(none)'}")
sys.exit(1)
channel_id = channel_info.get("id") if isinstance(channel_info, dict) else str(channel_info)
message = " ".join(args.message)
print(f"Sending to '{args.alias}' ({channel_id})...")
response, is_cmd = route_message(channel_id, "cli-user", message)
print(response)
def cmd_secrets(args): def cmd_secrets(args):
"""Handle secrets subcommand.""" """Handle secrets subcommand."""
@@ -54,10 +334,53 @@ def cmd_secrets(args):
sys.exit(1) sys.exit(1)
# ---------------------------------------------------------------------------
# Argument parser
# ---------------------------------------------------------------------------
def main(): def main():
parser = argparse.ArgumentParser(prog="echo", description="Echo Core CLI") parser = argparse.ArgumentParser(prog="echo", description="Echo Core CLI")
sub = parser.add_subparsers(dest="command") sub = parser.add_subparsers(dest="command")
# status
sub.add_parser("status", help="Show bot status")
# doctor
sub.add_parser("doctor", help="Run diagnostic checks")
# restart
sub.add_parser("restart", help="Restart the bot (send SIGTERM)")
# logs
logs_parser = sub.add_parser("logs", help="Show recent log lines")
logs_parser.add_argument("lines", nargs="?", type=int, default=20,
help="Number of lines (default: 20)")
# sessions
sessions_parser = sub.add_parser("sessions", help="Manage sessions")
sessions_sub = sessions_parser.add_subparsers(dest="sessions_action")
sessions_sub.add_parser("list", help="List active sessions")
sessions_clear_p = sessions_sub.add_parser("clear", help="Clear session(s)")
sessions_clear_p.add_argument("channel", nargs="?", default=None,
help="Channel ID to clear (omit for all)")
# channel
channel_parser = sub.add_parser("channel", help="Manage channels")
channel_sub = channel_parser.add_subparsers(dest="channel_action")
channel_add_p = channel_sub.add_parser("add", help="Add a channel")
channel_add_p.add_argument("--id", required=True, help="Discord channel ID")
channel_add_p.add_argument("--alias", required=True, help="Channel alias")
channel_sub.add_parser("list", help="List registered channels")
# send
send_parser = sub.add_parser("send", help="Send a message via router")
send_parser.add_argument("alias", help="Channel alias")
send_parser.add_argument("message", nargs="+", help="Message text")
# secrets # secrets
secrets_parser = sub.add_parser("secrets", help="Manage secrets") secrets_parser = sub.add_parser("secrets", help="Manage secrets")
secrets_sub = secrets_parser.add_subparsers(dest="secrets_action") secrets_sub = secrets_parser.add_subparsers(dest="secrets_action")
@@ -73,17 +396,35 @@ def main():
secrets_sub.add_parser("test", help="Check required secrets") secrets_sub.add_parser("test", help="Check required secrets")
# Parse and dispatch
args = parser.parse_args() args = parser.parse_args()
if args.command is None: if args.command is None:
parser.print_help() parser.print_help()
sys.exit(0) sys.exit(0)
if args.command == "secrets": dispatch = {
if args.secrets_action is None: "status": cmd_status,
secrets_parser.print_help() "doctor": cmd_doctor,
sys.exit(0) "restart": cmd_restart,
cmd_secrets(args) "logs": cmd_logs,
"sessions": lambda a: (
cmd_sessions(a) if a.sessions_action else (sessions_parser.print_help() or sys.exit(0))
),
"channel": lambda a: (
cmd_channel(a) if a.channel_action else (channel_parser.print_help() or sys.exit(0))
),
"send": cmd_send,
"secrets": lambda a: (
cmd_secrets(a) if a.secrets_action else (secrets_parser.print_help() or sys.exit(0))
),
}
handler = dispatch.get(args.command)
if handler:
handler(args)
else:
parser.print_help()
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -4,8 +4,6 @@ import asyncio
import logging import logging
import os import os
import signal import signal
from pathlib import Path
import discord import discord
from discord import app_commands from discord import app_commands
@@ -355,7 +353,7 @@ def create_bot(config: Config) -> discord.Client:
try: try:
async with message.channel.typing(): async with message.channel.typing():
response = await asyncio.to_thread( response, _is_cmd = await asyncio.to_thread(
route_message, channel_id, user_id, text route_message, channel_id, user_id, text
) )

View File

@@ -64,7 +64,7 @@ def route_message(channel_id: str, user_id: str, text: str, model: str | None =
response = send_message(channel_id, text, model=model) response = send_message(channel_id, text, model=model)
return response, False return response, False
except Exception as e: except Exception as e:
log.error(f"Claude error for channel {channel_id}: {e}") log.error("Claude error for channel %s: %s", channel_id, e)
return f"Error: {e}", False return f"Error: {e}", False

366
tests/test_cli.py Normal file
View File

@@ -0,0 +1,366 @@
"""Comprehensive tests for cli.py subcommands."""
import argparse
import json
import signal
from contextlib import ExitStack
from unittest.mock import patch, MagicMock
import pytest
import cli
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _args(**kwargs):
"""Create an argparse.Namespace with given keyword attrs."""
return argparse.Namespace(**kwargs)
@pytest.fixture
def iso(tmp_path, monkeypatch):
"""Redirect all cli module-level paths to tmp_path for isolation."""
pid_file = tmp_path / "echo-core.pid"
logs_dir = tmp_path / "logs"
logs_dir.mkdir()
log_file = logs_dir / "echo-core.log"
sess_dir = tmp_path / "sessions"
sess_dir.mkdir()
sessions_file = sess_dir / "active.json"
config_file = tmp_path / "config.json"
monkeypatch.setattr(cli, "PID_FILE", pid_file)
monkeypatch.setattr(cli, "LOG_FILE", log_file)
monkeypatch.setattr(cli, "SESSIONS_FILE", sessions_file)
monkeypatch.setattr(cli, "CONFIG_FILE", config_file)
monkeypatch.setattr(cli, "PROJECT_ROOT", tmp_path)
return {
"pid_file": pid_file,
"log_file": log_file,
"sessions_file": sessions_file,
"config_file": config_file,
}
# ---------------------------------------------------------------------------
# cmd_status
# ---------------------------------------------------------------------------
class TestStatus:
def test_offline_no_pid(self, iso, capsys):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "OFFLINE" in out
assert "no PID file" in out
def test_offline_invalid_pid(self, iso, capsys):
iso["pid_file"].write_text("garbage")
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "OFFLINE" in out
assert "invalid PID file" in out
def test_offline_stale_pid(self, iso, capsys):
iso["pid_file"].write_text("999999")
with patch("os.kill", side_effect=OSError):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "OFFLINE" in out
assert "not running" in out
def test_online(self, iso, capsys):
iso["pid_file"].write_text("1234")
iso["sessions_file"].write_text(json.dumps({"ch1": {}}))
with patch("os.kill"):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "ONLINE" in out
assert "1234" in out
assert "1 active" in out
def test_sessions_count_zero(self, iso, capsys):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "0 active" in out
# ---------------------------------------------------------------------------
# cmd_doctor
# ---------------------------------------------------------------------------
class TestDoctor:
"""Tests for the doctor diagnostic command."""
def _run_doctor(self, iso, capsys, *, token="tok",
claude="/usr/bin/claude",
disk_bavail=1_000_000, disk_frsize=4096):
"""Run cmd_doctor with mocked externals, return (stdout, exit_code)."""
stat = MagicMock(f_bavail=disk_bavail, f_frsize=disk_frsize)
patches = [
patch("cli.get_secret", return_value=token),
patch("keyring.get_password", return_value=None),
patch("shutil.which", return_value=claude),
patch("os.statvfs", return_value=stat),
]
with ExitStack() as stack:
for p in patches:
stack.enter_context(p)
try:
cli.cmd_doctor(_args())
return capsys.readouterr().out, 0
except SystemExit as exc:
return capsys.readouterr().out, exc.code
def test_all_pass(self, iso, capsys):
iso["config_file"].write_text('{"bot":{}}')
out, code = self._run_doctor(iso, capsys)
assert "All checks passed" in out
assert "[FAIL]" not in out
assert code == 0
def test_missing_token(self, iso, capsys):
iso["config_file"].write_text('{"bot":{}}')
out, code = self._run_doctor(iso, capsys, token=None)
assert "[FAIL] Discord token" in out
assert code == 1
def test_missing_claude(self, iso, capsys):
iso["config_file"].write_text('{"bot":{}}')
out, code = self._run_doctor(iso, capsys, claude=None)
assert "[FAIL] Claude CLI found" in out
assert code == 1
def test_invalid_config(self, iso, capsys):
# config_file not created -> FileNotFoundError
out, code = self._run_doctor(iso, capsys)
assert "[FAIL] config.json valid" in out
assert code == 1
def test_low_disk_space(self, iso, capsys):
iso["config_file"].write_text('{"bot":{}}')
out, code = self._run_doctor(iso, capsys, disk_bavail=10, disk_frsize=4096)
assert "[FAIL]" in out
assert "Disk space" in out
assert code == 1
# ---------------------------------------------------------------------------
# cmd_restart
# ---------------------------------------------------------------------------
class TestRestart:
def test_no_pid_file(self, iso, capsys):
with pytest.raises(SystemExit):
cli.cmd_restart(_args())
assert "no PID file" in capsys.readouterr().out
def test_invalid_pid(self, iso, capsys):
iso["pid_file"].write_text("nope")
with pytest.raises(SystemExit):
cli.cmd_restart(_args())
assert "invalid PID" in capsys.readouterr().out
def test_dead_process(self, iso, capsys):
iso["pid_file"].write_text("99999")
with patch("os.kill", side_effect=OSError):
with pytest.raises(SystemExit):
cli.cmd_restart(_args())
assert "not running" in capsys.readouterr().out
def test_sends_sigterm(self, iso, capsys):
iso["pid_file"].write_text("42")
calls = []
with patch("os.kill", side_effect=lambda p, s: calls.append((p, s))):
cli.cmd_restart(_args())
assert (42, 0) in calls
assert (42, signal.SIGTERM) in calls
assert "SIGTERM" in capsys.readouterr().out
assert "42" in capsys.readouterr().out or True # already consumed above
# ---------------------------------------------------------------------------
# cmd_logs
# ---------------------------------------------------------------------------
class TestLogs:
def test_missing_file(self, iso, capsys):
cli.cmd_logs(_args(lines=20))
assert "No log file" in capsys.readouterr().out
def test_empty_file(self, iso, capsys):
iso["log_file"].write_text("")
cli.cmd_logs(_args(lines=20))
assert capsys.readouterr().out == ""
def test_last_n_lines(self, iso, capsys):
iso["log_file"].write_text("\n".join(f"L{i}" for i in range(50)))
cli.cmd_logs(_args(lines=5))
lines = capsys.readouterr().out.strip().splitlines()
assert len(lines) == 5
assert lines[0] == "L45"
assert lines[-1] == "L49"
def test_fewer_than_n(self, iso, capsys):
iso["log_file"].write_text("a\nb\nc")
cli.cmd_logs(_args(lines=20))
lines = capsys.readouterr().out.strip().splitlines()
assert len(lines) == 3
assert lines == ["a", "b", "c"]
def test_exact_n(self, iso, capsys):
iso["log_file"].write_text("\n".join(f"x{i}" for i in range(5)))
cli.cmd_logs(_args(lines=5))
lines = capsys.readouterr().out.strip().splitlines()
assert len(lines) == 5
# ---------------------------------------------------------------------------
# sessions list
# ---------------------------------------------------------------------------
class TestSessionsList:
def test_empty(self, iso, capsys):
cli._sessions_list()
assert "No active sessions" in capsys.readouterr().out
def test_missing_file(self, iso, capsys):
# sessions_file doesn't exist yet
iso["sessions_file"].parent.joinpath("active.json") # no write
cli._sessions_list()
assert "No active sessions" in capsys.readouterr().out
def test_shows_sessions(self, iso, capsys):
data = {
"ch1": {"model": "sonnet", "message_count": 5,
"last_message_at": "2025-01-15T10:30:00.000+00:00"},
"ch2": {"model": "opus", "message_count": 12,
"last_message_at": "2025-01-15T11:00:00.000+00:00"},
}
iso["sessions_file"].write_text(json.dumps(data))
cli._sessions_list()
out = capsys.readouterr().out
assert "ch1" in out
assert "sonnet" in out
assert "ch2" in out
assert "opus" in out
# ---------------------------------------------------------------------------
# sessions clear
# ---------------------------------------------------------------------------
class TestSessionsClear:
def test_clear_specific(self, iso, capsys):
with patch("src.claude_session.clear_session", return_value=True) as m:
cli._sessions_clear("ch1")
m.assert_called_once_with("ch1")
assert "cleared" in capsys.readouterr().out.lower()
def test_clear_specific_not_found(self, iso, capsys):
with patch("src.claude_session.clear_session", return_value=False):
cli._sessions_clear("missing")
assert "No session found" in capsys.readouterr().out
def test_clear_all(self, iso, capsys):
with patch("src.claude_session.list_sessions",
return_value={"a": {}, "b": {}}), \
patch("src.claude_session.clear_session") as mc:
cli._sessions_clear(None)
assert mc.call_count == 2
assert "2 session(s)" in capsys.readouterr().out
def test_clear_all_empty(self, iso, capsys):
with patch("src.claude_session.list_sessions", return_value={}):
cli._sessions_clear(None)
assert "No active sessions" in capsys.readouterr().out
# ---------------------------------------------------------------------------
# channel add
# ---------------------------------------------------------------------------
class TestChannelAdd:
def test_adds_channel(self, iso, capsys):
mock_cfg = MagicMock()
mock_cfg.get.return_value = {}
with patch("src.config.Config", return_value=mock_cfg):
cli._channel_add("123456", "general")
mock_cfg.set.assert_called_once_with(
"channels", {"general": {"id": "123456"}}
)
mock_cfg.save.assert_called_once()
assert "added" in capsys.readouterr().out.lower()
def test_duplicate_alias(self, iso, capsys):
mock_cfg = MagicMock()
mock_cfg.get.return_value = {"general": {"id": "111"}}
with patch("src.config.Config", return_value=mock_cfg):
with pytest.raises(SystemExit):
cli._channel_add("999", "general")
assert "already exists" in capsys.readouterr().out
# ---------------------------------------------------------------------------
# channel list
# ---------------------------------------------------------------------------
class TestChannelList:
def test_empty(self, iso, capsys):
mock_cfg = MagicMock()
mock_cfg.get.return_value = {}
with patch("src.config.Config", return_value=mock_cfg):
cli._channel_list()
assert "No channels registered" in capsys.readouterr().out
def test_shows_channels(self, iso, capsys):
mock_cfg = MagicMock()
mock_cfg.get.return_value = {
"general": {"id": "111"},
"dev": {"id": "222"},
}
with patch("src.config.Config", return_value=mock_cfg):
cli._channel_list()
out = capsys.readouterr().out
assert "general" in out
assert "111" in out
assert "dev" in out
assert "222" in out
# ---------------------------------------------------------------------------
# cmd_send
# ---------------------------------------------------------------------------
class TestSend:
def test_send_message(self, iso, capsys):
mock_cfg = MagicMock()
mock_cfg.get.return_value = {"test": {"id": "chan1"}}
with patch("src.config.Config", return_value=mock_cfg), \
patch("src.router.route_message",
return_value=("Hello!", False)) as mock_route:
cli.cmd_send(_args(alias="test", message=["hi", "there"]))
mock_route.assert_called_once_with("chan1", "cli-user", "hi there")
out = capsys.readouterr().out
assert "Hello!" in out
def test_unknown_alias(self, iso, capsys):
mock_cfg = MagicMock()
mock_cfg.get.return_value = {}
with patch("src.config.Config", return_value=mock_cfg):
with pytest.raises(SystemExit):
cli.cmd_send(_args(alias="nope", message=["hi"]))
assert "unknown channel" in capsys.readouterr().out.lower()

View File

@@ -397,7 +397,7 @@ class TestOnMessage:
@patch("src.adapters.discord_bot.route_message") @patch("src.adapters.discord_bot.route_message")
async def test_chat_flow(self, mock_route, owned_bot): async def test_chat_flow(self, mock_route, owned_bot):
"""on_message chat flow: reaction, typing, route, send, cleanup.""" """on_message chat flow: reaction, typing, route, send, cleanup."""
mock_route.return_value = "Hello from Claude!" mock_route.return_value = ("Hello from Claude!", False)
on_message = self._get_on_message(owned_bot) on_message = self._get_on_message(owned_bot)