Compare commits

2 Commits

Author SHA1 Message Date
curo1305 aba28293b7 feat(cli,wizard): auto-offer telegram_bot setup after install; integrate into pyra setup
cli.py — plugin_install() now asks "Configure now?" after a successful install,
runs the plugin's setup wizard, and offers to enable inline. Failing to install
short-circuits before the prompt is shown.

wizard.py — _offer_telegram_setup_if_selected() runs install + wizard + enable
automatically at the end of pyra setup when the user selected "Communication bots".
Adds load_config import (was missing alongside save_config).

Tests: test_plugin_install_decline_setup, test_plugin_install_error_does_not_prompt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:15:29 +02:00
curo1305 f59aa1a758 feat(plugin/telegram_bot): replace bare prompts with 5-step guided setup wizard
Step 1 — create bot via @BotFather (instructions + press-any-key pause)
Step 2 — find Telegram user ID via @userinfobot (instructions + pause)
Step 3 — set session passphrase with security explanation
Step 4 — save all three vault keys, print ✓ confirmations
Step 5 — configuration complete marker

Adds setup cancellation on empty token, updated tests: happy path, mismatch,
and cancel all covered; press_any_key_to_continue calls properly patched.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 23:15:22 +02:00
5 changed files with 289 additions and 20 deletions
+84 -11
View File
@@ -129,35 +129,108 @@ class TelegramBotPlugin(BasePlugin):
def setup(self, console: Any, vault_writer: Callable[[str, str], None]) -> None:
import questionary
from rich.panel import Panel
from rich.rule import Rule
console.print("[bold]Telegram Bot Setup[/bold]")
console.print()
console.print(Panel(
"[bold]Telegram Bot Setup Wizard[/bold]\n\n"
"This wizard connects Pyra to Telegram so you can chat with your\n"
"assistant from anywhere. You will need Telegram open on your phone\n"
"or desktop to complete the next steps.",
border_style="cyan",
))
# ── Step 1: Create bot ────────────────────────────────────────────────
console.print()
console.print(Rule("[bold cyan]Step 1 / 5[/bold cyan] Create your Telegram bot"))
console.print()
console.print(
"1. Create a bot via @BotFather on Telegram to get your bot token.\n"
"2. Find your Telegram user ID by messaging @userinfobot.\n"
" 1. Open Telegram and search for [bold]@BotFather[/bold]\n"
" 2. Send [bold]/newbot[/bold] and follow the prompts\n"
" 3. Choose a display name (e.g. [dim]My Pyra Assistant[/dim])\n"
" 4. Choose a username ending in [bold]bot[/bold] "
"(e.g. [dim]my_pyra_bot[/dim])\n"
" 5. BotFather replies with a token that looks like:\n"
" [dim]123456789:AABBccDDeeFFggHHiiJJkkLL[/dim]"
)
console.print()
questionary.press_any_key_to_continue(
" Press any key when you have your token ready ..."
).ask()
console.print()
token = questionary.password("Bot token (from @BotFather):").ask()
if not token:
token = questionary.password(" Bot token:").ask()
if not token or not token.strip():
console.print("[dim]Setup cancelled.[/dim]")
return
token = token.strip()
# ── Step 2: Find user ID ──────────────────────────────────────────────
console.print()
console.print(Rule("[bold cyan]Step 2 / 5[/bold cyan] Find your Telegram user ID"))
console.print()
console.print(
" Your user ID is a permanent number that identifies your account.\n"
" It never changes, even if you change your username.\n\n"
" 1. Search for [bold]@userinfobot[/bold] in Telegram\n"
" 2. Send any message (e.g. [dim]/start[/dim])\n"
" 3. Copy the [bold]Id:[/bold] number from the reply "
"(e.g. [dim]123456789[/dim])"
)
console.print()
questionary.press_any_key_to_continue(
" Press any key when you have your user ID ready ..."
).ask()
console.print()
allowed = questionary.text(
"Allowed Telegram user IDs (comma-separated, e.g. 123456789):"
" Allowed Telegram user IDs (comma-separated, leave blank to allow anyone):"
).ask()
if allowed is None:
console.print("[dim]Setup cancelled.[/dim]")
return
passphrase = questionary.password("Session passphrase:").ask()
# ── Step 3: Session passphrase ────────────────────────────────────────
console.print()
console.print(Rule("[bold cyan]Step 3 / 5[/bold cyan] Set a session passphrase"))
console.print()
console.print(
" The passphrase is an extra layer of security. Every new chat\n"
" session must pass this challenge before Pyra responds — even\n"
" if someone else gains access to your Telegram account."
)
console.print()
passphrase = questionary.password(" Session passphrase:").ask()
if not passphrase:
console.print("[dim]Setup cancelled.[/dim]")
return
confirm = questionary.password("Confirm passphrase:").ask()
confirm = questionary.password(" Confirm passphrase:").ask()
if passphrase != confirm:
console.print("[red]Passphrases do not match.[/red]")
console.print("[red]Passphrases do not match. Run setup again to retry.[/red]")
return
# ── Step 4: Save to vault ─────────────────────────────────────────────
console.print()
console.print(Rule("[bold cyan]Step 4 / 5[/bold cyan] Saving configuration"))
console.print()
pw_hash = bcrypt.hashpw(passphrase.encode(), bcrypt.gensalt()).decode()
vault_writer("plugin:telegram_bot:token", token.strip())
vault_writer("plugin:telegram_bot:token", token)
vault_writer("plugin:telegram_bot:allowed_users", (allowed or "").strip())
vault_writer("plugin:telegram_bot:passphrase_hash", pw_hash)
console.print("[green]Telegram bot configured. Enable it and start the daemon.[/green]")
allowed_display = (allowed or "").strip() or "[dim](any user — consider restricting)[/dim]"
console.print(f" [green]✓[/green] Bot token stored in vault")
console.print(f" [green]✓[/green] Allowed users: {allowed_display}")
console.print(f" [green]✓[/green] Passphrase stored as bcrypt hash")
# ── Step 5: Done ──────────────────────────────────────────────────────
console.print()
console.print(Rule("[bold cyan]Step 5 / 5[/bold cyan] Configuration complete"))
console.print()
def config_fields(self) -> list[ConfigField]:
return [
+46 -2
View File
@@ -165,6 +165,7 @@ def plugin_list() -> None:
@click.argument("name")
def plugin_install(name: str) -> None:
"""Install a bundled plugin to ~/.pyra/plugins/."""
import questionary
from pyra.plugins.install import get_bundled_plugins_dir, install_bundled_plugin
from pyra.utils.paths import pyra_home
@@ -173,12 +174,55 @@ def plugin_install(name: str) -> None:
try:
install_bundled_plugin(name, bundled_dir, plugins_dir)
console.print(f"[green]Installed:[/green] {name}")
console.print(f" Enable: [dim]pyra plugin enable {name}[/dim]")
console.print(f" Configure: [dim]pyra plugin setup {name}[/dim]")
except FileNotFoundError as exc:
console.print(f"[red]Error:[/red] {exc}")
return
except Exception as exc:
console.print(f"[red]Install failed:[/red] {exc}")
return
try:
configure_now = questionary.confirm(f"Configure {name} now?", default=True).ask()
except (KeyboardInterrupt, EOFError):
return
if not configure_now:
console.print(f" Configure later: [dim]pyra plugin setup {name}[/dim]")
console.print(f" Enable: [dim]pyra plugin enable {name}[/dim]")
return
from pyra.plugins.loader import load_plugin_by_name
from pyra.vault.writer import set_key as _set_key
p = load_plugin_by_name(name, plugins_dir)
if p is None:
console.print(f"[red]Could not load {name} for setup.[/red]")
return
try:
p.setup(console, _set_key)
except (KeyboardInterrupt, EOFError):
console.print("[dim]Setup cancelled.[/dim]")
return
except Exception as exc:
console.print(f"[red]Setup error:[/red] {exc}")
return
try:
enable_now = questionary.confirm(f"Enable {name} now?", default=True).ask()
except (KeyboardInterrupt, EOFError):
enable_now = False
if enable_now:
from pyra.config.manager import load_config, save_config
try:
cfg = load_config()
if name not in cfg.plugins.enabled:
cfg.plugins.enabled.append(name)
save_config(cfg)
console.print(f"[green]Enabled:[/green] {name}")
except Exception as exc:
console.print(f"[yellow]Could not enable automatically:[/yellow] {exc}")
console.print(f" Enable manually: [dim]pyra plugin enable {name}[/dim]")
console.print(f"[dim]Run [bold]pyra daemon start[/bold] to bring {name} online.[/dim]")
@plugin.command("enable")
+64 -1
View File
@@ -7,7 +7,7 @@ from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from pyra.config.manager import save_config
from pyra.config.manager import load_config, save_config
from pyra.config.schema import GeneralConfig, ProviderConfig, PyraConfig
from pyra.setup.providers import PROVIDERS, Provider, get_provider
from pyra.utils.paths import pyra_home, safe_chmod
@@ -167,6 +167,7 @@ def run_setup() -> None:
_delete_draft()
_suggest_plugins(use_cases)
_offer_telegram_setup_if_selected(use_cases)
console.print()
console.print(Panel(
@@ -238,6 +239,68 @@ def _suggest_plugins(use_cases: list[str]) -> None:
))
def _offer_telegram_setup_if_selected(use_cases: list[str]) -> None:
"""If the user chose 'Communication bots', offer to install and configure telegram_bot."""
relevant = any(
"telegram_bot" in _USE_CASE_PLUGINS.get(uc, [])
for uc in use_cases
)
if not relevant:
return
console.print()
try:
answer = questionary.confirm(
"Set up the Telegram bot for remote access to Pyra?", default=True
).ask()
except (KeyboardInterrupt, EOFError):
return
if not answer:
console.print(
" [dim]You can do this later: pyra plugin install telegram_bot[/dim]"
)
return
from pyra.plugins.install import get_bundled_plugins_dir, install_bundled_plugin
from pyra.plugins.loader import load_plugin_by_name
from pyra.utils.paths import pyra_home
from pyra.vault.writer import set_key
bundled_dir = get_bundled_plugins_dir()
plugins_dir = pyra_home() / "plugins"
try:
install_bundled_plugin("telegram_bot", bundled_dir, plugins_dir)
except Exception as exc:
console.print(f"[red]Could not install telegram_bot:[/red] {exc}")
return
p = load_plugin_by_name("telegram_bot", plugins_dir)
if p is None:
console.print("[red]Could not load telegram_bot for setup.[/red]")
return
try:
p.setup(console, set_key)
except (KeyboardInterrupt, EOFError):
console.print("[dim]Telegram setup skipped.[/dim]")
return
except Exception as exc:
console.print(f"[red]Telegram setup error:[/red] {exc}")
return
try:
cfg = load_config()
if "telegram_bot" not in cfg.plugins.enabled:
cfg.plugins.enabled.append("telegram_bot")
save_config(cfg)
console.print("[green]Telegram bot enabled.[/green]")
except Exception:
console.print(
" [dim]Enable manually: pyra plugin enable telegram_bot[/dim]"
)
def _choose_provider() -> Provider:
local = [p for p in PROVIDERS if p.group == "Local"]
cloud = [p for p in PROVIDERS if p.group == "Cloud"]
+44
View File
@@ -137,3 +137,47 @@ def test_main_skips_setup_when_config_exists(tmp_pyra_home, monkeypatch):
def test_config_slash_command_registered():
from pyra.chat.session import _STATIC_COMMANDS
assert "/config" in _STATIC_COMMANDS
# ── plugin install ────────────────────────────────────────────────────────────
def test_plugin_install_decline_setup(tmp_pyra_home, monkeypatch):
"""Declining 'Configure now?' shows manual instructions and exits cleanly."""
from unittest.mock import MagicMock
import questionary
monkeypatch.setattr(
"pyra.plugins.install.install_bundled_plugin", lambda *a, **kw: None
)
monkeypatch.setattr(
questionary, "confirm",
lambda *a, **kw: MagicMock(ask=lambda: False),
)
runner = CliRunner()
result = runner.invoke(main, ["plugin", "install", "telegram_bot"])
assert result.exit_code == 0
assert "Installed" in result.output
assert "Configure later" in result.output
def test_plugin_install_error_does_not_prompt(tmp_pyra_home, monkeypatch):
"""If install fails, the configure prompt is never shown."""
from unittest.mock import MagicMock
import questionary
monkeypatch.setattr(
"pyra.plugins.install.install_bundled_plugin",
lambda *a, **kw: (_ for _ in ()).throw(FileNotFoundError("not found")),
)
confirm_calls = []
monkeypatch.setattr(
questionary, "confirm",
lambda *a, **kw: confirm_calls.append(1) or MagicMock(ask=lambda: False),
)
runner = CliRunner()
result = runner.invoke(main, ["plugin", "install", "telegram_bot"])
assert result.exit_code == 0
assert "Error" in result.output
assert len(confirm_calls) == 0 # prompt never reached
+51 -6
View File
@@ -166,19 +166,64 @@ class TestPluginLifecycle:
fields = plugin.config_fields()
assert any(f.key == "rate_limit" for f in fields)
def test_setup_mismatched_passphrase(self, capsys):
"""setup() exits cleanly when passphrases don't match."""
def _patch_setup(self, token, allowed, pass1, pass2):
"""Return a context manager that patches all questionary calls used by setup()."""
pw_answers = iter([token, pass1, pass2])
return (
patch("questionary.password",
side_effect=lambda *a, **kw: MagicMock(ask=lambda: next(pw_answers))),
patch("questionary.text",
return_value=MagicMock(ask=lambda: allowed)),
patch("questionary.press_any_key_to_continue",
return_value=MagicMock(ask=lambda: None)),
)
def test_setup_mismatched_passphrase(self):
"""setup() writes nothing to the vault when passphrases don't match."""
plugin = TelegramBotPlugin()
console = MagicMock()
vault_writer = MagicMock()
answers = iter(["fake-token", "user123", "pass1", "pass2"])
with patch("questionary.password", side_effect=lambda *a, **kw: MagicMock(ask=lambda: next(answers))), \
patch("questionary.text", return_value=MagicMock(ask=lambda: next(answers))):
pw_patch, text_patch, pakc_patch = self._patch_setup(
"fake-token", "123456789", "pass1", "pass2"
)
with pw_patch, text_patch, pakc_patch:
plugin.setup(console, vault_writer)
vault_writer.assert_not_called()
console.print.assert_called_with(
"[red]Passphrases do not match. Run setup again to retry.[/red]"
)
def test_setup_happy_path(self):
"""setup() writes all three vault keys when credentials are valid."""
plugin = TelegramBotPlugin()
console = MagicMock()
vault_writer = MagicMock()
pw_patch, text_patch, pakc_patch = self._patch_setup(
"real-token", "111222333", "s3cr3t", "s3cr3t"
)
with pw_patch, text_patch, pakc_patch:
plugin.setup(console, vault_writer)
calls = {call[0][0]: call[0][1] for call in vault_writer.call_args_list}
assert calls.get("plugin:telegram_bot:token") == "real-token"
assert calls.get("plugin:telegram_bot:allowed_users") == "111222333"
assert "plugin:telegram_bot:passphrase_hash" in calls
def test_setup_cancelled_on_empty_token(self):
"""setup() exits without writing if the token prompt is cancelled."""
plugin = TelegramBotPlugin()
console = MagicMock()
vault_writer = MagicMock()
with patch("questionary.password", return_value=MagicMock(ask=lambda: None)), \
patch("questionary.press_any_key_to_continue",
return_value=MagicMock(ask=lambda: None)):
plugin.setup(console, vault_writer)
vault_writer.assert_not_called()
console.print.assert_called_with("[red]Passphrases do not match.[/red]")
# ── Auth session state ────────────────────────────────────────────────────────