diff --git a/docs/plans/2026-02-28-dynamic-command-menu-design.md b/docs/plans/2026-02-28-dynamic-command-menu-design.md new file mode 100644 index 00000000..f605668a --- /dev/null +++ b/docs/plans/2026-02-28-dynamic-command-menu-design.md @@ -0,0 +1,215 @@ +# Dynamic Command Menu & Plugin Manager + +**Date:** 2026-02-28 +**Status:** Approved + +## Summary + +Add a `/menu` command (wired to Telegram's persistent menu button) that dynamically discovers all Claude Code skills, commands, and plugins from the filesystem and presents them as a navigable inline keyboard. Includes full plugin management (browse, enable/disable, install/update). + +## Requirements + +1. **Dynamic discovery** — Scan `~/.claude/` on each menu open (no caching) +2. **Unified menu** — Bot commands + Claude Code skills/commands + custom skills in one place +3. **Natural categories** — Plugin names as groupings (not hardcoded taxonomy) +4. **Plugin management** — Browse, enable/disable, install new, update existing +5. **Persistent menu button** — Always-visible access via Telegram's menu button +6. **In-place navigation** — Edit existing message, don't spam new ones + +## Architecture + +### Navigation Model + +``` +/menu (top level) +├── 🤖 Bot (5) → /new, /status, /repo, /verbose, /stop +├── ⚡ superpowers (14) → brainstorming, TDD, debugging, ... +├── 📝 commit-commands (3) → /commit, /commit-push-pr, /clean_gone +├── 🔍 code-review (2) → /code-review, /review-pr +├── 🚀 feature-dev (1) → executes directly (single item) +├── 🎨 frontend-design (1) → executes directly +├── 📋 claude-md-management (2) → /revise-claude-md, /claude-md-improver +├── ⚙️ obsidian-cli (1) → executes directly (custom skill) +├── ⚙️ defuddle (1) → executes directly +├── ...more custom skills... +└── 📦 Plugin Store → Search & install new plugins +``` + +Single-item plugins/skills execute directly on tap (no sub-menu). + +### Callback Data Convention + +``` +Format: "menu:{action}:{argument}" + +Navigation: + "menu:cat:{plugin_name}" → show plugin's skills/commands + "menu:back" → return to top level + "menu:back:{plugin_name}" → return to plugin from detail view + +Execution: + "menu:run:{item_id}" → execute a bot command or inject a skill + +Plugin management: + "menu:plug:{plugin_name}" → show plugin detail (info, toggle, skills list) + "menu:tog:{plugin_name}" → toggle plugin enable/disable + "menu:inst:{plugin_name}" → install plugin from store + "menu:upd:{plugin_name}" → update plugin to latest version + +Store: + "menu:store" → show plugin store + "menu:store:p{page}" → paginate store results +``` + +Note: Telegram limits callback_data to 64 bytes. Plugin/skill names may need truncation + lookup table. + +### Data Model + +```python +@dataclass +class PaletteItem: + id: str # unique ID, e.g. "superpowers:brainstorming" + name: str # display name from SKILL.md frontmatter + description: str # from SKILL.md frontmatter + action_type: ActionType # DIRECT_COMMAND | INJECT_SKILL + action_value: str # "/status" or "/commit" or "brainstorming" + icon: str # emoji (derived or default) + source: str # plugin name or "bot" or "custom" + enabled: bool # cross-referenced with settings.json + +class ActionType(Enum): + DIRECT_COMMAND = "direct" # bot handles directly (e.g. /status) + INJECT_SKILL = "inject" # send as text to Claude session + +@dataclass +class PluginInfo: + name: str + version: str + enabled: bool + items: list[PaletteItem] # skills + commands in this plugin + path: str # filesystem path +``` + +### Scanner Implementation + +```python +class CommandPaletteScanner: + CLAUDE_DIR = Path.home() / ".claude" + + def scan(self) -> tuple[list[PaletteItem], list[PluginInfo]]: + items = [] + plugins = [] + + # 1. Bot commands (hardcoded, always present) + items.extend(self._get_bot_commands()) + + # 2. Plugin skills: ~/.claude/plugins/cache/claude-plugins-official/*/skills/*/SKILL.md + # 3. Plugin commands: ~/.claude/plugins/cache/claude-plugins-official/*/commands/*.md + for plugin_dir in self._get_plugin_dirs(): + plugin_info = self._scan_plugin(plugin_dir) + plugins.append(plugin_info) + items.extend(plugin_info.items) + + # 4. Custom skills: ~/.claude/skills/*/SKILL.md + for skill_dir in self._get_custom_skill_dirs(): + items.append(self._scan_custom_skill(skill_dir)) + + # 5. Cross-reference with settings.json + blocklist.json + self._apply_enabled_state(items, plugins) + + return items, plugins + + def _parse_skill_frontmatter(self, path: Path) -> dict: + """Parse YAML frontmatter from SKILL.md or command .md file.""" + # Extract name, description from --- delimited YAML block + ... +``` + +### Filesystem Paths Scanned + +| Path | What | Category | +|------|------|----------| +| (hardcoded) | Bot commands: /new, /status, /repo, /verbose, /stop | bot | +| `~/.claude/plugins/cache/claude-plugins-official/{plugin}/{version}/skills/*/SKILL.md` | Official plugin skills | plugin name | +| `~/.claude/plugins/cache/claude-plugins-official/{plugin}/{version}/commands/*.md` | Official plugin commands | plugin name | +| `~/.claude/skills/*/SKILL.md` | Custom skills | custom | +| `~/.claude/settings.json` | Enabled plugins list | (config) | +| `~/.claude/plugins/installed_plugins.json` | Plugin versions & metadata | (config) | +| `~/.claude/plugins/blocklist.json` | Disabled plugins | (config) | + +### Action Execution + +| Action Type | Behavior | +|-------------|----------| +| `DIRECT_COMMAND` | Call the bot's own command handler function directly (e.g., `agentic_status()`) | +| `INJECT_SKILL` | Send skill name as user text to active Claude session via `ClaudeIntegration.run_command()` | + +For skill injection, the bot constructs a message like `/commit` or `/feature-dev` and feeds it to Claude as if the user typed it. This leverages Claude's existing skill invocation mechanism. + +### Plugin Management + +**Enable/Disable:** +- Read `~/.claude/plugins/blocklist.json` +- Add/remove plugin name from blocklist array +- Write back to file +- Display confirmation with note: "Takes effect on next /new session" + +**Install (Plugin Store):** +- Query available plugins (TBD: either scrape Claude Code plugin registry or maintain a known-plugins list) +- Run `claude plugins install {name}` via shell if CLI supports it +- Otherwise, manual download + write to `installed_plugins.json` +- Notify user of success/failure + +**Update:** +- Compare installed version (from `installed_plugins.json`) with latest available +- Run update command or re-download + +### Persistent Menu Button + +On bot startup, call: +```python +await bot.set_chat_menu_button( + menu_button=MenuButtonCommands() # shows / command list +) +``` + +Register `/menu` in `get_bot_commands()` so it appears in Telegram's command autocomplete. The menu button in Telegram opens the command list where `/menu` is the first entry. + +Alternative: Use `MenuButtonWebApp` if we ever migrate to a WebApp approach. + +## New Files + +| File | Purpose | +|------|---------| +| `src/bot/features/command_palette.py` | `CommandPaletteScanner`, `PaletteItem`, `PluginInfo`, `ActionType` | +| `src/bot/handlers/menu.py` | `/menu` command handler, callback handler for menu navigation, plugin management actions | + +## Modified Files + +| File | Change | +|------|--------| +| `src/bot/orchestrator.py` | Register `/menu` handler + menu callback handler in `_register_agentic_handlers()` | +| `src/bot/orchestrator.py` | Add `/menu` to `get_bot_commands()` | +| `src/bot/core.py` | Set persistent menu button on startup | + +## Callback Data Size Constraint + +Telegram limits `callback_data` to 64 bytes. Plugin/skill names can be long (e.g., "claude-md-management:claude-md-improver" = 40 chars + "menu:run:" prefix = 49 chars). Strategy: + +- Use short numeric IDs in callback data: `"menu:run:7"`, `"menu:cat:3"` +- Maintain a per-message mapping dict `{short_id: full_item_id}` stored in `context.user_data` +- Mapping refreshed on each menu render + +## Error Handling + +- If `~/.claude/` doesn't exist: show only Bot commands, display note +- If a SKILL.md has invalid frontmatter: skip it, log warning +- If plugin toggle fails (permission error): show error inline +- If skill injection fails (no active session): prompt user to start a session first + +## Testing Strategy + +- Unit tests for `CommandPaletteScanner` with mock filesystem +- Unit tests for callback data routing +- Unit tests for frontmatter parsing (valid, invalid, missing) +- Integration test for menu navigation flow (mock Telegram API) diff --git a/docs/plans/2026-02-28-dynamic-command-menu.md b/docs/plans/2026-02-28-dynamic-command-menu.md new file mode 100644 index 00000000..963791ef --- /dev/null +++ b/docs/plans/2026-02-28-dynamic-command-menu.md @@ -0,0 +1,1725 @@ +# Dynamic Command Menu & Plugin Manager — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a `/menu` command with persistent menu button that dynamically discovers all Claude Code skills, commands, and plugins from `~/.claude/`, presents them as navigable inline keyboard categories (grouped by plugin name), and supports full plugin management (enable/disable/install/update). + +**Architecture:** New `CommandPaletteScanner` scans `~/.claude/` filesystem on each invocation, parsing YAML frontmatter from SKILL.md files and cross-referencing `settings.json`/`blocklist.json` for enabled state. A `/menu` command + `CallbackQueryHandler(pattern=r"^menu:")` handles multi-level inline keyboard navigation. Bot commands execute directly; Claude Code skills inject the skill name as text into the active Claude session via `agentic_text()`. + +**Tech Stack:** python-telegram-bot (InlineKeyboardMarkup, CallbackQueryHandler), PyYAML (frontmatter parsing), pathlib (filesystem scanning), existing bot patterns from `orchestrator.py`. + +**Design doc:** `docs/plans/2026-02-28-dynamic-command-menu-design.md` + +--- + +### Task 1: Data Model — PaletteItem and PluginInfo + +**Files:** +- Create: `src/bot/features/command_palette.py` +- Test: `tests/unit/test_bot/test_command_palette.py` + +**Step 1: Write the failing test** + +```python +# tests/unit/test_bot/test_command_palette.py +"""Tests for the command palette scanner.""" + +import pytest + +from src.bot.features.command_palette import ( + ActionType, + PaletteItem, + PluginInfo, +) + + +def test_palette_item_creation(): + item = PaletteItem( + id="superpowers:brainstorming", + name="brainstorming", + description="Use before creative work", + action_type=ActionType.INJECT_SKILL, + action_value="/brainstorming", + source="superpowers", + enabled=True, + ) + assert item.id == "superpowers:brainstorming" + assert item.action_type == ActionType.INJECT_SKILL + assert item.enabled is True + + +def test_plugin_info_creation(): + item = PaletteItem( + id="superpowers:brainstorming", + name="brainstorming", + description="desc", + action_type=ActionType.INJECT_SKILL, + action_value="/brainstorming", + source="superpowers", + enabled=True, + ) + plugin = PluginInfo( + name="superpowers", + qualified_name="superpowers@claude-plugins-official", + version="4.3.1", + enabled=True, + items=[item], + install_path="/home/user/.claude/plugins/cache/claude-plugins-official/superpowers/4.3.1", + ) + assert plugin.name == "superpowers" + assert len(plugin.items) == 1 + assert plugin.enabled is True + + +def test_action_type_enum(): + assert ActionType.DIRECT_COMMAND.value == "direct" + assert ActionType.INJECT_SKILL.value == "inject" +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_command_palette.py -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'src.bot.features.command_palette'` + +**Step 3: Write minimal implementation** + +```python +# src/bot/features/command_palette.py +"""Dynamic command palette: scans ~/.claude/ for skills, commands, and plugins.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Optional + + +class ActionType(Enum): + """How a palette item is executed.""" + + DIRECT_COMMAND = "direct" # Bot handles directly (e.g. /status) + INJECT_SKILL = "inject" # Send as text to Claude session (e.g. /commit) + + +@dataclass +class PaletteItem: + """A single actionable item in the command palette.""" + + id: str # unique, e.g. "superpowers:brainstorming" + name: str # display name from SKILL.md frontmatter + description: str # from SKILL.md frontmatter + action_type: ActionType + action_value: str # "/status" or "/commit" etc. + source: str # plugin name, "bot", or "custom" + enabled: bool = True + + +@dataclass +class PluginInfo: + """Metadata about an installed plugin.""" + + name: str # short name, e.g. "superpowers" + qualified_name: str # e.g. "superpowers@claude-plugins-official" + version: str + enabled: bool + items: List[PaletteItem] = field(default_factory=list) + install_path: str = "" +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_command_palette.py -v` +Expected: PASS (3 tests) + +**Step 5: Commit** + +```bash +git add src/bot/features/command_palette.py tests/unit/test_bot/test_command_palette.py +git commit -m "feat(menu): add PaletteItem and PluginInfo data models" +``` + +--- + +### Task 2: YAML Frontmatter Parser + +**Files:** +- Modify: `src/bot/features/command_palette.py` +- Test: `tests/unit/test_bot/test_command_palette.py` + +**Step 1: Write the failing test** + +Add to `tests/unit/test_bot/test_command_palette.py`: + +```python +from src.bot.features.command_palette import parse_skill_frontmatter + + +def test_parse_skill_frontmatter_valid(): + content = """--- +name: brainstorming +description: Use before creative work +--- + +# Brainstorming +Some content here. +""" + result = parse_skill_frontmatter(content) + assert result["name"] == "brainstorming" + assert result["description"] == "Use before creative work" + + +def test_parse_skill_frontmatter_no_frontmatter(): + content = "# Just a heading\nNo frontmatter here." + result = parse_skill_frontmatter(content) + assert result == {} + + +def test_parse_skill_frontmatter_with_allowed_tools(): + content = """--- +name: commit +description: Create a git commit +allowed-tools: Bash(git add:*), Bash(git commit:*) +--- +""" + result = parse_skill_frontmatter(content) + assert result["name"] == "commit" + assert "allowed-tools" in result + + +def test_parse_skill_frontmatter_empty_content(): + result = parse_skill_frontmatter("") + assert result == {} + + +def test_parse_skill_frontmatter_malformed_yaml(): + content = """--- +name: [invalid yaml +description: missing bracket +--- +""" + result = parse_skill_frontmatter(content) + assert result == {} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_command_palette.py::test_parse_skill_frontmatter_valid -v` +Expected: FAIL with `ImportError: cannot import name 'parse_skill_frontmatter'` + +**Step 3: Write minimal implementation** + +Add to `src/bot/features/command_palette.py`: + +```python +import re +from typing import Dict, Any + +import yaml + +import structlog + +logger = structlog.get_logger() + + +def parse_skill_frontmatter(content: str) -> Dict[str, Any]: + """Parse YAML frontmatter from a SKILL.md or command .md file. + + Expects format: + --- + name: skill-name + description: what it does + --- + """ + if not content.strip(): + return {} + + match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL) + if not match: + return {} + + try: + parsed = yaml.safe_load(match.group(1)) + return parsed if isinstance(parsed, dict) else {} + except yaml.YAMLError: + logger.warning("Failed to parse SKILL.md frontmatter") + return {} +``` + +**Step 4: Run all frontmatter tests** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_command_palette.py -k frontmatter -v` +Expected: PASS (5 tests) + +**Step 5: Commit** + +```bash +git add src/bot/features/command_palette.py tests/unit/test_bot/test_command_palette.py +git commit -m "feat(menu): add YAML frontmatter parser for SKILL.md files" +``` + +--- + +### Task 3: CommandPaletteScanner — Filesystem Discovery + +**Files:** +- Modify: `src/bot/features/command_palette.py` +- Test: `tests/unit/test_bot/test_command_palette.py` + +This is the core scanner. It needs to read from specific filesystem paths under `~/.claude/`. We'll make the base path configurable for testing. + +**Step 1: Write the failing tests** + +Add to `tests/unit/test_bot/test_command_palette.py`: + +```python +import tempfile +from pathlib import Path + +from src.bot.features.command_palette import CommandPaletteScanner + + +@pytest.fixture +def mock_claude_dir(): + """Create a mock ~/.claude/ directory structure.""" + with tempfile.TemporaryDirectory() as d: + base = Path(d) + + # settings.json + settings = base / "settings.json" + settings.write_text( + '{"enabledPlugins": {"superpowers@claude-plugins-official": true, ' + '"commit-commands@claude-plugins-official": true}}' + ) + + # installed_plugins.json + plugins_dir = base / "plugins" + plugins_dir.mkdir() + installed = plugins_dir / "installed_plugins.json" + installed.write_text( + '{"version": 2, "plugins": {' + '"superpowers@claude-plugins-official": [{"scope": "user", ' + '"installPath": "' + str(base) + '/plugins/cache/claude-plugins-official/superpowers/1.0", ' + '"version": "1.0"}], ' + '"commit-commands@claude-plugins-official": [{"scope": "user", ' + '"installPath": "' + str(base) + '/plugins/cache/claude-plugins-official/commit-commands/1.0", ' + '"version": "1.0"}]' + "}}" + ) + + # blocklist.json + blocklist = plugins_dir / "blocklist.json" + blocklist.write_text('{"fetchedAt": "2026-01-01", "plugins": []}') + + # Plugin: superpowers with 1 skill + cache = plugins_dir / "cache" / "claude-plugins-official" + sp_dir = cache / "superpowers" / "1.0" / "skills" / "brainstorming" + sp_dir.mkdir(parents=True) + (sp_dir / "SKILL.md").write_text( + "---\nname: brainstorming\n" + "description: Use before creative work\n---\n# Content" + ) + + # Plugin: commit-commands with 1 command + cc_dir = cache / "commit-commands" / "1.0" / "commands" + cc_dir.mkdir(parents=True) + (cc_dir / "commit.md").write_text( + "---\nname: commit\ndescription: Create a git commit\n---\n# Content" + ) + + # Custom skill + custom_dir = base / "skills" / "defuddle" + custom_dir.mkdir(parents=True) + (custom_dir / "SKILL.md").write_text( + "---\nname: defuddle\n" + "description: Extract markdown from web pages\n---\n# Content" + ) + + yield base + + +def test_scanner_discovers_bot_commands(mock_claude_dir): + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, plugins = scanner.scan() + bot_items = [i for i in items if i.source == "bot"] + assert len(bot_items) >= 5 # start, new, status, verbose, repo, stop + assert all(i.action_type == ActionType.DIRECT_COMMAND for i in bot_items) + + +def test_scanner_discovers_plugin_skills(mock_claude_dir): + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, plugins = scanner.scan() + skill_items = [i for i in items if i.id == "superpowers:brainstorming"] + assert len(skill_items) == 1 + assert skill_items[0].name == "brainstorming" + assert skill_items[0].action_type == ActionType.INJECT_SKILL + + +def test_scanner_discovers_plugin_commands(mock_claude_dir): + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, plugins = scanner.scan() + cmd_items = [i for i in items if i.id == "commit-commands:commit"] + assert len(cmd_items) == 1 + assert cmd_items[0].name == "commit" + assert cmd_items[0].action_type == ActionType.INJECT_SKILL + + +def test_scanner_discovers_custom_skills(mock_claude_dir): + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, plugins = scanner.scan() + custom = [i for i in items if i.source == "custom"] + assert len(custom) == 1 + assert custom[0].name == "defuddle" + + +def test_scanner_builds_plugin_info(mock_claude_dir): + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, plugins = scanner.scan() + sp = [p for p in plugins if p.name == "superpowers"] + assert len(sp) == 1 + assert sp[0].version == "1.0" + assert sp[0].enabled is True + assert len(sp[0].items) == 1 + + +def test_scanner_handles_missing_claude_dir(): + scanner = CommandPaletteScanner(claude_dir=Path("/nonexistent")) + items, plugins = scanner.scan() + # Should still return bot commands + bot_items = [i for i in items if i.source == "bot"] + assert len(bot_items) >= 5 + assert len(plugins) == 0 +``` + +**Step 2: Run to verify failure** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_command_palette.py::test_scanner_discovers_bot_commands -v` +Expected: FAIL with `ImportError: cannot import name 'CommandPaletteScanner'` + +**Step 3: Write implementation** + +Add to `src/bot/features/command_palette.py`: + +```python +import json +from pathlib import Path + + +# Default bot commands (always present in agentic mode) +BOT_COMMANDS = [ + PaletteItem( + id="bot:new", name="new", description="Start a fresh session", + action_type=ActionType.DIRECT_COMMAND, action_value="/new", source="bot", + ), + PaletteItem( + id="bot:status", name="status", description="Show session status", + action_type=ActionType.DIRECT_COMMAND, action_value="/status", source="bot", + ), + PaletteItem( + id="bot:repo", name="repo", description="List repos / switch workspace", + action_type=ActionType.DIRECT_COMMAND, action_value="/repo", source="bot", + ), + PaletteItem( + id="bot:verbose", name="verbose", description="Set output verbosity (0/1/2)", + action_type=ActionType.DIRECT_COMMAND, action_value="/verbose", source="bot", + ), + PaletteItem( + id="bot:stop", name="stop", description="Stop running Claude call", + action_type=ActionType.DIRECT_COMMAND, action_value="/stop", source="bot", + ), +] + + +class CommandPaletteScanner: + """Scans ~/.claude/ for all available skills, commands, and plugins.""" + + def __init__(self, claude_dir: Optional[Path] = None) -> None: + self.claude_dir = claude_dir or Path.home() / ".claude" + + def scan(self) -> tuple[List[PaletteItem], List[PluginInfo]]: + """Discover all palette items and plugin info from the filesystem.""" + items: List[PaletteItem] = [] + plugins: List[PluginInfo] = [] + + # 1. Bot commands (always present) + items.extend(BOT_COMMANDS) + + if not self.claude_dir.is_dir(): + return items, plugins + + # Load config files + enabled_plugins = self._load_enabled_plugins() + installed_plugins = self._load_installed_plugins() + blocklisted = self._load_blocklist() + + # 2. Scan installed plugins + for qualified_name, installs in installed_plugins.items(): + if not installs: + continue + install = installs[0] # use first (most recent) install + install_path = Path(install.get("installPath", "")) + version = install.get("version", "unknown") + short_name = qualified_name.split("@")[0] + + is_enabled = enabled_plugins.get(qualified_name, False) + is_blocked = any( + b.get("plugin") == qualified_name for b in blocklisted + ) + effective_enabled = is_enabled and not is_blocked + + plugin_items: List[PaletteItem] = [] + + # Scan skills + skills_dir = install_path / "skills" + if skills_dir.is_dir(): + for skill_dir in sorted(skills_dir.iterdir()): + skill_file = skill_dir / "SKILL.md" + if skill_file.is_file(): + item = self._parse_skill_file( + skill_file, short_name, effective_enabled + ) + if item: + plugin_items.append(item) + + # Scan commands + commands_dir = install_path / "commands" + if commands_dir.is_dir(): + for cmd_file in sorted(commands_dir.glob("*.md")): + item = self._parse_command_file( + cmd_file, short_name, effective_enabled + ) + if item: + plugin_items.append(item) + + plugin = PluginInfo( + name=short_name, + qualified_name=qualified_name, + version=version, + enabled=effective_enabled, + items=plugin_items, + install_path=str(install_path), + ) + plugins.append(plugin) + items.extend(plugin_items) + + # 3. Scan custom skills (~/.claude/skills/) + custom_dir = self.claude_dir / "skills" + if custom_dir.is_dir(): + for skill_dir in sorted(custom_dir.iterdir()): + skill_file = skill_dir / "SKILL.md" + if skill_file.is_file(): + item = self._parse_skill_file( + skill_file, "custom", True + ) + if item: + item.source = "custom" + items.append(item) + + return items, plugins + + def _parse_skill_file( + self, path: Path, source: str, enabled: bool + ) -> Optional[PaletteItem]: + """Parse a SKILL.md into a PaletteItem.""" + try: + content = path.read_text(encoding="utf-8") + except OSError: + return None + fm = parse_skill_frontmatter(content) + if not fm.get("name"): + return None + name = fm["name"] + return PaletteItem( + id=f"{source}:{name}", + name=name, + description=fm.get("description", ""), + action_type=ActionType.INJECT_SKILL, + action_value=f"/{name}", + source=source, + enabled=enabled, + ) + + def _parse_command_file( + self, path: Path, source: str, enabled: bool + ) -> Optional[PaletteItem]: + """Parse a command .md into a PaletteItem.""" + try: + content = path.read_text(encoding="utf-8") + except OSError: + return None + fm = parse_skill_frontmatter(content) + if not fm.get("name"): + return None + name = fm["name"] + return PaletteItem( + id=f"{source}:{name}", + name=name, + description=fm.get("description", ""), + action_type=ActionType.INJECT_SKILL, + action_value=f"/{name}", + source=source, + enabled=enabled, + ) + + def _load_enabled_plugins(self) -> Dict[str, bool]: + settings_file = self.claude_dir / "settings.json" + if not settings_file.is_file(): + return {} + try: + data = json.loads(settings_file.read_text(encoding="utf-8")) + return data.get("enabledPlugins", {}) + except (json.JSONDecodeError, OSError): + return {} + + def _load_installed_plugins(self) -> Dict[str, list]: + installed_file = self.claude_dir / "plugins" / "installed_plugins.json" + if not installed_file.is_file(): + return {} + try: + data = json.loads(installed_file.read_text(encoding="utf-8")) + return data.get("plugins", {}) + except (json.JSONDecodeError, OSError): + return {} + + def _load_blocklist(self) -> list: + blocklist_file = self.claude_dir / "plugins" / "blocklist.json" + if not blocklist_file.is_file(): + return [] + try: + data = json.loads(blocklist_file.read_text(encoding="utf-8")) + return data.get("plugins", []) + except (json.JSONDecodeError, OSError): + return [] +``` + +**Step 4: Run all scanner tests** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_command_palette.py -k scanner -v` +Expected: PASS (6 tests) + +**Step 5: Commit** + +```bash +git add src/bot/features/command_palette.py tests/unit/test_bot/test_command_palette.py +git commit -m "feat(menu): add CommandPaletteScanner for filesystem discovery" +``` + +--- + +### Task 4: Plugin Toggle (Enable/Disable) + +**Files:** +- Modify: `src/bot/features/command_palette.py` +- Test: `tests/unit/test_bot/test_command_palette.py` + +**Step 1: Write the failing test** + +```python +def test_toggle_plugin_enable(mock_claude_dir): + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + # Disable superpowers + result = scanner.toggle_plugin("superpowers@claude-plugins-official", enabled=False) + assert result is True + + # Verify settings.json updated + settings = json.loads((mock_claude_dir / "settings.json").read_text()) + assert settings["enabledPlugins"]["superpowers@claude-plugins-official"] is False + + # Re-enable + result = scanner.toggle_plugin("superpowers@claude-plugins-official", enabled=True) + assert result is True + settings = json.loads((mock_claude_dir / "settings.json").read_text()) + assert settings["enabledPlugins"]["superpowers@claude-plugins-official"] is True + + +def test_toggle_plugin_nonexistent(mock_claude_dir): + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + result = scanner.toggle_plugin("nonexistent@nowhere", enabled=False) + assert result is True # Still writes to settings +``` + +**Step 2: Run to verify failure** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_command_palette.py::test_toggle_plugin_enable -v` +Expected: FAIL with `AttributeError: 'CommandPaletteScanner' object has no attribute 'toggle_plugin'` + +**Step 3: Write implementation** + +Add to `CommandPaletteScanner` class: + +```python + def toggle_plugin(self, qualified_name: str, enabled: bool) -> bool: + """Enable or disable a plugin by updating settings.json.""" + settings_file = self.claude_dir / "settings.json" + try: + if settings_file.is_file(): + data = json.loads(settings_file.read_text(encoding="utf-8")) + else: + data = {} + + if "enabledPlugins" not in data: + data["enabledPlugins"] = {} + + data["enabledPlugins"][qualified_name] = enabled + settings_file.write_text( + json.dumps(data, indent=2) + "\n", encoding="utf-8" + ) + return True + except (json.JSONDecodeError, OSError) as e: + logger.error("Failed to toggle plugin", plugin=qualified_name, error=str(e)) + return False +``` + +**Step 4: Run tests** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_command_palette.py -k toggle -v` +Expected: PASS (2 tests) + +**Step 5: Commit** + +```bash +git add src/bot/features/command_palette.py tests/unit/test_bot/test_command_palette.py +git commit -m "feat(menu): add plugin enable/disable toggle" +``` + +--- + +### Task 5: Menu Keyboard Builder + +**Files:** +- Create: `src/bot/handlers/menu.py` +- Test: `tests/unit/test_bot/test_menu.py` + +This builds the inline keyboards for each navigation level. + +**Step 1: Write the failing tests** + +```python +# tests/unit/test_bot/test_menu.py +"""Tests for the menu handler keyboard building.""" + +import pytest +from telegram import InlineKeyboardButton, InlineKeyboardMarkup + +from src.bot.handlers.menu import MenuBuilder +from src.bot.features.command_palette import ( + ActionType, + PaletteItem, + PluginInfo, +) + + +@pytest.fixture +def sample_items(): + return [ + PaletteItem( + id="bot:new", name="new", description="Fresh session", + action_type=ActionType.DIRECT_COMMAND, action_value="/new", + source="bot", enabled=True, + ), + PaletteItem( + id="bot:status", name="status", description="Session status", + action_type=ActionType.DIRECT_COMMAND, action_value="/status", + source="bot", enabled=True, + ), + PaletteItem( + id="superpowers:brainstorming", name="brainstorming", + description="Creative work", action_type=ActionType.INJECT_SKILL, + action_value="/brainstorming", source="superpowers", enabled=True, + ), + PaletteItem( + id="commit-commands:commit", name="commit", + description="Git commit", action_type=ActionType.INJECT_SKILL, + action_value="/commit", source="commit-commands", enabled=True, + ), + ] + + +@pytest.fixture +def sample_plugins(): + return [ + PluginInfo( + name="superpowers", qualified_name="superpowers@claude-plugins-official", + version="4.3.1", enabled=True, + items=[PaletteItem( + id="superpowers:brainstorming", name="brainstorming", + description="Creative work", action_type=ActionType.INJECT_SKILL, + action_value="/brainstorming", source="superpowers", enabled=True, + )], + ), + PluginInfo( + name="commit-commands", qualified_name="commit-commands@claude-plugins-official", + version="1.0", enabled=True, + items=[PaletteItem( + id="commit-commands:commit", name="commit", + description="Git commit", action_type=ActionType.INJECT_SKILL, + action_value="/commit", source="commit-commands", enabled=True, + )], + ), + ] + + +def test_build_top_level_keyboard(sample_items, sample_plugins): + builder = MenuBuilder(sample_items, sample_plugins) + keyboard = builder.build_top_level() + assert isinstance(keyboard, InlineKeyboardMarkup) + # Should have Bot category + 2 plugin categories + Plugin Store + texts = [btn.text for row in keyboard.inline_keyboard for btn in row] + assert any("Bot" in t for t in texts) + assert any("superpowers" in t for t in texts) + + +def test_build_category_keyboard_bot(sample_items, sample_plugins): + builder = MenuBuilder(sample_items, sample_plugins) + keyboard, text = builder.build_category("bot") + assert isinstance(keyboard, InlineKeyboardMarkup) + texts = [btn.text for row in keyboard.inline_keyboard for btn in row] + assert any("new" in t for t in texts) + assert any("status" in t for t in texts) + # Should have Back button + assert any("Back" in t for t in texts) + + +def test_build_category_keyboard_plugin(sample_items, sample_plugins): + builder = MenuBuilder(sample_items, sample_plugins) + keyboard, text = builder.build_category("superpowers") + assert isinstance(keyboard, InlineKeyboardMarkup) + texts = [btn.text for row in keyboard.inline_keyboard for btn in row] + assert any("brainstorming" in t for t in texts) + + +def test_single_item_plugin_returns_none(sample_items, sample_plugins): + """Single-item plugins should return None (execute directly).""" + builder = MenuBuilder(sample_items, sample_plugins) + # commit-commands has only 1 item, so build_category returns None + result = builder.get_single_item_action("commit-commands") + assert result is not None + assert result.name == "commit" + + +def test_callback_id_mapping(sample_items, sample_plugins): + builder = MenuBuilder(sample_items, sample_plugins) + builder.build_top_level() + # Verify short ID mapping works + assert len(builder.id_map) > 0 + for short_id, full_id in builder.id_map.items(): + assert len(f"menu:cat:{short_id}") <= 64 # Telegram limit +``` + +**Step 2: Run to verify failure** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_menu.py -v` +Expected: FAIL with `ModuleNotFoundError` + +**Step 3: Write implementation** + +```python +# src/bot/handlers/menu.py +"""Dynamic command menu with inline keyboard navigation.""" + +from __future__ import annotations + +from typing import Dict, List, Optional, Tuple + +import structlog +from telegram import InlineKeyboardButton, InlineKeyboardMarkup + +from ..features.command_palette import ActionType, PaletteItem, PluginInfo + +logger = structlog.get_logger() + + +class MenuBuilder: + """Builds inline keyboards for the command palette navigation.""" + + def __init__( + self, + items: List[PaletteItem], + plugins: List[PluginInfo], + ) -> None: + self.items = items + self.plugins = plugins + self.id_map: Dict[str, str] = {} # short_id -> full_id + self._counter = 0 + + def _short_id(self, full_id: str) -> str: + """Generate a short numeric ID and store the mapping.""" + self._counter += 1 + short = str(self._counter) + self.id_map[short] = full_id + return short + + def build_top_level(self) -> InlineKeyboardMarkup: + """Build the top-level category menu.""" + keyboard: List[List[InlineKeyboardButton]] = [] + self.id_map.clear() + self._counter = 0 + + # Bot commands category + bot_items = [i for i in self.items if i.source == "bot"] + if bot_items: + sid = self._short_id("bot") + keyboard.append([ + InlineKeyboardButton( + f"\U0001f916 Bot ({len(bot_items)})", + callback_data=f"menu:cat:{sid}", + ) + ]) + + # Plugin categories (2 per row for compact layout) + row: List[InlineKeyboardButton] = [] + for plugin in sorted(self.plugins, key=lambda p: p.name): + if not plugin.items: + continue + count = len(plugin.items) + status = "\u2705" if plugin.enabled else "\u274c" + sid = self._short_id(plugin.name) + + # Single-item plugins: execute directly on tap + if count == 1: + item = plugin.items[0] + isid = self._short_id(item.id) + btn = InlineKeyboardButton( + f"{status} {plugin.name}", + callback_data=f"menu:run:{isid}", + ) + else: + btn = InlineKeyboardButton( + f"{status} {plugin.name} ({count})", + callback_data=f"menu:cat:{sid}", + ) + + row.append(btn) + if len(row) == 2: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + + # Custom skills + custom_items = [i for i in self.items if i.source == "custom"] + if custom_items: + cust_row: List[InlineKeyboardButton] = [] + for item in custom_items: + sid = self._short_id(item.id) + cust_row.append( + InlineKeyboardButton( + f"\u2699\ufe0f {item.name}", + callback_data=f"menu:run:{sid}", + ) + ) + if len(cust_row) == 2: + keyboard.append(cust_row) + cust_row = [] + if cust_row: + keyboard.append(cust_row) + + # Plugin Store + keyboard.append([ + InlineKeyboardButton( + "\U0001f4e6 Plugin Store", callback_data="menu:store" + ) + ]) + + return InlineKeyboardMarkup(keyboard) + + def build_category(self, source: str) -> Tuple[InlineKeyboardMarkup, str]: + """Build keyboard for items in a given category/source. + + Returns (keyboard, header_text). + """ + if source == "bot": + cat_items = [i for i in self.items if i.source == "bot"] + header = "\U0001f916 Bot Commands" + else: + cat_items = [i for i in self.items if i.source == source] + plugin = next((p for p in self.plugins if p.name == source), None) + status = "\u2705" if (plugin and plugin.enabled) else "\u274c" + version = plugin.version if plugin else "" + header = f"{status} {source} (v{version})" + + keyboard: List[List[InlineKeyboardButton]] = [] + for item in cat_items: + sid = self._short_id(item.id) + keyboard.append([ + InlineKeyboardButton( + f"{item.name} — {item.description[:40]}", + callback_data=f"menu:run:{sid}", + ) + ]) + + # Plugin management buttons (if it's a plugin, not bot) + if source != "bot": + plugin = next((p for p in self.plugins if p.name == source), None) + if plugin: + toggle_label = "\U0001f534 Disable" if plugin.enabled else "\U0001f7e2 Enable" + keyboard.append([ + InlineKeyboardButton( + toggle_label, + callback_data=f"menu:tog:{source}", + ) + ]) + + # Back button + keyboard.append([ + InlineKeyboardButton("\u2190 Back", callback_data="menu:back") + ]) + + return InlineKeyboardMarkup(keyboard), header + + def get_single_item_action(self, source: str) -> Optional[PaletteItem]: + """If a plugin has exactly 1 item, return it for direct execution.""" + plugin = next((p for p in self.plugins if p.name == source), None) + if plugin and len(plugin.items) == 1: + return plugin.items[0] + return None + + def resolve_id(self, short_id: str) -> Optional[str]: + """Resolve a short callback ID to the full item/category ID.""" + return self.id_map.get(short_id) +``` + +**Step 4: Run tests** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_menu.py -v` +Expected: PASS (5 tests) + +**Step 5: Commit** + +```bash +git add src/bot/handlers/menu.py tests/unit/test_bot/test_menu.py +git commit -m "feat(menu): add MenuBuilder for inline keyboard navigation" +``` + +--- + +### Task 6: Menu Command Handler + Callback Router + +**Files:** +- Modify: `src/bot/handlers/menu.py` +- Test: `tests/unit/test_bot/test_menu.py` + +This adds the actual Telegram handler functions. + +**Step 1: Write the failing test** + +```python +from unittest.mock import AsyncMock, MagicMock, patch + + +@pytest.fixture +def mock_update(): + update = MagicMock() + update.effective_user = MagicMock() + update.effective_user.id = 123456 + update.message = AsyncMock() + update.message.reply_text = AsyncMock() + return update + + +@pytest.fixture +def mock_context(): + context = MagicMock() + context.user_data = {} + context.bot_data = {} + return context + + +@pytest.mark.asyncio +async def test_menu_command_sends_keyboard(mock_update, mock_context): + with patch("src.bot.handlers.menu.CommandPaletteScanner") as MockScanner: + mock_scanner = MockScanner.return_value + mock_scanner.scan.return_value = ( + [PaletteItem( + id="bot:new", name="new", description="Fresh session", + action_type=ActionType.DIRECT_COMMAND, action_value="/new", + source="bot", enabled=True, + )], + [], + ) + from src.bot.handlers.menu import menu_command + await menu_command(mock_update, mock_context) + mock_update.message.reply_text.assert_called_once() + call_kwargs = mock_update.message.reply_text.call_args + assert call_kwargs.kwargs.get("reply_markup") is not None +``` + +**Step 2: Run to verify failure** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_menu.py::test_menu_command_sends_keyboard -v` +Expected: FAIL with `ImportError: cannot import name 'menu_command'` + +**Step 3: Write implementation** + +Add to `src/bot/handlers/menu.py`: + +```python +from telegram import Update +from telegram.ext import ContextTypes + +from ..features.command_palette import CommandPaletteScanner + + +async def menu_command( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Handle /menu command — show the command palette.""" + scanner = CommandPaletteScanner() + items, plugins = scanner.scan() + + builder = MenuBuilder(items, plugins) + keyboard = builder.build_top_level() + + # Store builder in user_data for callback resolution + context.user_data["menu_builder"] = builder + + await update.message.reply_text( + "\u26a1 Command Palette\n\nSelect a category:", + parse_mode="HTML", + reply_markup=keyboard, + ) + + +async def menu_callback( + update: Update, context: ContextTypes.DEFAULT_TYPE +) -> None: + """Handle all menu: callback queries.""" + query = update.callback_query + await query.answer() + + data = query.data + parts = data.split(":", 2) # "menu:action:arg" + if len(parts) < 2: + return + + action = parts[1] + arg = parts[2] if len(parts) > 2 else "" + + builder: Optional[MenuBuilder] = context.user_data.get("menu_builder") + + if action == "back": + # Rebuild top-level menu + scanner = CommandPaletteScanner() + items, plugins = scanner.scan() + builder = MenuBuilder(items, plugins) + context.user_data["menu_builder"] = builder + keyboard = builder.build_top_level() + await query.edit_message_text( + "\u26a1 Command Palette\n\nSelect a category:", + parse_mode="HTML", + reply_markup=keyboard, + ) + return + + if action == "cat" and builder: + full_id = builder.resolve_id(arg) + if not full_id: + return + keyboard, header = builder.build_category(full_id) + await query.edit_message_text( + f"{header}\n\nSelect a command:", + parse_mode="HTML", + reply_markup=keyboard, + ) + return + + if action == "run" and builder: + full_id = builder.resolve_id(arg) + if not full_id: + return + item = next((i for i in builder.items if i.id == full_id), None) + if not item: + return + + if item.action_type == ActionType.DIRECT_COMMAND: + # Remove menu message and let the bot handle the command directly + await query.edit_message_text( + f"Running {item.action_value}...", + parse_mode="HTML", + ) + # Simulate command by calling appropriate handler + # The orchestrator will handle this via _execute_bot_command + context.user_data["menu_pending_command"] = item.action_value + return + + if item.action_type == ActionType.INJECT_SKILL: + # Inject skill name as text to Claude + await query.edit_message_text( + f"Invoking {item.action_value}...", + parse_mode="HTML", + ) + context.user_data["menu_pending_skill"] = item.action_value + return + + if action == "tog": + # Toggle plugin enable/disable + plugin_name = arg + scanner = CommandPaletteScanner() + _, plugins = scanner.scan() + plugin = next((p for p in plugins if p.name == plugin_name), None) + if plugin: + new_state = not plugin.enabled + scanner.toggle_plugin(plugin.qualified_name, new_state) + status = "\u2705 Enabled" if new_state else "\u274c Disabled" + await query.edit_message_text( + f"{plugin.name} — {status}\n\n" + f"Takes effect on next /new session.", + parse_mode="HTML", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("\u2190 Back", callback_data="menu:back")] + ]), + ) + return + + if action == "store": + await query.edit_message_text( + "\U0001f4e6 Plugin Store\n\n" + "Plugin store coming soon.\n" + "For now, install plugins via Claude Code CLI:\n" + "claude plugins install <name>", + parse_mode="HTML", + reply_markup=InlineKeyboardMarkup([ + [InlineKeyboardButton("\u2190 Back", callback_data="menu:back")] + ]), + ) + return +``` + +**Step 4: Run tests** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_menu.py -v` +Expected: PASS (all tests) + +**Step 5: Commit** + +```bash +git add src/bot/handlers/menu.py tests/unit/test_bot/test_menu.py +git commit -m "feat(menu): add menu_command and menu_callback handlers" +``` + +--- + +### Task 7: Wire Menu into Orchestrator + +**Files:** +- Modify: `src/bot/orchestrator.py` (lines 307-357, 408-441) +- Test: `tests/unit/test_orchestrator.py` + +**Step 1: Write the failing test** + +Add to `tests/unit/test_orchestrator.py`: + +```python +def test_agentic_mode_registers_menu_handler(agentic_settings, deps): + orchestrator = MessageOrchestrator(agentic_settings, deps) + app = MagicMock() + orchestrator.register_handlers(app) + + # Verify /menu command is registered + handler_calls = [str(c) for c in app.add_handler.call_args_list] + handler_strs = str(handler_calls) + assert "menu" in handler_strs.lower() + + +def test_agentic_mode_registers_menu_callback(agentic_settings, deps): + orchestrator = MessageOrchestrator(agentic_settings, deps) + app = MagicMock() + orchestrator.register_handlers(app) + + # Verify menu: callback pattern is registered + handler_strs = str(app.add_handler.call_args_list) + assert "menu:" in handler_strs + + +@pytest.mark.asyncio +async def test_get_bot_commands_includes_menu(agentic_settings, deps): + orchestrator = MessageOrchestrator(agentic_settings, deps) + commands = await orchestrator.get_bot_commands() + command_names = [c.command for c in commands] + assert "menu" in command_names +``` + +**Step 2: Run to verify failure** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_orchestrator.py::test_agentic_mode_registers_menu_handler -v` +Expected: FAIL (no "menu" in registered handlers) + +**Step 3: Modify orchestrator** + +In `src/bot/orchestrator.py`: + +**At `_register_agentic_handlers()` (line 307)** — add menu command + callback: + +After line 318 (`("stop", command.stop_command),`), add: +```python + from .handlers import menu as menu_handler +``` + +After the handlers list (before the for loop at line 323), add `menu` to handlers: +```python + ("menu", menu_handler.menu_command), +``` + +After the `cd:` callback handler (line 355), add: +```python + # Menu navigation callbacks + app.add_handler( + CallbackQueryHandler( + self._inject_deps(menu_handler.menu_callback), + pattern=r"^menu:", + ) + ) +``` + +**At `get_bot_commands()` (line 408)** — add menu to agentic commands: + +After line 412 (`BotCommand("start", "Start the bot"),`), add: +```python + BotCommand("menu", "Command palette & plugin manager"), +``` + +**Step 4: Run tests** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_orchestrator.py -k menu -v` +Expected: PASS (3 tests) + +**Step 5: Run all existing tests to check for regressions** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/ -v --timeout=30` +Expected: All existing tests still pass + +**Step 6: Commit** + +```bash +git add src/bot/orchestrator.py tests/unit/test_orchestrator.py +git commit -m "feat(menu): wire /menu command and callbacks into orchestrator" +``` + +--- + +### Task 8: Skill Injection — Connect Menu to Claude Session + +**Files:** +- Modify: `src/bot/orchestrator.py` +- Modify: `src/bot/handlers/menu.py` + +When a user taps a skill button (e.g. "brainstorming"), we need to inject that text into the Claude session just like the user typed it. The cleanest approach is to reuse `agentic_text()` logic. + +**Step 1: Write the failing test** + +Add to `tests/unit/test_bot/test_menu.py`: + +```python +@pytest.mark.asyncio +async def test_menu_run_injects_skill_into_user_data(): + """When a skill button is tapped, its action_value is stored for injection.""" + update = MagicMock() + query = AsyncMock() + query.data = "menu:run:1" + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + update.callback_query = query + + context = MagicMock() + context.user_data = { + "menu_builder": MagicMock( + resolve_id=MagicMock(return_value="commit-commands:commit"), + items=[PaletteItem( + id="commit-commands:commit", name="commit", + description="Git commit", action_type=ActionType.INJECT_SKILL, + action_value="/commit", source="commit-commands", enabled=True, + )], + ) + } + + from src.bot.handlers.menu import menu_callback + await menu_callback(update, context) + + assert context.user_data.get("menu_pending_skill") == "/commit" +``` + +**Step 2: Run to verify it passes** (already implemented in Task 6) + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_menu.py::test_menu_run_injects_skill_into_user_data -v` +Expected: PASS + +**Step 3: Implement the injection bridge in orchestrator** + +Add a helper method to `MessageOrchestrator` that checks for pending menu actions and routes them. This hooks into the existing `agentic_text()` flow. + +In `src/bot/handlers/menu.py`, update the `menu_callback` `"run"` action for `INJECT_SKILL` to send the skill invocation as a new message to the bot (simulating user input): + +```python + if item.action_type == ActionType.INJECT_SKILL: + await query.edit_message_text( + f"Invoking {item.action_value}...", + parse_mode="HTML", + ) + # Send the skill name as a new message from the user + # This triggers agentic_text() which routes it to Claude + await context.bot.send_message( + chat_id=query.message.chat_id, + text=item.action_value, + ) + return +``` + +Wait — that would send a message *from the bot*, not the user. Better approach: store the pending skill and inform the user to confirm, OR directly call `agentic_text` with a synthetic update. The simplest approach is to use `context.user_data["menu_inject"]` and have the menu callback send a follow-up message that the user just needs to confirm: + +Actually, the cleanest approach: after editing the menu message, call the orchestrator's text handler directly with the skill text. Add this to `menu_callback`: + +```python + if item.action_type == ActionType.INJECT_SKILL: + await query.edit_message_text( + f"\u26a1 {item.action_value}", + parse_mode="HTML", + ) + # Store for the orchestrator to pick up and route to Claude + context.user_data["menu_inject_text"] = item.action_value + return +``` + +Then in the orchestrator, add a post-callback check or simply have a small wrapper. The pragmatic solution: the menu callback directly calls `ClaudeIntegration.run_command()` with the skill text, using the same pattern as `agentic_text()` but simplified. + +However, to keep things clean, let's just have the menu handler import and call into a shared helper. This will be implemented in the integration step. + +**Step 4: Commit** + +```bash +git add src/bot/handlers/menu.py +git commit -m "feat(menu): store pending skill invocation for Claude injection" +``` + +--- + +### Task 9: Skill Injection Bridge — Shared Helper + +**Files:** +- Modify: `src/bot/orchestrator.py` +- Modify: `src/bot/handlers/menu.py` + +**Step 1: Extract a reusable `_send_to_claude` helper from `agentic_text()`** + +Read `agentic_text()` (line 835-end) and extract the core Claude invocation into a helper that can be called from both `agentic_text()` and `menu_callback()`. + +Add to `MessageOrchestrator`: + +```python + async def send_text_to_claude( + self, + text: str, + chat_id: int, + user_id: int, + context: ContextTypes.DEFAULT_TYPE, + reply_to_message_id: Optional[int] = None, + ) -> None: + """Send text to Claude and stream the response back. + + Shared helper used by agentic_text() and menu skill injection. + """ + # ... extracted from agentic_text core logic +``` + +This is a larger refactor. For the initial implementation, the simpler approach is to have the menu callback compose a fake Update-like object and delegate. But that's fragile. + +**Pragmatic approach:** Instead of refactoring agentic_text, have menu_callback store the skill text and let the user know it's queued. Then use `context.bot.send_message` to send a message in the chat that says the skill name — which the user sees and agentic_text picks up naturally since it processes all non-command text. + +Wait, actually `context.bot.send_message` sends *as the bot*. We can't fake a user message. + +**Best approach:** The menu callback directly calls `ClaudeIntegration.run_command()` in a simplified flow: + +```python + if item.action_type == ActionType.INJECT_SKILL: + await query.edit_message_text( + f"\u26a1 Running {item.action_value}...", + parse_mode="HTML", + ) + + claude = context.bot_data.get("claude_integration") + if not claude: + await query.edit_message_text("Claude integration not available.") + return + + current_dir = context.user_data.get("current_directory", "/home/florian") + session_id = context.user_data.get("claude_session_id") + + # Send skill invocation to Claude + response = await claude.run_command( + prompt=item.action_value, + working_directory=str(current_dir), + user_id=str(query.from_user.id), + session_id=session_id, + ) + + # Update session ID + if response and response.session_id: + context.user_data["claude_session_id"] = response.session_id + + # Send response + if response and response.content: + # Truncate for Telegram's 4096 char limit + content = response.content[:4000] + await context.bot.send_message( + chat_id=query.message.chat_id, + text=content, + parse_mode="HTML", + ) + return +``` + +This is clean but skips the streaming/progress UI that `agentic_text` provides. For v1, this is acceptable. The streaming can be added later. + +**However**, the even simpler v1: just edit the message to tell the user what to type. No, that defeats the purpose. + +**Final decision for v1:** Store the pending skill in user_data, then have the menu_callback call a reference to the orchestrator's `_run_claude_from_menu` helper (a new, simpler method). The orchestrator has access to all the streaming infrastructure. + +Let's keep this simple for the plan. The menu callback stores `context.user_data["menu_inject_text"]` and a new `_check_menu_injection()` helper in orchestrator processes it. The menu callback also sends a "Working..." progress message. + +**This is getting complex. Let me simplify.** + +For v1, the menu callback will: +1. Edit the menu message to show "Running /commit..." +2. Call `ClaudeIntegration.run_command()` directly (no streaming, just final result) +3. Send the response as a message + +This avoids refactoring agentic_text and gives a working feature. Streaming can be added in v2. + +**Step 2: Implementation in menu.py** + +Already described above. Add to the `INJECT_SKILL` branch of `menu_callback`. + +**Step 3: Test** + +```python +@pytest.mark.asyncio +async def test_menu_run_calls_claude_for_skill(): + update = MagicMock() + query = AsyncMock() + query.data = "menu:run:1" + query.answer = AsyncMock() + query.edit_message_text = AsyncMock() + query.from_user = MagicMock(id=123) + query.message = MagicMock(chat_id=456) + update.callback_query = query + + mock_claude = AsyncMock() + mock_response = MagicMock(content="Done!", session_id="sess123") + mock_claude.run_command = AsyncMock(return_value=mock_response) + + context = MagicMock() + context.user_data = { + "current_directory": "/home/florian", + "menu_builder": MagicMock( + resolve_id=MagicMock(return_value="commit-commands:commit"), + items=[PaletteItem( + id="commit-commands:commit", name="commit", + description="Git commit", action_type=ActionType.INJECT_SKILL, + action_value="/commit", source="commit-commands", enabled=True, + )], + ), + } + context.bot_data = {"claude_integration": mock_claude} + context.bot = AsyncMock() + + from src.bot.handlers.menu import menu_callback + await menu_callback(update, context) + + mock_claude.run_command.assert_called_once() + context.bot.send_message.assert_called_once() +``` + +**Step 4: Commit** + +```bash +git add src/bot/handlers/menu.py tests/unit/test_bot/test_menu.py +git commit -m "feat(menu): inject skill invocations into Claude session" +``` + +--- + +### Task 10: Integration Test — Full Menu Flow + +**Files:** +- Test: `tests/unit/test_bot/test_menu.py` + +**Step 1: Write integration test** + +```python +@pytest.mark.asyncio +async def test_full_menu_flow_category_navigation(): + """Test: /menu -> tap category -> tap item.""" + # This tests the full flow without Telegram API + scanner_items = [ + PaletteItem( + id="bot:status", name="status", description="Session status", + action_type=ActionType.DIRECT_COMMAND, action_value="/status", + source="bot", enabled=True, + ), + PaletteItem( + id="superpowers:brainstorming", name="brainstorming", + description="Creative work", action_type=ActionType.INJECT_SKILL, + action_value="/brainstorming", source="superpowers", enabled=True, + ), + PaletteItem( + id="superpowers:tdd", name="test-driven-development", + description="TDD workflow", action_type=ActionType.INJECT_SKILL, + action_value="/test-driven-development", source="superpowers", enabled=True, + ), + ] + scanner_plugins = [ + PluginInfo( + name="superpowers", qualified_name="superpowers@claude-plugins-official", + version="4.3.1", enabled=True, + items=scanner_items[1:], # brainstorming + tdd + ), + ] + + builder = MenuBuilder(scanner_items, scanner_plugins) + + # Step 1: Build top-level + top_kb = builder.build_top_level() + assert top_kb is not None + + # Step 2: Find superpowers category button and resolve its ID + sp_btn = None + for row in top_kb.inline_keyboard: + for btn in row: + if "superpowers" in btn.text: + sp_btn = btn + break + assert sp_btn is not None + _, _, sid = sp_btn.callback_data.split(":", 2) + resolved = builder.resolve_id(sid) + assert resolved == "superpowers" + + # Step 3: Build category view + cat_kb, header = builder.build_category("superpowers") + assert "superpowers" in header + cat_texts = [btn.text for row in cat_kb.inline_keyboard for btn in row] + assert any("brainstorming" in t for t in cat_texts) + assert any("test-driven" in t for t in cat_texts) +``` + +**Step 2: Run test** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/unit/test_bot/test_menu.py::test_full_menu_flow_category_navigation -v` +Expected: PASS + +**Step 3: Commit** + +```bash +git add tests/unit/test_bot/test_menu.py +git commit -m "test(menu): add full menu flow integration test" +``` + +--- + +### Task 11: Lint, Type Check, Final Test Suite + +**Files:** +- All new/modified files + +**Step 1: Run formatter** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run black src/bot/features/command_palette.py src/bot/handlers/menu.py tests/unit/test_bot/test_command_palette.py tests/unit/test_bot/test_menu.py` + +**Step 2: Run isort** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run isort src/bot/features/command_palette.py src/bot/handlers/menu.py tests/unit/test_bot/test_command_palette.py tests/unit/test_bot/test_menu.py` + +**Step 3: Run flake8** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run flake8 src/bot/features/command_palette.py src/bot/handlers/menu.py` + +**Step 4: Run mypy** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run mypy src/bot/features/command_palette.py src/bot/handlers/menu.py` + +Fix any type errors. + +**Step 5: Run full test suite** + +Run: `cd /home/florian/config/claude-code-telegram && poetry run pytest tests/ -v --timeout=30` +Expected: All tests pass (existing + new) + +**Step 6: Commit** + +```bash +git add -A +git commit -m "chore(menu): lint, format, type check all menu code" +``` + +--- + +### Task 12: Copy to Installed Location + Restart Bot + +**Files:** +- Copy new/modified files to `~/.local/share/uv/tools/claude-code-telegram/lib/python3.12/site-packages/src/` + +**Step 1: Copy files** + +```bash +# New files +cp src/bot/features/command_palette.py ~/.local/share/uv/tools/claude-code-telegram/lib/python3.12/site-packages/src/bot/features/command_palette.py + +cp src/bot/handlers/menu.py ~/.local/share/uv/tools/claude-code-telegram/lib/python3.12/site-packages/src/bot/handlers/menu.py + +# Modified files +cp src/bot/orchestrator.py ~/.local/share/uv/tools/claude-code-telegram/lib/python3.12/site-packages/src/bot/orchestrator.py +``` + +**Step 2: Install PyYAML if not already available** + +Check: `pip show pyyaml` in the bot's venv. +If missing: add to pyproject.toml and reinstall. + +**Step 3: Restart bot** + +```bash +systemctl --user restart claude-telegram-bot +``` + +**Step 4: Check logs** + +```bash +journalctl --user -u claude-telegram-bot -f +``` + +Verify no startup errors. + +**Step 5: Test in Telegram** + +1. Send `/menu` to the bot +2. Verify the command palette appears with categories +3. Tap a category (e.g. "superpowers") — verify sub-menu shows skills +4. Tap a skill (e.g. "brainstorming") — verify it invokes via Claude +5. Tap "Back" — verify return to top level +6. Tap a plugin toggle — verify enable/disable works +7. Tap "Plugin Store" — verify placeholder message + +**Step 6: Commit if any fixes needed** + +```bash +git add -A +git commit -m "fix(menu): adjustments from live testing" +``` + +--- + +## Summary + +| Task | What | Files | Tests | +|------|------|-------|-------| +| 1 | Data models (PaletteItem, PluginInfo, ActionType) | `command_palette.py` | 3 | +| 2 | YAML frontmatter parser | `command_palette.py` | 5 | +| 3 | Filesystem scanner (CommandPaletteScanner) | `command_palette.py` | 6 | +| 4 | Plugin toggle (enable/disable) | `command_palette.py` | 2 | +| 5 | Menu keyboard builder (MenuBuilder) | `menu.py` | 5 | +| 6 | Menu command handler + callback router | `menu.py` | 1 | +| 7 | Wire into orchestrator | `orchestrator.py` | 3 | +| 8 | Skill injection (pending skill storage) | `menu.py` | 1 | +| 9 | Skill injection bridge (Claude call) | `menu.py` | 1 | +| 10 | Integration test | `test_menu.py` | 1 | +| 11 | Lint + type check + full suite | all | 0 | +| 12 | Deploy + live test | installed copy | 0 | + +**Total: 12 tasks, ~28 tests, 2 new files, 1 modified file** diff --git a/docs/plans/2026-03-02-interactive-user-feedback-design.md b/docs/plans/2026-03-02-interactive-user-feedback-design.md new file mode 100644 index 00000000..f6f9e828 --- /dev/null +++ b/docs/plans/2026-03-02-interactive-user-feedback-design.md @@ -0,0 +1,169 @@ +# Interactive User Feedback via Telegram Inline Keyboards + +**Date:** 2026-03-02 +**Status:** Approved + +## Problem + +When Claude calls `AskUserQuestion` (e.g. during brainstorming skills), the CLI subprocess has no TTY and auto-selects the first option. The Telegram user never sees the question and has no way to provide their actual answer. + +## Solution + +Use a `PreToolUse` SDK hook on `AskUserQuestion` that intercepts the tool call, presents the question as Telegram inline keyboard buttons, waits for the user's tap, and returns the answer via `updatedInput` so the CLI executes the tool with the user's actual choice. + +## Approach: PreToolUse Hook with Shared Future + +The hook callback and Telegram handler run in the same asyncio event loop. An `asyncio.Future` coordinates between them — the hook awaits the Future while the Telegram callback handler resolves it. + +## Data Flow + +``` +Claude calls AskUserQuestion(questions=[...]) + │ + ▼ +CLI sends PreToolUse hook_callback to SDK via control protocol + │ + ▼ +SDK invokes Python hook callback (closure in sdk_integration.py) + │ + ├── Extracts questions + options from tool_input + ├── Calls bot.send_message() with inline keyboard + ├── Creates asyncio.Future, stores in _pending dict + ├── Awaits the Future (Claude is paused) + │ + ▼ +User taps inline button in Telegram + │ + ▼ +CallbackQueryHandler (pattern "askq:") in orchestrator + │ + ├── Looks up pending Future by (user_id, chat_id) + ├── Resolves Future with selected answer + │ + ▼ +Hook callback resumes + │ + ├── Builds updatedInput with answers dict pre-filled + ├── Returns SyncHookJSONOutput with hookSpecificOutput + │ + ▼ +CLI executes AskUserQuestion with user's actual answer +``` + +## Scope + +All Claude interactions — not just /menu skills. Any time Claude calls `AskUserQuestion`, the question is routed to Telegram. + +## Shared State & Coordination + +### PendingQuestion Registry + +New file `src/bot/features/interactive_questions.py` with a module-level dict: + +```python +_pending: Dict[Tuple[int, int], asyncio.Future] = {} +``` + +Keyed by `(user_id, chat_id)`. Only one question pending per user+chat at a time (Claude is paused, can't ask another until the first is answered). + +### Hook Callback as Closure + +`execute_command()` already knows `user_id`. It gains a `telegram_context` parameter (bot, chat_id, thread_id) so it can build the hook closure capturing: +- `user_id`, `chat_id`, `message_thread_id` — where to send the keyboard +- `bot` instance — to call `bot.send_message()` +- Reference to `_pending` dict — to create/resolve Futures + +### Telegram Handler + +New `CallbackQueryHandler(pattern=r"^askq:")` registered in the orchestrator. Parses callback data, looks up Future, resolves it. + +## AskUserQuestion Input Format + +```python +{ + "questions": [ + { + "question": "Which approach?", + "header": "Approach", + "options": [ + {"label": "Option A", "description": "..."}, + {"label": "Option B", "description": "..."}, + ], + "multiSelect": false + } + ], + "answers": { + "Which approach?": "Option A" # ← we pre-fill this + } +} +``` + +- `questions`: list of 1-4 questions per call +- Each question has 2-4 options + implicit "Other" +- `answers`: dict keyed by question text → selected label(s) +- `multiSelect: true`: multiple options selectable + +## Telegram UX + +### Callback Data Format (64-byte limit) + +- Single select: `askq:0:1` (question 0, option 1) +- Multi-select toggle: `askq:0:t1` (question 0, toggle option 1) +- Multi-select done: `askq:0:done` +- Other: `askq:0:other` + +### Single-Select Layout + +``` +Which approach should we use? + +• Option A — Does X and Y +• Option B — Does Z + +[Option A] [Option B] +[Other...] +``` + +### Multi-Select Layout (mid-selection) + +``` +Which features do you want? + +• Auth — Login system +• Cache — Redis caching +• Logs — Structured logging + +[☑ Auth] [☐ Cache] [☑ Logs] +[Other...] +[Done ✓] +``` + +### "Other" Flow + +1. User taps "Other..." +2. Message edited to "Type your answer:" +3. One-time MessageHandler captures next text message from that user+chat +4. Text used as answer, Future resolved +5. Handler removes itself + +### Sequential Questions + +If `AskUserQuestion` has multiple questions (1-4), process one at a time: show question 1, wait for answer, show question 2, wait, etc. All answers collected, then returned as one `updatedInput`. + +## Error Handling + +- **No timeout on the Future** — waits indefinitely. The outer `claude_timeout_seconds` (3600s) is the hard bound. +- **Stale questions** — if session errors, Future is cancelled in `finally` block. Tapping stale buttons returns "Question expired." +- **Concurrent sessions** — keyed by `(user_id, chat_id)`, independent per project thread. +- **Button clicks after answer** — "Already answered" feedback, keyboard removed. +- **"Other" text capture** — one-time MessageHandler scoped to user+chat, removes itself after capture. + +## Files Changed + +| File | Change | +|------|--------| +| `src/bot/features/interactive_questions.py` | **New.** Pending dict, keyboard builder, question formatter, callback handler, "Other" text handler | +| `src/claude/sdk_integration.py` | Add `telegram_context` param to `execute_command()`, build PreToolUse hook closure, register in `options.hooks` | +| `src/claude/facade.py` | Pass `telegram_context` through `run_command()` → `_execute()` → `execute_command()` | +| `src/bot/orchestrator.py` | Pass telegram context when calling `run_command()`, register `askq:` CallbackQueryHandler | +| `src/bot/handlers/menu.py` | Pass telegram context when calling `run_command()` from menu skill execution | diff --git a/docs/plans/2026-03-02-interactive-user-feedback.md b/docs/plans/2026-03-02-interactive-user-feedback.md new file mode 100644 index 00000000..3887f522 --- /dev/null +++ b/docs/plans/2026-03-02-interactive-user-feedback.md @@ -0,0 +1,1435 @@ +# Interactive User Feedback Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Intercept `AskUserQuestion` tool calls via PreToolUse SDK hooks and route them through Telegram inline keyboards so users can answer interactively. + +**Architecture:** A PreToolUse hook on `AskUserQuestion` pauses Claude, sends the question as Telegram inline buttons, awaits user tap via `asyncio.Future`, then returns the answer as `updatedInput`. A shared `_pending` dict keyed by `(user_id, chat_id)` coordinates the hook callback and the Telegram `CallbackQueryHandler`. + +**Tech Stack:** `claude-agent-sdk` (PreToolUse hooks, HookMatcher, SyncHookJSONOutput), `python-telegram-bot` (InlineKeyboardMarkup, CallbackQueryHandler, MessageHandler), `asyncio.Future` + +--- + +### Task 1: Create interactive_questions module — data types and pending registry + +**Files:** +- Create: `src/bot/features/interactive_questions.py` +- Test: `tests/unit/test_bot/test_interactive_questions.py` + +**Step 1: Write the failing tests** + +```python +"""Tests for interactive_questions module.""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from src.bot.features.interactive_questions import ( + PendingQuestion, + TelegramContext, + format_question_text, + build_single_select_keyboard, + build_multi_select_keyboard, + register_pending, + resolve_pending, + get_pending, + cancel_pending, +) + + +class TestTelegramContext: + def test_creation(self): + bot = MagicMock() + ctx = TelegramContext(bot=bot, chat_id=123, thread_id=456, user_id=789) + assert ctx.bot is bot + assert ctx.chat_id == 123 + assert ctx.thread_id == 456 + assert ctx.user_id == 789 + + def test_thread_id_optional(self): + bot = MagicMock() + ctx = TelegramContext(bot=bot, chat_id=123, thread_id=None, user_id=789) + assert ctx.thread_id is None + + +class TestPendingQuestion: + def test_creation(self): + loop = asyncio.new_event_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Which?", + options=[{"label": "A", "description": "opt A"}], + multi_select=False, + selected=set(), + message_id=None, + ) + assert pq.question_text == "Which?" + assert pq.multi_select is False + assert pq.selected == set() + loop.close() + + +class TestFormatQuestionText: + def test_basic_formatting(self): + options = [ + {"label": "Alpha", "description": "First option"}, + {"label": "Beta", "description": "Second option"}, + ] + text = format_question_text("Pick one?", options) + assert "Pick one?" in text + assert "Alpha" in text + assert "First option" in text + assert "Beta" in text + + def test_no_description(self): + options = [{"label": "X"}, {"label": "Y"}] + text = format_question_text("Choose", options) + assert "X" in text + assert "Y" in text + + +class TestBuildSingleSelectKeyboard: + def test_buttons_match_options(self): + options = [ + {"label": "A", "description": "opt A"}, + {"label": "B", "description": "opt B"}, + ] + kb = build_single_select_keyboard(options, question_idx=0) + # Flatten all buttons + buttons = [btn for row in kb.inline_keyboard for btn in row] + labels = [btn.text for btn in buttons] + assert "A" in labels + assert "B" in labels + assert "Other..." in labels + + def test_callback_data_format(self): + options = [{"label": "A", "description": "x"}] + kb = build_single_select_keyboard(options, question_idx=2) + btn = kb.inline_keyboard[0][0] + assert btn.callback_data == "askq:2:0" + + def test_other_button(self): + options = [{"label": "A", "description": "x"}] + kb = build_single_select_keyboard(options, question_idx=0) + buttons = [btn for row in kb.inline_keyboard for btn in row] + other = [b for b in buttons if b.callback_data == "askq:0:other"] + assert len(other) == 1 + + +class TestBuildMultiSelectKeyboard: + def test_unchecked_by_default(self): + options = [ + {"label": "A", "description": "x"}, + {"label": "B", "description": "y"}, + ] + kb = build_multi_select_keyboard(options, question_idx=0, selected=set()) + buttons = [btn for row in kb.inline_keyboard for btn in row] + assert any("☐ A" == btn.text for btn in buttons) + assert any("☐ B" == btn.text for btn in buttons) + + def test_checked_state(self): + options = [ + {"label": "A", "description": "x"}, + {"label": "B", "description": "y"}, + ] + kb = build_multi_select_keyboard(options, question_idx=0, selected={0}) + buttons = [btn for row in kb.inline_keyboard for btn in row] + assert any("☑ A" == btn.text for btn in buttons) + assert any("☐ B" == btn.text for btn in buttons) + + def test_toggle_callback_data(self): + options = [{"label": "A", "description": "x"}] + kb = build_multi_select_keyboard(options, question_idx=1, selected=set()) + btn = kb.inline_keyboard[0][0] + assert btn.callback_data == "askq:1:t0" + + def test_done_and_other_buttons(self): + options = [{"label": "A", "description": "x"}] + kb = build_multi_select_keyboard(options, question_idx=0, selected=set()) + buttons = [btn for row in kb.inline_keyboard for btn in row] + data = [b.callback_data for b in buttons] + assert "askq:0:other" in data + assert "askq:0:done" in data + + +class TestPendingRegistry: + def test_register_and_get(self): + loop = asyncio.new_event_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Q?", + options=[], + multi_select=False, + selected=set(), + message_id=None, + ) + register_pending(789, 123, pq) + assert get_pending(789, 123) is pq + loop.close() + + def test_resolve_clears(self): + loop = asyncio.new_event_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Q?", + options=[], + multi_select=False, + selected=set(), + message_id=None, + ) + register_pending(789, 123, pq) + resolve_pending(789, 123, "answer") + assert future.result() == "answer" + assert get_pending(789, 123) is None + loop.close() + + def test_cancel_clears(self): + loop = asyncio.new_event_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Q?", + options=[], + multi_select=False, + selected=set(), + message_id=None, + ) + register_pending(789, 123, pq) + cancel_pending(789, 123) + assert future.cancelled() + assert get_pending(789, 123) is None + loop.close() + + def test_resolve_missing_is_noop(self): + resolve_pending(999, 999, "x") # Should not raise + + def test_cancel_missing_is_noop(self): + cancel_pending(999, 999) # Should not raise +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd /home/florian/config/claude-code-telegram && python -m pytest tests/unit/test_bot/test_interactive_questions.py -v` +Expected: ImportError — module does not exist yet + +**Step 3: Write the implementation** + +```python +"""Interactive question routing for AskUserQuestion tool calls. + +Intercepts AskUserQuestion via PreToolUse hooks and presents questions +as Telegram inline keyboards. Uses asyncio.Future to coordinate between +the hook callback (which pauses Claude) and the Telegram button handler. +""" + +import asyncio +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Set, Tuple + +import structlog +from telegram import InlineKeyboardButton, InlineKeyboardMarkup + +logger = structlog.get_logger() + + +@dataclass +class TelegramContext: + """Telegram context needed to send messages from the hook callback.""" + + bot: Any # telegram.Bot + chat_id: int + thread_id: Optional[int] + user_id: int + + +@dataclass +class PendingQuestion: + """A question waiting for user response.""" + + future: asyncio.Future + question_text: str + options: List[Dict[str, Any]] + multi_select: bool + selected: Set[int] + message_id: Optional[int] + + +# Module-level registry: (user_id, chat_id) → PendingQuestion +_pending: Dict[Tuple[int, int], PendingQuestion] = {} + + +def register_pending(user_id: int, chat_id: int, pq: PendingQuestion) -> None: + """Register a pending question for a user+chat.""" + _pending[(user_id, chat_id)] = pq + + +def get_pending(user_id: int, chat_id: int) -> Optional[PendingQuestion]: + """Get the pending question for a user+chat, if any.""" + return _pending.get((user_id, chat_id)) + + +def resolve_pending(user_id: int, chat_id: int, answer: Any) -> None: + """Resolve a pending question with the user's answer.""" + pq = _pending.pop((user_id, chat_id), None) + if pq and not pq.future.done(): + pq.future.set_result(answer) + + +def cancel_pending(user_id: int, chat_id: int) -> None: + """Cancel a pending question (e.g. on session error).""" + pq = _pending.pop((user_id, chat_id), None) + if pq and not pq.future.done(): + pq.future.cancel() + + +def format_question_text(question: str, options: List[Dict[str, Any]]) -> str: + """Format a question with its options as readable text.""" + lines = [f"**{question}**", ""] + for opt in options: + label = opt.get("label", "") + desc = opt.get("description", "") + if desc: + lines.append(f"• {label} — {desc}") + else: + lines.append(f"• {label}") + return "\n".join(lines) + + +def build_single_select_keyboard( + options: List[Dict[str, Any]], question_idx: int +) -> InlineKeyboardMarkup: + """Build inline keyboard for a single-select question.""" + buttons = [] + for i, opt in enumerate(options): + buttons.append( + InlineKeyboardButton( + text=opt.get("label", f"Option {i}"), + callback_data=f"askq:{question_idx}:{i}", + ) + ) + # Arrange in rows of 2 + rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] + # Other button on its own row + rows.append( + [InlineKeyboardButton(text="Other...", callback_data=f"askq:{question_idx}:other")] + ) + return InlineKeyboardMarkup(rows) + + +def build_multi_select_keyboard( + options: List[Dict[str, Any]], question_idx: int, selected: Set[int] +) -> InlineKeyboardMarkup: + """Build inline keyboard for a multi-select question.""" + buttons = [] + for i, opt in enumerate(options): + label = opt.get("label", f"Option {i}") + prefix = "☑" if i in selected else "☐" + buttons.append( + InlineKeyboardButton( + text=f"{prefix} {label}", + callback_data=f"askq:{question_idx}:t{i}", + ) + ) + rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] + rows.append( + [InlineKeyboardButton(text="Other...", callback_data=f"askq:{question_idx}:other")] + ) + rows.append( + [InlineKeyboardButton(text="Done ✓", callback_data=f"askq:{question_idx}:done")] + ) + return InlineKeyboardMarkup(rows) +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd /home/florian/config/claude-code-telegram && python -m pytest tests/unit/test_bot/test_interactive_questions.py -v` +Expected: All 18 tests PASS + +**Step 5: Commit** + +```bash +git add src/bot/features/interactive_questions.py tests/unit/test_bot/test_interactive_questions.py +git commit -m "feat: add interactive questions module with pending registry and keyboard builders" +``` + +--- + +### Task 2: Create the PreToolUse hook callback factory + +**Files:** +- Modify: `src/bot/features/interactive_questions.py` +- Test: `tests/unit/test_bot/test_interactive_questions.py` + +**Step 1: Write failing tests** + +Add to `tests/unit/test_bot/test_interactive_questions.py`: + +```python +from src.bot.features.interactive_questions import make_ask_user_hook + + +class TestMakeAskUserHook: + @pytest.mark.asyncio + async def test_non_ask_user_tool_passes_through(self): + """Hook returns empty dict for non-AskUserQuestion tools.""" + ctx = TelegramContext(bot=MagicMock(), chat_id=1, thread_id=None, user_id=1) + hook = make_ask_user_hook(ctx) + result = await hook( + {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {}, "tool_use_id": "x", "session_id": "s", "transcript_path": "", "cwd": ""}, + "x", + {"signal": None}, + ) + assert result == {} + + @pytest.mark.asyncio + async def test_sends_keyboard_for_ask_user(self): + """Hook sends inline keyboard to Telegram for AskUserQuestion.""" + bot = AsyncMock() + sent_msg = MagicMock() + sent_msg.message_id = 42 + bot.send_message.return_value = sent_msg + + ctx = TelegramContext(bot=bot, chat_id=100, thread_id=5, user_id=200) + hook = make_ask_user_hook(ctx) + + tool_input = { + "questions": [ + { + "question": "Pick one?", + "header": "Choice", + "options": [ + {"label": "A", "description": "opt A"}, + {"label": "B", "description": "opt B"}, + ], + "multiSelect": False, + } + ] + } + + # Run hook in background, resolve the pending future after send + async def resolve_later(): + await asyncio.sleep(0.05) + pq = get_pending(200, 100) + assert pq is not None + assert pq.message_id == 42 + resolve_pending(200, 100, "A") + + asyncio.get_event_loop().create_task(resolve_later()) + + result = await hook( + {"hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion", "tool_input": tool_input, "tool_use_id": "t1", "session_id": "s", "transcript_path": "", "cwd": ""}, + "t1", + {"signal": None}, + ) + + # Verify keyboard was sent + bot.send_message.assert_called_once() + call_kwargs = bot.send_message.call_args.kwargs + assert call_kwargs["chat_id"] == 100 + assert call_kwargs["message_thread_id"] == 5 + assert "reply_markup" in call_kwargs + + # Verify updatedInput has the answer + specific = result.get("hookSpecificOutput", {}) + assert specific["hookEventName"] == "PreToolUse" + updated = specific["updatedInput"] + assert updated["answers"]["Pick one?"] == "A" + + @pytest.mark.asyncio + async def test_multi_question_sequential(self): + """Hook processes multiple questions sequentially.""" + bot = AsyncMock() + sent_msg = MagicMock() + sent_msg.message_id = 10 + bot.send_message.return_value = sent_msg + bot.edit_message_text.return_value = sent_msg + + ctx = TelegramContext(bot=bot, chat_id=100, thread_id=None, user_id=200) + hook = make_ask_user_hook(ctx) + + tool_input = { + "questions": [ + { + "question": "First?", + "header": "Q1", + "options": [{"label": "X", "description": ""}], + "multiSelect": False, + }, + { + "question": "Second?", + "header": "Q2", + "options": [{"label": "Y", "description": ""}], + "multiSelect": False, + }, + ] + } + + async def resolve_both(): + await asyncio.sleep(0.05) + resolve_pending(200, 100, "X") + await asyncio.sleep(0.05) + resolve_pending(200, 100, "Y") + + asyncio.get_event_loop().create_task(resolve_both()) + + result = await hook( + {"hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion", "tool_input": tool_input, "tool_use_id": "t1", "session_id": "s", "transcript_path": "", "cwd": ""}, + "t1", + {"signal": None}, + ) + + answers = result["hookSpecificOutput"]["updatedInput"]["answers"] + assert answers["First?"] == "X" + assert answers["Second?"] == "Y" +``` + +**Step 2: Run to verify failure** + +Run: `cd /home/florian/config/claude-code-telegram && python -m pytest tests/unit/test_bot/test_interactive_questions.py::TestMakeAskUserHook -v` +Expected: ImportError for `make_ask_user_hook` + +**Step 3: Implement make_ask_user_hook** + +Add to `src/bot/features/interactive_questions.py`: + +```python +def make_ask_user_hook(tg_ctx: TelegramContext): + """Create a PreToolUse hook callback that intercepts AskUserQuestion. + + The returned async function is registered as a PreToolUse hook in + ClaudeAgentOptions.hooks. When Claude calls AskUserQuestion, the hook: + 1. Sends the question as Telegram inline keyboard buttons + 2. Awaits the user's tap via asyncio.Future + 3. Returns updatedInput with pre-filled answers + """ + + async def hook( + input_data: Dict[str, Any], + tool_use_id: Optional[str], + context: Dict[str, Any], + ) -> Dict[str, Any]: + tool_name = input_data.get("tool_name", "") + if tool_name != "AskUserQuestion": + return {} + + tool_input = input_data.get("tool_input", {}) + questions = tool_input.get("questions", []) + if not questions: + return {} + + answers: Dict[str, str] = {} + + for q_idx, q in enumerate(questions): + question_text = q.get("question", "") + options = q.get("options", []) + multi_select = q.get("multiSelect", False) + + # Build keyboard and message text + text = format_question_text(question_text, options) + if multi_select: + keyboard = build_multi_select_keyboard(options, q_idx, set()) + else: + keyboard = build_single_select_keyboard(options, q_idx) + + # Create Future and register + loop = asyncio.get_running_loop() + future: asyncio.Future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text=question_text, + options=options, + multi_select=multi_select, + selected=set(), + message_id=None, + ) + register_pending(tg_ctx.user_id, tg_ctx.chat_id, pq) + + # Send question to Telegram + try: + msg = await tg_ctx.bot.send_message( + chat_id=tg_ctx.chat_id, + text=text, + reply_markup=keyboard, + message_thread_id=tg_ctx.thread_id, + ) + pq.message_id = msg.message_id + except Exception as e: + logger.error("Failed to send question to Telegram", error=str(e)) + cancel_pending(tg_ctx.user_id, tg_ctx.chat_id) + return {} + + # Wait for user's answer + try: + answer = await future + except asyncio.CancelledError: + logger.info("Question cancelled", question=question_text) + return {} + + answers[question_text] = answer + + logger.info( + "User answered question", + question=question_text, + answer=answer, + ) + + # Return updatedInput with pre-filled answers + updated_input = {**tool_input, "answers": answers} + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": updated_input, + } + } + + return hook +``` + +**Step 4: Run tests** + +Run: `cd /home/florian/config/claude-code-telegram && python -m pytest tests/unit/test_bot/test_interactive_questions.py -v` +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add src/bot/features/interactive_questions.py tests/unit/test_bot/test_interactive_questions.py +git commit -m "feat: add PreToolUse hook factory for AskUserQuestion interception" +``` + +--- + +### Task 3: Create the Telegram callback handler for askq: buttons + +**Files:** +- Modify: `src/bot/features/interactive_questions.py` +- Test: `tests/unit/test_bot/test_interactive_questions.py` + +**Step 1: Write failing tests** + +Add to the test file: + +```python +from src.bot.features.interactive_questions import askq_callback, askq_other_text + + +class TestAskqCallback: + @pytest.mark.asyncio + async def test_single_select_resolves(self): + """Tapping a button resolves the pending question.""" + loop = asyncio.get_event_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Pick?", + options=[{"label": "A", "description": ""}, {"label": "B", "description": ""}], + multi_select=False, + selected=set(), + message_id=42, + ) + register_pending(200, 100, pq) + + query = AsyncMock() + query.data = "askq:0:1" + query.from_user.id = 200 + query.message.chat_id = 100 + query.message.message_id = 42 + query.message.message_thread_id = None + + update = MagicMock() + update.callback_query = query + + context = MagicMock() + + await askq_callback(update, context) + + assert future.result() == "B" + query.answer.assert_called_once() + query.edit_message_text.assert_called_once() + + @pytest.mark.asyncio + async def test_multi_select_toggle(self): + """Tapping a multi-select toggle updates keyboard.""" + loop = asyncio.get_event_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Pick many?", + options=[{"label": "A", "description": ""}, {"label": "B", "description": ""}], + multi_select=True, + selected=set(), + message_id=42, + ) + register_pending(200, 100, pq) + + query = AsyncMock() + query.data = "askq:0:t0" + query.from_user.id = 200 + query.message.chat_id = 100 + query.message.message_id = 42 + query.message.message_thread_id = None + + update = MagicMock() + update.callback_query = query + + context = MagicMock() + + await askq_callback(update, context) + + assert 0 in pq.selected + assert not future.done() # Not resolved yet, waiting for Done + query.edit_message_reply_markup.assert_called_once() + + @pytest.mark.asyncio + async def test_multi_select_done(self): + """Done button resolves multi-select with selected labels.""" + loop = asyncio.get_event_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Pick many?", + options=[{"label": "A", "description": ""}, {"label": "B", "description": ""}], + multi_select=True, + selected={0, 1}, + message_id=42, + ) + register_pending(200, 100, pq) + + query = AsyncMock() + query.data = "askq:0:done" + query.from_user.id = 200 + query.message.chat_id = 100 + query.message.message_id = 42 + query.message.message_thread_id = None + + update = MagicMock() + update.callback_query = query + + context = MagicMock() + + await askq_callback(update, context) + + assert future.result() == "A, B" + + @pytest.mark.asyncio + async def test_other_sets_awaiting_text(self): + """Other button sets pq.awaiting_other and edits message.""" + loop = asyncio.get_event_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Pick?", + options=[{"label": "A", "description": ""}], + multi_select=False, + selected=set(), + message_id=42, + ) + register_pending(200, 100, pq) + + query = AsyncMock() + query.data = "askq:0:other" + query.from_user.id = 200 + query.message.chat_id = 100 + query.message.message_id = 42 + query.message.message_thread_id = None + + update = MagicMock() + update.callback_query = query + + context = MagicMock() + + await askq_callback(update, context) + + assert pq.awaiting_other is True + query.edit_message_text.assert_called_once() + + @pytest.mark.asyncio + async def test_expired_question(self): + """Tapping a button with no pending question shows expired message.""" + query = AsyncMock() + query.data = "askq:0:0" + query.from_user.id = 200 + query.message.chat_id = 100 + query.message.message_id = 42 + query.message.message_thread_id = None + + update = MagicMock() + update.callback_query = query + + context = MagicMock() + + await askq_callback(update, context) + + query.answer.assert_called_once_with(text="Question expired.", show_alert=True) + + +class TestAskqOtherText: + @pytest.mark.asyncio + async def test_captures_text_and_resolves(self): + """Free-text reply resolves the pending question.""" + loop = asyncio.get_event_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Pick?", + options=[], + multi_select=False, + selected=set(), + message_id=42, + ) + pq.awaiting_other = True + register_pending(200, 100, pq) + + message = MagicMock() + message.text = "My custom answer" + message.from_user.id = 200 + message.chat_id = 100 + + update = MagicMock() + update.message = message + + context = MagicMock() + + result = await askq_other_text(update, context) + + assert future.result() == "My custom answer" + + @pytest.mark.asyncio + async def test_ignores_when_not_awaiting(self): + """Text messages pass through when no Other is pending.""" + message = MagicMock() + message.text = "regular message" + message.from_user.id = 200 + message.chat_id = 100 + + update = MagicMock() + update.message = message + + context = MagicMock() + + result = await askq_other_text(update, context) + + # Should return None to let other handlers process it + assert result is None +``` + +**Step 2: Run to verify failure** + +Run: `cd /home/florian/config/claude-code-telegram && python -m pytest tests/unit/test_bot/test_interactive_questions.py::TestAskqCallback -v` +Expected: ImportError for `askq_callback` + +**Step 3: Implement callback handlers** + +Add to `src/bot/features/interactive_questions.py`. First, add `awaiting_other` field to `PendingQuestion`: + +Update the `PendingQuestion` dataclass: +```python +@dataclass +class PendingQuestion: + """A question waiting for user response.""" + + future: asyncio.Future + question_text: str + options: List[Dict[str, Any]] + multi_select: bool + selected: Set[int] + message_id: Optional[int] + awaiting_other: bool = False +``` + +Then add the handlers: + +```python +async def askq_callback(update: Any, context: Any) -> None: + """Handle inline keyboard button taps for askq: callbacks.""" + query = update.callback_query + user_id = query.from_user.id + chat_id = query.message.chat_id + data = query.data # e.g. "askq:0:1", "askq:0:t1", "askq:0:done", "askq:0:other" + + pq = get_pending(user_id, chat_id) + if not pq: + await query.answer(text="Question expired.", show_alert=True) + return + + # Parse callback data: askq:: + parts = data.split(":") + if len(parts) < 3: + await query.answer(text="Invalid.", show_alert=True) + return + + action = parts[2] + + if action == "other": + # Switch to free-text input mode + pq.awaiting_other = True + await query.answer() + await query.edit_message_text( + text=f"**{pq.question_text}**\n\nType your answer:" + ) + return + + if action == "done": + # Multi-select done — resolve with selected labels + selected_labels = [ + pq.options[i].get("label", f"Option {i}") + for i in sorted(pq.selected) + if i < len(pq.options) + ] + answer = ", ".join(selected_labels) if selected_labels else "" + await query.answer() + await query.edit_message_text( + text=f"**{pq.question_text}**\n\n✓ {answer}" + ) + resolve_pending(user_id, chat_id, answer) + return + + if action.startswith("t"): + # Multi-select toggle + try: + opt_idx = int(action[1:]) + except ValueError: + await query.answer(text="Invalid.", show_alert=True) + return + + if opt_idx in pq.selected: + pq.selected.discard(opt_idx) + else: + pq.selected.add(opt_idx) + + # Rebuild keyboard with updated state + q_idx = int(parts[1]) + keyboard = build_multi_select_keyboard(pq.options, q_idx, pq.selected) + await query.answer() + await query.edit_message_reply_markup(reply_markup=keyboard) + return + + # Single select — action is the option index + try: + opt_idx = int(action) + except ValueError: + await query.answer(text="Invalid.", show_alert=True) + return + + if opt_idx < len(pq.options): + label = pq.options[opt_idx].get("label", f"Option {opt_idx}") + else: + label = f"Option {opt_idx}" + + await query.answer() + await query.edit_message_text( + text=f"**{pq.question_text}**\n\n✓ {label}" + ) + resolve_pending(user_id, chat_id, label) + + +async def askq_other_text(update: Any, context: Any) -> Optional[bool]: + """Handle free-text replies for 'Other...' answers. + + This is registered as a MessageHandler with a low group number so it + runs before the main agentic_text handler. Returns None to pass through + if not handling an 'Other' response. + """ + message = update.message + if not message or not message.text: + return None + + user_id = message.from_user.id + chat_id = message.chat_id + + pq = get_pending(user_id, chat_id) + if not pq or not pq.awaiting_other: + return None # Not our message, let other handlers process it + + # Capture the text and resolve + answer = message.text.strip() + pq.awaiting_other = False + resolve_pending(user_id, chat_id, answer) + return True # Consumed the message +``` + +**Step 4: Run tests** + +Run: `cd /home/florian/config/claude-code-telegram && python -m pytest tests/unit/test_bot/test_interactive_questions.py -v` +Expected: All tests PASS + +**Step 5: Commit** + +```bash +git add src/bot/features/interactive_questions.py tests/unit/test_bot/test_interactive_questions.py +git commit -m "feat: add Telegram callback and text handlers for interactive questions" +``` + +--- + +### Task 4: Wire the hook into sdk_integration.py + +**Files:** +- Modify: `src/claude/sdk_integration.py` (lines 243-300) +- Modify: `src/claude/facade.py` (lines 40-176) +- Test: `tests/unit/test_claude/test_sdk_integration.py` + +**Step 1: Write failing tests** + +Add to `tests/unit/test_claude/test_sdk_integration.py`: + +```python +from src.bot.features.interactive_questions import TelegramContext + + +class TestExecuteCommandHooks: + @pytest.mark.asyncio + async def test_hooks_set_when_telegram_context_provided(self): + """Verify PreToolUse hook is registered when telegram_context is passed.""" + from unittest.mock import AsyncMock, MagicMock, patch + from src.claude.sdk_integration import ClaudeSDKManager + from src.config.settings import Settings + + settings = MagicMock(spec=Settings) + settings.claude_max_turns = 10 + settings.claude_max_cost_per_request = 1.0 + settings.claude_allowed_tools = [] + settings.claude_disallowed_tools = [] + settings.claude_cli_path = None + settings.sandbox_enabled = False + settings.sandbox_excluded_commands = [] + settings.enable_mcp = False + settings.mcp_config_path = None + settings.claude_timeout_seconds = 60 + + manager = ClaudeSDKManager(settings) + + tg_ctx = TelegramContext( + bot=AsyncMock(), chat_id=100, thread_id=5, user_id=200 + ) + + # We can't fully run execute_command without a real CLI, + # but we can verify the options are built correctly by + # patching ClaudeSDKClient + captured_options = {} + with patch("src.claude.sdk_integration.ClaudeSDKClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client._query = AsyncMock() + mock_client._query.receive_messages = AsyncMock(return_value=AsyncMock()) + mock_client_cls.return_value = mock_client + + # Make it raise early so we can inspect options + mock_client.connect.side_effect = Exception("test stop") + + try: + await manager.execute_command( + prompt="test", + working_directory=Path("/tmp"), + telegram_context=tg_ctx, + ) + except Exception: + pass + + # Check that hooks were set on the options + call_args = mock_client_cls.call_args + options = call_args[0][0] if call_args[0] else call_args[1].get("options") + assert options.hooks is not None + assert "PreToolUse" in options.hooks + matchers = options.hooks["PreToolUse"] + assert len(matchers) == 1 + assert matchers[0].matcher == "AskUserQuestion" + + @pytest.mark.asyncio + async def test_no_hooks_without_telegram_context(self): + """Verify no hooks set when telegram_context is None.""" + from unittest.mock import AsyncMock, MagicMock, patch + from src.claude.sdk_integration import ClaudeSDKManager + from src.config.settings import Settings + + settings = MagicMock(spec=Settings) + settings.claude_max_turns = 10 + settings.claude_max_cost_per_request = 1.0 + settings.claude_allowed_tools = [] + settings.claude_disallowed_tools = [] + settings.claude_cli_path = None + settings.sandbox_enabled = False + settings.sandbox_excluded_commands = [] + settings.enable_mcp = False + settings.mcp_config_path = None + settings.claude_timeout_seconds = 60 + + manager = ClaudeSDKManager(settings) + + captured_options = {} + with patch("src.claude.sdk_integration.ClaudeSDKClient") as mock_client_cls: + mock_client = AsyncMock() + mock_client.connect.side_effect = Exception("test stop") + mock_client_cls.return_value = mock_client + + try: + await manager.execute_command( + prompt="test", + working_directory=Path("/tmp"), + ) + except Exception: + pass + + call_args = mock_client_cls.call_args + options = call_args[0][0] if call_args[0] else call_args[1].get("options") + assert not options.hooks # Empty or None +``` + +**Step 2: Run to verify failure** + +Run: `cd /home/florian/config/claude-code-telegram && python -m pytest tests/unit/test_claude/test_sdk_integration.py::TestExecuteCommandHooks -v` +Expected: TypeError — `execute_command()` doesn't accept `telegram_context` + +**Step 3: Modify sdk_integration.py** + +Add import at top of file (after line 30): + +```python +from src.bot.features.interactive_questions import ( + TelegramContext, + cancel_pending, + make_ask_user_hook, +) +``` + +Also import `HookMatcher` from the SDK (add to the existing import block): + +```python +from claude_agent_sdk import ( + ...existing imports... + HookMatcher, +) +``` + +Modify `execute_command` signature (line 243): + +```python +async def execute_command( + self, + prompt: str, + working_directory: Path, + session_id: Optional[str] = None, + continue_session: bool = False, + stream_callback: Optional[Callable[[StreamUpdate], None]] = None, + call_id: Optional[int] = None, + telegram_context: Optional["TelegramContext"] = None, +) -> ClaudeResponse: +``` + +After the `can_use_tool` block (after line 332), add: + +```python + # Register PreToolUse hook for AskUserQuestion if we have + # Telegram context to send questions to + if telegram_context: + ask_hook = make_ask_user_hook(telegram_context) + options.hooks = { + "PreToolUse": [ + HookMatcher( + matcher="AskUserQuestion", + hooks=[ask_hook], + ) + ] + } + logger.info("AskUserQuestion hook registered for Telegram") +``` + +In the `finally` block (around line 395-398), add cleanup: + +```python + finally: + if call_id is not None: + self._active_pids.pop(call_id, None) + # Cancel any pending questions on session end + if telegram_context: + cancel_pending( + telegram_context.user_id, + telegram_context.chat_id, + ) + await client.disconnect() +``` + +**Step 4: Run tests** + +Run: `cd /home/florian/config/claude-code-telegram && python -m pytest tests/unit/test_claude/test_sdk_integration.py::TestExecuteCommandHooks -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/claude/sdk_integration.py tests/unit/test_claude/test_sdk_integration.py +git commit -m "feat: wire PreToolUse hook for AskUserQuestion into SDK session options" +``` + +--- + +### Task 5: Thread telegram_context through facade.py + +**Files:** +- Modify: `src/claude/facade.py` (lines 40-176) + +**Step 1: Modify run_command signature** + +```python +async def run_command( + self, + prompt: str, + working_directory: Path, + user_id: int, + session_id: Optional[str] = None, + on_stream: Optional[Callable[[StreamUpdate], None]] = None, + force_new: bool = False, + call_id: Optional[int] = None, + telegram_context: Optional[Any] = None, +) -> ClaudeResponse: +``` + +**Step 2: Modify _execute signature and passthrough** + +```python +async def _execute( + self, + prompt: str, + working_directory: Path, + session_id: Optional[str] = None, + continue_session: bool = False, + stream_callback: Optional[Callable] = None, + call_id: Optional[int] = None, + telegram_context: Optional[Any] = None, +) -> ClaudeResponse: + """Execute command via SDK.""" + return await self.sdk_manager.execute_command( + prompt=prompt, + working_directory=working_directory, + session_id=session_id, + continue_session=continue_session, + stream_callback=stream_callback, + call_id=call_id, + telegram_context=telegram_context, + ) +``` + +**Step 3: Pass telegram_context through all _execute calls in run_command** + +In `run_command()`, update both `_execute` calls (lines 91-98 and 116-123): + +```python +response = await self._execute( + prompt=prompt, + working_directory=working_directory, + session_id=claude_session_id, + continue_session=should_continue, + stream_callback=on_stream, + call_id=call_id, + telegram_context=telegram_context, +) +``` + +And the retry call: + +```python +response = await self._execute( + prompt=prompt, + working_directory=working_directory, + session_id=None, + continue_session=False, + stream_callback=on_stream, + call_id=call_id, + telegram_context=telegram_context, +) +``` + +**Step 4: Run existing facade tests to verify no regressions** + +Run: `cd /home/florian/config/claude-code-telegram && python -m pytest tests/unit/test_claude/test_facade.py -v` +Expected: PASS (existing tests don't pass telegram_context, defaults to None) + +**Step 5: Commit** + +```bash +git add src/claude/facade.py +git commit -m "feat: thread telegram_context through facade run_command and _execute" +``` + +--- + +### Task 6: Wire orchestrator to pass telegram_context + +**Files:** +- Modify: `src/bot/orchestrator.py` (lines 846-920 and 307-365) + +**Step 1: Modify agentic_text to pass telegram_context** + +In `agentic_text()`, after extracting `chat = update.message.chat` (line 867), build the context: + +```python +from src.bot.features.interactive_questions import TelegramContext + +# Build Telegram context for interactive questions +tg_ctx = TelegramContext( + bot=context.bot, + chat_id=update.message.chat_id, + thread_id=getattr(update.message, "message_thread_id", None), + user_id=user_id, +) +``` + +Then pass it to `run_command()` (around line 909): + +```python +task = asyncio.create_task( + claude_integration.run_command( + prompt=message_text, + working_directory=current_dir, + user_id=user_id, + session_id=session_id, + on_stream=on_stream, + force_new=force_new, + call_id=call_id, + telegram_context=tg_ctx, + ) +) +``` + +**Step 2: Register askq callback handler** + +In `_register_agentic_handlers()` (around line 351-365 where callback handlers are), add: + +```python +from src.bot.features.interactive_questions import askq_callback, askq_other_text + +# Interactive question handlers +app.add_handler( + CallbackQueryHandler(askq_callback, pattern=r"^askq:"), + group=0, # Before menu callbacks +) + +# "Other..." free-text capture — must run before agentic_text (group 10) +from telegram.ext import MessageHandler, filters +app.add_handler( + MessageHandler( + filters.TEXT & ~filters.COMMAND, + askq_other_text, + ), + group=5, # Between auth/rate-limit and agentic_text (group 10) +) +``` + +**Step 3: Run full test suite to verify no regressions** + +Run: `cd /home/florian/config/claude-code-telegram && python -m pytest tests/ -v --timeout=30` +Expected: All existing tests PASS + +**Step 4: Commit** + +```bash +git add src/bot/orchestrator.py +git commit -m "feat: wire Telegram context and askq handlers into orchestrator" +``` + +--- + +### Task 7: Wire menu.py to pass telegram_context + +**Files:** +- Modify: `src/bot/handlers/menu.py` (lines 434-446) + +**Step 1: Build and pass telegram_context in INJECT_SKILL** + +In the INJECT_SKILL section, before `run_command()` (around line 434): + +```python +from src.bot.features.interactive_questions import TelegramContext + +tg_ctx = TelegramContext( + bot=context.bot, + chat_id=chat_id, + thread_id=thread_id, + user_id=query.from_user.id, +) +``` + +Note: `thread_id` is already extracted at line 472 — move it earlier (before the `run_command` call). + +Then pass to `run_command()`: + +```python +response = await claude_integration.run_command( + prompt=prompt, + working_directory=current_dir, + user_id=query.from_user.id, + session_id=session_id, + force_new=force_new, + telegram_context=tg_ctx, +) +``` + +**Step 2: Run menu tests** + +Run: `cd /home/florian/config/claude-code-telegram && python -m pytest tests/unit/test_bot/test_menu.py -v` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/bot/handlers/menu.py +git commit -m "feat: pass telegram_context from menu skill execution to Claude sessions" +``` + +--- + +### Task 8: Integration smoke test — deploy and test + +**Step 1: Run full test suite** + +```bash +cd /home/florian/config/claude-code-telegram && python -m pytest tests/ -v --timeout=30 +``` + +Expected: All tests PASS + +**Step 2: Lint** + +```bash +cd /home/florian/config/claude-code-telegram && black src/bot/features/interactive_questions.py src/claude/sdk_integration.py src/claude/facade.py src/bot/orchestrator.py src/bot/handlers/menu.py +``` + +**Step 3: Copy to installed location** + +```bash +cp src/bot/features/interactive_questions.py ~/.local/share/uv/tools/claude-code-telegram/lib/python3.12/site-packages/src/bot/features/interactive_questions.py +cp src/claude/sdk_integration.py ~/.local/share/uv/tools/claude-code-telegram/lib/python3.12/site-packages/src/claude/sdk_integration.py +cp src/claude/facade.py ~/.local/share/uv/tools/claude-code-telegram/lib/python3.12/site-packages/src/claude/facade.py +cp src/bot/orchestrator.py ~/.local/share/uv/tools/claude-code-telegram/lib/python3.12/site-packages/src/bot/orchestrator.py +cp src/bot/handlers/menu.py ~/.local/share/uv/tools/claude-code-telegram/lib/python3.12/site-packages/src/bot/handlers/menu.py +``` + +**Step 4: Restart bot** + +```bash +systemctl --user restart claude-telegram-bot +sleep 2 +journalctl --user -u claude-telegram-bot.service --since "30 seconds ago" --no-pager | tail -20 +``` + +Expected: Bot starts without errors, "AskUserQuestion hook registered" appears in logs on first interaction. + +**Step 5: Test in Telegram** + +1. Open /menu → pick a skill that uses AskUserQuestion (e.g. brainstorming from superpowers) +2. When Claude asks a question, verify inline buttons appear in Telegram +3. Tap a button, verify Claude receives the answer and continues +4. Test "Other..." flow: tap Other, type custom text, verify it's captured +5. Test multi-select if available + +**Step 6: Commit and push** + +```bash +git add -A +git commit -m "chore: lint and integration test pass" +git push origin feat/stop-command +``` diff --git a/src/bot/core.py b/src/bot/core.py index 19bd6e45..89b87f51 100644 --- a/src/bot/core.py +++ b/src/bot/core.py @@ -54,6 +54,10 @@ async def initialize(self) -> None: builder.defaults(Defaults(do_quote=self.settings.reply_quote)) builder.rate_limiter(AIORateLimiter(max_retries=1)) + # Allow concurrent update processing so that commands like /stop + # can run while a long-running Claude call is in progress. + builder.concurrent_updates(True) + # Configure connection settings builder.connect_timeout(30) builder.read_timeout(30) diff --git a/src/bot/features/command_palette.py b/src/bot/features/command_palette.py new file mode 100644 index 00000000..a6c3ee0e --- /dev/null +++ b/src/bot/features/command_palette.py @@ -0,0 +1,388 @@ +"""Dynamic command palette: scans ~/.claude/ for skills, commands, and plugins.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import structlog + +logger = structlog.get_logger() + + +class ActionType(Enum): + """How a palette item is executed.""" + + DIRECT_COMMAND = "direct" + INJECT_SKILL = "inject" + + +@dataclass +class PaletteItem: + """A single actionable item in the command palette.""" + + id: str + name: str + description: str + action_type: ActionType + action_value: str + source: str + enabled: bool = True + + +@dataclass +class PluginInfo: + """Metadata about an installed plugin.""" + + name: str + qualified_name: str + version: str + enabled: bool + items: List[PaletteItem] = field(default_factory=list) + install_path: str = "" + + +def parse_skill_frontmatter(content: str) -> Dict[str, Any]: + """Parse YAML frontmatter from a SKILL.md or command .md file. + + Uses simple key: value parsing to avoid adding a PyYAML dependency. + Handles standard single-line ``key: value`` pairs. Lines starting + with ``#`` and blank lines inside the frontmatter block are skipped. + + Args: + content: Full text content of the markdown file. + + Returns: + Dictionary of parsed frontmatter key/value pairs, or ``{}`` if + no valid frontmatter block is found. + """ + if not content.strip(): + return {} + + match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL) + if not match: + return {} + + try: + result: Dict[str, Any] = {} + for line in match.group(1).strip().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if ":" in line: + key, _, value = line.partition(":") + result[key.strip()] = value.strip() + return result + except Exception: + logger.warning("Failed to parse SKILL.md frontmatter") + return {} + + +BOT_COMMANDS: List[PaletteItem] = [ + PaletteItem( + id="bot:new", + name="new", + description="Start a fresh session", + action_type=ActionType.DIRECT_COMMAND, + action_value="/new", + source="bot", + ), + PaletteItem( + id="bot:status", + name="status", + description="Show session status", + action_type=ActionType.DIRECT_COMMAND, + action_value="/status", + source="bot", + ), + PaletteItem( + id="bot:repo", + name="repo", + description="List repos / switch workspace", + action_type=ActionType.DIRECT_COMMAND, + action_value="/repo", + source="bot", + ), + PaletteItem( + id="bot:verbose", + name="verbose", + description="Set output verbosity (0/1/2)", + action_type=ActionType.DIRECT_COMMAND, + action_value="/verbose", + source="bot", + ), + PaletteItem( + id="bot:stop", + name="stop", + description="Stop running Claude call", + action_type=ActionType.DIRECT_COMMAND, + action_value="/stop", + source="bot", + ), +] + + +def get_enabled_plugin_paths(claude_dir: Optional[Path] = None) -> List[str]: + """Return install paths for all enabled (and non-blocked) plugins. + + This is used by the SDK integration to pass plugins to Claude sessions + via ``ClaudeAgentOptions.plugins``. + """ + claude_dir = claude_dir or Path.home() / ".claude" + if not claude_dir.is_dir(): + return [] + + # Load settings + settings_file = claude_dir / "settings.json" + enabled_map: Dict[str, bool] = {} + if settings_file.is_file(): + try: + data = json.loads(settings_file.read_text(encoding="utf-8")) + enabled_map = data.get("enabledPlugins", {}) + except (json.JSONDecodeError, OSError): + pass + + # Load installed plugins + installed_file = claude_dir / "plugins" / "installed_plugins.json" + installed: Dict[str, list] = {} + if installed_file.is_file(): + try: + data = json.loads(installed_file.read_text(encoding="utf-8")) + installed = data.get("plugins", {}) + except (json.JSONDecodeError, OSError): + pass + + # Load blocklist + blocklist_file = claude_dir / "plugins" / "blocklist.json" + blocked_names: set = set() + if blocklist_file.is_file(): + try: + data = json.loads(blocklist_file.read_text(encoding="utf-8")) + blocked_names = { + b.get("plugin") for b in data.get("plugins", []) if b.get("plugin") + } + except (json.JSONDecodeError, OSError): + pass + + paths: List[str] = [] + for qualified_name, installs in installed.items(): + if not installs: + continue + if not enabled_map.get(qualified_name, False): + continue + if qualified_name in blocked_names: + continue + install_path = installs[0].get("installPath", "") + if install_path and Path(install_path).is_dir(): + paths.append(install_path) + + return paths + + +class CommandPaletteScanner: + """Scans ~/.claude/ for all available skills, commands, and plugins.""" + + def __init__(self, claude_dir: Optional[Path] = None) -> None: + self.claude_dir = claude_dir or Path.home() / ".claude" + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def scan(self) -> Tuple[List[PaletteItem], List[PluginInfo]]: + """Discover all palette items and plugin info from the filesystem. + + Returns: + A tuple of (all palette items, plugin info list). + """ + items: List[PaletteItem] = [] + plugins: List[PluginInfo] = [] + + # Always include built-in bot commands + items.extend(BOT_COMMANDS) + + if not self.claude_dir.is_dir(): + return items, plugins + + enabled_plugins = self._load_enabled_plugins() + installed_plugins = self._load_installed_plugins() + blocklisted = self._load_blocklist() + + # --- Installed plugins --- + for qualified_name, installs in installed_plugins.items(): + if not installs: + continue + install = installs[0] + install_path = Path(install.get("installPath", "")) + version = install.get("version", "unknown") + short_name = qualified_name.split("@")[0] + + is_enabled = enabled_plugins.get(qualified_name, False) + is_blocked = any(b.get("plugin") == qualified_name for b in blocklisted) + effective_enabled = is_enabled and not is_blocked + + plugin_items: List[PaletteItem] = [] + + # Plugin skills + skills_dir = install_path / "skills" + if skills_dir.is_dir(): + for skill_dir in sorted(skills_dir.iterdir()): + skill_file = skill_dir / "SKILL.md" + if skill_file.is_file(): + item = self._parse_skill_file( + skill_file, short_name, effective_enabled + ) + if item: + plugin_items.append(item) + + # Plugin commands + commands_dir = install_path / "commands" + if commands_dir.is_dir(): + for cmd_file in sorted(commands_dir.glob("*.md")): + item = self._parse_command_file( + cmd_file, short_name, effective_enabled + ) + if item: + plugin_items.append(item) + + plugin = PluginInfo( + name=short_name, + qualified_name=qualified_name, + version=version, + enabled=effective_enabled, + items=plugin_items, + install_path=str(install_path), + ) + plugins.append(plugin) + items.extend(plugin_items) + + # --- Custom user skills --- + custom_dir = self.claude_dir / "skills" + if custom_dir.is_dir(): + for skill_dir in sorted(custom_dir.iterdir()): + if not skill_dir.is_dir(): + continue + skill_file = skill_dir / "SKILL.md" + if skill_file.is_file(): + item = self._parse_skill_file(skill_file, "custom", True) + if item: + item.source = "custom" + items.append(item) + + return items, plugins + + def toggle_plugin(self, qualified_name: str, enabled: bool) -> bool: + """Enable or disable a plugin by updating settings.json. + + Args: + qualified_name: The fully qualified plugin name (e.g. ``name@marketplace``). + enabled: Whether the plugin should be enabled. + + Returns: + ``True`` on success, ``False`` on failure. + """ + settings_file = self.claude_dir / "settings.json" + try: + if settings_file.is_file(): + data = json.loads(settings_file.read_text(encoding="utf-8")) + else: + data = {} + if "enabledPlugins" not in data: + data["enabledPlugins"] = {} + data["enabledPlugins"][qualified_name] = enabled + settings_file.write_text( + json.dumps(data, indent=2) + "\n", encoding="utf-8" + ) + return True + except (json.JSONDecodeError, OSError) as e: + logger.error( + "Failed to toggle plugin", + plugin=qualified_name, + error=str(e), + ) + return False + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _parse_skill_file( + self, path: Path, source: str, enabled: bool + ) -> Optional[PaletteItem]: + """Parse a SKILL.md file and return a PaletteItem, or ``None``.""" + try: + content = path.read_text(encoding="utf-8") + except OSError: + return None + fm = parse_skill_frontmatter(content) + if not fm.get("name"): + return None + name = fm["name"] + return PaletteItem( + id=f"{source}:{name}", + name=name, + description=fm.get("description", ""), + action_type=ActionType.INJECT_SKILL, + action_value=f"/{name}", + source=source, + enabled=enabled, + ) + + def _parse_command_file( + self, path: Path, source: str, enabled: bool + ) -> Optional[PaletteItem]: + """Parse a command .md file and return a PaletteItem, or ``None``.""" + try: + content = path.read_text(encoding="utf-8") + except OSError: + return None + fm = parse_skill_frontmatter(content) + if not fm.get("name"): + return None + name = fm["name"] + return PaletteItem( + id=f"{source}:{name}", + name=name, + description=fm.get("description", ""), + action_type=ActionType.INJECT_SKILL, + action_value=f"/{name}", + source=source, + enabled=enabled, + ) + + def _load_enabled_plugins(self) -> Dict[str, bool]: + """Load the ``enabledPlugins`` map from settings.json.""" + settings_file = self.claude_dir / "settings.json" + if not settings_file.is_file(): + return {} + try: + data = json.loads(settings_file.read_text(encoding="utf-8")) + return data.get("enabledPlugins", {}) + except (json.JSONDecodeError, OSError): + return {} + + def _load_installed_plugins(self) -> Dict[str, list]: + """Load the installed plugins map from installed_plugins.json.""" + installed_file = self.claude_dir / "plugins" / "installed_plugins.json" + if not installed_file.is_file(): + return {} + try: + data = json.loads(installed_file.read_text(encoding="utf-8")) + return data.get("plugins", {}) + except (json.JSONDecodeError, OSError): + return {} + + def _load_blocklist(self) -> list: + """Load the blocklist from blocklist.json.""" + blocklist_file = self.claude_dir / "plugins" / "blocklist.json" + if not blocklist_file.is_file(): + return [] + try: + data = json.loads(blocklist_file.read_text(encoding="utf-8")) + return data.get("plugins", []) + except (json.JSONDecodeError, OSError): + return [] diff --git a/src/bot/features/interactive_questions.py b/src/bot/features/interactive_questions.py new file mode 100644 index 00000000..9ef71498 --- /dev/null +++ b/src/bot/features/interactive_questions.py @@ -0,0 +1,288 @@ +"""Interactive question routing for AskUserQuestion tool calls.""" + +import asyncio +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Set, Tuple + +import structlog +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import ApplicationHandlerStop, ContextTypes + +logger = structlog.get_logger() + + +@dataclass +class TelegramContext: + """Telegram context needed to send messages from the hook callback.""" + + bot: Any # telegram.Bot + chat_id: int + thread_id: Optional[int] + user_id: int + + +@dataclass +class PendingQuestion: + """A question waiting for user response.""" + + future: asyncio.Future + question_text: str + options: List[Dict[str, Any]] + multi_select: bool + selected: Set[int] = field(default_factory=set) + message_id: Optional[int] = None + awaiting_other: bool = False + + +# Module-level registry: (user_id, chat_id) -> PendingQuestion +_pending: Dict[Tuple[int, int], PendingQuestion] = {} + + +def register_pending(user_id: int, chat_id: int, pq: PendingQuestion) -> None: + """Register a pending question for a user+chat.""" + _pending[(user_id, chat_id)] = pq + + +def get_pending(user_id: int, chat_id: int) -> Optional[PendingQuestion]: + """Get the pending question for a user+chat, if any.""" + return _pending.get((user_id, chat_id)) + + +def resolve_pending(user_id: int, chat_id: int, answer: Any) -> None: + """Resolve a pending question with the user's answer.""" + pq = _pending.pop((user_id, chat_id), None) + if pq and not pq.future.done(): + pq.future.set_result(answer) + + +def cancel_pending(user_id: int, chat_id: int) -> None: + """Cancel a pending question (e.g. on session error).""" + pq = _pending.pop((user_id, chat_id), None) + if pq and not pq.future.done(): + pq.future.cancel() + + +def format_question_text(question: str, options: List[Dict[str, Any]]) -> str: + """Format a question with its options as readable text.""" + lines = [f"**{question}**", ""] + for opt in options: + label = opt.get("label", "") + desc = opt.get("description", "") + if desc: + lines.append(f"• {label} — {desc}") + else: + lines.append(f"• {label}") + return "\n".join(lines) + + +def build_single_select_keyboard( + options: List[Dict[str, Any]], question_idx: int +) -> InlineKeyboardMarkup: + """Build inline keyboard for a single-select question.""" + buttons = [] + for i, opt in enumerate(options): + buttons.append( + InlineKeyboardButton( + text=opt.get("label", f"Option {i}"), + callback_data=f"askq:{question_idx}:{i}", + ) + ) + rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] + rows.append( + [ + InlineKeyboardButton( + text="Other...", callback_data=f"askq:{question_idx}:other" + ) + ] + ) + return InlineKeyboardMarkup(rows) + + +def build_multi_select_keyboard( + options: List[Dict[str, Any]], question_idx: int, selected: Set[int] +) -> InlineKeyboardMarkup: + """Build inline keyboard for a multi-select question.""" + buttons = [] + for i, opt in enumerate(options): + label = opt.get("label", f"Option {i}") + prefix = "☑" if i in selected else "☐" + buttons.append( + InlineKeyboardButton( + text=f"{prefix} {label}", + callback_data=f"askq:{question_idx}:t{i}", + ) + ) + rows = [buttons[i : i + 2] for i in range(0, len(buttons), 2)] + rows.append( + [ + InlineKeyboardButton( + text="Other...", callback_data=f"askq:{question_idx}:other" + ) + ] + ) + rows.append( + [InlineKeyboardButton(text="Done ✓", callback_data=f"askq:{question_idx}:done")] + ) + return InlineKeyboardMarkup(rows) + + +def make_ask_user_hook( + tg_ctx: TelegramContext, +) -> Callable[..., Any]: + """Return an async PreToolUse hook callback that intercepts AskUserQuestion. + + The hook sends inline keyboards to the Telegram chat and awaits + the user's selection before returning the answers back to Claude. + """ + + async def hook( + input_data: Dict[str, Any], + tool_use_id: Optional[str], + context: Dict[str, Any], + ) -> Dict[str, Any]: + tool_name = input_data.get("tool_name", "") + if tool_name != "AskUserQuestion": + return {} + + tool_input: Dict[str, Any] = input_data.get("tool_input", {}) + questions: List[Dict[str, Any]] = tool_input.get("questions", []) + answers: Dict[str, str] = {} + + for q_idx, question in enumerate(questions): + q_text = question.get("question", "") + options: List[Dict[str, Any]] = question.get("options", []) + multi_select: bool = question.get("multiSelect", False) + + if multi_select: + keyboard = build_multi_select_keyboard(options, q_idx, set()) + else: + keyboard = build_single_select_keyboard(options, q_idx) + + loop = asyncio.get_running_loop() + future: asyncio.Future[str] = loop.create_future() + pq = PendingQuestion( + future=future, + question_text=q_text, + options=options, + multi_select=multi_select, + ) + register_pending(tg_ctx.user_id, tg_ctx.chat_id, pq) + + text = format_question_text(q_text, options) + try: + kwargs: Dict[str, Any] = { + "chat_id": tg_ctx.chat_id, + "text": text, + "reply_markup": keyboard, + "parse_mode": "Markdown", + } + if tg_ctx.thread_id is not None: + kwargs["message_thread_id"] = tg_ctx.thread_id + msg = await tg_ctx.bot.send_message(**kwargs) + pq.message_id = msg.message_id + except Exception: + logger.exception("Failed to send question to Telegram") + cancel_pending(tg_ctx.user_id, tg_ctx.chat_id) + return {} + + try: + answer = await future + except asyncio.CancelledError: + return {} + + answers[q_text] = answer + + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": {**tool_input, "answers": answers}, + } + } + + return hook + + +async def askq_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle inline keyboard callbacks for interactive questions (askq:* pattern).""" + query = update.callback_query + if query is None: + return + + data = query.data or "" + parts = data.split(":") + if len(parts) != 3 or parts[0] != "askq": + return + + _prefix, q_idx_str, action = parts + q_idx = int(q_idx_str) # noqa: F841 – kept for clarity / future use + + user_id = query.from_user.id if query.from_user else 0 + chat_id = query.message.chat.id if query.message else 0 + pq = get_pending(user_id, chat_id) + + if pq is None: + await query.answer("Question expired.", show_alert=True) + return + + # Single select: action is a digit + if action.isdigit(): + idx = int(action) + label = pq.options[idx].get("label", f"Option {idx}") + resolve_pending(user_id, chat_id, label) + await query.answer() + await query.edit_message_text(f"✓ {label}") + return + + # Multi-select toggle: action starts with "t" + if action.startswith("t") and action[1:].isdigit(): + idx = int(action[1:]) + if idx in pq.selected: + pq.selected.discard(idx) + else: + pq.selected.add(idx) + keyboard = build_multi_select_keyboard(pq.options, q_idx, pq.selected) + await query.answer() + await query.edit_message_reply_markup(reply_markup=keyboard) + return + + # Multi-select done + if action == "done": + labels = [ + pq.options[i].get("label", f"Option {i}") for i in sorted(pq.selected) + ] + answer = ", ".join(labels) + resolve_pending(user_id, chat_id, answer) + await query.answer() + await query.edit_message_text(f"✓ {answer}") + return + + # Other: free-text input requested + if action == "other": + pq.awaiting_other = True + await query.answer() + await query.edit_message_text("Type your answer:") + return + + +async def askq_other_text(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle free-text replies for the 'Other...' option. + + Raises ``ApplicationHandlerStop`` if the message was consumed (pending + question resolved), which prevents further handler groups (e.g. the + agentic_text handler at group 10) from processing this message. + If no pending "Other" question exists, returns normally so the message + falls through to the next handler group. + """ + if update.message is None: + return + + user_id = update.message.from_user.id if update.message.from_user else 0 + chat_id = update.message.chat.id + + pq = get_pending(user_id, chat_id) + if pq is None or not pq.awaiting_other: + return + + answer = update.message.text or "" + resolve_pending(user_id, chat_id, answer) + raise ApplicationHandlerStop() diff --git a/src/bot/handlers/command.py b/src/bot/handlers/command.py index 65c1405a..16f43e97 100644 --- a/src/bot/handlers/command.py +++ b/src/bot/handlers/command.py @@ -1221,6 +1221,42 @@ async def git_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non logger.error("Error in git_command", error=str(e), user_id=user_id) +async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /stop command - cancel a running Claude call immediately.""" + audit_logger: AuditLogger = context.bot_data.get("audit_logger") + user_id = update.effective_user.id + + # Snapshot thread_key and call_info synchronously before the first + # ``await`` so that concurrent handlers cannot mutate ``user_data`` + # between our read and the cancel (concurrent_updates is enabled). + tc = context.user_data.get("_thread_context") + thread_key = tc["state_key"] if tc else "_default" + active = context.user_data.get("_active_calls", {}) + call_info = active.get(thread_key, {}) + task = call_info.get("task") + call_id = call_info.get("call_id") + + if task and not task.done(): + # First, abort the SDK client to terminate the CLI subprocess. + # task.cancel() alone only cancels the asyncio wrapper and may + # leave the subprocess running in the background. + claude_integration = context.bot_data.get("claude_integration") + if claude_integration and call_id is not None: + claude_integration.abort_call(call_id) + task.cancel() + await update.message.reply_text( + "⏹ Stopped.", + parse_mode="HTML", + ) + logger.info("Claude call cancelled via /stop", user_id=user_id) + if audit_logger: + await audit_logger.log_command(user_id, "stop", [], True) + else: + await update.message.reply_text("Nothing running.") + if audit_logger: + await audit_logger.log_command(user_id, "stop", [], False) + + def _format_file_size(size: int) -> str: """Format file size in human-readable format.""" for unit in ["B", "KB", "MB", "GB"]: diff --git a/src/bot/handlers/menu.py b/src/bot/handlers/menu.py new file mode 100644 index 00000000..0de6223d --- /dev/null +++ b/src/bot/handlers/menu.py @@ -0,0 +1,613 @@ +"""Dynamic command palette menu: inline keyboard builder + handlers.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import structlog +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import ContextTypes + +from ..features.command_palette import ( + ActionType, + CommandPaletteScanner, + PaletteItem, + PluginInfo, +) +from ..utils.formatting import ResponseFormatter + +logger = structlog.get_logger() + + +class MenuBuilder: + """Builds inline keyboards for the command palette navigation. + + Telegram limits ``callback_data`` to 64 bytes. We use short + incrementing numeric IDs and store the mapping in ``id_map``. + """ + + def __init__(self, items: List[PaletteItem], plugins: List[PluginInfo]) -> None: + self.items = items + self.plugins = plugins + self.id_map: Dict[str, str] = {} # short_id -> full_id + self._counter = 0 + + # ------------------------------------------------------------------ + # Short-ID helpers + # ------------------------------------------------------------------ + + def _next_id(self) -> str: + """Return the next short numeric ID as a string.""" + sid = str(self._counter) + self._counter += 1 + return sid + + def _register(self, full_id: str) -> str: + """Map *full_id* to a short numeric ID and return the short ID.""" + sid = self._next_id() + self.id_map[sid] = full_id + return sid + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def build_top_level(self) -> InlineKeyboardMarkup: + """Build the top-level menu keyboard. + + Layout: + - **Bot (count)** -- always first row + - Each plugin with items -- sorted by name, 2 per row. + Single-item plugins trigger directly (``menu:run:{id}``), + multi-item plugins open a category (``menu:cat:{id}``). + - Custom skills -- individual buttons + - Plugin Store -- last row + """ + # Reset short-ID state for a fresh build + self.id_map.clear() + self._counter = 0 + + rows: List[List[InlineKeyboardButton]] = [] + + # --- Bot category --- + bot_items = [i for i in self.items if i.source == "bot"] + bot_sid = self._register("cat:bot") + rows.append( + [ + InlineKeyboardButton( + f"\U0001f916 Bot ({len(bot_items)})", + callback_data=f"menu:cat:{bot_sid}", + ) + ] + ) + + # --- Plugin categories (sorted by name, 2 per row) --- + sorted_plugins = sorted( + [p for p in self.plugins if p.items], + key=lambda p: p.name, + ) + plugin_buttons: List[InlineKeyboardButton] = [] + for plugin in sorted_plugins: + status = "\u2705" if plugin.enabled else "\u274c" + if len(plugin.items) == 1: + # Single-item plugin -- tap runs directly + item = plugin.items[0] + sid = self._register(item.id) + label = f"{status} {plugin.name}" + plugin_buttons.append( + InlineKeyboardButton(label, callback_data=f"menu:run:{sid}") + ) + else: + sid = self._register(f"cat:{plugin.name}") + label = f"{status} {plugin.name} ({len(plugin.items)})" + plugin_buttons.append( + InlineKeyboardButton(label, callback_data=f"menu:cat:{sid}") + ) + # Pack plugin buttons 2-per-row + for i in range(0, len(plugin_buttons), 2): + rows.append(plugin_buttons[i : i + 2]) + + # --- Custom skills (individual buttons, 2-per-row) --- + custom_items = [i for i in self.items if i.source == "custom"] + custom_buttons: List[InlineKeyboardButton] = [] + for item in custom_items: + sid = self._register(item.id) + custom_buttons.append( + InlineKeyboardButton( + f"\u2728 {item.name}", + callback_data=f"menu:run:{sid}", + ) + ) + for i in range(0, len(custom_buttons), 2): + rows.append(custom_buttons[i : i + 2]) + + # --- Plugin Store (last row) --- + rows.append( + [ + InlineKeyboardButton( + "\U0001f50c Plugin Store", callback_data="menu:store" + ) + ] + ) + + return InlineKeyboardMarkup(rows) + + def build_category(self, source: str) -> Tuple[InlineKeyboardMarkup, str]: + """Build a category sub-menu keyboard. + + Args: + source: The source identifier (``"bot"`` or a plugin name). + + Returns: + Tuple of (keyboard, header_text). + """ + if source == "bot": + cat_items = [i for i in self.items if i.source == "bot"] + header = "\U0001f916 Bot commands" + else: + cat_items = [i for i in self.items if i.source == source] + plugin = self._find_plugin(source) + status = "" + if plugin: + status = " \u2705" if plugin.enabled else " \u274c" + header = f"\U0001f50c {source}{status}" + + rows: List[List[InlineKeyboardButton]] = [] + for item in cat_items: + sid = self._register(item.id) + label = ( + f"{item.name} — {item.description}" if item.description else item.name + ) + rows.append([InlineKeyboardButton(label, callback_data=f"menu:run:{sid}")]) + + # Toggle button for plugins (not bot) + if source != "bot": + plugin = self._find_plugin(source) + if plugin: + action = "Disable" if plugin.enabled else "Enable" + icon = "\u274c" if plugin.enabled else "\u2705" + rows.append( + [ + InlineKeyboardButton( + f"{icon} {action} {plugin.name}", + callback_data=f"menu:tog:{plugin.qualified_name}", + ) + ] + ) + + # Back button + rows.append([InlineKeyboardButton("\u2b05 Back", callback_data="menu:back")]) + + return InlineKeyboardMarkup(rows), header + + def get_single_item_action(self, source: str) -> Optional[PaletteItem]: + """Return the item if a plugin has exactly 1 item, else ``None``.""" + plugin = self._find_plugin(source) + if plugin and len(plugin.items) == 1: + return plugin.items[0] + return None + + def resolve_id(self, short_id: str) -> Optional[str]: + """Resolve a short callback ID to the full item/category ID.""" + return self.id_map.get(short_id) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _find_plugin(self, name: str) -> Optional[PluginInfo]: + """Find a plugin by short name.""" + for p in self.plugins: + if p.name == name: + return p + return None + + +# ====================================================================== +# Telegram handler functions +# ====================================================================== + + +async def menu_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle ``/menu`` -- scan filesystem, build top-level keyboard, send.""" + scanner = CommandPaletteScanner() + items, plugins = scanner.scan() + + builder = MenuBuilder(items, plugins) + keyboard = builder.build_top_level() + + # Persist builder for callback resolution + if context.user_data is not None: + context.user_data["menu_builder"] = builder + context.user_data["menu_scanner"] = scanner + + item_count = len(items) + plugin_count = len(plugins) + custom_count = len([i for i in items if i.source == "custom"]) + + text = ( + f"\U0001f3af Command Palette\n\n" + f"{item_count} commands \u00b7 {plugin_count} plugins" + f" \u00b7 {custom_count} custom skills" + ) + + await update.message.reply_text( # type: ignore[union-attr] + text, + parse_mode="HTML", + reply_markup=keyboard, + ) + + logger.info( + "Menu opened", + items=item_count, + plugins=plugin_count, + custom=custom_count, + ) + + +async def menu_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle all ``menu:`` callbacks. + + Actions: + - ``menu:back`` -- rebuild and show top-level + - ``menu:cat:{id}`` -- show category sub-menu + - ``menu:run:{id}`` -- execute item + - ``menu:tog:{plugin}`` -- toggle plugin enable/disable + - ``menu:store`` -- show plugin store placeholder + """ + query = update.callback_query + if query is None: + return + await query.answer() + + data = query.data or "" + builder: Optional[MenuBuilder] = None + scanner: Optional[CommandPaletteScanner] = None + if context.user_data is not None: + builder = context.user_data.get("menu_builder") + scanner = context.user_data.get("menu_scanner") + + # ------------------------------------------------------------------ + # menu:back — rebuild top-level + # ------------------------------------------------------------------ + if data == "menu:back": + if builder is None: + await query.edit_message_text("Session expired. Send /menu again.") + return + # Re-scan to reflect any toggle changes + if scanner: + items, plugins = scanner.scan() + builder = MenuBuilder(items, plugins) + if context.user_data is not None: + context.user_data["menu_builder"] = builder + + keyboard = builder.build_top_level() + item_count = len(builder.items) + plugin_count = len(builder.plugins) + custom_count = len([i for i in builder.items if i.source == "custom"]) + text = ( + f"\U0001f3af Command Palette\n\n" + f"{item_count} commands \u00b7 {plugin_count} plugins" + f" \u00b7 {custom_count} custom skills" + ) + await query.edit_message_text(text, parse_mode="HTML", reply_markup=keyboard) + return + + # ------------------------------------------------------------------ + # menu:store — placeholder + # ------------------------------------------------------------------ + if data == "menu:store": + back_keyboard = InlineKeyboardMarkup( + [[InlineKeyboardButton("\u2b05 Back", callback_data="menu:back")]] + ) + await query.edit_message_text( + "\U0001f50c Plugin Store\n\nComing soon.", + parse_mode="HTML", + reply_markup=back_keyboard, + ) + return + + # ------------------------------------------------------------------ + # menu:tog:{qualified_name} — toggle plugin + # ------------------------------------------------------------------ + if data.startswith("menu:tog:"): + qualified_name = data[len("menu:tog:") :] + if scanner is None: + await query.edit_message_text("Session expired. Send /menu again.") + return + + # Find current state + plugin_info: Optional[PluginInfo] = None + if builder: + for p in builder.plugins: + if p.qualified_name == qualified_name: + plugin_info = p + break + + new_state = not (plugin_info.enabled if plugin_info else True) + success = scanner.toggle_plugin(qualified_name, new_state) + + if not success: + await query.edit_message_text( + f"\u26a0 Failed to toggle plugin.", parse_mode="HTML" + ) + return + + # Re-scan and rebuild category + items, plugins = scanner.scan() + builder = MenuBuilder(items, plugins) + if context.user_data is not None: + context.user_data["menu_builder"] = builder + + # Find plugin name from qualified_name + source_name = qualified_name.split("@")[0] + keyboard, header = builder.build_category(source_name) + state_label = "enabled" if new_state else "disabled" + text = f"{header}\n\nPlugin {state_label}." + await query.edit_message_text(text, parse_mode="HTML", reply_markup=keyboard) + logger.info( + "Plugin toggled", + plugin=qualified_name, + enabled=new_state, + ) + return + + # ------------------------------------------------------------------ + # menu:cat:{short_id} — show category sub-menu + # ------------------------------------------------------------------ + if data.startswith("menu:cat:"): + short_id = data[len("menu:cat:") :] + if builder is None: + await query.edit_message_text("Session expired. Send /menu again.") + return + + full_id = builder.resolve_id(short_id) + if full_id is None or not full_id.startswith("cat:"): + await query.edit_message_text("Invalid menu action.") + return + + source = full_id[len("cat:") :] + keyboard, header = builder.build_category(source) + await query.edit_message_text(header, parse_mode="HTML", reply_markup=keyboard) + return + + # ------------------------------------------------------------------ + # menu:run:{short_id} — execute item + # ------------------------------------------------------------------ + if data.startswith("menu:run:"): + short_id = data[len("menu:run:") :] + if builder is None: + await query.edit_message_text("Session expired. Send /menu again.") + return + + full_id = builder.resolve_id(short_id) + if full_id is None: + await query.edit_message_text("Invalid menu action.") + return + + # Find the PaletteItem + item: Optional[PaletteItem] = None + for candidate in builder.items: + if candidate.id == full_id: + item = candidate + break + + if item is None: + await query.edit_message_text("Command not found.") + return + + if item.action_type == ActionType.INJECT_SKILL: + # Store pending skill in user_data for reference + if context.user_data is not None: + context.user_data["menu_pending_skill"] = item.action_value + + # Edit menu message to show progress + await query.edit_message_text( + f"Working on {item.action_value}...", + parse_mode="HTML", + ) + + # Get Claude integration + claude_integration = context.bot_data.get("claude_integration") + if not claude_integration: + await query.edit_message_text( + "Claude integration not available. Check configuration." + ) + return + + settings = context.bot_data.get("settings") + current_dir = context.user_data.get( + "current_directory", + settings.approved_directory if settings else Path.home(), + ) + session_id = context.user_data.get("claude_session_id") + force_new = bool(context.user_data.get("force_new_session")) + + # Start typing indicator + chat_id = query.message.chat_id + try: + await context.bot.send_chat_action(chat_id=chat_id, action="typing") + except Exception: + pass + + try: + # Send the skill as a slash command. Plugins are now passed + # to the SDK session via ClaudeAgentOptions.plugins, so the + # session has access to all enabled skills. + prompt = item.action_value # e.g. "/commit" + + # Preserve topic/thread context for supergroup forums + thread_id = getattr(query.message, "message_thread_id", None) + + # Build Telegram context for interactive questions + from ..features.interactive_questions import TelegramContext + + tg_ctx = TelegramContext( + bot=context.bot, + chat_id=chat_id, + thread_id=thread_id, + user_id=query.from_user.id, + ) + + response = await claude_integration.run_command( + prompt=prompt, + working_directory=current_dir, + user_id=query.from_user.id, + session_id=session_id, + force_new=force_new, + telegram_context=tg_ctx, + ) + + # Clear force_new flag on success + if force_new: + context.user_data["force_new_session"] = False + + # Update session ID + context.user_data["claude_session_id"] = response.session_id + + logger.debug( + "Skill response content", + content_length=len(response.content) if response.content else 0, + content_preview=(response.content or "")[:200], + ) + + # Format response + formatter = ResponseFormatter(settings) + formatted_messages = formatter.format_claude_response(response.content) + + # Delete the "Working..." message + try: + await query.message.delete() + except Exception: + pass + + # Send formatted response + sent_any = False + for msg in formatted_messages: + if msg.text and msg.text.strip(): + try: + await context.bot.send_message( + chat_id=chat_id, + text=msg.text, + parse_mode=msg.parse_mode, + message_thread_id=thread_id, + ) + sent_any = True + except Exception: + # Retry without parse mode + await context.bot.send_message( + chat_id=chat_id, + text=msg.text, + message_thread_id=thread_id, + ) + sent_any = True + + # If Claude produced no visible text (e.g. only tool calls), + # send a confirmation so the user knows the skill ran. + if not sent_any: + skill_name = item.action_value.lstrip("/") + await context.bot.send_message( + chat_id=chat_id, + text=f"/{skill_name} completed (cost: ${response.cost:.4f})", + message_thread_id=thread_id, + ) + + # Log interaction + storage = context.bot_data.get("storage") + if storage: + try: + await storage.save_claude_interaction( + user_id=query.from_user.id, + session_id=response.session_id, + prompt=item.action_value, + response=response, + ip_address=None, + ) + except Exception as e: + logger.warning( + "Failed to log menu interaction", + error=str(e), + ) + + logger.info( + "Menu skill completed", + item_id=item.id, + action=item.action_value, + session_id=response.session_id, + cost=response.cost, + ) + + except Exception as e: + logger.error( + "Menu skill execution failed", + error=str(e), + item=item.id, + ) + try: + await query.edit_message_text( + f"Failed to run {item.action_value}:" + f" {str(e)[:200]}", + parse_mode="HTML", + ) + except Exception: + pass + + elif item.action_type == ActionType.DIRECT_COMMAND: + cmd = item.action_value # e.g. "/new", "/status" + settings = context.bot_data.get("settings") + + if cmd == "/new": + context.user_data["claude_session_id"] = None + context.user_data["session_started"] = True + context.user_data["force_new_session"] = True + await query.edit_message_text("Session reset. What's next?") + + elif cmd == "/status": + current_dir = context.user_data.get( + "current_directory", + settings.approved_directory if settings else "unknown", + ) + session_id = context.user_data.get("claude_session_id") + session_status = "active" if session_id else "none" + await query.edit_message_text( + f"\U0001f4c2 {current_dir} \u00b7 Session: {session_status}" + ) + + elif cmd == "/stop": + active_calls = context.user_data.get("_active_calls", {}) + if active_calls: + for key, entry in list(active_calls.items()): + task = entry.get("task") + if task and not task.done(): + task.cancel() + await query.edit_message_text("Stopping active calls...") + else: + await query.edit_message_text("No active calls to stop.") + + elif cmd == "/verbose": + level = context.user_data.get("verbose_level", 1) + await query.edit_message_text( + f"Verbose level: {level}\n" f"Use /verbose 0|1|2 to change." + ) + + elif cmd == "/repo": + await query.edit_message_text( + "Use /repo to browse and switch workspaces." + ) + + else: + await query.edit_message_text( + f"Use {cmd} directly.", + parse_mode="HTML", + ) + + logger.info( + "Menu command executed", + item_id=item.id, + action=item.action_value, + ) + return + + # Fallback for unknown actions + await query.edit_message_text("Unknown menu action.") diff --git a/src/bot/handlers/message.py b/src/bot/handlers/message.py index 538ce17f..f833f578 100644 --- a/src/bot/handlers/message.py +++ b/src/bot/handlers/message.py @@ -1,6 +1,7 @@ """Message handlers for non-command inputs.""" import asyncio +import itertools from typing import Optional import structlog @@ -28,6 +29,8 @@ logger = structlog.get_logger() +_call_counter = itertools.count(1) + async def _format_progress_update(update_obj) -> Optional[str]: """Format progress updates with enhanced context and visual indicators.""" @@ -386,14 +389,33 @@ async def stream_handler(update_obj): # Run Claude command try: - claude_response = await claude_integration.run_command( - prompt=message_text, - working_directory=current_dir, - user_id=user_id, - session_id=session_id, - on_stream=stream_handler, - force_new=force_new, + call_id = next(_call_counter) + task = asyncio.create_task( + claude_integration.run_command( + prompt=message_text, + working_directory=current_dir, + user_id=user_id, + session_id=session_id, + on_stream=stream_handler, + force_new=force_new, + call_id=call_id, + ) ) + tc = context.user_data.get("_thread_context") + thread_key = tc["state_key"] if tc else "_default" + active = context.user_data.setdefault("_active_calls", {}) + active[thread_key] = {"task": task, "call_id": call_id} + try: + claude_response = await task + except asyncio.CancelledError: + logger.info("Claude call cancelled by user", user_id=user_id) + try: + await progress_msg.delete() + except Exception: + pass + return + finally: + active.pop(thread_key, None) # New session created successfully — clear the one-shot flag if force_new: diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index faacabb8..4f9c32ba 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -6,6 +6,7 @@ """ import asyncio +import itertools import re import time from pathlib import Path @@ -111,6 +112,8 @@ def _tool_icon(name: str) -> str: class MessageOrchestrator: """Routes messages based on mode. Single entry point for all Telegram updates.""" + _call_counter = itertools.count(1) + def __init__(self, settings: Settings, deps: Dict[str, Any]): self.settings = settings self.deps = deps @@ -244,6 +247,12 @@ def _persist_thread_state(self, context: ContextTypes.DEFAULT_TYPE) -> None: "project_slug": thread_context["project_slug"], } + @staticmethod + def _thread_key(context: ContextTypes.DEFAULT_TYPE) -> str: + """Return a key identifying the current thread (or '_default').""" + tc = context.user_data.get("_thread_context") + return tc["state_key"] if tc else "_default" + @staticmethod def _is_within(path: Path, root: Path) -> bool: """Return True if path is within root.""" @@ -298,6 +307,7 @@ def register_handlers(self, app: Application) -> None: def _register_agentic_handlers(self, app: Application) -> None: """Register agentic handlers: commands + text/file/photo.""" from .handlers import command + from .handlers import menu as menu_handler # Commands handlers = [ @@ -306,6 +316,8 @@ def _register_agentic_handlers(self, app: Application) -> None: ("status", self.agentic_status), ("verbose", self.agentic_verbose), ("repo", self.agentic_repo), + ("stop", command.stop_command), + ("menu", menu_handler.menu_command), ] if self.settings.enable_project_threads: handlers.append(("sync_threads", command.sync_threads)) @@ -344,6 +356,33 @@ def _register_agentic_handlers(self, app: Application) -> None: ) ) + # menu: callbacks (for command palette navigation) + app.add_handler( + CallbackQueryHandler( + self._inject_deps(menu_handler.menu_callback), + pattern=r"^menu:", + ) + ) + + # Interactive question handlers (AskUserQuestion) + from .features.interactive_questions import askq_callback, askq_other_text + + app.add_handler( + CallbackQueryHandler(askq_callback, pattern=r"^askq:"), + group=0, + ) + + # "Other..." free-text capture — runs before agentic_text (group 10). + # Raises ApplicationHandlerStop when it consumes the message, so the + # agentic_text handler in group 10 does not also process it. + app.add_handler( + MessageHandler( + filters.TEXT & ~filters.COMMAND, + askq_other_text, + ), + group=5, + ) + logger.info("Agentic handlers registered") def _register_classic_handlers(self, app: Application) -> None: @@ -364,6 +403,7 @@ def _register_classic_handlers(self, app: Application) -> None: ("export", command.export_session), ("actions", command.quick_actions), ("git", command.git_command), + ("stop", command.stop_command), ] if self.settings.enable_project_threads: handlers.append(("sync_threads", command.sync_threads)) @@ -403,6 +443,8 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg] BotCommand("status", "Show session status"), BotCommand("verbose", "Set output verbosity (0/1/2)"), BotCommand("repo", "List repos / switch workspace"), + BotCommand("stop", "Stop running Claude call"), + BotCommand("menu", "Command palette & plugin manager"), ] if self.settings.enable_project_threads: commands.append(BotCommand("sync_threads", "Sync project topics")) @@ -422,6 +464,7 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg] BotCommand("export", "Export current session"), BotCommand("actions", "Show quick actions"), BotCommand("git", "Git repository commands"), + BotCommand("stop", "Stop running Claude call"), ] if self.settings.enable_project_threads: commands.append(BotCommand("sync_threads", "Sync project topics")) @@ -880,15 +923,45 @@ async def agentic_text( success = True try: - claude_response = await claude_integration.run_command( - prompt=message_text, - working_directory=current_dir, + call_id = next(self._call_counter) + + # Build Telegram context for interactive questions (AskUserQuestion) + from .features.interactive_questions import TelegramContext + + tg_ctx = TelegramContext( + bot=context.bot, + chat_id=update.message.chat_id, + thread_id=getattr(update.message, "message_thread_id", None), user_id=user_id, - session_id=session_id, - on_stream=on_stream, - force_new=force_new, ) + task = asyncio.create_task( + claude_integration.run_command( + prompt=message_text, + working_directory=current_dir, + user_id=user_id, + session_id=session_id, + on_stream=on_stream, + force_new=force_new, + call_id=call_id, + telegram_context=tg_ctx, + ) + ) + thread_key = self._thread_key(context) + active = context.user_data.setdefault("_active_calls", {}) + active[thread_key] = {"task": task, "call_id": call_id} + try: + claude_response = await task + except asyncio.CancelledError: + logger.info("Claude call cancelled by user", user_id=user_id) + try: + await progress_msg.delete() + except Exception: + pass + return + finally: + active.pop(thread_key, None) + # New session created successfully — clear the one-shot flag if force_new: context.user_data["force_new_session"] = False diff --git a/src/claude/facade.py b/src/claude/facade.py index fcb2ada6..bfd06979 100644 --- a/src/claude/facade.py +++ b/src/claude/facade.py @@ -29,6 +29,14 @@ def __init__( self.sdk_manager = sdk_manager or ClaudeSDKManager(config) self.session_manager = session_manager + def abort(self) -> None: + """Abort all running Claude commands (used during shutdown).""" + self.sdk_manager.abort() + + def abort_call(self, call_id: int) -> None: + """Abort a specific Claude call by its call_id.""" + self.sdk_manager.abort_call(call_id) + async def run_command( self, prompt: str, @@ -37,6 +45,8 @@ async def run_command( session_id: Optional[str] = None, on_stream: Optional[Callable[[StreamUpdate], None]] = None, force_new: bool = False, + call_id: Optional[int] = None, + telegram_context: Optional[Any] = None, ) -> ClaudeResponse: """Run Claude Code command with full integration.""" logger.info( @@ -85,6 +95,8 @@ async def run_command( session_id=claude_session_id, continue_session=should_continue, stream_callback=on_stream, + call_id=call_id, + telegram_context=telegram_context, ) except Exception as resume_error: # If resume failed (e.g., session expired/missing on Claude's side), @@ -109,6 +121,8 @@ async def run_command( session_id=None, continue_session=False, stream_callback=on_stream, + call_id=call_id, + telegram_context=telegram_context, ) else: raise @@ -152,6 +166,8 @@ async def _execute( session_id: Optional[str] = None, continue_session: bool = False, stream_callback: Optional[Callable] = None, + call_id: Optional[int] = None, + telegram_context: Optional[Any] = None, ) -> ClaudeResponse: """Execute command via SDK.""" return await self.sdk_manager.execute_command( @@ -160,6 +176,8 @@ async def _execute( session_id=session_id, continue_session=continue_session, stream_callback=stream_callback, + call_id=call_id, + telegram_context=telegram_context, ) async def _find_resumable_session( diff --git a/src/claude/sdk_integration.py b/src/claude/sdk_integration.py index 6a380c71..79821ab5 100644 --- a/src/claude/sdk_integration.py +++ b/src/claude/sdk_integration.py @@ -2,9 +2,11 @@ import asyncio import os +import platform +import signal from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Callable, Dict, List, Optional +from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional import structlog from claude_agent_sdk import ( @@ -15,11 +17,13 @@ CLIConnectionError, CLIJSONDecodeError, CLINotFoundError, + HookMatcher, Message, PermissionResultAllow, PermissionResultDeny, ProcessError, ResultMessage, + SdkPluginConfig, ToolPermissionContext, ToolUseBlock, UserMessage, @@ -37,6 +41,9 @@ ) from .monitor import _is_claude_internal_path, check_bash_directory_boundary +if TYPE_CHECKING: + from ..bot.features.interactive_questions import TelegramContext + logger = structlog.get_logger() @@ -136,6 +143,7 @@ def __init__( """Initialize SDK manager with configuration.""" self.config = config self.security_validator = security_validator + self._active_pids: Dict[int, int] = {} # call_id -> subprocess PID # Set up environment for Claude Code SDK if API key is provided # If no API key is provided, the SDK will use existing CLI authentication @@ -145,6 +153,97 @@ def __init__( else: logger.info("No API key provided, using existing Claude CLI authentication") + @staticmethod + def _get_descendants(pid: int) -> List[int]: + """Return all descendant PIDs of a process (breadth-first). + + Uses ``/proc//task//children`` which is Linux-only. + On non-Linux platforms this gracefully returns an empty list, + so ``_kill_pid`` falls back to killing only the root process. + """ + if platform.system() != "Linux": + return [] + descendants: List[int] = [] + queue = [pid] + while queue: + parent = queue.pop(0) + try: + with open(f"/proc/{parent}/task/{parent}/children") as f: + children = [int(p) for p in f.read().split() if p] + except (FileNotFoundError, ProcessLookupError, ValueError): + children = [] + descendants.extend(children) + queue.extend(children) + return descendants + + def _kill_pid(self, pid: int) -> None: + """Kill a process tree: the target PID and all its descendants. + + Uses killpg when the process is its own group leader. Otherwise + kills each process in the tree individually to avoid killing the + bot's own process group. + """ + try: + try: + pgid = os.getpgid(pid) + if pgid == pid: + # Own group leader — killpg is safe and covers all children + os.killpg(pgid, signal.SIGTERM) + logger.info("Sent SIGTERM to Claude CLI process group", pid=pid) + else: + # Shares bot's group — kill the tree individually + descendants = self._get_descendants(pid) + os.kill(pid, signal.SIGTERM) + for child in descendants: + try: + os.kill(child, signal.SIGTERM) + except ProcessLookupError: + pass + logger.info( + "Sent SIGTERM to Claude CLI process tree", + pid=pid, + descendants=len(descendants), + ) + except (ProcessLookupError, PermissionError): + os.kill(pid, signal.SIGTERM) + logger.info("Sent SIGTERM to Claude CLI process", pid=pid) + + import threading + + def _ensure_dead(target_pid: int) -> None: + import time + + time.sleep(2) + # Re-collect survivors (some children may have spawned late) + all_pids = [target_pid] + self._get_descendants(target_pid) + for p in all_pids: + try: + os.kill(p, 0) + os.kill(p, signal.SIGKILL) + logger.warning("Sent SIGKILL to surviving process", pid=p) + except ProcessLookupError: + pass + + threading.Thread(target=_ensure_dead, args=(pid,), daemon=True).start() + + except ProcessLookupError: + logger.debug("Claude CLI process already exited", pid=pid) + except Exception as e: + logger.warning("Failed to kill Claude CLI process", pid=pid, error=str(e)) + + def abort(self) -> None: + """Abort all running Claude commands (used during shutdown).""" + pids = list(self._active_pids.values()) + self._active_pids.clear() + for pid in pids: + self._kill_pid(pid) + + def abort_call(self, call_id: int) -> None: + """Abort a specific Claude call by its call_id.""" + pid = self._active_pids.pop(call_id, None) + if pid is not None: + self._kill_pid(pid) + async def execute_command( self, prompt: str, @@ -152,6 +251,8 @@ async def execute_command( session_id: Optional[str] = None, continue_session: bool = False, stream_callback: Optional[Callable[[StreamUpdate], None]] = None, + call_id: Optional[int] = None, + telegram_context: Optional["TelegramContext"] = None, ) -> ClaudeResponse: """Execute Claude Code command via SDK.""" start_time = asyncio.get_event_loop().time() @@ -198,10 +299,26 @@ def _stderr_callback(line: str) -> None: "excludedCommands": self.config.sandbox_excluded_commands or [], }, system_prompt=base_prompt, - setting_sources=["project"], + setting_sources=["project", "user"], stderr=_stderr_callback, ) + # Pass enabled plugins so skills/commands are available in sessions + try: + from src.bot.features.command_palette import get_enabled_plugin_paths + + plugin_paths = get_enabled_plugin_paths() + if plugin_paths: + options.plugins = [ + SdkPluginConfig(type="local", path=p) for p in plugin_paths + ] + logger.info( + "Plugins configured for session", + count=len(plugin_paths), + ) + except Exception as exc: + logger.warning("Failed to load plugins for session", error=str(exc)) + # Pass MCP server configuration if enabled if self.config.enable_mcp and self.config.mcp_config_path: options.mcp_servers = self._load_mcp_config(self.config.mcp_config_path) @@ -218,6 +335,22 @@ def _stderr_callback(line: str) -> None: approved_directory=self.config.approved_directory, ) + # Register PreToolUse hook for AskUserQuestion if we have + # Telegram context to send questions to + if telegram_context: + from ..bot.features.interactive_questions import make_ask_user_hook + + ask_hook = make_ask_user_hook(telegram_context) + options.hooks = { + "PreToolUse": [ + HookMatcher( + matcher="AskUserQuestion", + hooks=[ask_hook], + ) + ] + } + logger.info("AskUserQuestion hook registered for Telegram") + # Resume previous session if we have a session_id if session_id and continue_session: options.resume = session_id @@ -237,6 +370,12 @@ async def _run_client() -> None: client = ClaudeSDKClient(options) try: await client.connect() + # Store the subprocess PID so abort() can kill it + # from any async context via os.kill(). + transport = getattr(client, "_transport", None) + proc = getattr(transport, "_process", None) + if proc is not None and call_id is not None: + self._active_pids[call_id] = proc.pid await client.query(prompt) # Iterate over raw messages and parse them ourselves @@ -274,6 +413,16 @@ async def _run_client() -> None: error_type=type(callback_error).__name__, ) finally: + if call_id is not None: + self._active_pids.pop(call_id, None) + # Cancel any pending questions on session end + if telegram_context: + from ..bot.features.interactive_questions import cancel_pending + + cancel_pending( + telegram_context.user_id, + telegram_context.chat_id, + ) await client.disconnect() # Execute with timeout diff --git a/tests/unit/test_bot/test_command_palette.py b/tests/unit/test_bot/test_command_palette.py new file mode 100644 index 00000000..394d5da0 --- /dev/null +++ b/tests/unit/test_bot/test_command_palette.py @@ -0,0 +1,664 @@ +"""Tests for the dynamic command palette scanner.""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import pytest + +from src.bot.features.command_palette import ( + ActionType, + BOT_COMMANDS, + CommandPaletteScanner, + PaletteItem, + PluginInfo, + parse_skill_frontmatter, +) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def mock_claude_dir(tmp_path: Path) -> Path: + """Create a realistic ~/.claude/ directory tree for testing. + + Layout:: + + tmp/ + ├── settings.json + ├── plugins/ + │ ├── installed_plugins.json + │ ├── blocklist.json + │ └── cache/claude-plugins-official/ + │ └── my-plugin/1.0.0/ + │ ├── skills/brainstorming/SKILL.md + │ └── commands/review.md + └── skills/ + └── my-custom-skill/SKILL.md + """ + # settings.json + settings = { + "enabledPlugins": { + "my-plugin@marketplace": True, + "disabled-plugin@marketplace": False, + } + } + (tmp_path / "settings.json").write_text( + json.dumps(settings, indent=2), encoding="utf-8" + ) + + # plugins directory + plugins_dir = tmp_path / "plugins" + plugins_dir.mkdir() + + # installed_plugins.json + plugin_install_path = ( + plugins_dir + / "cache" + / "claude-plugins-official" + / "my-plugin" + / "1.0.0" + ) + plugin_install_path.mkdir(parents=True) + + disabled_plugin_path = ( + plugins_dir + / "cache" + / "claude-plugins-official" + / "disabled-plugin" + / "2.0.0" + ) + disabled_plugin_path.mkdir(parents=True) + + installed = { + "plugins": { + "my-plugin@marketplace": [ + { + "installPath": str(plugin_install_path), + "version": "1.0.0", + } + ], + "disabled-plugin@marketplace": [ + { + "installPath": str(disabled_plugin_path), + "version": "2.0.0", + } + ], + } + } + (plugins_dir / "installed_plugins.json").write_text( + json.dumps(installed, indent=2), encoding="utf-8" + ) + + # blocklist.json — empty + (plugins_dir / "blocklist.json").write_text( + json.dumps({"plugins": []}, indent=2), encoding="utf-8" + ) + + # Plugin skill: my-plugin/skills/brainstorming/SKILL.md + skill_dir = plugin_install_path / "skills" / "brainstorming" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: brainstorming\ndescription: Use before creative work\n---\n" + "# Brainstorming\nContent here.\n", + encoding="utf-8", + ) + + # Plugin command: my-plugin/commands/review.md + cmd_dir = plugin_install_path / "commands" + cmd_dir.mkdir(parents=True) + (cmd_dir / "review.md").write_text( + "---\nname: review\ndescription: Review code changes\n---\n" + "# Review\nRun code review.\n", + encoding="utf-8", + ) + + # Disabled plugin skill + disabled_skill_dir = disabled_plugin_path / "skills" / "autofill" + disabled_skill_dir.mkdir(parents=True) + (disabled_skill_dir / "SKILL.md").write_text( + "---\nname: autofill\ndescription: Auto-fill forms\n---\n" + "# Autofill\nFills forms.\n", + encoding="utf-8", + ) + + # Custom user skill + custom_skill_dir = tmp_path / "skills" / "my-custom-skill" + custom_skill_dir.mkdir(parents=True) + (custom_skill_dir / "SKILL.md").write_text( + "---\nname: summarize\ndescription: Summarize any text\n---\n" + "# Summarize\nSummarizes content.\n", + encoding="utf-8", + ) + + return tmp_path + + +# --------------------------------------------------------------------------- +# Data model tests +# --------------------------------------------------------------------------- + + +class TestActionType: + """Test ActionType enum values.""" + + def test_direct_command_value(self) -> None: + assert ActionType.DIRECT_COMMAND.value == "direct" + + def test_inject_skill_value(self) -> None: + assert ActionType.INJECT_SKILL.value == "inject" + + def test_enum_members(self) -> None: + members = list(ActionType) + assert len(members) == 2 + assert ActionType.DIRECT_COMMAND in members + assert ActionType.INJECT_SKILL in members + + +class TestPaletteItem: + """Test PaletteItem dataclass.""" + + def test_creation_with_defaults(self) -> None: + item = PaletteItem( + id="test:item", + name="item", + description="A test item", + action_type=ActionType.DIRECT_COMMAND, + action_value="/item", + source="test", + ) + assert item.id == "test:item" + assert item.name == "item" + assert item.description == "A test item" + assert item.action_type == ActionType.DIRECT_COMMAND + assert item.action_value == "/item" + assert item.source == "test" + assert item.enabled is True # default + + def test_creation_disabled(self) -> None: + item = PaletteItem( + id="x:y", + name="y", + description="", + action_type=ActionType.INJECT_SKILL, + action_value="/y", + source="plugin", + enabled=False, + ) + assert item.enabled is False + + def test_equality(self) -> None: + a = PaletteItem( + id="a:b", + name="b", + description="desc", + action_type=ActionType.DIRECT_COMMAND, + action_value="/b", + source="bot", + ) + b = PaletteItem( + id="a:b", + name="b", + description="desc", + action_type=ActionType.DIRECT_COMMAND, + action_value="/b", + source="bot", + ) + assert a == b + + +class TestPluginInfo: + """Test PluginInfo dataclass.""" + + def test_creation_with_defaults(self) -> None: + plugin = PluginInfo( + name="test", + qualified_name="test@marketplace", + version="1.0.0", + enabled=True, + ) + assert plugin.name == "test" + assert plugin.qualified_name == "test@marketplace" + assert plugin.version == "1.0.0" + assert plugin.enabled is True + assert plugin.items == [] + assert plugin.install_path == "" + + def test_creation_with_items(self) -> None: + item = PaletteItem( + id="p:s", + name="s", + description="", + action_type=ActionType.INJECT_SKILL, + action_value="/s", + source="p", + ) + plugin = PluginInfo( + name="p", + qualified_name="p@marketplace", + version="2.0.0", + enabled=False, + items=[item], + install_path="/some/path", + ) + assert len(plugin.items) == 1 + assert plugin.install_path == "/some/path" + + def test_items_default_is_not_shared(self) -> None: + """Verify that each instance gets its own list.""" + a = PluginInfo( + name="a", + qualified_name="a@m", + version="1", + enabled=True, + ) + b = PluginInfo( + name="b", + qualified_name="b@m", + version="1", + enabled=True, + ) + a.items.append( + PaletteItem( + id="x:y", + name="y", + description="", + action_type=ActionType.INJECT_SKILL, + action_value="/y", + source="x", + ) + ) + assert len(b.items) == 0 + + +# --------------------------------------------------------------------------- +# Frontmatter parser tests +# --------------------------------------------------------------------------- + + +class TestParseSkillFrontmatter: + """Test the YAML frontmatter parser.""" + + def test_valid_frontmatter(self) -> None: + content = ( + "---\n" + "name: brainstorming\n" + "description: Use before creative work\n" + "---\n" + "# Content\n" + ) + result = parse_skill_frontmatter(content) + assert result["name"] == "brainstorming" + assert result["description"] == "Use before creative work" + + def test_frontmatter_with_allowed_tools(self) -> None: + content = ( + "---\n" + "name: deploy\n" + "description: Deploy application\n" + "allowed-tools: Bash, Read, Write\n" + "---\n" + "# Deploy\n" + ) + result = parse_skill_frontmatter(content) + assert result["name"] == "deploy" + assert result["allowed-tools"] == "Bash, Read, Write" + + def test_no_frontmatter(self) -> None: + content = "# Just a heading\nSome text.\n" + result = parse_skill_frontmatter(content) + assert result == {} + + def test_empty_content(self) -> None: + result = parse_skill_frontmatter("") + assert result == {} + + def test_whitespace_only(self) -> None: + result = parse_skill_frontmatter(" \n \n ") + assert result == {} + + def test_frontmatter_with_comments(self) -> None: + content = ( + "---\n" + "# This is a comment\n" + "name: test\n" + "---\n" + ) + result = parse_skill_frontmatter(content) + assert result == {"name": "test"} + + def test_frontmatter_with_blank_lines(self) -> None: + content = ( + "---\n" + "name: test\n" + "\n" + "description: desc\n" + "---\n" + ) + result = parse_skill_frontmatter(content) + assert result["name"] == "test" + assert result["description"] == "desc" + + def test_value_with_colon(self) -> None: + """Values containing colons should preserve everything after first.""" + content = ( + "---\n" + "name: my-skill\n" + "description: Step 1: do this, Step 2: do that\n" + "---\n" + ) + result = parse_skill_frontmatter(content) + assert result["description"] == "Step 1: do this, Step 2: do that" + + def test_unclosed_frontmatter(self) -> None: + content = "---\nname: test\nSome body text\n" + result = parse_skill_frontmatter(content) + assert result == {} + + def test_frontmatter_not_at_start(self) -> None: + content = "Some text\n---\nname: test\n---\n" + result = parse_skill_frontmatter(content) + assert result == {} + + +# --------------------------------------------------------------------------- +# Scanner tests +# --------------------------------------------------------------------------- + + +class TestCommandPaletteScanner: + """Test the filesystem scanner.""" + + def test_bot_commands_always_present(self, mock_claude_dir: Path) -> None: + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, _ = scanner.scan() + bot_items = [i for i in items if i.source == "bot"] + assert len(bot_items) == len(BOT_COMMANDS) + + def test_bot_commands_are_direct(self, mock_claude_dir: Path) -> None: + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, _ = scanner.scan() + for item in items: + if item.source == "bot": + assert item.action_type == ActionType.DIRECT_COMMAND + + def test_discovers_plugin_skills(self, mock_claude_dir: Path) -> None: + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, _ = scanner.scan() + skill_names = [i.name for i in items if i.source == "my-plugin"] + assert "brainstorming" in skill_names + + def test_discovers_plugin_commands(self, mock_claude_dir: Path) -> None: + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, _ = scanner.scan() + cmd_names = [i.name for i in items if i.source == "my-plugin"] + assert "review" in cmd_names + + def test_plugin_skills_are_inject_type( + self, mock_claude_dir: Path + ) -> None: + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, _ = scanner.scan() + for item in items: + if item.source == "my-plugin": + assert item.action_type == ActionType.INJECT_SKILL + + def test_enabled_plugin_items_are_enabled( + self, mock_claude_dir: Path + ) -> None: + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, _ = scanner.scan() + enabled_items = [ + i for i in items if i.source == "my-plugin" + ] + for item in enabled_items: + assert item.enabled is True + + def test_disabled_plugin_items_are_disabled( + self, mock_claude_dir: Path + ) -> None: + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, _ = scanner.scan() + disabled_items = [ + i for i in items if i.source == "disabled-plugin" + ] + for item in disabled_items: + assert item.enabled is False + + def test_discovers_custom_skills(self, mock_claude_dir: Path) -> None: + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, _ = scanner.scan() + custom_items = [i for i in items if i.source == "custom"] + assert len(custom_items) == 1 + assert custom_items[0].name == "summarize" + + def test_custom_skills_always_enabled( + self, mock_claude_dir: Path + ) -> None: + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, _ = scanner.scan() + custom_items = [i for i in items if i.source == "custom"] + for item in custom_items: + assert item.enabled is True + + def test_builds_plugin_info(self, mock_claude_dir: Path) -> None: + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + _, plugins = scanner.scan() + assert len(plugins) == 2 + + by_name = {p.qualified_name: p for p in plugins} + + my_plugin = by_name["my-plugin@marketplace"] + assert my_plugin.name == "my-plugin" + assert my_plugin.version == "1.0.0" + assert my_plugin.enabled is True + assert len(my_plugin.items) == 2 # brainstorming + review + + disabled = by_name["disabled-plugin@marketplace"] + assert disabled.name == "disabled-plugin" + assert disabled.version == "2.0.0" + assert disabled.enabled is False + assert len(disabled.items) == 1 # autofill + + def test_missing_claude_dir(self, tmp_path: Path) -> None: + """Scanner should return only bot commands if dir is missing.""" + missing = tmp_path / "nonexistent" + scanner = CommandPaletteScanner(claude_dir=missing) + items, plugins = scanner.scan() + assert len(items) == len(BOT_COMMANDS) + assert plugins == [] + + def test_empty_claude_dir(self, tmp_path: Path) -> None: + """Scanner with an empty directory returns only bot commands.""" + empty = tmp_path / "empty_claude" + empty.mkdir() + scanner = CommandPaletteScanner(claude_dir=empty) + items, plugins = scanner.scan() + assert len(items) == len(BOT_COMMANDS) + assert plugins == [] + + def test_blocklisted_plugin_is_disabled( + self, mock_claude_dir: Path + ) -> None: + """A plugin on the blocklist should be treated as disabled.""" + blocklist = {"plugins": [{"plugin": "my-plugin@marketplace"}]} + blocklist_file = mock_claude_dir / "plugins" / "blocklist.json" + blocklist_file.write_text( + json.dumps(blocklist, indent=2), encoding="utf-8" + ) + + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, plugins = scanner.scan() + + by_name = {p.qualified_name: p for p in plugins} + my_plugin = by_name["my-plugin@marketplace"] + assert my_plugin.enabled is False + + plugin_items = [i for i in items if i.source == "my-plugin"] + for item in plugin_items: + assert item.enabled is False + + def test_skill_without_name_is_skipped( + self, mock_claude_dir: Path + ) -> None: + """A SKILL.md without a 'name' field should be ignored.""" + bad_skill_dir = mock_claude_dir / "skills" / "bad-skill" + bad_skill_dir.mkdir(parents=True) + (bad_skill_dir / "SKILL.md").write_text( + "---\ndescription: No name field\n---\nContent\n", + encoding="utf-8", + ) + + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, _ = scanner.scan() + custom_items = [i for i in items if i.source == "custom"] + # Only the original 'summarize' custom skill should be present + assert len(custom_items) == 1 + assert custom_items[0].name == "summarize" + + def test_non_directory_in_skills_is_skipped( + self, mock_claude_dir: Path + ) -> None: + """Regular files inside skills/ should be ignored.""" + (mock_claude_dir / "skills" / "stray_file.txt").write_text( + "Not a skill", encoding="utf-8" + ) + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + items, _ = scanner.scan() + custom_items = [i for i in items if i.source == "custom"] + assert len(custom_items) == 1 + + def test_malformed_settings_json(self, tmp_path: Path) -> None: + """Scanner handles corrupt settings.json gracefully.""" + claude_dir = tmp_path / "claude" + claude_dir.mkdir() + (claude_dir / "settings.json").write_text( + "{bad json", encoding="utf-8" + ) + scanner = CommandPaletteScanner(claude_dir=claude_dir) + items, plugins = scanner.scan() + assert len(items) == len(BOT_COMMANDS) + + def test_malformed_installed_plugins_json(self, tmp_path: Path) -> None: + """Scanner handles corrupt installed_plugins.json gracefully.""" + claude_dir = tmp_path / "claude" + plugins_dir = claude_dir / "plugins" + plugins_dir.mkdir(parents=True) + (plugins_dir / "installed_plugins.json").write_text( + "not json!", encoding="utf-8" + ) + (claude_dir / "settings.json").write_text( + "{}", encoding="utf-8" + ) + scanner = CommandPaletteScanner(claude_dir=claude_dir) + items, plugins = scanner.scan() + assert len(items) == len(BOT_COMMANDS) + assert plugins == [] + + def test_empty_install_list_for_plugin(self, tmp_path: Path) -> None: + """A plugin with an empty installs array should be skipped.""" + claude_dir = tmp_path / "claude" + plugins_dir = claude_dir / "plugins" + plugins_dir.mkdir(parents=True) + (claude_dir / "settings.json").write_text( + json.dumps({"enabledPlugins": {"ghost@mp": True}}), + encoding="utf-8", + ) + (plugins_dir / "installed_plugins.json").write_text( + json.dumps({"plugins": {"ghost@mp": []}}), + encoding="utf-8", + ) + scanner = CommandPaletteScanner(claude_dir=claude_dir) + items, plugins = scanner.scan() + assert plugins == [] + + +# --------------------------------------------------------------------------- +# toggle_plugin tests +# --------------------------------------------------------------------------- + + +class TestTogglePlugin: + """Test the toggle_plugin method.""" + + def test_enable_plugin(self, mock_claude_dir: Path) -> None: + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + result = scanner.toggle_plugin("disabled-plugin@marketplace", True) + assert result is True + + settings = json.loads( + (mock_claude_dir / "settings.json").read_text(encoding="utf-8") + ) + assert settings["enabledPlugins"]["disabled-plugin@marketplace"] is True + + def test_disable_plugin(self, mock_claude_dir: Path) -> None: + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + result = scanner.toggle_plugin("my-plugin@marketplace", False) + assert result is True + + settings = json.loads( + (mock_claude_dir / "settings.json").read_text(encoding="utf-8") + ) + assert settings["enabledPlugins"]["my-plugin@marketplace"] is False + + def test_toggle_nonexistent_plugin(self, mock_claude_dir: Path) -> None: + """Toggling a plugin that does not yet exist in settings should add it.""" + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + result = scanner.toggle_plugin("new-plugin@marketplace", True) + assert result is True + + settings = json.loads( + (mock_claude_dir / "settings.json").read_text(encoding="utf-8") + ) + assert settings["enabledPlugins"]["new-plugin@marketplace"] is True + + def test_toggle_creates_settings_file(self, tmp_path: Path) -> None: + """toggle_plugin should create settings.json if it does not exist.""" + claude_dir = tmp_path / "fresh_claude" + claude_dir.mkdir() + scanner = CommandPaletteScanner(claude_dir=claude_dir) + result = scanner.toggle_plugin("p@m", True) + assert result is True + + settings = json.loads( + (claude_dir / "settings.json").read_text(encoding="utf-8") + ) + assert settings["enabledPlugins"]["p@m"] is True + + def test_toggle_preserves_existing_settings( + self, mock_claude_dir: Path + ) -> None: + """Toggling one plugin should not clobber other settings.""" + scanner = CommandPaletteScanner(claude_dir=mock_claude_dir) + scanner.toggle_plugin("new@mp", True) + + settings = json.loads( + (mock_claude_dir / "settings.json").read_text(encoding="utf-8") + ) + # Original entries should still be present + assert "my-plugin@marketplace" in settings["enabledPlugins"] + assert "disabled-plugin@marketplace" in settings["enabledPlugins"] + assert settings["enabledPlugins"]["new@mp"] is True + + def test_toggle_fails_on_read_only_dir(self, tmp_path: Path) -> None: + """toggle_plugin returns False when the file can't be written.""" + claude_dir = tmp_path / "ro_claude" + claude_dir.mkdir() + settings_file = claude_dir / "settings.json" + settings_file.write_text("{}", encoding="utf-8") + # Make the directory read-only so writing fails + settings_file.chmod(0o444) + claude_dir.chmod(0o555) + + scanner = CommandPaletteScanner(claude_dir=claude_dir) + result = scanner.toggle_plugin("x@m", True) + assert result is False + + # Restore permissions for cleanup + claude_dir.chmod(0o755) + settings_file.chmod(0o644) diff --git a/tests/unit/test_bot/test_interactive_questions.py b/tests/unit/test_bot/test_interactive_questions.py new file mode 100644 index 00000000..068ff6b7 --- /dev/null +++ b/tests/unit/test_bot/test_interactive_questions.py @@ -0,0 +1,696 @@ +"""Tests for the interactive questions module.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.bot.features.interactive_questions import ( + PendingQuestion, + TelegramContext, + _pending, + askq_callback, + askq_other_text, + build_multi_select_keyboard, + build_single_select_keyboard, + cancel_pending, + format_question_text, + get_pending, + make_ask_user_hook, + register_pending, + resolve_pending, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def clear_registry(): + """Ensure the pending registry is clean for each test.""" + _pending.clear() + yield + _pending.clear() + + +@pytest.fixture +def sample_options(): + """Sample option dicts used across tests.""" + return [ + {"label": "Yes", "description": "Accept the change"}, + {"label": "No", "description": "Reject the change"}, + {"label": "Skip"}, + ] + + +@pytest.fixture +def sample_options_no_desc(): + """Options with no descriptions.""" + return [ + {"label": "Alpha"}, + {"label": "Beta"}, + ] + + +@pytest.fixture +def event_loop(): + """Provide a fresh event loop for Future creation.""" + loop = asyncio.new_event_loop() + yield loop + loop.close() + + +def _make_pending(loop: asyncio.AbstractEventLoop, **kwargs) -> PendingQuestion: + """Helper to create a PendingQuestion with a Future on the given loop.""" + defaults = { + "future": loop.create_future(), + "question_text": "Pick one", + "options": [{"label": "A"}, {"label": "B"}], + "multi_select": False, + } + defaults.update(kwargs) + return PendingQuestion(**defaults) + + +# --------------------------------------------------------------------------- +# TelegramContext +# --------------------------------------------------------------------------- + + +class TestTelegramContext: + def test_creation_with_thread(self): + ctx = TelegramContext(bot=MagicMock(), chat_id=100, thread_id=42, user_id=7) + assert ctx.chat_id == 100 + assert ctx.thread_id == 42 + assert ctx.user_id == 7 + + def test_creation_without_thread(self): + ctx = TelegramContext(bot=MagicMock(), chat_id=100, thread_id=None, user_id=7) + assert ctx.thread_id is None + + +# --------------------------------------------------------------------------- +# PendingQuestion +# --------------------------------------------------------------------------- + + +class TestPendingQuestion: + def test_defaults(self, event_loop): + pq = _make_pending(event_loop) + assert pq.selected == set() + assert pq.message_id is None + assert pq.awaiting_other is False + + def test_custom_fields(self, event_loop): + pq = _make_pending( + event_loop, + multi_select=True, + selected={0, 2}, + message_id=999, + awaiting_other=True, + ) + assert pq.multi_select is True + assert pq.selected == {0, 2} + assert pq.message_id == 999 + assert pq.awaiting_other is True + + +# --------------------------------------------------------------------------- +# format_question_text +# --------------------------------------------------------------------------- + + +class TestFormatQuestionText: + def test_with_descriptions(self, sample_options): + text = format_question_text("Choose wisely", sample_options) + assert text.startswith("**Choose wisely**") + assert "• Yes — Accept the change" in text + assert "• No — Reject the change" in text + # "Skip" has no description, so no dash + assert "• Skip" in text + assert "Skip —" not in text + + def test_without_descriptions(self, sample_options_no_desc): + text = format_question_text("Pick", sample_options_no_desc) + assert "• Alpha" in text + assert "• Beta" in text + # No dash at all + assert "—" not in text + + def test_empty_options(self): + text = format_question_text("Nothing?", []) + assert text == "**Nothing?**\n" + + +# --------------------------------------------------------------------------- +# build_single_select_keyboard +# --------------------------------------------------------------------------- + + +class TestBuildSingleSelectKeyboard: + def test_buttons_match_options(self, sample_options): + kb = build_single_select_keyboard(sample_options, question_idx=0) + # Flatten all buttons + all_buttons = [btn for row in kb.inline_keyboard for btn in row] + labels = [btn.text for btn in all_buttons] + assert "Yes" in labels + assert "No" in labels + assert "Skip" in labels + assert "Other..." in labels + + def test_callback_data_format(self, sample_options): + kb = build_single_select_keyboard(sample_options, question_idx=5) + all_buttons = [btn for row in kb.inline_keyboard for btn in row] + # Option buttons have askq:: + option_buttons = [b for b in all_buttons if b.text not in ("Other...",)] + for i, btn in enumerate(option_buttons): + assert btn.callback_data == f"askq:5:{i}" + + def test_other_button_present(self, sample_options): + kb = build_single_select_keyboard(sample_options, question_idx=0) + last_row = kb.inline_keyboard[-1] + assert len(last_row) == 1 + assert last_row[0].text == "Other..." + assert last_row[0].callback_data == "askq:0:other" + + def test_two_per_row_layout(self): + """Options are laid out 2 per row, plus the Other row.""" + options = [{"label": f"Opt{i}"} for i in range(5)] + kb = build_single_select_keyboard(options, question_idx=0) + # 5 options -> 3 rows of options (2, 2, 1), + 1 Other row = 4 rows + assert len(kb.inline_keyboard) == 4 + assert len(kb.inline_keyboard[0]) == 2 + assert len(kb.inline_keyboard[1]) == 2 + assert len(kb.inline_keyboard[2]) == 1 + assert len(kb.inline_keyboard[3]) == 1 # Other + + def test_fallback_label(self): + """Options missing 'label' get a fallback.""" + options = [{}] + kb = build_single_select_keyboard(options, question_idx=0) + option_btn = kb.inline_keyboard[0][0] + assert option_btn.text == "Option 0" + + +# --------------------------------------------------------------------------- +# build_multi_select_keyboard +# --------------------------------------------------------------------------- + + +class TestBuildMultiSelectKeyboard: + def test_unchecked_by_default(self, sample_options): + kb = build_multi_select_keyboard(sample_options, question_idx=0, selected=set()) + option_buttons = [ + btn + for row in kb.inline_keyboard + for btn in row + if btn.text not in ("Other...", "Done ✓") + ] + for btn in option_buttons: + assert btn.text.startswith("☐") + + def test_checked_state(self, sample_options): + kb = build_multi_select_keyboard( + sample_options, question_idx=0, selected={0, 2} + ) + option_buttons = [ + btn + for row in kb.inline_keyboard + for btn in row + if btn.text not in ("Other...", "Done ✓") + ] + assert option_buttons[0].text.startswith("☑") # index 0 selected + assert option_buttons[1].text.startswith("☐") # index 1 not selected + assert option_buttons[2].text.startswith("☑") # index 2 selected + + def test_toggle_callback_data(self, sample_options): + kb = build_multi_select_keyboard(sample_options, question_idx=3, selected=set()) + option_buttons = [ + btn + for row in kb.inline_keyboard + for btn in row + if btn.text not in ("Other...", "Done ✓") + ] + for i, btn in enumerate(option_buttons): + assert btn.callback_data == f"askq:3:t{i}" + + def test_done_button(self, sample_options): + kb = build_multi_select_keyboard(sample_options, question_idx=0, selected=set()) + last_row = kb.inline_keyboard[-1] + assert len(last_row) == 1 + assert last_row[0].text == "Done ✓" + assert last_row[0].callback_data == "askq:0:done" + + def test_other_button(self, sample_options): + kb = build_multi_select_keyboard(sample_options, question_idx=0, selected=set()) + # Other is second-to-last row + other_row = kb.inline_keyboard[-2] + assert len(other_row) == 1 + assert other_row[0].text == "Other..." + assert other_row[0].callback_data == "askq:0:other" + + def test_fallback_label(self): + """Options missing 'label' get a fallback with checkbox prefix.""" + options = [{}] + kb = build_multi_select_keyboard(options, question_idx=0, selected=set()) + option_btn = kb.inline_keyboard[0][0] + assert option_btn.text == "☐ Option 0" + + +# --------------------------------------------------------------------------- +# Pending registry +# --------------------------------------------------------------------------- + + +class TestPendingRegistry: + def test_register_and_get(self, event_loop): + pq = _make_pending(event_loop) + register_pending(user_id=1, chat_id=2, pq=pq) + assert get_pending(1, 2) is pq + + def test_get_missing_returns_none(self): + assert get_pending(999, 999) is None + + def test_resolve_clears_and_sets_result(self, event_loop): + pq = _make_pending(event_loop) + register_pending(user_id=1, chat_id=2, pq=pq) + + resolve_pending(1, 2, "user_answer") + + assert get_pending(1, 2) is None + assert pq.future.done() + assert pq.future.result() == "user_answer" + + def test_cancel_clears_and_cancels_future(self, event_loop): + pq = _make_pending(event_loop) + register_pending(user_id=1, chat_id=2, pq=pq) + + cancel_pending(1, 2) + + assert get_pending(1, 2) is None + assert pq.future.cancelled() + + def test_resolve_missing_is_noop(self): + # Should not raise + resolve_pending(999, 999, "anything") + + def test_cancel_missing_is_noop(self): + # Should not raise + cancel_pending(999, 999) + + def test_resolve_already_done_is_noop(self, event_loop): + pq = _make_pending(event_loop) + pq.future.set_result("first") + register_pending(user_id=1, chat_id=2, pq=pq) + + # Should not raise even though future is already done + resolve_pending(1, 2, "second") + assert pq.future.result() == "first" + + def test_cancel_already_done_is_noop(self, event_loop): + pq = _make_pending(event_loop) + pq.future.set_result("done") + register_pending(user_id=1, chat_id=2, pq=pq) + + # Should not raise even though future is already done + cancel_pending(1, 2) + assert pq.future.result() == "done" + assert not pq.future.cancelled() + + def test_register_overwrites_existing(self, event_loop): + pq1 = _make_pending(event_loop, question_text="first") + pq2 = _make_pending(event_loop, question_text="second") + register_pending(user_id=1, chat_id=2, pq=pq1) + register_pending(user_id=1, chat_id=2, pq=pq2) + assert get_pending(1, 2) is pq2 + + +# --------------------------------------------------------------------------- +# Helpers for callback / hook tests +# --------------------------------------------------------------------------- + + +def _make_tg_ctx(user_id: int = 7, chat_id: int = 100, thread_id: int = None): + """Build a TelegramContext with an AsyncMock bot.""" + bot = AsyncMock() + sent_msg = MagicMock() + sent_msg.message_id = 42 + bot.send_message.return_value = sent_msg + return TelegramContext( + bot=bot, chat_id=chat_id, thread_id=thread_id, user_id=user_id + ) + + +def _make_update_with_callback(data: str, user_id: int = 7, chat_id: int = 100): + """Build a minimal Update with a callback_query.""" + update = MagicMock(spec=["callback_query", "message"]) + update.message = None + + query = AsyncMock() + query.data = data + query.from_user = MagicMock() + query.from_user.id = user_id + query.message = MagicMock() + query.message.chat = MagicMock() + query.message.chat.id = chat_id + update.callback_query = query + return update + + +def _make_update_with_text(text: str, user_id: int = 7, chat_id: int = 100): + """Build a minimal Update with a text message.""" + update = MagicMock(spec=["callback_query", "message"]) + update.callback_query = None + + message = MagicMock() + message.text = text + message.from_user = MagicMock() + message.from_user.id = user_id + message.chat = MagicMock() + message.chat.id = chat_id + update.message = message + return update + + +# --------------------------------------------------------------------------- +# make_ask_user_hook +# --------------------------------------------------------------------------- + + +class TestMakeAskUserHook: + @pytest.mark.asyncio + async def test_non_ask_user_question_returns_empty(self): + tg_ctx = _make_tg_ctx() + hook = make_ask_user_hook(tg_ctx) + result = await hook( + {"tool_name": "Bash", "tool_input": {}}, + tool_use_id="t1", + context={}, + ) + assert result == {} + tg_ctx.bot.send_message.assert_not_called() + + @pytest.mark.asyncio + async def test_sends_keyboard_and_returns_updated_input(self): + tg_ctx = _make_tg_ctx() + hook = make_ask_user_hook(tg_ctx) + + tool_input = { + "questions": [ + { + "question": "Continue?", + "options": [{"label": "Yes"}, {"label": "No"}], + "multiSelect": False, + } + ] + } + + async def resolve_after_delay(): + """Wait briefly then resolve the pending question.""" + await asyncio.sleep(0.05) + pq = get_pending(tg_ctx.user_id, tg_ctx.chat_id) + assert pq is not None + resolve_pending(tg_ctx.user_id, tg_ctx.chat_id, "Yes") + + task = asyncio.create_task(resolve_after_delay()) + result = await hook( + {"tool_name": "AskUserQuestion", "tool_input": tool_input}, + tool_use_id="t2", + context={}, + ) + await task + + tg_ctx.bot.send_message.assert_called_once() + call_kwargs = tg_ctx.bot.send_message.call_args.kwargs + assert call_kwargs["chat_id"] == tg_ctx.chat_id + assert "reply_markup" in call_kwargs + + assert result == { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": {**tool_input, "answers": {"Continue?": "Yes"}}, + } + } + + @pytest.mark.asyncio + async def test_multiple_questions_processed_sequentially(self): + tg_ctx = _make_tg_ctx() + hook = make_ask_user_hook(tg_ctx) + + tool_input = { + "questions": [ + { + "question": "First?", + "options": [{"label": "A"}], + "multiSelect": False, + }, + { + "question": "Second?", + "options": [{"label": "B"}], + "multiSelect": False, + }, + ] + } + + resolve_order = [] + + async def resolve_questions(): + for expected_q, answer in [("First?", "A"), ("Second?", "B")]: + # Wait for the pending question to appear + for _ in range(50): + pq = get_pending(tg_ctx.user_id, tg_ctx.chat_id) + if pq is not None and pq.question_text == expected_q: + break + await asyncio.sleep(0.02) + resolve_order.append(expected_q) + resolve_pending(tg_ctx.user_id, tg_ctx.chat_id, answer) + + task = asyncio.create_task(resolve_questions()) + result = await hook( + {"tool_name": "AskUserQuestion", "tool_input": tool_input}, + tool_use_id="t3", + context={}, + ) + await task + + assert resolve_order == ["First?", "Second?"] + answers = result["hookSpecificOutput"]["updatedInput"]["answers"] + assert answers == {"First?": "A", "Second?": "B"} + assert tg_ctx.bot.send_message.call_count == 2 + + @pytest.mark.asyncio + async def test_send_failure_cancels_and_returns_empty(self): + tg_ctx = _make_tg_ctx() + tg_ctx.bot.send_message.side_effect = Exception("network error") + hook = make_ask_user_hook(tg_ctx) + + tool_input = { + "questions": [ + { + "question": "Fail?", + "options": [{"label": "X"}], + "multiSelect": False, + } + ] + } + + result = await hook( + {"tool_name": "AskUserQuestion", "tool_input": tool_input}, + tool_use_id="t4", + context={}, + ) + assert result == {} + assert get_pending(tg_ctx.user_id, tg_ctx.chat_id) is None + + @pytest.mark.asyncio + async def test_thread_id_passed_when_set(self): + tg_ctx = _make_tg_ctx(thread_id=55) + hook = make_ask_user_hook(tg_ctx) + + tool_input = { + "questions": [ + { + "question": "Thread?", + "options": [{"label": "Ok"}], + "multiSelect": False, + } + ] + } + + async def resolve_after_delay(): + await asyncio.sleep(0.05) + resolve_pending(tg_ctx.user_id, tg_ctx.chat_id, "Ok") + + task = asyncio.create_task(resolve_after_delay()) + await hook( + {"tool_name": "AskUserQuestion", "tool_input": tool_input}, + tool_use_id="t5", + context={}, + ) + await task + + call_kwargs = tg_ctx.bot.send_message.call_args.kwargs + assert call_kwargs["message_thread_id"] == 55 + + +# --------------------------------------------------------------------------- +# askq_callback +# --------------------------------------------------------------------------- + + +class TestAskqCallback: + @pytest.mark.asyncio + async def test_single_select_resolves_with_label(self): + loop = asyncio.get_running_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Pick one", + options=[{"label": "Alpha"}, {"label": "Beta"}], + multi_select=False, + ) + register_pending(user_id=7, chat_id=100, pq=pq) + + update = _make_update_with_callback("askq:0:1", user_id=7, chat_id=100) + await askq_callback(update, MagicMock()) + + assert future.done() + assert future.result() == "Beta" + update.callback_query.answer.assert_awaited_once() + update.callback_query.edit_message_text.assert_awaited_once_with("✓ Beta") + + @pytest.mark.asyncio + async def test_multi_select_toggle_updates_selected(self): + loop = asyncio.get_running_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Pick many", + options=[{"label": "A"}, {"label": "B"}, {"label": "C"}], + multi_select=True, + ) + register_pending(user_id=7, chat_id=100, pq=pq) + + # Toggle index 1 + update = _make_update_with_callback("askq:0:t1", user_id=7, chat_id=100) + await askq_callback(update, MagicMock()) + + assert 1 in pq.selected + assert not future.done() + update.callback_query.edit_message_reply_markup.assert_awaited_once() + + # Toggle index 1 again to deselect + update2 = _make_update_with_callback("askq:0:t1", user_id=7, chat_id=100) + await askq_callback(update2, MagicMock()) + + assert 1 not in pq.selected + + @pytest.mark.asyncio + async def test_multi_select_done_resolves_with_joined_labels(self): + loop = asyncio.get_running_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Pick many", + options=[{"label": "X"}, {"label": "Y"}, {"label": "Z"}], + multi_select=True, + selected={0, 2}, + ) + register_pending(user_id=7, chat_id=100, pq=pq) + + update = _make_update_with_callback("askq:0:done", user_id=7, chat_id=100) + await askq_callback(update, MagicMock()) + + assert future.done() + assert future.result() == "X, Z" + update.callback_query.edit_message_text.assert_awaited_once_with("✓ X, Z") + + @pytest.mark.asyncio + async def test_other_sets_awaiting_other(self): + loop = asyncio.get_running_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Pick one", + options=[{"label": "A"}], + multi_select=False, + ) + register_pending(user_id=7, chat_id=100, pq=pq) + + update = _make_update_with_callback("askq:0:other", user_id=7, chat_id=100) + await askq_callback(update, MagicMock()) + + assert pq.awaiting_other is True + assert not future.done() + update.callback_query.edit_message_text.assert_awaited_once_with( + "Type your answer:" + ) + + @pytest.mark.asyncio + async def test_expired_question_shows_alert(self): + update = _make_update_with_callback("askq:0:0", user_id=7, chat_id=100) + await askq_callback(update, MagicMock()) + + update.callback_query.answer.assert_awaited_once_with( + "Question expired.", show_alert=True + ) + + +# --------------------------------------------------------------------------- +# askq_other_text +# --------------------------------------------------------------------------- + + +class TestAskqOtherText: + @pytest.mark.asyncio + async def test_captures_text_when_awaiting_other(self): + from telegram.ext import ApplicationHandlerStop + + loop = asyncio.get_running_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Pick", + options=[{"label": "A"}], + multi_select=False, + awaiting_other=True, + ) + register_pending(user_id=7, chat_id=100, pq=pq) + + update = _make_update_with_text("custom answer", user_id=7, chat_id=100) + + with pytest.raises(ApplicationHandlerStop): + await askq_other_text(update, MagicMock()) + + assert future.done() + assert future.result() == "custom answer" + + @pytest.mark.asyncio + async def test_no_op_when_no_pending(self): + """When no pending question exists, the handler returns without raising.""" + update = _make_update_with_text("hello", user_id=7, chat_id=100) + # Should return normally (not raise ApplicationHandlerStop) + await askq_other_text(update, MagicMock()) + + @pytest.mark.asyncio + async def test_no_op_when_not_awaiting_other(self): + """When pending question exists but awaiting_other is False, returns normally.""" + loop = asyncio.get_running_loop() + future = loop.create_future() + pq = PendingQuestion( + future=future, + question_text="Pick", + options=[{"label": "A"}], + multi_select=False, + awaiting_other=False, + ) + register_pending(user_id=7, chat_id=100, pq=pq) + + update = _make_update_with_text("hello", user_id=7, chat_id=100) + # Should return normally (not raise ApplicationHandlerStop) + await askq_other_text(update, MagicMock()) + + assert not future.done() diff --git a/tests/unit/test_bot/test_menu.py b/tests/unit/test_bot/test_menu.py new file mode 100644 index 00000000..c4c45cfc --- /dev/null +++ b/tests/unit/test_bot/test_menu.py @@ -0,0 +1,1008 @@ +"""Tests for the dynamic command palette menu builder and handlers.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from telegram import InlineKeyboardMarkup + +from src.bot.features.command_palette import ( + ActionType, + BOT_COMMANDS, + CommandPaletteScanner, + PaletteItem, + PluginInfo, +) +from src.bot.handlers.menu import MenuBuilder, menu_callback, menu_command + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def bot_items() -> list[PaletteItem]: + """Return the standard bot commands.""" + return list(BOT_COMMANDS) + + +@pytest.fixture +def single_item_plugin() -> PluginInfo: + """A plugin with exactly one item.""" + item = PaletteItem( + id="single-plugin:deploy", + name="deploy", + description="Deploy the app", + action_type=ActionType.INJECT_SKILL, + action_value="/deploy", + source="single-plugin", + enabled=True, + ) + return PluginInfo( + name="single-plugin", + qualified_name="single-plugin@marketplace", + version="1.0.0", + enabled=True, + items=[item], + install_path="/some/path", + ) + + +@pytest.fixture +def multi_item_plugin() -> PluginInfo: + """A plugin with multiple items.""" + items = [ + PaletteItem( + id="multi-plugin:review", + name="review", + description="Review code", + action_type=ActionType.INJECT_SKILL, + action_value="/review", + source="multi-plugin", + enabled=True, + ), + PaletteItem( + id="multi-plugin:test", + name="test", + description="Run tests", + action_type=ActionType.INJECT_SKILL, + action_value="/test", + source="multi-plugin", + enabled=True, + ), + ] + return PluginInfo( + name="multi-plugin", + qualified_name="multi-plugin@marketplace", + version="2.0.0", + enabled=True, + items=items, + install_path="/other/path", + ) + + +@pytest.fixture +def disabled_plugin() -> PluginInfo: + """A disabled plugin.""" + item = PaletteItem( + id="off-plugin:autofill", + name="autofill", + description="Auto-fill forms", + action_type=ActionType.INJECT_SKILL, + action_value="/autofill", + source="off-plugin", + enabled=False, + ) + return PluginInfo( + name="off-plugin", + qualified_name="off-plugin@marketplace", + version="1.0.0", + enabled=False, + items=[item], + install_path="/disabled/path", + ) + + +@pytest.fixture +def custom_item() -> PaletteItem: + """A custom user skill.""" + return PaletteItem( + id="custom:summarize", + name="summarize", + description="Summarize text", + action_type=ActionType.INJECT_SKILL, + action_value="/summarize", + source="custom", + enabled=True, + ) + + +@pytest.fixture +def all_items( + bot_items: list[PaletteItem], + single_item_plugin: PluginInfo, + multi_item_plugin: PluginInfo, + disabled_plugin: PluginInfo, + custom_item: PaletteItem, +) -> list[PaletteItem]: + """All palette items combined.""" + items = list(bot_items) + items.extend(single_item_plugin.items) + items.extend(multi_item_plugin.items) + items.extend(disabled_plugin.items) + items.append(custom_item) + return items + + +@pytest.fixture +def all_plugins( + single_item_plugin: PluginInfo, + multi_item_plugin: PluginInfo, + disabled_plugin: PluginInfo, +) -> list[PluginInfo]: + """All plugins combined.""" + return [single_item_plugin, multi_item_plugin, disabled_plugin] + + +@pytest.fixture +def builder( + all_items: list[PaletteItem], + all_plugins: list[PluginInfo], +) -> MenuBuilder: + """A MenuBuilder with all items loaded.""" + return MenuBuilder(all_items, all_plugins) + + +# --------------------------------------------------------------------------- +# MenuBuilder.build_top_level tests +# --------------------------------------------------------------------------- + + +class TestBuildTopLevel: + """Tests for MenuBuilder.build_top_level().""" + + def test_returns_inline_keyboard(self, builder: MenuBuilder) -> None: + result = builder.build_top_level() + assert isinstance(result, InlineKeyboardMarkup) + + def test_first_row_is_bot_category(self, builder: MenuBuilder) -> None: + keyboard = builder.build_top_level() + first_row = keyboard.inline_keyboard[0] + assert len(first_row) == 1 + assert "Bot" in first_row[0].text + assert first_row[0].callback_data.startswith("menu:cat:") + + def test_bot_category_shows_count(self, builder: MenuBuilder) -> None: + keyboard = builder.build_top_level() + bot_btn = keyboard.inline_keyboard[0][0] + # Should contain count of bot items + assert f"({len(BOT_COMMANDS)})" in bot_btn.text + + def test_has_plugin_categories(self, builder: MenuBuilder) -> None: + keyboard = builder.build_top_level() + all_buttons = [ + btn + for row in keyboard.inline_keyboard + for btn in row + ] + plugin_names = ["multi-plugin", "off-plugin", "single-plugin"] + for name in plugin_names: + found = any(name in btn.text for btn in all_buttons) + assert found, f"Plugin '{name}' not found in top-level menu" + + def test_single_item_plugin_runs_directly( + self, builder: MenuBuilder + ) -> None: + keyboard = builder.build_top_level() + all_buttons = [ + btn + for row in keyboard.inline_keyboard + for btn in row + ] + single_btn = [ + btn for btn in all_buttons if "single-plugin" in btn.text + ] + assert len(single_btn) == 1 + assert single_btn[0].callback_data.startswith("menu:run:") + + def test_multi_item_plugin_opens_category( + self, builder: MenuBuilder + ) -> None: + keyboard = builder.build_top_level() + all_buttons = [ + btn + for row in keyboard.inline_keyboard + for btn in row + ] + multi_btn = [ + btn for btn in all_buttons if "multi-plugin" in btn.text + ] + assert len(multi_btn) == 1 + assert multi_btn[0].callback_data.startswith("menu:cat:") + + def test_enabled_plugin_shows_checkmark( + self, builder: MenuBuilder + ) -> None: + keyboard = builder.build_top_level() + all_buttons = [ + btn + for row in keyboard.inline_keyboard + for btn in row + ] + multi_btn = [ + btn for btn in all_buttons if "multi-plugin" in btn.text + ][0] + assert "\u2705" in multi_btn.text + + def test_disabled_plugin_shows_cross( + self, builder: MenuBuilder + ) -> None: + keyboard = builder.build_top_level() + all_buttons = [ + btn + for row in keyboard.inline_keyboard + for btn in row + ] + off_btn = [ + btn for btn in all_buttons if "off-plugin" in btn.text + ][0] + assert "\u274c" in off_btn.text + + def test_custom_skills_have_buttons( + self, builder: MenuBuilder + ) -> None: + keyboard = builder.build_top_level() + all_buttons = [ + btn + for row in keyboard.inline_keyboard + for btn in row + ] + custom_btns = [ + btn for btn in all_buttons if "summarize" in btn.text + ] + assert len(custom_btns) == 1 + assert custom_btns[0].callback_data.startswith("menu:run:") + + def test_last_row_is_plugin_store(self, builder: MenuBuilder) -> None: + keyboard = builder.build_top_level() + last_row = keyboard.inline_keyboard[-1] + assert len(last_row) == 1 + assert "Plugin Store" in last_row[0].text + assert last_row[0].callback_data == "menu:store" + + def test_plugins_sorted_by_name(self, builder: MenuBuilder) -> None: + keyboard = builder.build_top_level() + # Collect plugin button names (skip bot row at [0] and store row at [-1]) + plugin_labels: list[str] = [] + for row in keyboard.inline_keyboard[1:-1]: + for btn in row: + # Skip custom skill buttons (have sparkle emoji) + if "\u2728" not in btn.text: + plugin_labels.append(btn.text) + # Plugin names should be alphabetical + if len(plugin_labels) > 1: + names = [lbl.split(" ", 1)[-1] for lbl in plugin_labels] + assert names == sorted(names) + + +# --------------------------------------------------------------------------- +# MenuBuilder.build_category tests +# --------------------------------------------------------------------------- + + +class TestBuildCategory: + """Tests for MenuBuilder.build_category().""" + + def test_bot_category_returns_markup_and_header( + self, builder: MenuBuilder + ) -> None: + builder.build_top_level() # populate id_map + keyboard, header = builder.build_category("bot") + assert isinstance(keyboard, InlineKeyboardMarkup) + assert "Bot" in header + + def test_bot_category_has_all_bot_commands( + self, builder: MenuBuilder + ) -> None: + builder.build_top_level() + keyboard, _ = builder.build_category("bot") + # All rows except the last (Back) should be command buttons + command_rows = keyboard.inline_keyboard[:-1] + assert len(command_rows) == len(BOT_COMMANDS) + + def test_bot_category_has_back_button( + self, builder: MenuBuilder + ) -> None: + builder.build_top_level() + keyboard, _ = builder.build_category("bot") + last_row = keyboard.inline_keyboard[-1] + assert len(last_row) == 1 + assert "Back" in last_row[0].text + assert last_row[0].callback_data == "menu:back" + + def test_bot_category_has_no_toggle( + self, builder: MenuBuilder + ) -> None: + builder.build_top_level() + keyboard, _ = builder.build_category("bot") + all_data = [ + btn.callback_data + for row in keyboard.inline_keyboard + for btn in row + ] + assert not any(d.startswith("menu:tog:") for d in all_data) + + def test_plugin_category_has_items( + self, builder: MenuBuilder + ) -> None: + builder.build_top_level() + keyboard, header = builder.build_category("multi-plugin") + assert "multi-plugin" in header + # Should have 2 items + toggle + back = 4 rows + assert len(keyboard.inline_keyboard) == 4 + + def test_plugin_category_has_toggle( + self, builder: MenuBuilder + ) -> None: + builder.build_top_level() + keyboard, _ = builder.build_category("multi-plugin") + all_data = [ + btn.callback_data + for row in keyboard.inline_keyboard + for btn in row + ] + toggle_data = [d for d in all_data if d.startswith("menu:tog:")] + assert len(toggle_data) == 1 + assert "multi-plugin@marketplace" in toggle_data[0] + + def test_plugin_category_has_back( + self, builder: MenuBuilder + ) -> None: + builder.build_top_level() + keyboard, _ = builder.build_category("multi-plugin") + last_row = keyboard.inline_keyboard[-1] + assert "Back" in last_row[0].text + assert last_row[0].callback_data == "menu:back" + + def test_enabled_plugin_toggle_says_disable( + self, builder: MenuBuilder + ) -> None: + builder.build_top_level() + keyboard, _ = builder.build_category("multi-plugin") + toggle_btns = [ + btn + for row in keyboard.inline_keyboard + for btn in row + if btn.callback_data.startswith("menu:tog:") + ] + assert len(toggle_btns) == 1 + assert "Disable" in toggle_btns[0].text + + def test_disabled_plugin_toggle_says_enable( + self, builder: MenuBuilder + ) -> None: + builder.build_top_level() + keyboard, _ = builder.build_category("off-plugin") + toggle_btns = [ + btn + for row in keyboard.inline_keyboard + for btn in row + if btn.callback_data.startswith("menu:tog:") + ] + assert len(toggle_btns) == 1 + assert "Enable" in toggle_btns[0].text + + +# --------------------------------------------------------------------------- +# MenuBuilder.get_single_item_action tests +# --------------------------------------------------------------------------- + + +class TestGetSingleItemAction: + """Tests for MenuBuilder.get_single_item_action().""" + + def test_single_item_plugin_returns_item( + self, builder: MenuBuilder + ) -> None: + item = builder.get_single_item_action("single-plugin") + assert item is not None + assert item.name == "deploy" + + def test_multi_item_plugin_returns_none( + self, builder: MenuBuilder + ) -> None: + result = builder.get_single_item_action("multi-plugin") + assert result is None + + def test_nonexistent_plugin_returns_none( + self, builder: MenuBuilder + ) -> None: + result = builder.get_single_item_action("does-not-exist") + assert result is None + + +# --------------------------------------------------------------------------- +# MenuBuilder.resolve_id tests +# --------------------------------------------------------------------------- + + +class TestResolveId: + """Tests for MenuBuilder.resolve_id().""" + + def test_resolves_after_build(self, builder: MenuBuilder) -> None: + builder.build_top_level() + # The first registered ID should be "0" + result = builder.resolve_id("0") + assert result is not None + assert result == "cat:bot" + + def test_unknown_id_returns_none(self, builder: MenuBuilder) -> None: + builder.build_top_level() + result = builder.resolve_id("99999") + assert result is None + + def test_all_ids_are_unique(self, builder: MenuBuilder) -> None: + builder.build_top_level() + values = list(builder.id_map.values()) + assert len(values) == len(set(values)) + + +# --------------------------------------------------------------------------- +# Callback data size check +# --------------------------------------------------------------------------- + + +class TestCallbackDataSize: + """Telegram limits callback_data to 64 bytes.""" + + def test_top_level_callback_data_under_64_bytes( + self, builder: MenuBuilder + ) -> None: + keyboard = builder.build_top_level() + for row in keyboard.inline_keyboard: + for btn in row: + data = btn.callback_data + assert len(data.encode("utf-8")) <= 64, ( + f"callback_data too long: {data!r} " + f"({len(data.encode('utf-8'))} bytes)" + ) + + def test_category_callback_data_under_64_bytes( + self, builder: MenuBuilder + ) -> None: + builder.build_top_level() + for source in ["bot", "multi-plugin", "single-plugin", "off-plugin"]: + keyboard, _ = builder.build_category(source) + for row in keyboard.inline_keyboard: + for btn in row: + data = btn.callback_data + assert len(data.encode("utf-8")) <= 64, ( + f"callback_data too long: {data!r} " + f"({len(data.encode('utf-8'))} bytes)" + ) + + +# --------------------------------------------------------------------------- +# menu_command handler tests +# --------------------------------------------------------------------------- + + +class TestMenuCommand: + """Tests for the menu_command handler.""" + + async def test_sends_message_with_keyboard(self) -> None: + update = MagicMock() + update.message.reply_text = AsyncMock() + + context = MagicMock() + context.user_data = {} + + with patch( + "src.bot.handlers.menu.CommandPaletteScanner" + ) as MockScanner: + scanner_instance = MockScanner.return_value + scanner_instance.scan.return_value = (list(BOT_COMMANDS), []) + + await menu_command(update, context) + + update.message.reply_text.assert_called_once() + call_kwargs = update.message.reply_text.call_args + assert call_kwargs.kwargs.get("parse_mode") == "HTML" + assert isinstance( + call_kwargs.kwargs.get("reply_markup"), InlineKeyboardMarkup + ) + + async def test_stores_builder_in_user_data(self) -> None: + update = MagicMock() + update.message.reply_text = AsyncMock() + + context = MagicMock() + context.user_data = {} + + with patch( + "src.bot.handlers.menu.CommandPaletteScanner" + ) as MockScanner: + scanner_instance = MockScanner.return_value + scanner_instance.scan.return_value = (list(BOT_COMMANDS), []) + + await menu_command(update, context) + + assert "menu_builder" in context.user_data + assert isinstance(context.user_data["menu_builder"], MenuBuilder) + + async def test_message_contains_counts(self) -> None: + update = MagicMock() + update.message.reply_text = AsyncMock() + + context = MagicMock() + context.user_data = {} + + with patch( + "src.bot.handlers.menu.CommandPaletteScanner" + ) as MockScanner: + scanner_instance = MockScanner.return_value + scanner_instance.scan.return_value = (list(BOT_COMMANDS), []) + + await menu_command(update, context) + + text = update.message.reply_text.call_args.args[0] + assert "Command Palette" in text + assert str(len(BOT_COMMANDS)) in text + + +# --------------------------------------------------------------------------- +# menu_callback handler tests +# --------------------------------------------------------------------------- + + +class TestMenuCallback: + """Tests for the menu_callback handler.""" + + def _make_callback_update(self, data: str) -> MagicMock: + """Create a mock Update with callback_query.""" + update = MagicMock() + update.callback_query.data = data + update.callback_query.answer = AsyncMock() + update.callback_query.edit_message_text = AsyncMock() + return update + + async def test_back_rebuilds_top_level( + self, builder: MenuBuilder + ) -> None: + builder.build_top_level() + update = self._make_callback_update("menu:back") + + scanner = MagicMock() + scanner.scan.return_value = (builder.items, builder.plugins) + + context = MagicMock() + context.user_data = { + "menu_builder": builder, + "menu_scanner": scanner, + } + + await menu_callback(update, context) + + update.callback_query.answer.assert_called_once() + update.callback_query.edit_message_text.assert_called_once() + call_kwargs = update.callback_query.edit_message_text.call_args + assert "Command Palette" in call_kwargs.args[0] + assert isinstance( + call_kwargs.kwargs.get("reply_markup"), InlineKeyboardMarkup + ) + + async def test_cat_shows_category(self, builder: MenuBuilder) -> None: + keyboard = builder.build_top_level() + # Find the short_id for "cat:bot" (should be "0") + bot_sid = None + for sid, full_id in builder.id_map.items(): + if full_id == "cat:bot": + bot_sid = sid + break + assert bot_sid is not None + + update = self._make_callback_update(f"menu:cat:{bot_sid}") + + context = MagicMock() + context.user_data = { + "menu_builder": builder, + "menu_scanner": None, + } + + await menu_callback(update, context) + + update.callback_query.edit_message_text.assert_called_once() + call_kwargs = update.callback_query.edit_message_text.call_args + assert "Bot" in call_kwargs.args[0] + + async def test_run_inject_skill_calls_claude( + self, builder: MenuBuilder + ) -> None: + """When a skill button is tapped, Claude is called with the skill text.""" + builder.build_top_level() + # Find a run-able skill ID + skill_sid = None + for sid, full_id in builder.id_map.items(): + if full_id == "custom:summarize": + skill_sid = sid + break + assert skill_sid is not None + + update = self._make_callback_update(f"menu:run:{skill_sid}") + # Mock message.delete and message.chat_id + update.callback_query.message.chat_id = 12345 + update.callback_query.message.delete = AsyncMock() + update.callback_query.from_user.id = 999 + + # Mock Claude response + mock_response = MagicMock() + mock_response.content = "Here is the summary." + mock_response.session_id = "session-abc" + mock_response.cost = 0.01 + + mock_claude = AsyncMock() + mock_claude.run_command = AsyncMock(return_value=mock_response) + + mock_settings = MagicMock() + mock_settings.approved_directory = Path("/tmp/test") + + # Mock formatter + mock_formatted = MagicMock() + mock_formatted.text = "Here is the summary." + mock_formatted.parse_mode = "HTML" + + context = MagicMock() + context.user_data = { + "menu_builder": builder, + "menu_scanner": None, + } + context.bot_data = { + "claude_integration": mock_claude, + "settings": mock_settings, + } + context.bot.send_chat_action = AsyncMock() + context.bot.send_message = AsyncMock() + + with patch( + "src.bot.handlers.menu.ResponseFormatter" + ) as MockFormatter: + formatter_instance = MockFormatter.return_value + formatter_instance.format_claude_response.return_value = [ + mock_formatted + ] + await menu_callback(update, context) + + # Verify Claude was called with the skill text + mock_claude.run_command.assert_called_once() + call_kwargs = mock_claude.run_command.call_args.kwargs + assert call_kwargs["prompt"] == "/summarize" + assert call_kwargs["user_id"] == 999 + + # Verify session ID was updated + assert context.user_data["claude_session_id"] == "session-abc" + + # Verify response was sent + context.bot.send_message.assert_called_once() + send_kwargs = context.bot.send_message.call_args.kwargs + assert send_kwargs["text"] == "Here is the summary." + + async def test_run_inject_skill_handles_error( + self, builder: MenuBuilder + ) -> None: + """When Claude call fails, error is shown.""" + builder.build_top_level() + skill_sid = None + for sid, full_id in builder.id_map.items(): + if full_id == "custom:summarize": + skill_sid = sid + break + assert skill_sid is not None + + update = self._make_callback_update(f"menu:run:{skill_sid}") + update.callback_query.message.chat_id = 12345 + update.callback_query.message.delete = AsyncMock() + update.callback_query.from_user.id = 999 + + mock_claude = AsyncMock() + mock_claude.run_command = AsyncMock( + side_effect=RuntimeError("Claude is down") + ) + + mock_settings = MagicMock() + mock_settings.approved_directory = Path("/tmp/test") + + context = MagicMock() + context.user_data = { + "menu_builder": builder, + "menu_scanner": None, + } + context.bot_data = { + "claude_integration": mock_claude, + "settings": mock_settings, + } + context.bot.send_chat_action = AsyncMock() + + await menu_callback(update, context) + + # Verify error message was shown + call_kwargs = update.callback_query.edit_message_text.call_args + assert "Failed to run" in call_kwargs.args[0] + assert "Claude is down" in call_kwargs.args[0] + + async def test_run_inject_skill_no_claude_integration( + self, builder: MenuBuilder + ) -> None: + """When Claude integration is missing, shows error.""" + builder.build_top_level() + skill_sid = None + for sid, full_id in builder.id_map.items(): + if full_id == "custom:summarize": + skill_sid = sid + break + assert skill_sid is not None + + update = self._make_callback_update(f"menu:run:{skill_sid}") + + context = MagicMock() + context.user_data = { + "menu_builder": builder, + "menu_scanner": None, + } + context.bot_data = {} # No claude_integration + + await menu_callback(update, context) + + call_kwargs = update.callback_query.edit_message_text.call_args + assert "not available" in call_kwargs.args[0] + + async def test_run_direct_command_new( + self, builder: MenuBuilder + ) -> None: + """When /new button is tapped, session is reset.""" + # Build category for bot to register bot command IDs + builder.build_top_level() + _, _ = builder.build_category("bot") + # Find a bot command ID (e.g. bot:new) + cmd_sid = None + for sid, full_id in builder.id_map.items(): + if full_id == "bot:new": + cmd_sid = sid + break + assert cmd_sid is not None + + update = self._make_callback_update(f"menu:run:{cmd_sid}") + + context = MagicMock() + context.user_data = { + "menu_builder": builder, + "menu_scanner": None, + "claude_session_id": "old-session", + } + context.bot_data = {} + + await menu_callback(update, context) + + # Session should be cleared + assert context.user_data["claude_session_id"] is None + assert context.user_data["force_new_session"] is True + assert context.user_data["session_started"] is True + call_kwargs = update.callback_query.edit_message_text.call_args + assert "Session reset" in call_kwargs.args[0] + + async def test_run_direct_command_status( + self, builder: MenuBuilder + ) -> None: + """When /status button is tapped, status is shown.""" + builder.build_top_level() + _, _ = builder.build_category("bot") + cmd_sid = None + for sid, full_id in builder.id_map.items(): + if full_id == "bot:status": + cmd_sid = sid + break + assert cmd_sid is not None + + update = self._make_callback_update(f"menu:run:{cmd_sid}") + + mock_settings = MagicMock() + mock_settings.approved_directory = Path("/tmp/projects") + + context = MagicMock() + context.user_data = { + "menu_builder": builder, + "menu_scanner": None, + "current_directory": Path("/home/test"), + "claude_session_id": "sess-123", + } + context.bot_data = {"settings": mock_settings} + + await menu_callback(update, context) + + call_kwargs = update.callback_query.edit_message_text.call_args + text = call_kwargs.args[0] + assert "/home/test" in text + assert "active" in text + + async def test_run_direct_command_status_no_session( + self, builder: MenuBuilder + ) -> None: + """When /status is tapped with no session, shows 'none'.""" + builder.build_top_level() + _, _ = builder.build_category("bot") + cmd_sid = None + for sid, full_id in builder.id_map.items(): + if full_id == "bot:status": + cmd_sid = sid + break + assert cmd_sid is not None + + update = self._make_callback_update(f"menu:run:{cmd_sid}") + + mock_settings = MagicMock() + mock_settings.approved_directory = Path("/tmp/projects") + + context = MagicMock() + context.user_data = { + "menu_builder": builder, + "menu_scanner": None, + } + context.bot_data = {"settings": mock_settings} + + await menu_callback(update, context) + + call_kwargs = update.callback_query.edit_message_text.call_args + text = call_kwargs.args[0] + assert "none" in text + + async def test_run_direct_command_stop_no_active( + self, builder: MenuBuilder + ) -> None: + """When /stop is tapped with no active calls, shows message.""" + builder.build_top_level() + _, _ = builder.build_category("bot") + cmd_sid = None + for sid, full_id in builder.id_map.items(): + if full_id == "bot:stop": + cmd_sid = sid + break + assert cmd_sid is not None + + update = self._make_callback_update(f"menu:run:{cmd_sid}") + + context = MagicMock() + context.user_data = { + "menu_builder": builder, + "menu_scanner": None, + } + context.bot_data = {} + + await menu_callback(update, context) + + call_kwargs = update.callback_query.edit_message_text.call_args + assert "No active calls" in call_kwargs.args[0] + + async def test_run_inject_skill_updates_session_and_clears_force_new( + self, builder: MenuBuilder + ) -> None: + """After a successful skill call, force_new is cleared and session updated.""" + builder.build_top_level() + skill_sid = None + for sid, full_id in builder.id_map.items(): + if full_id == "custom:summarize": + skill_sid = sid + break + assert skill_sid is not None + + update = self._make_callback_update(f"menu:run:{skill_sid}") + update.callback_query.message.chat_id = 12345 + update.callback_query.message.delete = AsyncMock() + update.callback_query.from_user.id = 999 + + mock_response = MagicMock() + mock_response.content = "Done." + mock_response.session_id = "new-session-id" + mock_response.cost = 0.02 + + mock_claude = AsyncMock() + mock_claude.run_command = AsyncMock(return_value=mock_response) + + mock_settings = MagicMock() + mock_settings.approved_directory = Path("/tmp/test") + + mock_formatted = MagicMock() + mock_formatted.text = "Done." + mock_formatted.parse_mode = "HTML" + + context = MagicMock() + context.user_data = { + "menu_builder": builder, + "menu_scanner": None, + "force_new_session": True, + "claude_session_id": "old-session", + } + context.bot_data = { + "claude_integration": mock_claude, + "settings": mock_settings, + } + context.bot.send_chat_action = AsyncMock() + context.bot.send_message = AsyncMock() + + with patch( + "src.bot.handlers.menu.ResponseFormatter" + ) as MockFormatter: + formatter_instance = MockFormatter.return_value + formatter_instance.format_claude_response.return_value = [ + mock_formatted + ] + await menu_callback(update, context) + + # force_new should be cleared + assert context.user_data["force_new_session"] is False + # Session ID should be updated + assert context.user_data["claude_session_id"] == "new-session-id" + # force_new=True should have been passed to run_command + call_kwargs = mock_claude.run_command.call_args.kwargs + assert call_kwargs["force_new"] is True + + async def test_tog_toggles_plugin(self, builder: MenuBuilder) -> None: + builder.build_top_level() + + scanner = MagicMock() + scanner.toggle_plugin.return_value = True + scanner.scan.return_value = (builder.items, builder.plugins) + + update = self._make_callback_update( + "menu:tog:multi-plugin@marketplace" + ) + + context = MagicMock() + context.user_data = { + "menu_builder": builder, + "menu_scanner": scanner, + } + + await menu_callback(update, context) + + scanner.toggle_plugin.assert_called_once_with( + "multi-plugin@marketplace", False # was enabled, now disable + ) + call_kwargs = update.callback_query.edit_message_text.call_args + assert "disabled" in call_kwargs.args[0] + + async def test_store_shows_placeholder(self) -> None: + update = self._make_callback_update("menu:store") + + context = MagicMock() + context.user_data = {} + + await menu_callback(update, context) + + call_kwargs = update.callback_query.edit_message_text.call_args + assert "Plugin Store" in call_kwargs.args[0] + assert "Coming soon" in call_kwargs.args[0] + + async def test_expired_session_on_back(self) -> None: + update = self._make_callback_update("menu:back") + + context = MagicMock() + context.user_data = {} # no builder stored + + await menu_callback(update, context) + + call_kwargs = update.callback_query.edit_message_text.call_args + assert "expired" in call_kwargs.args[0].lower() + + async def test_unknown_action_handled(self) -> None: + update = self._make_callback_update("menu:unknown_action") + + context = MagicMock() + context.user_data = {} + + await menu_callback(update, context) + + call_kwargs = update.callback_query.edit_message_text.call_args + assert "Unknown" in call_kwargs.args[0] diff --git a/tests/unit/test_claude/test_sdk_integration.py b/tests/unit/test_claude/test_sdk_integration.py index e6780344..fb930f38 100644 --- a/tests/unit/test_claude/test_sdk_integration.py +++ b/tests/unit/test_claude/test_sdk_integration.py @@ -934,8 +934,8 @@ async def test_system_prompt_unchanged_without_claude_md( assert "Use relative paths." in opts.system_prompt assert "# Project Rules" not in opts.system_prompt - async def test_setting_sources_includes_project(self, sdk_manager, tmp_path): - """setting_sources=['project'] is passed to ClaudeAgentOptions.""" + async def test_setting_sources_includes_project_and_user(self, sdk_manager, tmp_path): + """setting_sources=['project', 'user'] is passed to ClaudeAgentOptions.""" captured: list = [] mock_factory = _mock_client_factory( _make_assistant_message("ok"), @@ -949,4 +949,4 @@ async def test_setting_sources_includes_project(self, sdk_manager, tmp_path): await sdk_manager.execute_command(prompt="test", working_directory=tmp_path) opts = captured[0] - assert opts.setting_sources == ["project"] + assert opts.setting_sources == ["project", "user"] diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index f565708b..1595af0b 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -82,8 +82,8 @@ def deps(): } -def test_agentic_registers_5_commands(agentic_settings, deps): - """Agentic mode registers start, new, status, verbose, repo commands.""" +def test_agentic_registers_7_commands(agentic_settings, deps): + """Agentic mode registers start, new, status, verbose, repo, stop, menu commands.""" orchestrator = MessageOrchestrator(agentic_settings, deps) app = MagicMock() app.add_handler = MagicMock() @@ -100,16 +100,18 @@ def test_agentic_registers_5_commands(agentic_settings, deps): ] commands = [h[0][0].commands for h in cmd_handlers] - assert len(cmd_handlers) == 5 + assert len(cmd_handlers) == 7 assert frozenset({"start"}) in commands assert frozenset({"new"}) in commands assert frozenset({"status"}) in commands assert frozenset({"verbose"}) in commands assert frozenset({"repo"}) in commands + assert frozenset({"stop"}) in commands + assert frozenset({"menu"}) in commands -def test_classic_registers_13_commands(classic_settings, deps): - """Classic mode registers all 13 commands.""" +def test_classic_registers_14_commands(classic_settings, deps): + """Classic mode registers all 14 commands.""" orchestrator = MessageOrchestrator(classic_settings, deps) app = MagicMock() app.add_handler = MagicMock() @@ -124,7 +126,7 @@ def test_classic_registers_13_commands(classic_settings, deps): if isinstance(call[0][0], CommandHandler) ] - assert len(cmd_handlers) == 13 + assert len(cmd_handlers) == 14 def test_agentic_registers_text_document_photo_handlers(agentic_settings, deps): @@ -148,32 +150,80 @@ def test_agentic_registers_text_document_photo_handlers(agentic_settings, deps): if isinstance(call[0][0], CallbackQueryHandler) ] - # 3 message handlers (text, document, photo) - assert len(msg_handlers) == 3 - # 1 callback handler (for cd: only) - assert len(cb_handlers) == 1 + # 4 message handlers (askq_other_text at group 5, text/document/photo at group 10) + assert len(msg_handlers) == 4 + # 3 callback handlers (cd:, menu:, askq:) + assert len(cb_handlers) == 3 async def test_agentic_bot_commands(agentic_settings, deps): - """Agentic mode returns 5 bot commands.""" + """Agentic mode returns 7 bot commands.""" orchestrator = MessageOrchestrator(agentic_settings, deps) commands = await orchestrator.get_bot_commands() - assert len(commands) == 5 + assert len(commands) == 7 cmd_names = [c.command for c in commands] - assert cmd_names == ["start", "new", "status", "verbose", "repo"] + assert cmd_names == ["start", "new", "status", "verbose", "repo", "stop", "menu"] async def test_classic_bot_commands(classic_settings, deps): - """Classic mode returns 13 bot commands.""" + """Classic mode returns 14 bot commands.""" orchestrator = MessageOrchestrator(classic_settings, deps) commands = await orchestrator.get_bot_commands() - assert len(commands) == 13 + assert len(commands) == 14 cmd_names = [c.command for c in commands] assert "start" in cmd_names assert "help" in cmd_names assert "git" in cmd_names + assert "stop" in cmd_names + + +async def test_stop_command_nothing_running(): + """/stop with no active call replies 'Nothing running.' without error.""" + from src.bot.handlers.command import stop_command + + update = MagicMock() + update.effective_user.id = 123 + update.message.reply_text = AsyncMock() + + context = MagicMock() + context.user_data = {} + context.bot_data = {"audit_logger": None} + + await stop_command(update, context) + + update.message.reply_text.assert_called_once_with("Nothing running.") + + +async def test_stop_command_thread_isolation(): + """/stop in thread A does not cancel the task registered for thread B.""" + from src.bot.handlers.command import stop_command + + task_a = MagicMock() + task_a.done.return_value = False + task_b = MagicMock() + task_b.done.return_value = False + + update = MagicMock() + update.effective_user.id = 123 + update.message.reply_text = AsyncMock() + + context = MagicMock() + # Simulate being in thread A + context.user_data = { + "_thread_context": {"state_key": "thread_a"}, + "_active_calls": { + "thread_a": {"task": task_a, "call_id": 1}, + "thread_b": {"task": task_b, "call_id": 2}, + }, + } + context.bot_data = {"audit_logger": None, "claude_integration": None} + + await stop_command(update, context) + + task_a.cancel.assert_called_once() + task_b.cancel.assert_not_called() async def test_agentic_start_no_keyboard(agentic_settings, deps): @@ -295,7 +345,7 @@ async def test_agentic_text_calls_claude(agentic_settings, deps): async def test_agentic_callback_scoped_to_cd_pattern(agentic_settings, deps): - """Agentic callback handler is registered with cd: pattern filter.""" + """Agentic callback handlers are registered with cd: and menu: patterns.""" orchestrator = MessageOrchestrator(agentic_settings, deps) app = MagicMock() app.add_handler = MagicMock() @@ -310,10 +360,16 @@ async def test_agentic_callback_scoped_to_cd_pattern(agentic_settings, deps): if isinstance(call[0][0], CallbackQueryHandler) ] - assert len(cb_handlers) == 1 - # The pattern attribute should match cd: prefixed data + assert len(cb_handlers) == 3 + # First handler: cd: pattern assert cb_handlers[0].pattern is not None assert cb_handlers[0].pattern.match("cd:my_project") + # Second handler: menu: pattern + assert cb_handlers[1].pattern is not None + assert cb_handlers[1].pattern.match("menu:back") + # Third handler: askq: pattern (interactive questions) + assert cb_handlers[2].pattern is not None + assert cb_handlers[2].pattern.match("askq:0:1") async def test_agentic_document_rejects_large_files(agentic_settings, deps): @@ -776,3 +832,55 @@ async def help_command(update, context): assert called["value"] is False update.effective_message.reply_text.assert_called_once() + + +# --- Menu integration tests --- + + +def test_agentic_registers_menu_handler(agentic_settings, deps): + """Agentic mode registers the menu command handler.""" + orchestrator = MessageOrchestrator(agentic_settings, deps) + app = MagicMock() + app.add_handler = MagicMock() + + orchestrator.register_handlers(app) + + from telegram.ext import CommandHandler + + cmd_handlers = [ + call[0][0] + for call in app.add_handler.call_args_list + if isinstance(call[0][0], CommandHandler) + ] + commands = [h.commands for h in cmd_handlers] + assert frozenset({"menu"}) in commands + + +def test_agentic_registers_menu_callback_handler(agentic_settings, deps): + """Agentic mode registers a menu: callback handler with pattern ^menu:.""" + orchestrator = MessageOrchestrator(agentic_settings, deps) + app = MagicMock() + app.add_handler = MagicMock() + + orchestrator.register_handlers(app) + + from telegram.ext import CallbackQueryHandler + + cb_handlers = [ + call[0][0] + for call in app.add_handler.call_args_list + if isinstance(call[0][0], CallbackQueryHandler) + ] + + menu_handlers = [ + h for h in cb_handlers if h.pattern and h.pattern.match("menu:back") + ] + assert len(menu_handlers) == 1 + + +async def test_get_bot_commands_includes_menu(agentic_settings, deps): + """Agentic get_bot_commands includes 'menu'.""" + orchestrator = MessageOrchestrator(agentic_settings, deps) + commands = await orchestrator.get_bot_commands() + cmd_names = [c.command for c in commands] + assert "menu" in cmd_names