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>
This commit is contained in:
curo1305
2026-05-19 23:15:22 +02:00
parent 3f30b782d2
commit f59aa1a758
2 changed files with 135 additions and 17 deletions
+84 -11
View File
@@ -129,35 +129,108 @@ class TelegramBotPlugin(BasePlugin):
def setup(self, console: Any, vault_writer: Callable[[str, str], None]) -> None: def setup(self, console: Any, vault_writer: Callable[[str, str], None]) -> None:
import questionary 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( console.print(
"1. Create a bot via @BotFather on Telegram to get your bot token.\n" " 1. Open Telegram and search for [bold]@BotFather[/bold]\n"
"2. Find your Telegram user ID by messaging @userinfobot.\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() token = questionary.password(" Bot token:").ask()
if not token: if not token or not token.strip():
console.print("[dim]Setup cancelled.[/dim]")
return 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 = questionary.text(
"Allowed Telegram user IDs (comma-separated, e.g. 123456789):" " Allowed Telegram user IDs (comma-separated, leave blank to allow anyone):"
).ask() ).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: if not passphrase:
console.print("[dim]Setup cancelled.[/dim]")
return return
confirm = questionary.password("Confirm passphrase:").ask() confirm = questionary.password(" Confirm passphrase:").ask()
if passphrase != confirm: 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 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() 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:allowed_users", (allowed or "").strip())
vault_writer("plugin:telegram_bot:passphrase_hash", pw_hash) 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]: def config_fields(self) -> list[ConfigField]:
return [ return [
+51 -6
View File
@@ -166,19 +166,64 @@ class TestPluginLifecycle:
fields = plugin.config_fields() fields = plugin.config_fields()
assert any(f.key == "rate_limit" for f in fields) assert any(f.key == "rate_limit" for f in fields)
def test_setup_mismatched_passphrase(self, capsys): def _patch_setup(self, token, allowed, pass1, pass2):
"""setup() exits cleanly when passphrases don't match.""" """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() plugin = TelegramBotPlugin()
console = MagicMock() console = MagicMock()
vault_writer = MagicMock() vault_writer = MagicMock()
answers = iter(["fake-token", "user123", "pass1", "pass2"]) pw_patch, text_patch, pakc_patch = self._patch_setup(
with patch("questionary.password", side_effect=lambda *a, **kw: MagicMock(ask=lambda: next(answers))), \ "fake-token", "123456789", "pass1", "pass2"
patch("questionary.text", return_value=MagicMock(ask=lambda: next(answers))): )
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) plugin.setup(console, vault_writer)
vault_writer.assert_not_called() vault_writer.assert_not_called()
console.print.assert_called_with("[red]Passphrases do not match.[/red]")
# ── Auth session state ──────────────────────────────────────────────────────── # ── Auth session state ────────────────────────────────────────────────────────