diff --git a/README.md b/README.md index 7e90202..ad34661 100644 --- a/README.md +++ b/README.md @@ -948,10 +948,14 @@ nanobot gateway Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required. -**1. Install the optional dependency** +> Weixin support is available from source checkout, but is not included in the current PyPI release yet. + +**1. Install from source** ```bash -pip install nanobot-ai[weixin] +git clone https://github.com/HKUDS/nanobot.git +cd nanobot +pip install -e ".[weixin]" ``` **2. Configure** diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index dc05ac8..965e49f 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -33,7 +33,7 @@ from rich.text import Text from nanobot import __logo__, __version__ from nanobot.cli.stream import StreamRenderer, ThinkingSpinner -from nanobot.config.paths import get_workspace_path +from nanobot.config.paths import get_workspace_path, is_default_workspace from nanobot.config.schema import Config from nanobot.utils.helpers import sync_workspace_templates @@ -507,6 +507,17 @@ def _warn_deprecated_config_keys(config_path: Path | None) -> None: ) +def _migrate_cron_store(config: "Config") -> None: + """One-time migration: move legacy global cron store into the workspace.""" + from nanobot.config.paths import get_cron_dir + + legacy_path = get_cron_dir() / "jobs.json" + new_path = config.workspace_path / "cron" / "jobs.json" + if legacy_path.is_file() and not new_path.exists(): + new_path.parent.mkdir(parents=True, exist_ok=True) + import shutil + shutil.move(str(legacy_path), str(new_path)) + # ============================================================================ # Gateway / Server @@ -525,7 +536,6 @@ def gateway( from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager from nanobot.config.loader import get_config_path - from nanobot.config.paths import get_cron_dir from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.gateway.http import GatewayHttpServer @@ -545,8 +555,12 @@ def gateway( provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) - # Create cron service first (callback set after agent creation) - cron_store_path = get_cron_dir() / "jobs.json" + # Preserve existing single-workspace installs, but keep custom workspaces clean. + if is_default_workspace(config.workspace_path): + _migrate_cron_store(config) + + # Create cron service with workspace-scoped store + cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) # Create agent with cron service @@ -732,7 +746,6 @@ def agent( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.config.loader import get_config_path - from nanobot.config.paths import get_cron_dir from nanobot.cron.service import CronService config = _load_runtime_config(config, workspace) @@ -741,8 +754,12 @@ def agent( bus = MessageBus() provider = _make_provider(config) - # Create cron service for tool usage (no callback needed for CLI unless running) - cron_store_path = get_cron_dir() / "jobs.json" + # Preserve existing single-workspace installs, but keep custom workspaces clean. + if is_default_workspace(config.workspace_path): + _migrate_cron_store(config) + + # Create cron service with workspace-scoped store + cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) if logs: diff --git a/nanobot/config/__init__.py b/nanobot/config/__init__.py index e2c24f8..4b9fcce 100644 --- a/nanobot/config/__init__.py +++ b/nanobot/config/__init__.py @@ -7,6 +7,7 @@ from nanobot.config.paths import ( get_cron_dir, get_data_dir, get_legacy_sessions_dir, + is_default_workspace, get_logs_dir, get_media_dir, get_runtime_subdir, @@ -24,6 +25,7 @@ __all__ = [ "get_cron_dir", "get_logs_dir", "get_workspace_path", + "is_default_workspace", "get_cli_history_path", "get_bridge_install_dir", "get_legacy_sessions_dir", diff --git a/nanobot/config/paths.py b/nanobot/config/paths.py index f4dfbd9..527c5f3 100644 --- a/nanobot/config/paths.py +++ b/nanobot/config/paths.py @@ -40,6 +40,13 @@ def get_workspace_path(workspace: str | None = None) -> Path: return ensure_dir(path) +def is_default_workspace(workspace: str | Path | None) -> bool: + """Return whether a workspace resolves to nanobot's default workspace path.""" + current = Path(workspace).expanduser() if workspace is not None else Path.home() / ".nanobot" / "workspace" + default = Path.home() / ".nanobot" / "workspace" + return current.resolve(strict=False) == default.resolve(strict=False) + + def get_cli_history_path() -> Path: """Return the shared CLI history file path.""" return Path.home() / ".nanobot" / "history" / "cli_history" diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index f2f224a..1d8d5d1 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -584,12 +584,15 @@ class Config(BaseSettings): self, model: str | None = None ) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" - from nanobot.providers.registry import PROVIDERS + from nanobot.providers.registry import PROVIDERS, find_by_name forced = self.agents.defaults.provider if forced != "auto": - p = getattr(self.providers, forced, None) - return (p, forced) if p else (None, None) + spec = find_by_name(forced) + if spec: + p = getattr(self.providers, spec.name, None) + return (p, spec.name) if p else (None, None) + return None, None model_lower = (model or self.agents.defaults.model).lower() model_normalized = model_lower.replace("-", "_") diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 6e4fc7f..fe0abad 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -15,6 +15,8 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any +from pydantic.alias_generators import to_snake + @dataclass(frozen=True) class ProviderSpec: @@ -544,7 +546,8 @@ def find_gateway( def find_by_name(name: str) -> ProviderSpec | None: """Find a provider spec by config field name, e.g. "dashscope".""" + normalized = to_snake(name.replace("-", "_")) for spec in PROVIDERS: - if spec.name == name: + if spec.name == normalized: return spec return None diff --git a/tests/test_commands.py b/tests/test_commands.py index 5f989cf..ba2492c 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -12,7 +12,7 @@ from nanobot.cli.commands import _make_provider, app from nanobot.config.schema import Config from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import _strip_model_prefix -from nanobot.providers.registry import find_by_model +from nanobot.providers.registry import find_by_model, find_by_name def _strip_ansi(text: str) -> str: @@ -236,6 +236,34 @@ def test_config_explicit_ollama_provider_uses_default_localhost_api_base(): assert config.get_api_base() == "http://localhost:11434" +def test_config_accepts_camel_case_explicit_provider_name_for_coding_plan(): + config = Config.model_validate( + { + "agents": { + "defaults": { + "provider": "volcengineCodingPlan", + "model": "doubao-1-5-pro", + } + }, + "providers": { + "volcengineCodingPlan": { + "apiKey": "test-key", + } + }, + } + ) + + assert config.get_provider_name() == "volcengine_coding_plan" + assert config.get_api_base() == "https://ark.cn-beijing.volces.com/api/coding/v3" + + +def test_find_by_name_accepts_camel_case_and_hyphen_aliases(): + assert find_by_name("volcengineCodingPlan") is not None + assert find_by_name("volcengineCodingPlan").name == "volcengine_coding_plan" + assert find_by_name("github-copilot") is not None + assert find_by_name("github-copilot").name == "github_copilot" + + def test_config_auto_detects_ollama_from_local_api_base(): config = Config.model_validate( { @@ -329,10 +357,8 @@ def mock_agent_runtime(tmp_path): """Mock agent command dependencies for focused CLI tests.""" config = Config() config.agents.defaults.workspace = str(tmp_path / "default-workspace") - cron_dir = tmp_path / "data" / "cron" with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \ - patch("nanobot.config.paths.get_cron_dir", return_value=cron_dir), \ patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \ patch("nanobot.cli.commands._make_provider", return_value=object()), \ patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \ @@ -408,7 +434,6 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None: lambda path: seen.__setitem__("config_path", path), ) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron") monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) @@ -433,6 +458,147 @@ def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None: assert seen["config_path"] == config_file.resolve() +def test_agent_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "agent-workspace") + seen: dict[str, Path] = {} + + monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) + monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) + + class _FakeCron: + def __init__(self, store_path: Path) -> None: + seen["cron_store"] = store_path + + class _FakeAgentLoop: + def __init__(self, *args, **kwargs) -> None: + pass + + async def process_direct(self, *_args, **_kwargs): + return OutboundMessage(channel="cli", chat_id="direct", content="ok") + + async def close_mcp(self) -> None: + return None + + monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) + monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) + monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None) + + result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)]) + + assert result.exit_code == 0 + assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json" + + +def test_agent_workspace_override_does_not_migrate_legacy_cron( + monkeypatch, tmp_path: Path +) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + legacy_dir = tmp_path / "global" / "cron" + legacy_dir.mkdir(parents=True) + legacy_file = legacy_dir / "jobs.json" + legacy_file.write_text('{"jobs": []}') + + override = tmp_path / "override-workspace" + config = Config() + seen: dict[str, Path] = {} + + monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) + monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) + monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir) + + class _FakeCron: + def __init__(self, store_path: Path) -> None: + seen["cron_store"] = store_path + + class _FakeAgentLoop: + def __init__(self, *args, **kwargs) -> None: + pass + + async def process_direct(self, *_args, **_kwargs): + return OutboundMessage(channel="cli", chat_id="direct", content="ok") + + async def close_mcp(self) -> None: + return None + + monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) + monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) + monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None) + + result = runner.invoke( + app, + ["agent", "-m", "hello", "-c", str(config_file), "-w", str(override)], + ) + + assert result.exit_code == 0 + assert seen["cron_store"] == override / "cron" / "jobs.json" + assert legacy_file.exists() + assert not (override / "cron" / "jobs.json").exists() + + +def test_agent_custom_config_workspace_does_not_migrate_legacy_cron( + monkeypatch, tmp_path: Path +) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + legacy_dir = tmp_path / "global" / "cron" + legacy_dir.mkdir(parents=True) + legacy_file = legacy_dir / "jobs.json" + legacy_file.write_text('{"jobs": []}') + + custom_workspace = tmp_path / "custom-workspace" + config = Config() + config.agents.defaults.workspace = str(custom_workspace) + seen: dict[str, Path] = {} + + monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) + monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) + monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir) + + class _FakeCron: + def __init__(self, store_path: Path) -> None: + seen["cron_store"] = store_path + + class _FakeAgentLoop: + def __init__(self, *args, **kwargs) -> None: + pass + + async def process_direct(self, *_args, **_kwargs): + return OutboundMessage(channel="cli", chat_id="direct", content="ok") + + async def close_mcp(self) -> None: + return None + + monkeypatch.setattr("nanobot.cron.service.CronService", _FakeCron) + monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) + monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None) + + result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)]) + + assert result.exit_code == 0 + assert seen["cron_store"] == custom_workspace / "cron" / "jobs.json" + assert legacy_file.exists() + assert not (custom_workspace / "cron" / "jobs.json").exists() + + def test_agent_overrides_workspace_path(mock_agent_runtime): workspace_path = Path("/tmp/agent-workspace") @@ -574,7 +740,7 @@ def test_gateway_warns_about_deprecated_memory_window(monkeypatch, tmp_path: Pat assert "contextWindowTokens" in result.stdout -def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None: +def test_gateway_uses_workspace_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None: config_file = tmp_path / "instance" / "config.json" config_file.parent.mkdir(parents=True) config_file.write_text("{}") @@ -585,7 +751,6 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) - monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron") monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) @@ -601,7 +766,130 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat result = runner.invoke(app, ["gateway", "--config", str(config_file)]) assert isinstance(result.exception, _StopGatewayError) - assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json" + assert seen["cron_store"] == config.workspace_path / "cron" / "jobs.json" + + +def test_gateway_workspace_override_does_not_migrate_legacy_cron( + monkeypatch, tmp_path: Path +) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + legacy_dir = tmp_path / "global" / "cron" + legacy_dir.mkdir(parents=True) + legacy_file = legacy_dir / "jobs.json" + legacy_file.write_text('{"jobs": []}') + + override = tmp_path / "override-workspace" + config = Config() + seen: dict[str, Path] = {} + + monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) + monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) + monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object()) + monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir) + + class _StopCron: + def __init__(self, store_path: Path) -> None: + seen["cron_store"] = store_path + raise _StopGatewayError("stop") + + monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron) + + result = runner.invoke( + app, + ["gateway", "--config", str(config_file), "--workspace", str(override)], + ) + + assert isinstance(result.exception, _StopGatewayError) + assert seen["cron_store"] == override / "cron" / "jobs.json" + assert legacy_file.exists() + assert not (override / "cron" / "jobs.json").exists() + + +def test_gateway_custom_config_workspace_does_not_migrate_legacy_cron( + monkeypatch, tmp_path: Path +) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + legacy_dir = tmp_path / "global" / "cron" + legacy_dir.mkdir(parents=True) + legacy_file = legacy_dir / "jobs.json" + legacy_file.write_text('{"jobs": []}') + + custom_workspace = tmp_path / "custom-workspace" + config = Config() + config.agents.defaults.workspace = str(custom_workspace) + seen: dict[str, Path] = {} + + monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) + monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) + monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object()) + monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: legacy_dir) + + class _StopCron: + def __init__(self, store_path: Path) -> None: + seen["cron_store"] = store_path + raise _StopGatewayError("stop") + + monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert isinstance(result.exception, _StopGatewayError) + assert seen["cron_store"] == custom_workspace / "cron" / "jobs.json" + assert legacy_file.exists() + assert not (custom_workspace / "cron" / "jobs.json").exists() + + +def test_migrate_cron_store_moves_legacy_file(tmp_path: Path) -> None: + """Legacy global jobs.json is moved into the workspace on first run.""" + from nanobot.cli.commands import _migrate_cron_store + + legacy_dir = tmp_path / "global" / "cron" + legacy_dir.mkdir(parents=True) + legacy_file = legacy_dir / "jobs.json" + legacy_file.write_text('{"jobs": []}') + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "workspace") + workspace_cron = config.workspace_path / "cron" / "jobs.json" + + with patch("nanobot.config.paths.get_cron_dir", return_value=legacy_dir): + _migrate_cron_store(config) + + assert workspace_cron.exists() + assert workspace_cron.read_text() == '{"jobs": []}' + assert not legacy_file.exists() + + +def test_migrate_cron_store_skips_when_workspace_file_exists(tmp_path: Path) -> None: + """Migration does not overwrite an existing workspace cron store.""" + from nanobot.cli.commands import _migrate_cron_store + + legacy_dir = tmp_path / "global" / "cron" + legacy_dir.mkdir(parents=True) + (legacy_dir / "jobs.json").write_text('{"old": true}') + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "workspace") + workspace_cron = config.workspace_path / "cron" / "jobs.json" + workspace_cron.parent.mkdir(parents=True) + workspace_cron.write_text('{"new": true}') + + with patch("nanobot.config.paths.get_cron_dir", return_value=legacy_dir): + _migrate_cron_store(config) + + assert workspace_cron.read_text() == '{"new": true}' def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None: diff --git a/tests/test_config_paths.py b/tests/test_config_paths.py index 473a6c8..6c560ce 100644 --- a/tests/test_config_paths.py +++ b/tests/test_config_paths.py @@ -10,6 +10,7 @@ from nanobot.config.paths import ( get_media_dir, get_runtime_subdir, get_workspace_path, + is_default_workspace, ) @@ -40,3 +41,9 @@ def test_shared_and_legacy_paths_remain_global() -> None: def test_workspace_path_is_explicitly_resolved() -> None: assert get_workspace_path() == Path.home() / ".nanobot" / "workspace" assert get_workspace_path("~/custom-workspace") == Path.home() / "custom-workspace" + + +def test_is_default_workspace_distinguishes_default_and_custom_paths() -> None: + assert is_default_workspace(None) is True + assert is_default_workspace(Path.home() / ".nanobot" / "workspace") is True + assert is_default_workspace("~/custom-workspace") is False