refactor(channels): make built-in channels self-contained (#4908)

* refactor(channels): own setup and instance contracts

* refactor(channels): isolate management contracts

* refactor(channels): normalize activation contracts

* fix(channels): enforce management contracts

* refactor(channels): finish setup ownership migration

* fix(channels): harden management contracts

* fix(channels): enforce lazy loading and runtime ownership

* fix(feishu): make multi-instance startup idempotent

* fix(webui): render channel setup contracts cleanly

* fix(feishu): stop websocket clients cleanly

* fix(channels): enforce persistence and activation gates

* fix(channels): preserve global feature action scope

* fix(channels): apply defaults for single plugins

* fix(channels): enforce management contract boundaries

* refactor(feishu): remove identity helper indirection

* fix(channels): preserve management setup contracts

* refactor(channels): generalize instance settings UI

* refactor(channels): package channel plugins with web UI metadata

* refactor(channels): make built-ins self-contained packages

* test(channels): colocate tests with channel packages

* fix(dingtalk): use official brand icon

* feat(channels): colocate webui translations

* docs(channels): clarify plugin ownership

* test(exec): remove output wait race

* refactor(channels): unify plugin descriptors

* fix(channels): enforce descriptor-owned contracts

* refactor(channels): finish package-owned plugin setup

* refactor(channels): use repository-owned packages only

* fix(channels): self-describe dependencies and runtime state

* fix(channels): warn about legacy entry points
This commit is contained in:
chengyongru
2026-07-19 23:30:49 +08:00
committed by GitHub
parent 7aaac37bca
commit 462a0dfb0f
388 changed files with 17093 additions and 5110 deletions
+23
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import os
import tomllib
from pathlib import Path
from nanobot.webui.build import ensure_webui_bundle, inspect_webui_bundle
@@ -56,6 +57,28 @@ def test_inspect_webui_bundle_detects_source_newer_than_dist(tmp_path: Path) ->
assert status.newest_source == source / "src" / "App.tsx"
def test_inspect_webui_bundle_detects_channel_owned_ui_source(tmp_path: Path) -> None:
source = tmp_path / "webui"
dist = tmp_path / "nanobot" / "web" / "dist"
channel_ui = tmp_path / "nanobot" / "channels" / "example" / "webui" / "index.tsx"
_touch(source / "package.json", mtime_ns=10)
_touch(dist / "index.html", mtime_ns=20)
_touch(channel_ui, mtime_ns=30)
status = inspect_webui_bundle(source_dir=source, dist_dir=dist)
assert status.needs_build is True
assert status.reason == "source_newer"
assert status.newest_source == channel_ui
def test_channel_owned_ui_sources_are_included_in_distributions() -> None:
project_root = Path(__file__).resolve().parents[2]
pyproject = tomllib.loads((project_root / "pyproject.toml").read_text(encoding="utf-8"))
assert "nanobot/channels/*/webui/**/*" in pyproject["tool"]["hatch"]["build"]["include"]
def test_inspect_webui_bundle_accepts_fresh_dist(tmp_path: Path) -> None:
source = tmp_path / "webui"
dist = tmp_path / "nanobot" / "web" / "dist"
-99
View File
@@ -1,99 +0,0 @@
from __future__ import annotations
import json
from typing import Any
import pytest
from nanobot.channels.weixin import WeixinChannel
from nanobot.config.loader import save_config
from nanobot.config.schema import Config
from nanobot.webui.channel_connect import WeixinConnectStore
@pytest.mark.asyncio
async def test_weixin_connect_store_saves_confirmed_qr_login(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
state_dir = tmp_path / "weixin-state"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-1", "https://qr.example/1"
async def fake_api_get_with_base(
self: WeixinChannel,
*,
base_url: str,
endpoint: str,
params: dict[str, Any],
auth: bool,
) -> dict[str, str]:
assert base_url == "https://ilinkai.weixin.qq.com"
assert endpoint == "ilink/bot/get_qrcode_status"
assert params == {"qrcode": "qr-1"}
assert auth is False
return {
"status": "confirmed",
"bot_token": "wx-token",
"baseurl": "https://weixin.example",
"ilink_user_id": "wx-user",
}
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
monkeypatch.setattr(WeixinChannel, "_api_get_with_base", fake_api_get_with_base)
store = WeixinConnectStore()
started = await store.start()
assert started["status"] == "pending"
assert started["qr_url"] == "https://qr.example/1"
completed = await store.poll(started["session_id"])
assert completed["status"] == "succeeded"
assert completed["account"] == "wx-user"
saved = json.loads((state_dir / "account.json").read_text())
assert saved["token"] == "wx-token"
assert saved["base_url"] == "https://weixin.example"
@pytest.mark.asyncio
async def test_weixin_reconnect_keeps_existing_account_until_scan_succeeds(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
state_dir = tmp_path / "weixin-state"
state_dir.mkdir()
existing = {
"token": "working-token",
"base_url": "https://working.weixin.example",
"context_tokens": {"user-1": "context-1"},
}
state_file = state_dir / "account.json"
state_file.write_text(json.dumps(existing), encoding="utf-8")
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"weixin": {"stateDir": str(state_dir)}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
async def fake_fetch_qr_code(self: WeixinChannel) -> tuple[str, str]:
return "qr-reconnect", "https://qr.example/reconnect"
monkeypatch.setattr(WeixinChannel, "_fetch_qr_code", fake_fetch_qr_code)
store = WeixinConnectStore()
started = await store.start(force=True)
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
cancelled = await store.cancel(started["session_id"])
assert cancelled["status"] == "cancelled"
assert json.loads(state_file.read_text(encoding="utf-8")) == existing
-231
View File
@@ -1,231 +0,0 @@
from __future__ import annotations
import httpx
import pytest
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config
from nanobot.webui import channel_validation
from nanobot.webui.channel_validation import validate_channel_config
def test_validate_channel_does_not_write_config(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
config = Config.model_validate(
{
"channels": {
"slack": {
"appToken": "xapp-old",
"botToken": "xoxb-old",
"groupPolicy": "mention",
}
}
}
)
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(channel_validation, "_http_post", lambda *_args, **_kwargs: {"ok": True})
payload = validate_channel_config(
"slack",
{
"channels.slack.appToken": "",
"channels.slack.botToken": "",
},
)
assert payload["status"] == "connected"
saved = load_config(config_path)
assert saved.channels.slack["appToken"] == "xapp-old"
assert saved.channels.slack["botToken"] == "xoxb-old"
def test_validate_telegram_bad_token_is_invalid(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = validate_channel_config("telegram", {"channels.telegram.token": "not-a-token"})
assert payload["status"] == "invalid"
assert payload["can_enable"] is False
assert payload["missing_fields"] == []
def test_validate_telegram_does_not_expose_saved_token_in_http_errors(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
token = "123456:abcdefghijklmnopqrstuvwxyz"
config_path = tmp_path / "config.json"
save_config(
Config.model_validate({"channels": {"telegram": {"token": token}}}),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
def raise_http_error(url: str, **_kwargs) -> dict:
request = httpx.Request("GET", url)
response = httpx.Response(401, request=request)
raise httpx.HTTPStatusError("unauthorized", request=request, response=response)
monkeypatch.setattr(channel_validation, "_http_get", raise_http_error)
payload = validate_channel_config("telegram", {"channels.telegram.token": ""})
assert token not in str(payload)
assert any("HTTP 401" in check.get("message", "") for check in payload["checks"])
def test_validate_email_presets_are_checked_without_saving(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(channel_validation, "_probe_tcp", lambda *_args, **_kwargs: None)
payload = validate_channel_config(
"email",
{
"channels.email.consentGranted": "true",
"channels.email.imapHost": "imap.gmail.com",
"channels.email.imapUsername": "bot@example.com",
"channels.email.imapPassword": "imap-secret",
"channels.email.smtpHost": "smtp.gmail.com",
"channels.email.smtpUsername": "bot@example.com",
"channels.email.smtpPassword": "smtp-secret",
},
)
assert payload["status"] == "connected"
assert payload["can_enable"] is True
assert not hasattr(load_config(config_path).channels, "email")
def test_validate_email_blocks_private_targets_when_local_access_is_disabled(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
config_path = tmp_path / "config.json"
config = Config()
config.tools.webui_allow_local_service_access = False
save_config(config, config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
monkeypatch.setattr(
channel_validation.socket,
"create_connection",
lambda *_args, **_kwargs: pytest.fail("blocked target must not be connected"),
)
payload = validate_channel_config(
"email",
{
"channels.email.consentGranted": "true",
"channels.email.imapHost": "127.0.0.1",
"channels.email.imapUsername": "bot@example.com",
"channels.email.imapPassword": "imap-secret",
"channels.email.smtpHost": "192.168.1.10",
"channels.email.smtpUsername": "bot@example.com",
"channels.email.smtpPassword": "smtp-secret",
},
)
warnings = [check["message"] for check in payload["checks"] if check["status"] == "warn"]
assert len(warnings) == 2
assert all("private/internal" in message for message in warnings)
def test_probe_tcp_connects_to_the_validated_ip(monkeypatch: pytest.MonkeyPatch) -> None:
connected: list[tuple[str, int]] = []
class FakeSocket:
def __enter__(self):
return self
def __exit__(self, *_args):
return None
monkeypatch.setattr(
channel_validation,
"resolve_url_target",
lambda *_args, **_kwargs: (True, "", ("203.0.113.10",)),
)
monkeypatch.setattr(
channel_validation.socket,
"create_connection",
lambda target, **_kwargs: connected.append(target) or FakeSocket(),
)
channel_validation._probe_tcp("mail.example.com", 2525)
assert connected == [("203.0.113.10", 2525)]
def test_validate_manual_channel_returns_configured(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
config_path = tmp_path / "config.json"
save_config(
Config.model_validate(
{
"channels": {
"dingtalk": {
"clientId": "ding-client",
"clientSecret": "ding-secret",
}
}
}
),
config_path,
)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = validate_channel_config("dingtalk", {})
assert payload["status"] == "configured"
assert payload["can_enable"] is True
assert any(check["status"] == "skipped" for check in payload["checks"])
@pytest.mark.parametrize(
("credentials", "expected_status", "expected_missing"),
[
({}, "needs_setup", "password_or_accessToken"),
({"channels.matrix.accessToken": "token"}, "needs_setup", "deviceId"),
({"channels.matrix.password": "secret"}, "configured", None),
(
{
"channels.matrix.accessToken": "token",
"channels.matrix.deviceId": "DEVICE",
},
"configured",
None,
),
],
)
def test_validate_matrix_requires_a_complete_login_method(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
credentials: dict[str, str],
expected_status: str,
expected_missing: str | None,
) -> None:
config_path = tmp_path / "config.json"
save_config(Config(), config_path)
monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path)
payload = validate_channel_config(
"matrix",
{
"channels.matrix.homeserver": "https://matrix.example",
"channels.matrix.userId": "@nanobot:matrix.example",
**credentials,
},
)
assert payload["status"] == expected_status
assert payload["can_enable"] is (expected_status == "configured")
if expected_missing is None:
assert payload["missing_fields"] == []
else:
assert expected_missing in payload["missing_fields"]