Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions astrbot/builtin_stars/astrbot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from astrbot.api.message_components import Image, Plain
from astrbot.api.provider import LLMResponse, ProviderRequest
from astrbot.core import logger
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.message.message_event_result import MessageChain, MessageEventResult
from astrbot.core.platform.message_type import MessageType
from astrbot.core.utils.session_waiter import (
FILTERS,
Expand Down Expand Up @@ -42,12 +42,14 @@ def __init__(self, context: star.Context) -> None:
logger.error(f"group chat context init failed: {e}")

@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize)
async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:
async def handle_session_control_agent(self, event: AstrMessageEvent):
"""会话控制代理"""
for session_filter in FILTERS:
session_id = session_filter.filter(event)
if session_id in USER_SESSIONS:
await SessionWaiter.trigger(session_id, event)
result = await SessionWaiter.trigger(session_id, event)
if isinstance(result, MessageEventResult):
yield result
event.stop_event()

@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
Expand Down
7 changes: 4 additions & 3 deletions astrbot/core/utils/session_waiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,11 @@ def _cleanup(self, error: Exception | None = None) -> None:
self.session_controller.stop(error)

@classmethod
async def trigger(cls, session_id: str, event: AstrMessageEvent) -> None:
async def trigger(cls, session_id: str, event: AstrMessageEvent) -> Any:
"""外部输入触发会话处理"""
session = USER_SESSIONS.get(session_id)
if not session or session.session_controller.future.done():
return
return None

async with session._lock:
if not session.session_controller.future.done():
Expand All @@ -166,9 +166,10 @@ async def trigger(cls, session_id: str, event: AstrMessageEvent) -> None:
try:
# TODO: 这里使用 create_task,跟踪 task,防止超时后这里 handler 仍然在执行
assert session.handler is not None
await session.handler(session.session_controller, event)
return await session.handler(session.session_controller, event)
except Exception as e:
session.session_controller.stop(e)
return None


def session_waiter(timeout: int = 30, record_history_chains: bool = False):
Expand Down
29 changes: 10 additions & 19 deletions docs/en/dev/star/guides/session-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,7 @@ Import:

```py
import astrbot.api.message_components as Comp
from astrbot.core.utils.session_waiter import (
session_waiter,
SessionController,
)
```

Code within the handler can be written as follows:

```python
from astrbot.api.utils import SessionController, session_waiter
from astrbot.api.event import filter, AstrMessageEvent

@filter.command("idiom-chain")
Expand All @@ -40,27 +32,22 @@ async def handle_empty_mention(self, event: AstrMessageEvent):
# How to use the session controller
@session_waiter(timeout=60, record_history_chains=False) # Register a session controller with a 60-second timeout, without recording message history
async def empty_mention_waiter(controller: SessionController, event: AstrMessageEvent):
idiom = event.message_str # The idiom sent by the user, e.g., "one horse takes the lead"
idiom = event.message_str # The idiom sent by the user, e.g., "一马当先"

if idiom == "exit": # If the user wants to exit the idiom chain game by typing "exit"
await event.send(event.plain_result("Exited the idiom chain game~"))
controller.stop() # Stop the session controller, which will end immediately.
return
return event.plain_result("Exited the idiom chain game~")

if len(idiom) != 4: # If the user's input is not a 4-character idiom
await event.send(event.plain_result("The idiom must be four characters~")) # Send a reply, cannot use yield
return
# Exit the current method without executing subsequent logic, but the session is not interrupted; subsequent user input will still enter the current session
return event.plain_result("The idiom must be four characters~")

# ...
message_result = event.make_result()
message_result.chain = [Comp.Plain("Foresight")] # import astrbot.api.message_components as Comp
await event.send(message_result) # Send a reply, cannot use yield

controller.keep(timeout=60, reset_timeout=True) # Reset timeout to 60s. If not reset, it will continue the previous timeout countdown.

# controller.stop() # Stop the session controller, which will end immediately.
# If history chains are recorded, you can retrieve them via controller.get_history_chains()
return event.plain_result("先见之明") # Send a reply

try:
await empty_mention_waiter(event)
Expand All @@ -74,6 +61,10 @@ async def handle_empty_mention(self, event: AstrMessageEvent):
logger.error("handle_empty_mention error: " + str(e))
```

> [!TIP]
> Inside the session controller handler, you **cannot use `yield`**, but you can `return` a `MessageEventResult`. The returned result is automatically sent through the framework's message decoration pipeline, behaving the same as a regular command reply.\
> If you call `event.send()` directly, the message bypasses the decoration pipeline and is sent directly.

Once the session controller is activated, messages subsequently sent by that sender will first be processed by the `empty_mention_waiter` function you defined above, until the session controller is stopped or times out.

## SessionController
Expand All @@ -92,7 +83,7 @@ By default, the AstrBot session controller uses `sender_id` (the sender's ID) as

```py
import astrbot.api.message_components as Comp
from astrbot.core.utils.session_waiter import (
from astrbot.api.utils import (
session_waiter,
SessionFilter,
SessionController,
Expand Down
30 changes: 9 additions & 21 deletions docs/zh/dev/star/guides/session-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,9 @@

AstrBot 提供了开箱即用的会话控制功能:

导入:

```py
import astrbot.api.message_components as Comp
from astrbot.core.utils.session_waiter import (
session_waiter,
SessionController,
)
```

handler 内的代码可以如下:

```python
from astrbot.api.utils import SessionController, session_waiter
from astrbot.api.event import filter, AstrMessageEvent

@filter.command("成语接龙")
Expand All @@ -43,24 +33,18 @@ async def handle_empty_mention(self, event: AstrMessageEvent):
idiom = event.message_str # 用户发来的成语,假设是 "一马当先"

if idiom == "退出": # 假设用户想主动退出成语接龙,输入了 "退出"
await event.send(event.plain_result("已退出成语接龙~"))
controller.stop() # 停止会话控制器,会立即结束。
return
return event.plain_result("已退出成语接龙~")

if len(idiom) != 4: # 假设用户输入的不是4字成语
await event.send(event.plain_result("成语必须是四个字的呢~")) # 发送回复,不能使用 yield
return
# 退出当前方法,不执行后续逻辑,但此会话并未中断,后续的用户输入仍然会进入当前会话
return event.plain_result("成语必须是四个字的呢~") # 返回后不执行后续逻辑,但此会话并未中断,后续的用户输入仍然会进入当前会话

# ...
message_result = event.make_result()
message_result.chain = [Comp.Plain("先见之明")] # import astrbot.api.message_components as Comp
await event.send(message_result) # 发送回复,不能使用 yield

controller.keep(timeout=60, reset_timeout=True) # 重置超时时间为 60s,如果不重置,则会继续之前的超时时间计时。

# controller.stop() # 停止会话控制器,会立即结束。
# 如果记录了历史消息链,可以通过 controller.get_history_chains() 获取历史消息链
return event.plain_result("先见之明") # 发送回复

try:
await empty_mention_waiter(event)
Expand All @@ -74,6 +58,10 @@ async def handle_empty_mention(self, event: AstrMessageEvent):
logger.error("handle_empty_mention error: " + str(e))
```

> [!TIP]
> 会话控制器处理函数内**不能使用 `yield`**,但可以 `return` 一个 `MessageEventResult`。返回的结果会被框架自动送入消息装饰流程后再发送,与普通指令回复的行为一致。\
> 如果直接调用 `event.send()`,消息会绕过装饰流程直接发送。

当激活会话控制器后,该发送人之后发送的消息会首先经过上面你定义的 `empty_mention_waiter` 函数处理,直到会话控制器被停止或者超时。

## SessionController
Expand All @@ -92,7 +80,7 @@ async def handle_empty_mention(self, event: AstrMessageEvent):

```py
import astrbot.api.message_components as Comp
from astrbot.core.utils.session_waiter import (
from astrbot.api.utils import (
session_waiter,
SessionFilter,
SessionController,
Expand Down
122 changes: 122 additions & 0 deletions tests/unit/test_session_control_agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Tests for Main.handle_session_control_agent result routing.

Covers the two branches introduced by the session-waiter return-value change:
- handler returns a MessageEventResult -> yield it, then stop_event
- handler returns None -> no yield, just stop_event
"""

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from astrbot.core.message.message_event_result import MessageEventResult
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.astrbot_message import AstrBotMessage, MessageMember
from astrbot.core.platform.message_type import MessageType
from astrbot.core.platform.platform_metadata import PlatformMetadata


class _ConcreteEvent(AstrMessageEvent):
async def send(self, message):
await super().send(message)


def _make_event() -> _ConcreteEvent:
meta = PlatformMetadata(name="test", description="t", id="test_id")
msg = AstrBotMessage()
msg.type = MessageType.FRIEND_MESSAGE
msg.self_id = "bot"
msg.session_id = "s"
msg.message_id = "m"
msg.sender = MessageMember(user_id="u", nickname="U")
msg.message_str = "hi"
return _ConcreteEvent("hi", msg, meta, "s")


def _build_main():
"""Construct a Main instance without running its heavy __init__."""
from astrbot.builtin_stars.astrbot.main import Main

main = Main.__new__(Main)
return main


async def _collect(gen):
"""Drain an async generator into a list of yielded values."""
out = []
async for item in gen:
out.append(item)
return out


@pytest.mark.asyncio
async def test_yields_message_event_result_when_trigger_returns_one():
"""When trigger returns a MessageEventResult, the handler should yield it."""
main = _build_main()
event = _make_event()
expected = MessageEventResult().message("先见之明")

fake_filter = MagicMock()
fake_filter.filter = MagicMock(return_value="sid")
with (
patch(
"astrbot.builtin_stars.astrbot.main.FILTERS",
[fake_filter],
),
patch(
"astrbot.builtin_stars.astrbot.main.USER_SESSIONS",
{"sid": MagicMock()},
),
patch(
"astrbot.builtin_stars.astrbot.main.SessionWaiter.trigger",
new=AsyncMock(return_value=expected),
),
):
yielded = await _collect(main.handle_session_control_agent(event))

assert yielded == [expected]
assert event.is_stopped() is True


@pytest.mark.asyncio
async def test_no_yield_when_trigger_returns_none():
"""When trigger returns None, the handler should not yield and should stop."""
main = _build_main()
event = _make_event()

fake_filter = MagicMock()
fake_filter.filter = MagicMock(return_value="sid")
with (
patch(
"astrbot.builtin_stars.astrbot.main.FILTERS",
[fake_filter],
),
patch(
"astrbot.builtin_stars.astrbot.main.USER_SESSIONS",
{"sid": MagicMock()},
),
patch(
"astrbot.builtin_stars.astrbot.main.SessionWaiter.trigger",
new=AsyncMock(return_value=None),
),
):
yielded = await _collect(main.handle_session_control_agent(event))

assert yielded == []
assert event.is_stopped() is True


@pytest.mark.asyncio
async def test_no_op_when_no_session_matches():
"""When no session_filter matches, nothing happens and event is not stopped."""
main = _build_main()
event = _make_event()

with (
patch("astrbot.builtin_stars.astrbot.main.FILTERS", []),
patch("astrbot.builtin_stars.astrbot.main.USER_SESSIONS", {}),
):
yielded = await _collect(main.handle_session_control_agent(event))

assert yielded == []
assert event.is_stopped() is False
Loading
Loading