#644: gate Telegram /create + /cancel to base @aipass bot only — base_bot.py guards on branch_name (branch bots return False in _dispatch_command + omit from get_custom_commands; base bot None still routes both). Rides along @skills #669.2/#669.3 fail-loud (botfather_client _load_telethon_config raises RuntimeError naming config path vs silent None) + #668 offset tests. 133 TG tests, seedgo 31/31 both files.

This commit is contained in:
AIOSAI
2026-07-10 21:19:04 -07:00
parent c8b7250995
commit b4e2370ee8
6 changed files with 310 additions and 73 deletions
+13
View File
@@ -63,6 +63,19 @@ PyPI version — not the changelog header.
### Fixed
- **Telegram `/create` + `/cancel` are now gated to the base @aipass bot (issue
#644).** Every per-branch bot inherited `BaseBot`'s `/create` + `/cancel` and
could mint new bots — but Patrick designated the base @aipass bot as the *sole*
spawner. `base_bot.py` now guards on bot identity (branch bots carry a
`branch_name`; the base bot's is `None`): `_dispatch_command` returns `False` for
`create`/`cancel` on a branch bot (falls through to normal handling), and
`get_custom_commands` advertises them only for the base bot. Rode along in the
same @skills pass: fail-loud fixes to `botfather_client.py` (issues #669.2/#669.3,
already closed) — `_load_telethon_config` now raises `RuntimeError` naming the
config path and the `drone @api set-secret telegram telethon_config` command
instead of silently returning `None` — plus poll-offset test coverage (#668).
133 telegram tests pass, seedgo 31/31 on both source files.
- **seedgo no longer lints throwaway code (issue #675).** A single disposable POC
used to fire 8 standard violations (architecture, meta, shebang…). The audit and
checklist now skip any file resolved under a system temp dir
@@ -576,12 +576,14 @@ class BaseBot:
self._handle_monitor_command(chat_id, cmd_args)
return True
# /create command — multi-step bot creation
# /create and /cancel — base bot only (branch bots must not spawn bots)
if cmd_name in ("create", "cancel") and self.branch_name is not None:
return False
if cmd_name == "create":
self._handle_create_command(chat_id, cmd_args)
return True
# /cancel command — cancel active /create flow
if cmd_name == "cancel":
if chat_id in self._create_state:
del self._create_state[chat_id]
@@ -2335,20 +2337,22 @@ class BaseBot:
Returns:
Dict of commands in telegram_standards format
"""
return {
commands = {
"monitor": {
"description": "Subscribe to system-wide log alerts — /monitor on, off, all, status",
"menu_text": "Log monitor",
},
"create": {
}
if self.branch_name is None:
commands["create"] = {
"description": "Create a Telegram bot for a branch — e.g. /create chat devpulse",
"menu_text": "New branch bot",
},
"cancel": {
}
commands["cancel"] = {
"description": "Cancel an in-progress /create",
"menu_text": "Cancel create",
},
}
}
return commands
# =============================================
# LOCK FILE MANAGEMENT
@@ -71,7 +71,7 @@ MAX_USERNAME_ATTEMPTS = 3
# =============================================
def _load_telethon_config() -> Optional[dict]:
def _load_telethon_config() -> dict:
"""
Load Telethon API credentials from the @api secrets store.
@@ -79,31 +79,34 @@ def _load_telethon_config() -> Optional[dict]:
{"api_id": 12345, "api_hash": "abc123..."}
Returns:
Dict with "api_id" (int) and "api_hash" (str), or None on failure.
Dict with "api_id" (int) and "api_hash" (str).
Raises:
RuntimeError: If config is missing, incomplete, or unreadable.
"""
config = _get_secret("telethon_config")
if config is None:
raise RuntimeError(
"Telethon config not found in secrets store (telegram/telethon_config). "
"Set it with: drone @api set-secret telegram telethon_config "
'\'{"api_id": ..., "api_hash": "..."}\''
)
api_id = config.get("api_id")
api_hash = config.get("api_hash")
if not api_id or not api_hash:
raise RuntimeError("Telethon config incomplete — missing api_id or api_hash (telegram/telethon_config)")
try:
config = _get_secret("telethon_config")
if config is None:
logger.warning("Telethon config not found in secrets store")
return None
api_id = config.get("api_id")
api_hash = config.get("api_hash")
if not api_id or not api_hash:
logger.warning("Telethon config missing api_id or api_hash")
return None
# Ensure api_id is an integer
config["api_id"] = int(api_id)
config["api_hash"] = str(api_hash)
except (ValueError, TypeError) as e:
raise RuntimeError(f"Telethon config api_id is not a valid integer: {e}") from e
logger.info("Telethon config loaded successfully")
return config
config["api_hash"] = str(api_hash)
except (ValueError, OSError) as e:
logger.warning("Failed to load Telethon config: %s", e)
return None
logger.info("Telethon config loaded successfully")
return config
# =============================================
@@ -127,13 +130,11 @@ def check_telethon_setup() -> tuple[bool, str]:
if not TELETHON_AVAILABLE:
return (False, "Telethon library not installed. Run: pip install telethon")
config = _get_secret("telethon_config")
if config is None:
return (False, "Telethon config not found in secrets store")
config = _load_telethon_config()
if config is None:
return (False, "Telethon config is invalid (missing api_id or api_hash)")
try:
_load_telethon_config()
except RuntimeError as e:
logger.warning("Telethon setup check failed: %s", e)
return (False, str(e))
# Telethon creates session files with .session extension
session_file = Path(str(SESSION_PATH) + ".session")
@@ -478,11 +479,6 @@ def create_bot_via_botfather(branch_name: str) -> Optional[dict]:
raise RuntimeError(f"Telethon setup failed: {reason}")
config = _load_telethon_config()
if config is None:
raise RuntimeError(
"Telethon config could not be loaded (missing api_id or api_hash). "
'Set it with: drone @api set-secret telethon_config \'{"api_id": ..., "api_hash": "..."}\''
)
api_id = config["api_id"]
api_hash = config["api_hash"]
@@ -47,18 +47,22 @@ class TestLoadTelethonConfig:
mock_get_secret.assert_called_once_with("telethon_config")
@patch("apps.handlers.botfather_client._get_secret")
def test_returns_none_when_secret_missing(self, mock_get_secret):
"""Returns None when the secret doesn't exist."""
def test_raises_when_secret_missing(self, mock_get_secret):
"""Raises RuntimeError when the secret doesn't exist."""
mock_get_secret.return_value = None
result = _load_telethon_config()
assert result is None
import pytest
with pytest.raises(RuntimeError, match="not found in secrets store"):
_load_telethon_config()
@patch("apps.handlers.botfather_client._get_secret")
def test_returns_none_when_api_id_missing(self, mock_get_secret):
"""Returns None when api_id is missing from config."""
def test_raises_when_api_id_missing(self, mock_get_secret):
"""Raises RuntimeError when api_id is missing from config."""
mock_get_secret.return_value = {"api_hash": "abc123def"}
result = _load_telethon_config()
assert result is None
import pytest
with pytest.raises(RuntimeError, match="missing api_id or api_hash"):
_load_telethon_config()
@patch("apps.handlers.botfather_client._get_secret")
def test_coerces_api_id_from_string(self, mock_get_secret):
@@ -78,14 +82,10 @@ class TestLoadTelethonConfig:
class TestCheckTelethonSetup:
"""Test check_telethon_setup: checks library, config, session file."""
@patch("apps.handlers.botfather_client._get_secret")
@patch("apps.handlers.botfather_client._load_telethon_config")
def test_returns_ready_when_all_in_place(self, mock_load_config, mock_get_secret, tmp_path, monkeypatch):
def test_returns_ready_when_all_in_place(self, mock_load_config, tmp_path, monkeypatch):
"""Returns (True, 'ready') when Telethon is available, config valid, session exists."""
monkeypatch.setattr("apps.handlers.botfather_client.TELETHON_AVAILABLE", True)
# _get_secret called directly in check_telethon_setup for existence check
mock_get_secret.return_value = {"api_id": 12345, "api_hash": "abc123"}
# _load_telethon_config called for validation check
mock_load_config.return_value = {"api_id": 12345, "api_hash": "abc123"}
# Create session file at the new path
@@ -108,34 +108,30 @@ class TestCheckTelethonSetup:
assert ready is False
assert "not installed" in reason.lower() or "telethon" in reason.lower()
@patch("apps.handlers.botfather_client._get_secret")
def test_returns_false_when_config_not_in_secrets(self, mock_get_secret, monkeypatch):
@patch("apps.handlers.botfather_client._load_telethon_config")
def test_returns_false_when_config_not_in_secrets(self, mock_load_config, monkeypatch):
"""Returns (False, ...) when secret store has no telethon config."""
monkeypatch.setattr("apps.handlers.botfather_client.TELETHON_AVAILABLE", True)
mock_get_secret.return_value = None
mock_load_config.side_effect = RuntimeError(
"Telethon config not found in secrets store (telegram/telethon_config)"
)
ready, reason = check_telethon_setup()
assert ready is False
assert "config" in reason.lower() or "not found" in reason.lower()
assert "not found" in reason.lower()
@patch("apps.handlers.botfather_client._load_telethon_config")
@patch("apps.handlers.botfather_client._get_secret")
def test_returns_false_when_config_invalid(self, mock_get_secret, mock_load_config, monkeypatch):
def test_returns_false_when_config_invalid(self, mock_load_config, monkeypatch):
"""Returns (False, ...) when config exists but is invalid (missing fields)."""
monkeypatch.setattr("apps.handlers.botfather_client.TELETHON_AVAILABLE", True)
# _get_secret returns something (config exists) but _load_telethon_config
# returns None (validation fails due to missing api_id)
mock_get_secret.return_value = {"api_hash": "abc123"}
mock_load_config.return_value = None
mock_load_config.side_effect = RuntimeError("Telethon config incomplete — missing api_id or api_hash")
ready, reason = check_telethon_setup()
assert ready is False
assert "invalid" in reason.lower()
assert "missing api_id or api_hash" in reason
@patch("apps.handlers.botfather_client._load_telethon_config")
@patch("apps.handlers.botfather_client._get_secret")
def test_returns_false_when_session_file_missing(self, mock_get_secret, mock_load_config, tmp_path, monkeypatch):
def test_returns_false_when_session_file_missing(self, mock_load_config, tmp_path, monkeypatch):
"""Returns (False, ...) when session file doesn't exist."""
monkeypatch.setattr("apps.handlers.botfather_client.TELETHON_AVAILABLE", True)
mock_get_secret.return_value = {"api_id": 12345, "api_hash": "abc123"}
mock_load_config.return_value = {"api_id": 12345, "api_hash": "abc123"}
session_path = tmp_path / ".telethon"
@@ -614,18 +610,22 @@ class TestCreateBotViaBotfather:
create_bot_via_botfather("dev_central")
def test_raises_when_config_load_fails(self, monkeypatch):
"""Raises RuntimeError when _load_telethon_config returns None."""
"""Raises RuntimeError when _load_telethon_config raises."""
monkeypatch.setattr(
"apps.handlers.botfather_client.check_telethon_setup",
lambda: (True, "ready"),
)
def _raise():
raise RuntimeError("Telethon config not found in secrets store (telegram/telethon_config)")
monkeypatch.setattr(
"apps.handlers.botfather_client._load_telethon_config",
lambda: None,
_raise,
)
import pytest
with pytest.raises(RuntimeError, match="could not be loaded"):
with pytest.raises(RuntimeError, match="not found in secrets store"):
create_bot_via_botfather("dev_central")
def test_returns_result_on_success(self, monkeypatch):
@@ -899,8 +899,11 @@ class TestCommandMenuSync:
build_help_text,
)
from apps.handlers.base_bot import BaseBot
from unittest.mock import MagicMock
custom = BaseBot.get_custom_commands(None)
mock_self = MagicMock(spec=BaseBot)
mock_self.branch_name = None
custom = BaseBot.get_custom_commands(mock_self)
menu_commands = build_botfather_commands(custom_commands=custom)
menu_names = {c["command"] for c in menu_commands}
@@ -917,8 +920,11 @@ class TestCommandMenuSync:
"""The /help text includes the enriched descriptions."""
from apps.handlers.telegram_standards import build_help_text
from apps.handlers.base_bot import BaseBot
from unittest.mock import MagicMock
custom = BaseBot.get_custom_commands(None)
mock_self = MagicMock(spec=BaseBot)
mock_self.branch_name = None
custom = BaseBot.get_custom_commands(mock_self)
help_text = build_help_text(custom_commands=custom)
assert "what this bot is and how to use it" in help_text.lower()
@@ -966,6 +972,7 @@ class TestBaseBotStartupMenu:
bot = BaseBot.__new__(BaseBot)
bot.bot_token = "123:ABC"
bot.custom_commands = {}
bot.branch_name = None
bot._set_command_menu()
@@ -982,6 +989,7 @@ class TestBaseBotStartupMenu:
bot = BaseBot.__new__(BaseBot)
bot.bot_token = "123:ABC"
bot.custom_commands = {}
bot.branch_name = None
bot._set_command_menu()
@@ -0,0 +1,216 @@
"""
Tests for poll-offset advancement (#668) and /create+/cancel base-bot gate (#644).
Tests cover:
- Poll loop advances offset past rate-limited updates
- Poll loop advances offset past rejected (unauthorized) updates
- Poll loop advances offset on normal processing
- _dispatch_command gates /create to base bot only (branch_name is None)
- _dispatch_command gates /cancel to base bot only
- _dispatch_command allows /create on base bot
- get_custom_commands omits /create+/cancel for branch bots
- get_custom_commands includes /create+/cancel for base bot
"""
import pytest
from unittest.mock import patch, MagicMock
from apps.handlers.base_bot import BaseBot # type: ignore[import-not-found]
@pytest.fixture
def _patch_base_bot_deps(tmp_path):
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_bot(tmp_path, _patch_base_bot_deps, branch_name=None):
workdir = tmp_path / "workdir"
workdir.mkdir(exist_ok=True)
bot = BaseBot(
bot_id="test_bot",
bot_token="123:FAKETOKEN",
work_dir=workdir,
bot_name="Test Bot",
branch_name=branch_name,
)
bot.verify_connection = lambda timeout=15: True
bot._set_command_menu = lambda: None
bot._boot_monitor = lambda: None
bot._check_lock = lambda: False
bot._create_lock = lambda: None
bot._remove_lock = lambda: None
return bot
# =============================================
# 1. Poll offset advancement (#668)
# =============================================
class TestPollOffsetAdvancement:
"""Offset advances past every consumed update regardless of processing outcome."""
def test_offset_advances_past_rate_limited_update(self, tmp_path, _patch_base_bot_deps):
"""Offset advances even when process_update hits the rate limiter."""
bot = _make_bot(tmp_path, _patch_base_bot_deps)
updates = [
{"update_id": 100, "message": {"chat": {"id": 1}, "from": {"id": 99}, "text": "hi"}},
{"update_id": 101, "message": {"chat": {"id": 1}, "from": {"id": 99}, "text": "hi2"}},
]
call_count = 0
def fake_poll(offset):
nonlocal call_count
call_count += 1
if call_count == 1:
return updates
bot.state["running"] = False
return []
bot.poll_updates = fake_poll
bot._load_offset = lambda: 0
saved_offsets = []
bot._save_offset = lambda o: saved_offsets.append(o)
bot.check_rate_limit = lambda uid: False
bot.send_message = MagicMock()
bot.run()
assert 102 in saved_offsets
assert saved_offsets[-1] == 102
def test_offset_advances_past_unauthorized_update(self, tmp_path, _patch_base_bot_deps):
"""Offset advances even when user is not in the allowlist."""
bot = _make_bot(tmp_path, _patch_base_bot_deps)
bot.allowed_user_ids = [777]
updates = [
{"update_id": 200, "message": {"chat": {"id": 1}, "from": {"id": 999}, "text": "intruder"}},
]
call_count = 0
def fake_poll(offset):
nonlocal call_count
call_count += 1
if call_count == 1:
return updates
bot.state["running"] = False
return []
bot.poll_updates = fake_poll
bot._load_offset = lambda: 0
saved_offsets = []
bot._save_offset = lambda o: saved_offsets.append(o)
bot.run()
assert saved_offsets == [201]
def test_offset_advances_on_normal_message(self, tmp_path, _patch_base_bot_deps):
"""Offset advances on successfully processed messages."""
bot = _make_bot(tmp_path, _patch_base_bot_deps)
updates = [
{"update_id": 50, "message": {"chat": {"id": 1}, "from": {"id": 1}, "text": "/help"}},
]
call_count = 0
def fake_poll(offset):
nonlocal call_count
call_count += 1
if call_count == 1:
return updates
bot.state["running"] = False
return []
bot.poll_updates = fake_poll
bot._load_offset = lambda: 0
saved_offsets = []
bot._save_offset = lambda o: saved_offsets.append(o)
bot.send_message = MagicMock()
bot.run()
assert saved_offsets == [51]
# =============================================
# 2. /create + /cancel base-bot gate (#644)
# =============================================
class TestCreateCancelGate:
"""Only the base bot (branch_name is None) routes /create and /cancel."""
def test_branch_bot_does_not_route_create(self, tmp_path, _patch_base_bot_deps):
"""A branch bot (branch_name set) falls through on /create."""
bot = _make_bot(tmp_path, _patch_base_bot_deps, branch_name="devpulse")
bot.send_message = MagicMock()
result = bot._dispatch_command(42, ("create", "chat devpulse"))
assert result is False
bot.send_message.assert_not_called()
def test_branch_bot_does_not_route_cancel(self, tmp_path, _patch_base_bot_deps):
"""A branch bot (branch_name set) falls through on /cancel."""
bot = _make_bot(tmp_path, _patch_base_bot_deps, branch_name="devpulse")
bot.send_message = MagicMock()
result = bot._dispatch_command(42, ("cancel", ""))
assert result is False
bot.send_message.assert_not_called()
def test_base_bot_routes_create(self, tmp_path, _patch_base_bot_deps):
"""The base bot (branch_name is None) handles /create."""
bot = _make_bot(tmp_path, _patch_base_bot_deps, branch_name=None)
bot.send_message = MagicMock()
bot._handle_create_command = MagicMock()
result = bot._dispatch_command(42, ("create", "chat devpulse"))
assert result is True
bot._handle_create_command.assert_called_once_with(42, "chat devpulse")
def test_base_bot_routes_cancel(self, tmp_path, _patch_base_bot_deps):
"""The base bot (branch_name is None) handles /cancel."""
bot = _make_bot(tmp_path, _patch_base_bot_deps, branch_name=None)
bot.send_message = MagicMock()
result = bot._dispatch_command(42, ("cancel", ""))
assert result is True
def test_branch_bot_still_routes_monitor(self, tmp_path, _patch_base_bot_deps):
"""Branch bots still handle /monitor (not gated)."""
bot = _make_bot(tmp_path, _patch_base_bot_deps, branch_name="devpulse")
bot._handle_monitor_command = MagicMock()
result = bot._dispatch_command(42, ("monitor", "status"))
assert result is True
bot._handle_monitor_command.assert_called_once()
class TestGetCustomCommandsGate:
"""get_custom_commands only advertises /create+/cancel for the base bot."""
def test_base_bot_includes_create_cancel(self, tmp_path, _patch_base_bot_deps):
"""Base bot (branch_name is None) advertises /create and /cancel."""
bot = _make_bot(tmp_path, _patch_base_bot_deps, branch_name=None)
commands = bot.get_custom_commands()
assert "create" in commands
assert "cancel" in commands
assert "monitor" in commands
def test_branch_bot_omits_create_cancel(self, tmp_path, _patch_base_bot_deps):
"""Branch bot (branch_name set) does NOT advertise /create or /cancel."""
bot = _make_bot(tmp_path, _patch_base_bot_deps, branch_name="devpulse")
commands = bot.get_custom_commands()
assert "create" not in commands
assert "cancel" not in commands
assert "monitor" in commands