mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
Add legacy session waiter compatibility
This commit is contained in:
@@ -39,9 +39,11 @@ plugins/.venv/
|
||||
|
||||
# Tool caches
|
||||
.uv-cache/
|
||||
.astrbot/
|
||||
|
||||
# IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.iml
|
||||
uv.lock
|
||||
/astrBot/
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
- 2026-03-13: The maintained sample plugins now live under `test_plugin/old/` and `test_plugin/new/`. Runtime/integration tests should copy those real fixture directories instead of inlining synthetic plugin writers, otherwise the sample plugin tree and the exercised test path drift apart.
|
||||
- 2026-03-13: Real legacy plugins in the wild may still import `astrbot.api.*` instead of `astrbot_sdk.api.*`. Keeping only the `astrbot_sdk.api` compat surface is not enough for no-touch migration tests; preserve the `astrbot.api` alias package while old-plugin support remains a goal.
|
||||
- 2026-03-13: Real legacy `main.py` plugins may rely on package-relative imports like `from .src.tool import ...`. Loading legacy `main.py` as a bare file module breaks those imports; the loader must execute it under a synthetic package module so relative imports resolve.
|
||||
- 2026-03-13: Real legacy plugins may also import `astrbot.core.utils.session_waiter` and use it as a first-class interactive flow primitive. A pure import stub is not enough; compat needs per-session follow-up message routing so awaited waiters can actually receive later messages.
|
||||
- 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.
|
||||
|
||||
# 开发命令
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
- 2026-03-13: The maintained sample plugins now live under `test_plugin/old/` and `test_plugin/new/`. Runtime/integration tests should copy those real fixture directories instead of inlining synthetic plugin writers, otherwise the sample plugin tree and the exercised test path drift apart.
|
||||
- 2026-03-13: Real legacy plugins in the wild may still import `astrbot.api.*` instead of `astrbot_sdk.api.*`. Keeping only the `astrbot_sdk.api` compat surface is not enough for no-touch migration tests; preserve the `astrbot.api` alias package while old-plugin support remains a goal.
|
||||
- 2026-03-13: Real legacy `main.py` plugins may rely on package-relative imports like `from .src.tool import ...`. Loading legacy `main.py` as a bare file module breaks those imports; the loader must execute it under a synthetic package module so relative imports resolve.
|
||||
- 2026-03-13: Real legacy plugins may also import `astrbot.core.utils.session_waiter` and use it as a first-class interactive flow primitive. A pure import stub is not enough; compat needs per-session follow-up message routing so awaited waiters can actually receive later messages.
|
||||
- 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.
|
||||
|
||||
# 开发命令
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""旧版 ``astrbot`` 包名兼容入口。"""
|
||||
|
||||
from . import api
|
||||
from . import api, core
|
||||
|
||||
__all__ = ["api"]
|
||||
__all__ = ["api", "core"]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""旧版 ``astrbot.core`` 导入路径兼容入口。"""
|
||||
|
||||
from . import utils
|
||||
|
||||
__all__ = ["utils"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""旧版 ``astrbot.core.utils`` 兼容入口。"""
|
||||
|
||||
from .session_waiter import SessionController, session_waiter
|
||||
|
||||
__all__ = ["SessionController", "session_waiter"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""旧版 ``astrbot.core.utils.session_waiter`` 导入路径兼容入口。"""
|
||||
|
||||
from astrbot_sdk._session_waiter import SessionController, session_waiter
|
||||
|
||||
__all__ = ["SessionController", "session_waiter"]
|
||||
@@ -400,6 +400,13 @@ class LegacyContext:
|
||||
raise RuntimeError("LegacyContext 尚未绑定运行时 Context")
|
||||
return self._runtime_context
|
||||
|
||||
def get_config(self) -> dict[str, Any]:
|
||||
runtime_context = self._runtime_context
|
||||
if runtime_context is None:
|
||||
return {}
|
||||
config = getattr(runtime_context, "_astrbot_config", None)
|
||||
return dict(config) if isinstance(config, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _merge_llm_kwargs(
|
||||
*,
|
||||
@@ -592,6 +599,53 @@ class LegacyStar(Star):
|
||||
if config is not None:
|
||||
self.config = config
|
||||
|
||||
def _require_legacy_context(self) -> LegacyContext:
|
||||
if self.context is None:
|
||||
raise RuntimeError("LegacyStar 尚未绑定 compat Context")
|
||||
return self.context
|
||||
|
||||
async def put_kv_data(self, key: str, value: Any) -> None:
|
||||
await self._require_legacy_context().put_kv_data(key, value)
|
||||
|
||||
async def get_kv_data(self, key: str, default: Any = None) -> Any:
|
||||
return await self._require_legacy_context().get_kv_data(key, default)
|
||||
|
||||
async def delete_kv_data(self, key: str) -> None:
|
||||
await self._require_legacy_context().delete_kv_data(key)
|
||||
|
||||
async def send_message(self, session: str, message_chain: Any) -> None:
|
||||
await self._require_legacy_context().send_message(session, message_chain)
|
||||
|
||||
async def llm_generate(
|
||||
self,
|
||||
chat_provider_id: str,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
return await self._require_legacy_context().llm_generate(
|
||||
chat_provider_id,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def tool_loop_agent(
|
||||
self,
|
||||
chat_provider_id: str,
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> Any:
|
||||
return await self._require_legacy_context().tool_loop_agent(
|
||||
chat_provider_id,
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def add_llm_tools(self, *tools: Any) -> None:
|
||||
await self._require_legacy_context().add_llm_tools(*tools)
|
||||
|
||||
def get_config(self) -> dict[str, Any]:
|
||||
return self._require_legacy_context().get_config()
|
||||
|
||||
@classmethod
|
||||
def __astrbot_is_new_star__(cls) -> bool:
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""旧版 ``session_waiter`` 的最小兼容实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SessionWaitState:
|
||||
queue: asyncio.Queue[Any]
|
||||
timeout: float | None
|
||||
stopped: bool = False
|
||||
|
||||
|
||||
class SessionController:
|
||||
"""兼容旧版交互式会话等待控制器。"""
|
||||
|
||||
def __init__(self, state: _SessionWaitState) -> None:
|
||||
self._state = state
|
||||
|
||||
def keep(
|
||||
self,
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
reset_timeout: bool = False,
|
||||
) -> None:
|
||||
if timeout is not None and (reset_timeout or self._state.timeout is None):
|
||||
self._state.timeout = timeout
|
||||
self._state.stopped = False
|
||||
|
||||
def stop(self) -> None:
|
||||
self._state.stopped = True
|
||||
|
||||
|
||||
class SessionWaiterManager:
|
||||
"""按会话路由后续消息到等待中的 compat 回调。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._waiters: dict[str, _SessionWaitState] = {}
|
||||
|
||||
@staticmethod
|
||||
def session_key(event: Any) -> str:
|
||||
event = SessionWaiterManager._coerce_event(event)
|
||||
unified = getattr(event, "unified_msg_origin", None)
|
||||
if unified:
|
||||
return str(unified)
|
||||
session = getattr(event, "session_id", "")
|
||||
return str(session)
|
||||
|
||||
def register(self, event: Any, state: _SessionWaitState) -> str:
|
||||
key = self.session_key(event)
|
||||
if key in self._waiters:
|
||||
raise RuntimeError(f"session_waiter 已存在活跃会话: {key}")
|
||||
self._waiters[key] = state
|
||||
return key
|
||||
|
||||
def unregister(self, key: str, state: _SessionWaitState) -> None:
|
||||
if self._waiters.get(key) is state:
|
||||
self._waiters.pop(key, None)
|
||||
|
||||
async def dispatch(self, event: Any) -> bool:
|
||||
key = self.session_key(event)
|
||||
state = self._waiters.get(key)
|
||||
if state is None:
|
||||
return False
|
||||
await state.queue.put(self._coerce_event(event))
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _coerce_event(event: Any) -> Any:
|
||||
from .api.event import AstrMessageEvent
|
||||
|
||||
if isinstance(event, AstrMessageEvent):
|
||||
return event
|
||||
return AstrMessageEvent.from_message_event(event)
|
||||
|
||||
|
||||
def session_waiter(
|
||||
*,
|
||||
timeout: float | None = None,
|
||||
record_history_chains: bool | None = None,
|
||||
):
|
||||
"""兼容旧版 ``@session_waiter`` 装饰器。
|
||||
|
||||
当前实现只保留运行时等待下一条同会话消息的核心语义;
|
||||
``record_history_chains`` 仅为兼容旧签名而保留。
|
||||
"""
|
||||
|
||||
del record_history_chains
|
||||
|
||||
def decorator(func):
|
||||
async def runner(event: Any, *args: Any, **kwargs: Any) -> None:
|
||||
context = getattr(event, "_context", None)
|
||||
manager = getattr(context, "_session_waiter_manager", None)
|
||||
if manager is None:
|
||||
raise RuntimeError("session_waiter 只能在插件运行时消息上下文中使用")
|
||||
|
||||
state = _SessionWaitState(queue=asyncio.Queue(), timeout=timeout)
|
||||
key = manager.register(event, state)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
next_event = await asyncio.wait_for(
|
||||
state.queue.get(),
|
||||
timeout=state.timeout,
|
||||
)
|
||||
except asyncio.TimeoutError as exc:
|
||||
raise TimeoutError from exc
|
||||
|
||||
controller = SessionController(state)
|
||||
result = func(controller, next_event, *args, **kwargs)
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
if state.stopped:
|
||||
return
|
||||
finally:
|
||||
manager.unregister(key, state)
|
||||
|
||||
runner.__name__ = getattr(func, "__name__", "session_waiter")
|
||||
runner.__doc__ = getattr(func, "__doc__", None)
|
||||
runner.__wrapped__ = func
|
||||
return runner
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
__all__ = ["SessionController", "SessionWaiterManager", "session_waiter"]
|
||||
@@ -452,6 +452,10 @@ def platform_adapter_type(
|
||||
|
||||
|
||||
class _FilterNamespace:
|
||||
ADMIN = ADMIN
|
||||
PermissionType = PermissionType
|
||||
EventMessageType = EventMessageType
|
||||
PlatformAdapterType = PlatformAdapterType
|
||||
command = staticmethod(command)
|
||||
regex = staticmethod(regex)
|
||||
permission = staticmethod(permission)
|
||||
|
||||
@@ -645,6 +645,7 @@ class PluginWorkerRuntime:
|
||||
self._lifecycle_context = RuntimeContext(
|
||||
peer=self.peer, plugin_id=self.plugin.name
|
||||
)
|
||||
self._bind_legacy_runtime_contexts(self._lifecycle_context)
|
||||
self.peer.set_invoke_handler(self._handle_invoke)
|
||||
self.peer.set_cancel_handler(self._handle_cancel)
|
||||
|
||||
@@ -717,6 +718,23 @@ class PluginWorkerRuntime:
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
|
||||
def _bind_legacy_runtime_contexts(self, runtime_context: RuntimeContext) -> None:
|
||||
seen: set[int] = set()
|
||||
for loaded in [
|
||||
*self.loaded_plugin.handlers,
|
||||
*self.loaded_plugin.capabilities,
|
||||
]:
|
||||
legacy_context = getattr(loaded, "legacy_context", None)
|
||||
if legacy_context is None:
|
||||
continue
|
||||
marker = id(legacy_context)
|
||||
if marker in seen:
|
||||
continue
|
||||
seen.add(marker)
|
||||
bind_runtime_context = getattr(legacy_context, "bind_runtime_context", None)
|
||||
if callable(bind_runtime_context):
|
||||
bind_runtime_context(runtime_context)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_lifecycle_hook(instance: Any, method_name: str):
|
||||
hook = getattr(instance, method_name, None)
|
||||
|
||||
@@ -72,6 +72,7 @@ import typing
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import Any, get_type_hints
|
||||
|
||||
from .._session_waiter import SessionWaiterManager
|
||||
from ..context import CancelToken, Context
|
||||
from ..errors import AstrBotError
|
||||
from ..events import MessageEvent, PlainTextResult
|
||||
@@ -86,6 +87,7 @@ class HandlerDispatcher:
|
||||
self._peer = peer
|
||||
self._handlers = {item.descriptor.id: item for item in handlers}
|
||||
self._active: dict[str, tuple[asyncio.Task[Any], CancelToken]] = {}
|
||||
self._session_waiters = SessionWaiterManager()
|
||||
|
||||
async def invoke(self, message, cancel_token: CancelToken) -> dict[str, Any]:
|
||||
handler_id = str(message.input.get("handler_id", ""))
|
||||
@@ -96,8 +98,11 @@ class HandlerDispatcher:
|
||||
ctx = Context(
|
||||
peer=self._peer, plugin_id=self._plugin_id, cancel_token=cancel_token
|
||||
)
|
||||
ctx._session_waiter_manager = self._session_waiters
|
||||
event = MessageEvent.from_payload(message.input.get("event", {}), context=ctx)
|
||||
event.bind_reply_handler(self._create_reply_handler(ctx, event))
|
||||
if await self._session_waiters.dispatch(event):
|
||||
return {}
|
||||
if loaded.legacy_context is not None:
|
||||
loaded.legacy_context.bind_runtime_context(ctx)
|
||||
|
||||
|
||||
@@ -198,6 +198,12 @@ class TestFilterNamespace:
|
||||
meta = get_handler_meta(admin_handler)
|
||||
assert meta.permissions.require_admin is True
|
||||
|
||||
def test_filter_namespace_exposes_legacy_enum_constants(self):
|
||||
"""filter namespace should carry the old enum/constant attributes."""
|
||||
assert filter.ADMIN == ADMIN
|
||||
assert filter.PermissionType is PermissionType
|
||||
assert filter.EventMessageType is EventMessageType
|
||||
|
||||
|
||||
class TestCompatFilterComposition:
|
||||
"""Tests for legacy filter composition helpers."""
|
||||
|
||||
@@ -14,6 +14,7 @@ from astrbot_sdk._legacy_api import (
|
||||
Context,
|
||||
LegacyContext,
|
||||
LegacyConversationManager,
|
||||
LegacyStar,
|
||||
)
|
||||
from astrbot_sdk.api.message import Comp, MessageChain
|
||||
from astrbot_sdk.star import Star
|
||||
@@ -49,6 +50,22 @@ class TestLegacyContext:
|
||||
|
||||
assert legacy_ctx.require_runtime_context() is mock_ctx
|
||||
|
||||
def test_get_config_returns_empty_dict_without_runtime_context(self):
|
||||
"""get_config() should gracefully degrade before runtime binding."""
|
||||
legacy_ctx = LegacyContext("test_plugin")
|
||||
|
||||
assert legacy_ctx.get_config() == {}
|
||||
|
||||
def test_get_config_reads_runtime_context_config_dict(self):
|
||||
"""get_config() should expose the bound runtime config mapping."""
|
||||
mock_ctx = MagicMock()
|
||||
mock_ctx._astrbot_config = {"admins_id": ["1001"]}
|
||||
|
||||
legacy_ctx = LegacyContext("test_plugin")
|
||||
legacy_ctx.bind_runtime_context(mock_ctx)
|
||||
|
||||
assert legacy_ctx.get_config() == {"admins_id": ["1001"]}
|
||||
|
||||
def test_context_alias_is_legacy_context(self):
|
||||
"""Context should be an alias for LegacyContext."""
|
||||
assert Context is LegacyContext
|
||||
@@ -516,6 +533,38 @@ class TestCommandComponent:
|
||||
assert ctx.plugin_id == "my_plugin"
|
||||
|
||||
|
||||
class TestLegacyStarDelegation:
|
||||
"""Tests for LegacyStar methods that proxy to LegacyContext."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_put_kv_data_delegates_to_context(self):
|
||||
legacy_ctx = LegacyContext("test_plugin")
|
||||
legacy_ctx.put_kv_data = AsyncMock()
|
||||
star = LegacyStar(legacy_ctx)
|
||||
|
||||
await star.put_kv_data("key", 1)
|
||||
|
||||
legacy_ctx.put_kv_data.assert_awaited_once_with("key", 1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_kv_data_delegates_to_context(self):
|
||||
legacy_ctx = LegacyContext("test_plugin")
|
||||
legacy_ctx.get_kv_data = AsyncMock(return_value=True)
|
||||
star = LegacyStar(legacy_ctx)
|
||||
|
||||
result = await star.get_kv_data("key", False)
|
||||
|
||||
legacy_ctx.get_kv_data.assert_awaited_once_with("key", False)
|
||||
assert result is True
|
||||
|
||||
def test_get_config_delegates_to_context(self):
|
||||
legacy_ctx = LegacyContext("test_plugin")
|
||||
legacy_ctx.get_config = MagicMock(return_value={"admins_id": ["42"]})
|
||||
star = LegacyStar(legacy_ctx)
|
||||
|
||||
assert star.get_config() == {"admins_id": ["42"]}
|
||||
|
||||
|
||||
class TestMigrationDocUrl:
|
||||
"""Tests for migration documentation URL."""
|
||||
|
||||
|
||||
@@ -105,7 +105,9 @@ class TestApiEventModule:
|
||||
assert MessageSession is not None
|
||||
assert MessageType is not None
|
||||
|
||||
def test_astr_message_event_preserves_legacy_message_str_and_private_group_none(self):
|
||||
def test_astr_message_event_preserves_legacy_message_str_and_private_group_none(
|
||||
self,
|
||||
):
|
||||
"""AstrMessageEvent should expose message_str and return None for missing group."""
|
||||
from astrbot_sdk.api.event import AstrMessageEvent
|
||||
|
||||
@@ -217,3 +219,13 @@ class TestAstrbotImportAlias:
|
||||
assert Star is not None
|
||||
assert callable(StarTools.get_data_dir)
|
||||
assert callable(register)
|
||||
|
||||
def test_legacy_astrbot_core_session_waiter_exports(self):
|
||||
"""astrbot.core.utils.session_waiter should expose the compat waiter helpers."""
|
||||
from astrbot.core.utils.session_waiter import (
|
||||
SessionController,
|
||||
session_waiter,
|
||||
)
|
||||
|
||||
assert SessionController is not None
|
||||
assert callable(session_waiter)
|
||||
|
||||
@@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from astrbot_sdk._legacy_api import LegacyContext
|
||||
from astrbot_sdk.context import CancelToken
|
||||
from astrbot_sdk.errors import AstrBotError
|
||||
from astrbot_sdk.protocol.descriptors import (
|
||||
@@ -953,6 +954,42 @@ class DemoComponent(Star):
|
||||
|
||||
assert called == ["initialize", "terminate"]
|
||||
|
||||
def test_bind_legacy_runtime_contexts_reuses_shared_context(self):
|
||||
"""legacy lifecycle helpers should see the worker runtime context."""
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
plugin_dir = Path(temp_dir)
|
||||
manifest_path = plugin_dir / "plugin.yaml"
|
||||
requirements_path = plugin_dir / "requirements.txt"
|
||||
|
||||
manifest_path.write_text(
|
||||
yaml.dump(
|
||||
{
|
||||
"name": "test_plugin",
|
||||
"runtime": {"python": "3.12"},
|
||||
"components": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
requirements_path.write_text("", encoding="utf-8")
|
||||
|
||||
runtime = PluginWorkerRuntime(
|
||||
plugin_dir=plugin_dir, transport=MemoryTransport()
|
||||
)
|
||||
legacy_context = LegacyContext("test_plugin")
|
||||
runtime.loaded_plugin.handlers.append(
|
||||
SimpleNamespace(legacy_context=legacy_context)
|
||||
)
|
||||
runtime.loaded_plugin.capabilities.append(
|
||||
SimpleNamespace(legacy_context=legacy_context)
|
||||
)
|
||||
|
||||
runtime._bind_legacy_runtime_contexts(runtime._lifecycle_context)
|
||||
|
||||
assert (
|
||||
legacy_context.require_runtime_context() is runtime._lifecycle_context
|
||||
)
|
||||
|
||||
|
||||
class TestIntegrationWithTransportPair:
|
||||
"""Integration tests using transport pairs."""
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""可选的外部插件兼容 smoke 测试。
|
||||
|
||||
默认不跑;手动验证真实外部插件时,设置
|
||||
``ASTRBOT_EXTERNAL_PLUGIN_REPO=https://...`` 即可启用。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot_sdk.runtime.loader import (
|
||||
PluginEnvironmentManager,
|
||||
load_plugin_spec,
|
||||
)
|
||||
|
||||
EXTERNAL_PLUGIN_REPO_ENV = "ASTRBOT_EXTERNAL_PLUGIN_REPO"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv(EXTERNAL_PLUGIN_REPO_ENV),
|
||||
reason=f"set {EXTERNAL_PLUGIN_REPO_ENV} to enable external plugin smoke tests",
|
||||
)
|
||||
def test_external_plugin_load_smoke():
|
||||
"""按需 clone 外部插件仓库并验证其能在独立环境里完成加载。"""
|
||||
repo_url = os.environ[EXTERNAL_PLUGIN_REPO_ENV]
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
spec = load_plugin_spec(clone_dir)
|
||||
manager = PluginEnvironmentManager(project_root)
|
||||
python_path = manager.prepare_environment(spec)
|
||||
script = textwrap.dedent(
|
||||
f"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
repo_root = Path({str(project_root)!r})
|
||||
plugin_dir = Path({str(clone_dir)!r})
|
||||
sys.path.insert(0, str((repo_root / "src-new").resolve()))
|
||||
|
||||
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))
|
||||
"""
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str((project_root / "src-new").resolve())
|
||||
result = subprocess.run(
|
||||
[str(python_path), "-c", script],
|
||||
check=False,
|
||||
cwd=project_root,
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, (
|
||||
f"stdout:\n{result.stdout}\n\nstderr:\n{result.stderr}"
|
||||
)
|
||||
assert "HANDLERS" in result.stdout
|
||||
assert "PLUGIN" in result.stdout
|
||||
@@ -10,6 +10,8 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.utils.session_waiter import SessionController, session_waiter
|
||||
from astrbot_sdk._legacy_api import LegacyContext
|
||||
from astrbot_sdk.api.event import AstrMessageEvent
|
||||
from astrbot_sdk.api.message import Comp, MessageChain
|
||||
from astrbot_sdk.context import CancelToken, Context
|
||||
@@ -429,6 +431,81 @@ class TestHandlerDispatcherInvoke:
|
||||
|
||||
assert sent_messages == [{"session_id": "session-sync", "text": "fallback"}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_routes_followup_message_to_session_waiter(self):
|
||||
"""compat session_waiter should capture the next message from the same session."""
|
||||
peer = MockPeer()
|
||||
legacy_context = LegacyContext("test_plugin")
|
||||
captured_replies = []
|
||||
|
||||
async def handler_func(event: AstrMessageEvent):
|
||||
await event.send(MessageChain().message("请输入确认内容"))
|
||||
|
||||
@session_waiter(timeout=0.2, record_history_chains=False)
|
||||
async def waiter(controller: SessionController, ev: AstrMessageEvent):
|
||||
captured_replies.append(ev.message_str)
|
||||
await ev.send(MessageChain().message(f"收到:{ev.message_str}"))
|
||||
controller.stop()
|
||||
|
||||
await waiter(event)
|
||||
|
||||
descriptor = HandlerDescriptor(
|
||||
id="legacy.waiter",
|
||||
trigger=CommandTrigger(command="ask"),
|
||||
)
|
||||
handler = LoadedHandler(
|
||||
descriptor=descriptor,
|
||||
callable=handler_func,
|
||||
owner=MagicMock(),
|
||||
legacy_context=legacy_context,
|
||||
)
|
||||
dispatcher = HandlerDispatcher(
|
||||
plugin_id="test_plugin",
|
||||
peer=peer,
|
||||
handlers=[handler],
|
||||
)
|
||||
|
||||
first_message = InvokeMessage(
|
||||
id="msg_waiter_1",
|
||||
capability="handler.invoke",
|
||||
input={
|
||||
"handler_id": "legacy.waiter",
|
||||
"event": {
|
||||
"text": "ask",
|
||||
"session_id": "session-waiter",
|
||||
"user_id": "user-1",
|
||||
"platform": "test",
|
||||
},
|
||||
},
|
||||
)
|
||||
followup_message = InvokeMessage(
|
||||
id="msg_waiter_2",
|
||||
capability="handler.invoke",
|
||||
input={
|
||||
"handler_id": "legacy.waiter",
|
||||
"event": {
|
||||
"text": "确认",
|
||||
"session_id": "session-waiter",
|
||||
"user_id": "user-1",
|
||||
"platform": "test",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
waiting_task = asyncio.create_task(
|
||||
dispatcher.invoke(first_message, CancelToken())
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
result = await dispatcher.invoke(followup_message, CancelToken())
|
||||
await waiting_task
|
||||
|
||||
assert result == {}
|
||||
assert captured_replies == ["确认"]
|
||||
assert peer.sent_messages == [
|
||||
{"session_id": "session-waiter", "text": "请输入确认内容"},
|
||||
{"session_id": "session-waiter", "text": "收到:确认"},
|
||||
]
|
||||
|
||||
|
||||
class TestHandlerDispatcherCancel:
|
||||
"""Tests for HandlerDispatcher.cancel method."""
|
||||
|
||||
Reference in New Issue
Block a user