From 9c7d1c9507da0da2efa6402388480fe609f7929c Mon Sep 17 00:00:00 2001 From: chengyongru Date: Thu, 18 Jun 2026 17:59:35 +0800 Subject: [PATCH] Use channel login in onboarding wizard --- docs/start-without-technical-background.md | 2 +- nanobot/cli/onboard.py | 77 +++++++++++++++ tests/agent/test_onboard_logic.py | 106 +++++++++++++++++++++ 3 files changed, 184 insertions(+), 1 deletion(-) diff --git a/docs/start-without-technical-background.md b/docs/start-without-technical-background.md index 11bf6bc9..7a39f845 100644 --- a/docs/start-without-technical-background.md +++ b/docs/start-without-technical-background.md @@ -190,7 +190,7 @@ For the first setup, choose `[Q] Quick Start (recommended)`. It asks for the mod If you are following the OpenRouter example: 1. Choose `[Q] Quick Start (recommended)`. -2. Choose `No chat channel yet` unless you already want to configure WebUI, Telegram, Feishu/Lark, Slack, or Discord now. +2. Choose `No chat channel yet` unless you already want to configure WebUI, Telegram, WeChat, WhatsApp, Feishu/Lark, Slack, or Discord now. 3. Select OpenRouter. 4. Paste your OpenRouter API key. 5. Enter a model ID, for example `anthropic/claude-sonnet-4.5`. diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index ad26f6ba..a481941f 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -1,5 +1,6 @@ """Interactive onboarding questionnaire for nanobot.""" +import asyncio import json import types from dataclasses import dataclass @@ -57,6 +58,8 @@ _QUICK_START_TARGETS = { "WebUI / local browser (recommended)": "websocket", "No chat channel yet": None, "Telegram": "telegram", + "WeChat": "weixin", + "WhatsApp": "whatsapp", "Feishu / Lark": "feishu", "Slack": "slack", "Discord": "discord", @@ -83,6 +86,8 @@ _UI_BORDER = "#4E5254" _UI_TEXT = "#A9B7C6" _UI_MUTED = "#80868B" _UI_SUCCESS = "#6AAB73" +_CHANNEL_LOGIN_CHOICE = "Login with QR/link" +_CHANNEL_ADVANCED_CHOICE = "Edit advanced settings" def _get_questionary(): @@ -1162,6 +1167,60 @@ def _get_channel_config_class(channel: str) -> type[BaseModel] | None: return entry[1] if entry else None +def _get_channel_class(channel: str) -> type[Any] | None: + """Get channel implementation class.""" + from nanobot.channels.registry import discover_all + + return discover_all().get(channel) + + +def _channel_supports_login(channel_cls: type[Any] | None) -> bool: + """Return True when a channel overrides BaseChannel.login.""" + if channel_cls is None: + return False + from nanobot.channels.base import BaseChannel + + return getattr(channel_cls, "login", None) is not BaseChannel.login + + +def _run_channel_login( + config: Config, + channel_name: str, + model: BaseModel, + display_name: str, +) -> bool: + """Run a channel's interactive login and enable it only on success.""" + channel_cls = _get_channel_class(channel_name) + if channel_cls is None: + console.print(f"[red]Unknown channel: {channel_name}[/red]") + return False + if not _channel_supports_login(channel_cls): + return False + + if hasattr(model, "enabled"): + setattr(model, "enabled", True) + + console.print(f"[{_UI_ACCENT}]Starting {display_name} login...[/]") + try: + channel = channel_cls(model, bus=None) + success = asyncio.run(channel.login(force=False)) + except KeyboardInterrupt: + console.print("\n[dim]Login cancelled.[/dim]") + return False + except Exception as exc: + logger.exception("{} login failed", display_name) + console.print(f"[red]{display_name} login failed:[/red] {exc}") + return False + + if not success: + console.print(f"[yellow]! {display_name} login did not complete; channel was not enabled[/yellow]") + return False + + setattr(config.channels, channel_name, model.model_dump(by_alias=True, exclude_none=True)) + console.print(f"[{_UI_SUCCESS}]{display_name} enabled[/]") + return True + + def _configure_channel(config: Config, channel_name: str) -> None: """Configure a single channel.""" channel_dict = getattr(config.channels, channel_name, None) @@ -1178,6 +1237,19 @@ def _configure_channel(config: Config, channel_name: str) -> None: model = config_cls.model_validate(channel_dict) if channel_dict else config_cls() + channel_cls = _get_channel_class(channel_name) + if _channel_supports_login(channel_cls): + action = _select_with_back( + f"Configure {display_name}:", + [_CHANNEL_LOGIN_CHOICE, _CHANNEL_ADVANCED_CHOICE, "<- Back"], + default=_CHANNEL_LOGIN_CHOICE, + ) + if action is _BACK_PRESSED or action is None or action == "<- Back": + return + if action == _CHANNEL_LOGIN_CHOICE: + _run_channel_login(config, channel_name, model, display_name) + return + updated_channel = _configure_pydantic_model( model, display_name, @@ -1458,6 +1530,11 @@ def _configure_quick_start_channel(config: Config, channel_name: str | None) -> setattr(config.channels, channel_name, updated.model_dump(by_alias=True, exclude_none=True)) return True + channel_cls = _get_channel_class(channel_name) + display_name = _get_channel_names().get(channel_name, channel_name) + if _channel_supports_login(channel_cls): + return _run_channel_login(config, channel_name, model, display_name) + required_fields = _QUICK_START_CHANNEL_FIELDS.get(channel_name, ()) for field_name, prompt in required_fields: value = _input_with_existing(prompt, getattr(model, field_name, ""), "str") diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index ef045ef2..5341d346 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -982,6 +982,112 @@ class TestMainMenuUpdate: assert onboard_wizard._configure_quick_start_channel(config, "telegram") is False assert getattr(config.channels, "telegram", None) is None + def test_quick_start_login_channel_runs_login_without_fields(self, monkeypatch): + """Quick Start should use interactive login for channels that support it.""" + from nanobot.channels.base import BaseChannel + + config = Config() + calls: dict[str, Any] = {} + + class LoginConfig(BaseModel): + enabled: bool = False + token: str = "" + + class LoginChannel(BaseChannel): + name = "loginchat" + display_name = "Login Chat" + + async def login(self, force: bool = False) -> bool: + calls["force"] = force + calls["enabled_during_login"] = self.config.enabled + return True + + async def start(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def send(self, msg) -> None: + pass + + def fail_input(*_args, **_kwargs): + raise AssertionError("Quick Start should not ask for manual channel fields") + + monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) + monkeypatch.setattr( + onboard_wizard, + "_get_channel_config_class", + lambda channel: LoginConfig if channel == "loginchat" else None, + ) + monkeypatch.setattr( + onboard_wizard, + "_get_channel_class", + lambda channel: LoginChannel if channel == "loginchat" else None, + ) + monkeypatch.setattr(onboard_wizard, "_get_channel_names", lambda: {"loginchat": "Login Chat"}) + monkeypatch.setattr(onboard_wizard, "_input_with_existing", fail_input) + + assert onboard_wizard._configure_quick_start_channel(config, "loginchat") is True + + loginchat = getattr(config.channels, "loginchat") + assert loginchat["enabled"] is True + assert calls == {"force": False, "enabled_during_login": True} + + def test_configure_login_channel_defaults_to_login(self, monkeypatch): + """The channel wizard should start login before exposing advanced fields.""" + from nanobot.channels.base import BaseChannel + + config = Config() + calls: dict[str, Any] = {} + + class LoginConfig(BaseModel): + enabled: bool = False + + class LoginChannel(BaseChannel): + name = "loginchat" + display_name = "Login Chat" + + async def login(self, force: bool = False) -> bool: + calls["force"] = force + return True + + async def start(self) -> None: + pass + + async def stop(self) -> None: + pass + + async def send(self, msg) -> None: + pass + + def fail_configure(*_args, **_kwargs): + raise AssertionError("Default action should run login, not open advanced fields") + + monkeypatch.setattr(onboard_wizard, "_get_channel_names", lambda: {"loginchat": "Login Chat"}) + monkeypatch.setattr( + onboard_wizard, + "_get_channel_config_class", + lambda channel: LoginConfig if channel == "loginchat" else None, + ) + monkeypatch.setattr( + onboard_wizard, + "_get_channel_class", + lambda channel: LoginChannel if channel == "loginchat" else None, + ) + monkeypatch.setattr( + onboard_wizard, + "_select_with_back", + lambda *_args, **_kwargs: onboard_wizard._CHANNEL_LOGIN_CHOICE, + ) + monkeypatch.setattr(onboard_wizard, "_configure_pydantic_model", fail_configure) + + onboard_wizard._configure_channel(config, "loginchat") + + loginchat = getattr(config.channels, "loginchat") + assert loginchat["enabled"] is True + assert calls == {"force": False} + def test_main_menu_dispatch_includes_channel_common(self): """Main menu dispatch should route [H] to Channel Common."""