mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
Use channel login in onboarding wizard
This commit is contained in:
parent
db68fc1c09
commit
9c7d1c9507
@ -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:
|
If you are following the OpenRouter example:
|
||||||
|
|
||||||
1. Choose `[Q] Quick Start (recommended)`.
|
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.
|
3. Select OpenRouter.
|
||||||
4. Paste your OpenRouter API key.
|
4. Paste your OpenRouter API key.
|
||||||
5. Enter a model ID, for example `anthropic/claude-sonnet-4.5`.
|
5. Enter a model ID, for example `anthropic/claude-sonnet-4.5`.
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""Interactive onboarding questionnaire for nanobot."""
|
"""Interactive onboarding questionnaire for nanobot."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import types
|
import types
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@ -57,6 +58,8 @@ _QUICK_START_TARGETS = {
|
|||||||
"WebUI / local browser (recommended)": "websocket",
|
"WebUI / local browser (recommended)": "websocket",
|
||||||
"No chat channel yet": None,
|
"No chat channel yet": None,
|
||||||
"Telegram": "telegram",
|
"Telegram": "telegram",
|
||||||
|
"WeChat": "weixin",
|
||||||
|
"WhatsApp": "whatsapp",
|
||||||
"Feishu / Lark": "feishu",
|
"Feishu / Lark": "feishu",
|
||||||
"Slack": "slack",
|
"Slack": "slack",
|
||||||
"Discord": "discord",
|
"Discord": "discord",
|
||||||
@ -83,6 +86,8 @@ _UI_BORDER = "#4E5254"
|
|||||||
_UI_TEXT = "#A9B7C6"
|
_UI_TEXT = "#A9B7C6"
|
||||||
_UI_MUTED = "#80868B"
|
_UI_MUTED = "#80868B"
|
||||||
_UI_SUCCESS = "#6AAB73"
|
_UI_SUCCESS = "#6AAB73"
|
||||||
|
_CHANNEL_LOGIN_CHOICE = "Login with QR/link"
|
||||||
|
_CHANNEL_ADVANCED_CHOICE = "Edit advanced settings"
|
||||||
|
|
||||||
|
|
||||||
def _get_questionary():
|
def _get_questionary():
|
||||||
@ -1162,6 +1167,60 @@ def _get_channel_config_class(channel: str) -> type[BaseModel] | None:
|
|||||||
return entry[1] if entry else 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:
|
def _configure_channel(config: Config, channel_name: str) -> None:
|
||||||
"""Configure a single channel."""
|
"""Configure a single channel."""
|
||||||
channel_dict = getattr(config.channels, channel_name, None)
|
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()
|
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(
|
updated_channel = _configure_pydantic_model(
|
||||||
model,
|
model,
|
||||||
display_name,
|
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))
|
setattr(config.channels, channel_name, updated.model_dump(by_alias=True, exclude_none=True))
|
||||||
return 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, ())
|
required_fields = _QUICK_START_CHANNEL_FIELDS.get(channel_name, ())
|
||||||
for field_name, prompt in required_fields:
|
for field_name, prompt in required_fields:
|
||||||
value = _input_with_existing(prompt, getattr(model, field_name, ""), "str")
|
value = _input_with_existing(prompt, getattr(model, field_name, ""), "str")
|
||||||
|
|||||||
@ -982,6 +982,112 @@ class TestMainMenuUpdate:
|
|||||||
assert onboard_wizard._configure_quick_start_channel(config, "telegram") is False
|
assert onboard_wizard._configure_quick_start_channel(config, "telegram") is False
|
||||||
assert getattr(config.channels, "telegram", None) is None
|
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):
|
def test_main_menu_dispatch_includes_channel_common(self):
|
||||||
"""Main menu dispatch should route [H] to Channel Common."""
|
"""Main menu dispatch should route [H] to Channel Common."""
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user