Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aba28293b7 | |||
| f59aa1a758 |
@@ -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
|
||||||
|
|
||||||
|
# ── 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()
|
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 [
|
||||||
|
|||||||
+46
-2
@@ -165,6 +165,7 @@ def plugin_list() -> None:
|
|||||||
@click.argument("name")
|
@click.argument("name")
|
||||||
def plugin_install(name: str) -> None:
|
def plugin_install(name: str) -> None:
|
||||||
"""Install a bundled plugin to ~/.pyra/plugins/."""
|
"""Install a bundled plugin to ~/.pyra/plugins/."""
|
||||||
|
import questionary
|
||||||
from pyra.plugins.install import get_bundled_plugins_dir, install_bundled_plugin
|
from pyra.plugins.install import get_bundled_plugins_dir, install_bundled_plugin
|
||||||
from pyra.utils.paths import pyra_home
|
from pyra.utils.paths import pyra_home
|
||||||
|
|
||||||
@@ -173,12 +174,55 @@ def plugin_install(name: str) -> None:
|
|||||||
try:
|
try:
|
||||||
install_bundled_plugin(name, bundled_dir, plugins_dir)
|
install_bundled_plugin(name, bundled_dir, plugins_dir)
|
||||||
console.print(f"[green]Installed:[/green] {name}")
|
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:
|
except FileNotFoundError as exc:
|
||||||
console.print(f"[red]Error:[/red] {exc}")
|
console.print(f"[red]Error:[/red] {exc}")
|
||||||
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
console.print(f"[red]Install failed:[/red] {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")
|
@plugin.command("enable")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from rich.console import Console
|
|||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.text import Text
|
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.config.schema import GeneralConfig, ProviderConfig, PyraConfig
|
||||||
from pyra.setup.providers import PROVIDERS, Provider, get_provider
|
from pyra.setup.providers import PROVIDERS, Provider, get_provider
|
||||||
from pyra.utils.paths import pyra_home, safe_chmod
|
from pyra.utils.paths import pyra_home, safe_chmod
|
||||||
@@ -167,6 +167,7 @@ def run_setup() -> None:
|
|||||||
_delete_draft()
|
_delete_draft()
|
||||||
|
|
||||||
_suggest_plugins(use_cases)
|
_suggest_plugins(use_cases)
|
||||||
|
_offer_telegram_setup_if_selected(use_cases)
|
||||||
|
|
||||||
console.print()
|
console.print()
|
||||||
console.print(Panel(
|
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:
|
def _choose_provider() -> Provider:
|
||||||
local = [p for p in PROVIDERS if p.group == "Local"]
|
local = [p for p in PROVIDERS if p.group == "Local"]
|
||||||
cloud = [p for p in PROVIDERS if p.group == "Cloud"]
|
cloud = [p for p in PROVIDERS if p.group == "Cloud"]
|
||||||
|
|||||||
@@ -137,3 +137,47 @@ def test_main_skips_setup_when_config_exists(tmp_pyra_home, monkeypatch):
|
|||||||
def test_config_slash_command_registered():
|
def test_config_slash_command_registered():
|
||||||
from pyra.chat.session import _STATIC_COMMANDS
|
from pyra.chat.session import _STATIC_COMMANDS
|
||||||
assert "/config" in _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
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user