Add legacy session waiter compatibility

This commit is contained in:
whatevertogo
2026-03-13 15:42:19 +08:00
parent 10d3990970
commit dc365fdad0
18 changed files with 498 additions and 3 deletions
+2
View File
@@ -39,9 +39,11 @@ plugins/.venv/
# Tool caches
.uv-cache/
.astrbot/
# IDE files
.idea/
.vscode/
*.iml
uv.lock
/astrBot/
+3
View File
@@ -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.
# 开发命令
+3
View File
@@ -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.
# 开发命令
+2 -2
View File
@@ -1,5 +1,5 @@
"""旧版 ``astrbot`` 包名兼容入口。"""
from . import api
from . import api, core
__all__ = ["api"]
__all__ = ["api", "core"]
+5
View File
@@ -0,0 +1,5 @@
"""旧版 ``astrbot.core`` 导入路径兼容入口。"""
from . import utils
__all__ = ["utils"]
+5
View File
@@ -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"]
+54
View File
@@ -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
+130
View File
@@ -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"]
+4
View File
@@ -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)
+18
View File
@@ -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)
+6
View File
@@ -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."""
+49
View File
@@ -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."""
+13 -1
View File
@@ -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)
+37
View File
@@ -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."""
+80
View File
@@ -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
+77
View File
@@ -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."""