diff --git a/AGENTS.md b/AGENTS.md index 8d51287aa..fad8e0f7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ - 2026-03-13: Real legacy `Star` plugins may call compat context helpers on `self` or `self.context` during `__init__()` and `initialize()`, including `self.put_kv_data(...)`, `self.get_kv_data(...)`, and `self.context.get_config()`. Keep proxy methods on `LegacyStar`, expose a safe `LegacyContext.get_config()`, and bind the shared legacy context before lifecycle hooks run. - 2026-03-13: On Windows, `.gitignore` matching is case-insensitive. A broad entry like `astrBot/` will also ignore `src-new/astrbot/...`, which can silently hide real compat alias packages from `git status`. Keep that ignore anchored as `/astrBot/` if it is only meant for a root-level scratch checkout. - 2026-03-13: The reference checkout under `astrBot/astrbot/api` exposes a broader old plugin-facing package surface than the current `src-new/astrbot` alias package. When improving package-name compatibility, compare against those public `api/*` modules as a set instead of only patching the one import path hit by the latest migrated plugin. +- 2026-03-13: Some real legacy plugins call `asyncio.create_task()` during object construction. Calling `load_plugin()` outside a running event loop can therefore produce false-negative compat failures even though the real worker path is fine. External compat smoke tests should load plugins under an active loop, and "real compatibility" claims should preferably exercise `SupervisorRuntime` + worker + a real handler invocation instead of import-only checks. # 开发命令 diff --git a/CLAUDE.md b/CLAUDE.md index 8d51287aa..fad8e0f7b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ - 2026-03-13: Real legacy `Star` plugins may call compat context helpers on `self` or `self.context` during `__init__()` and `initialize()`, including `self.put_kv_data(...)`, `self.get_kv_data(...)`, and `self.context.get_config()`. Keep proxy methods on `LegacyStar`, expose a safe `LegacyContext.get_config()`, and bind the shared legacy context before lifecycle hooks run. - 2026-03-13: On Windows, `.gitignore` matching is case-insensitive. A broad entry like `astrBot/` will also ignore `src-new/astrbot/...`, which can silently hide real compat alias packages from `git status`. Keep that ignore anchored as `/astrBot/` if it is only meant for a root-level scratch checkout. - 2026-03-13: The reference checkout under `astrBot/astrbot/api` exposes a broader old plugin-facing package surface than the current `src-new/astrbot` alias package. When improving package-name compatibility, compare against those public `api/*` modules as a set instead of only patching the one import path hit by the latest migrated plugin. +- 2026-03-13: Some real legacy plugins call `asyncio.create_task()` during object construction. Calling `load_plugin()` outside a running event loop can therefore produce false-negative compat failures even though the real worker path is fine. External compat smoke tests should load plugins under an active loop, and "real compatibility" claims should preferably exercise `SupervisorRuntime` + worker + a real handler invocation instead of import-only checks. # 开发命令 diff --git a/src-new/astrbot/core/__init__.py b/src-new/astrbot/core/__init__.py index 3bdc40383..2f599270a 100644 --- a/src-new/astrbot/core/__init__.py +++ b/src-new/astrbot/core/__init__.py @@ -3,7 +3,8 @@ from loguru import logger from astrbot_sdk._shared_preferences import sp +from astrbot_sdk.api.basic import AstrBotConfig from . import utils -__all__ = ["logger", "sp", "utils"] +__all__ = ["AstrBotConfig", "logger", "sp", "utils"] diff --git a/src-new/astrbot/core/message/__init__.py b/src-new/astrbot/core/message/__init__.py new file mode 100644 index 000000000..993eb0cf4 --- /dev/null +++ b/src-new/astrbot/core/message/__init__.py @@ -0,0 +1,3 @@ +"""旧版 ``astrbot.core.message`` 导入路径兼容入口。""" + +from .components import * # noqa: F403 diff --git a/src-new/astrbot/core/message/components.py b/src-new/astrbot/core/message/components.py new file mode 100644 index 000000000..005c08301 --- /dev/null +++ b/src-new/astrbot/core/message/components.py @@ -0,0 +1,3 @@ +"""旧版 ``astrbot.core.message.components`` 导入路径兼容入口。""" + +from astrbot_sdk.api.message.components import * # noqa: F403 diff --git a/tests_v4/test_api_modules.py b/tests_v4/test_api_modules.py index 226f28bcb..4e12ef835 100644 --- a/tests_v4/test_api_modules.py +++ b/tests_v4/test_api_modules.py @@ -240,6 +240,20 @@ class TestAstrbotImportAlias: assert callable(SessionWaiter.trigger) assert callable(session_waiter) + def test_legacy_astrbot_core_root_exports(self): + """astrbot.core root should expose common legacy root names.""" + from astrbot.core import AstrBotConfig, logger, sp + from astrbot.core.message.components import At, Image, Node, Nodes, Plain + + assert AstrBotConfig is not None + assert logger is not None + assert sp is not None + assert Plain(text="ok").text == "ok" + assert Image(file="https://example.com/test.png").file + assert At(qq="1").user_id == "1" + assert Node(uin="1", content=[]).sender_id == "1" + assert Nodes(nodes=[]).nodes == [] + def test_legacy_astrbot_event_filter_module_exports(self): """astrbot.api.event.filter should be importable from the old module path.""" from astrbot.api.event.filter import EventMessageType, command, llm_tool diff --git a/tests_v4/test_external_plugin_smoke.py b/tests_v4/test_external_plugin_smoke.py index 9a3c2c271..7a5f8757c 100644 --- a/tests_v4/test_external_plugin_smoke.py +++ b/tests_v4/test_external_plugin_smoke.py @@ -2,10 +2,17 @@ 默认不跑;手动验证真实外部插件时,设置 ``ASTRBOT_EXTERNAL_PLUGIN_REPO=https://...`` 即可启用。 + +如果还希望验证真实 handler 调用,而不是仅验证可加载,可以额外设置: + +- ``ASTRBOT_EXTERNAL_PLUGIN_COMMAND=`` +- ``ASTRBOT_EXTERNAL_PLUGIN_EVENT_TEXT=`` (可选,默认等于 command) +- ``ASTRBOT_EXTERNAL_PLUGIN_EXPECT_TEXT=`` (可选) """ from __future__ import annotations +import asyncio import os import subprocess import tempfile @@ -14,12 +21,35 @@ from pathlib import Path import pytest +from astrbot_sdk.protocol.messages import InitializeOutput, PeerInfo +from astrbot_sdk.runtime.bootstrap import SupervisorRuntime from astrbot_sdk.runtime.loader import ( PluginEnvironmentManager, load_plugin_spec, ) +from astrbot_sdk.runtime.peer import Peer + +from tests_v4.helpers import make_transport_pair EXTERNAL_PLUGIN_REPO_ENV = "ASTRBOT_EXTERNAL_PLUGIN_REPO" +EXTERNAL_PLUGIN_COMMAND_ENV = "ASTRBOT_EXTERNAL_PLUGIN_COMMAND" +EXTERNAL_PLUGIN_EVENT_TEXT_ENV = "ASTRBOT_EXTERNAL_PLUGIN_EVENT_TEXT" +EXTERNAL_PLUGIN_EXPECT_TEXT_ENV = "ASTRBOT_EXTERNAL_PLUGIN_EXPECT_TEXT" + + +def _clone_external_plugin( + *, + project_root: Path, + repo_url: str, + clone_dir: Path, +) -> None: + subprocess.run( + ["git", "clone", "--depth", "1", repo_url, str(clone_dir)], + check=True, + cwd=project_root, + capture_output=True, + text=True, + ) @pytest.mark.skipif( @@ -33,12 +63,10 @@ def test_external_plugin_load_smoke(): with tempfile.TemporaryDirectory(prefix="astrbot-external-plugin-") as temp_dir: clone_dir = Path(temp_dir) / "plugin" - subprocess.run( - ["git", "clone", "--depth", "1", repo_url, str(clone_dir)], - check=True, - cwd=project_root, - capture_output=True, - text=True, + _clone_external_plugin( + project_root=project_root, + repo_url=repo_url, + clone_dir=clone_dir, ) spec = load_plugin_spec(clone_dir) @@ -46,6 +74,7 @@ def test_external_plugin_load_smoke(): python_path = manager.prepare_environment(spec) script = textwrap.dedent( f""" + import asyncio import sys from pathlib import Path @@ -55,11 +84,14 @@ def test_external_plugin_load_smoke(): from astrbot_sdk.runtime.loader import load_plugin, load_plugin_spec - spec = load_plugin_spec(plugin_dir) - loaded = load_plugin(spec) - print("PLUGIN", loaded.plugin.name) - print("HANDLERS", len(loaded.handlers)) - print("CAPS", len(loaded.capabilities)) + async def main(): + spec = load_plugin_spec(plugin_dir) + loaded = load_plugin(spec) + print("PLUGIN", loaded.plugin.name) + print("HANDLERS", len(loaded.handlers)) + print("CAPS", len(loaded.capabilities)) + + asyncio.run(main()) """ ) env = os.environ.copy() @@ -78,3 +110,100 @@ def test_external_plugin_load_smoke(): ) assert "HANDLERS" in result.stdout assert "PLUGIN" in result.stdout + + +@pytest.mark.skipif( + not ( + os.getenv(EXTERNAL_PLUGIN_REPO_ENV) and os.getenv(EXTERNAL_PLUGIN_COMMAND_ENV) + ), + reason=( + f"set {EXTERNAL_PLUGIN_REPO_ENV} and {EXTERNAL_PLUGIN_COMMAND_ENV} " + "to enable external plugin runtime command smoke tests" + ), +) +@pytest.mark.asyncio +async def test_external_plugin_runtime_command_smoke(): + """按需拉起真实 supervisor/worker 链路并执行一个外部插件命令。""" + repo_url = os.environ[EXTERNAL_PLUGIN_REPO_ENV] + command_name = os.environ[EXTERNAL_PLUGIN_COMMAND_ENV] + event_text = os.getenv(EXTERNAL_PLUGIN_EVENT_TEXT_ENV) or command_name + expected_text = os.getenv(EXTERNAL_PLUGIN_EXPECT_TEXT_ENV) + project_root = Path(__file__).resolve().parent.parent + + with tempfile.TemporaryDirectory(prefix="astrbot-external-runtime-") as temp_dir: + plugins_root = Path(temp_dir) / "plugins" + plugin_root = plugins_root / "external_plugin" + _clone_external_plugin( + project_root=project_root, + repo_url=repo_url, + clone_dir=plugin_root, + ) + + left, right = make_transport_pair() + core = Peer( + transport=left, + peer_info=PeerInfo(name="outer-core", role="core", version="v4"), + ) + core.set_initialize_handler( + lambda _message: asyncio.sleep( + 0, + result=InitializeOutput( + peer=PeerInfo(name="outer-core", role="core", version="v4"), + capabilities=[], + metadata={}, + ), + ) + ) + + runtime = SupervisorRuntime( + transport=right, + plugins_dir=plugins_root, + env_manager=PluginEnvironmentManager(project_root), + ) + await core.start() + try: + await runtime.start() + await core.wait_until_remote_initialized() + + handler = next( + ( + item + for item in core.remote_handlers + if getattr(item.trigger, "command", None) == command_name + ), + None, + ) + assert handler is not None, ( + f"command handler not found: {command_name}; " + f"available={[getattr(item.trigger, 'command', None) for item in core.remote_handlers]}" + ) + + await core.invoke( + "handler.invoke", + { + "handler_id": handler.id, + "event": { + "text": event_text, + "session_id": "external-smoke-session", + "user_id": "user-1", + "platform": "test", + }, + }, + request_id="external-runtime-command", + ) + + sent_messages = list(runtime.capability_router.sent_messages) + assert sent_messages, ( + "external plugin command completed but did not emit any platform " + "message; this usually means the command path was not really exercised" + ) + + if expected_text is not None: + assert any( + expected_text in item.get("text", "") + for item in sent_messages + if "text" in item + ), sent_messages + finally: + await runtime.stop() + await core.stop()