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
16 changes: 14 additions & 2 deletions astrbot/core/utils/session_waiter.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,20 @@ def filter(self, event: AstrMessageEvent) -> str:

class DefaultSessionFilter(SessionFilter):
def filter(self, event: AstrMessageEvent) -> str:
"""默认实现,返回统一消息来源字符串作为会话标识符"""
return event.unified_msg_origin
"""默认实现,返回「消息来源 + 发送人」作为会话标识符。

两部分都是必需的: 只用 ``unified_msg_origin`` 会让群内任意成员的下一条
消息命中别人注册的等待器(等待器会截获并重新投递该消息); 只用
``sender_id`` 又会让同一用户在其他群聊/私聊中的消息命中此等待器。
需要整群共享一个会话时,请自定义 :class:`SessionFilter`。

Args:
event: 待判定的消息事件。

Returns:
会话标识符,格式为 ``{unified_msg_origin}!{sender_id}``。
"""
return f"{event.unified_msg_origin}!{event.get_sender_id()}"


class SessionWaiter:
Expand Down
135 changes: 135 additions & 0 deletions tests/unit/test_session_waiter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Tests for the session waiter's default session identity.

Regression coverage for the group-chat interception bug: a waiter registered by
one group member must not be triggered by a different member of the same group,
while the same member must still be isolated across different sessions.
"""

from __future__ import annotations

import asyncio

import pytest

from astrbot.core.message.components import Plain
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
from astrbot.core.utils.session_waiter import (
USER_SESSIONS,
DefaultSessionFilter,
SessionController,
SessionWaiter,
session_waiter,
)

PLATFORM_META = PlatformMetadata(
name="aiocqhttp",
description="test platform",
id="aiocqhttp",
)


def make_event(
sender_id: str,
session_id: str,
message_type: MessageType = MessageType.GROUP_MESSAGE,
text: str = "hello",
) -> AstrMessageEvent:
"""Build a minimal group/private message event.

Args:
sender_id: ID of the member that sent the message.
session_id: Platform session ID (group ID for group messages).
message_type: Message type of the event.
text: Plain text payload of the message.

Returns:
A usable ``AstrMessageEvent`` for session-identity assertions.
"""
message_obj = AstrBotMessage()
message_obj.type = message_type
message_obj.self_id = "bot"
message_obj.session_id = session_id
message_obj.message_id = "1"
message_obj.sender = MessageMember(user_id=sender_id, nickname=sender_id)
message_obj.message = [Plain(text=text)]
message_obj.message_str = text
message_obj.raw_message = None
if message_type == MessageType.GROUP_MESSAGE:
message_obj.group_id = session_id
return AstrMessageEvent(
message_str=text,
message_obj=message_obj,
platform_meta=PLATFORM_META,
session_id=session_id,
)


def test_default_filter_separates_members_of_the_same_group():
"""Two members of one group must map to different session identities."""
session_filter = DefaultSessionFilter()
event_a = make_event("member_a", "group_1")
event_b = make_event("member_b", "group_1")

assert session_filter.filter(event_a) != session_filter.filter(event_b)


def test_default_filter_is_stable_for_the_same_member():
"""The same member in the same group must map to one session identity."""
session_filter = DefaultSessionFilter()
first = make_event("member_a", "group_1", text="one")
second = make_event("member_a", "group_1", text="two")

assert session_filter.filter(first) == session_filter.filter(second)


def test_default_filter_separates_sessions_of_the_same_member():
"""One member must not share a waiter across groups or private chats."""
session_filter = DefaultSessionFilter()
in_group_1 = make_event("member_a", "group_1")
in_group_2 = make_event("member_a", "group_2")
in_private = make_event(
"member_a",
"member_a",
message_type=MessageType.FRIEND_MESSAGE,
)

identities = {
session_filter.filter(in_group_1),
session_filter.filter(in_group_2),
session_filter.filter(in_private),
}
assert len(identities) == 3


@pytest.mark.asyncio
async def test_waiter_ignores_other_members_and_accepts_the_owner():
"""A registered waiter only fires for the member that created it."""
USER_SESSIONS.clear()
session_filter = DefaultSessionFilter()
owner_event = make_event("member_a", "group_1", text="@bot")
other_event = make_event("member_b", "group_1", text="unrelated chatter")

triggered: list[str] = []

Comment on lines +107 to +116

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add coverage for the timeout/cleanup path of SessionWaiter

This test only covers the success path where the owner’s follow-up arrives and the waiter fires, ending with USER_SESSIONS empty. Please add a separate async test for the timeout path: register a waiter with a short timeout, ensure no matching follow-up arrives, then assert that after the timeout (1) the waiter is removed from USER_SESSIONS, and (2) non‑owner messages during the wait do not trigger the waiter. That will exercise both success and timeout branches.

Suggested implementation:

    @session_waiter(timeout=5)

@pytest.mark.asyncio
async def test_waiter_times_out_and_ignores_non_owner_messages():
    """A registered waiter times out cleanly and ignores non-owner messages during the wait."""
    USER_SESSIONS.clear()
    session_filter = DefaultSessionFilter()
    owner_event = make_event("member_a", "group_1", text="@bot")
    non_owner_event = make_event("member_b", "group_1", text="@bot")

    triggered: list[str] = []

    @session_waiter(timeout=0.1)
    async def handler(session, event):
        triggered.append(event.message.text)

    # Register the waiter via the owner event
    await handler(session_filter.filter(owner_event), owner_event)

    # Ensure a session is registered for the owner and no one else
    assert "member_a" in USER_SESSIONS
    assert "member_b" not in USER_SESSIONS

    # Send a matching follow-up from a non-owner member; it must not trigger the waiter
    await handler(session_filter.filter(non_owner_event), non_owner_event)
    assert triggered == []

    # Wait for the timeout to elapse
    await asyncio.sleep(0.2)

    # After timeout, the waiter is cleaned up
    assert "member_a" not in USER_SESSIONS
  1. Ensure asyncio is imported at the top of tests/unit/test_session_waiter.py:
    import asyncio
    If there is an existing import section, add it there rather than creating a new one.
  2. Place the new test near the other SessionWaiter tests (e.g., immediately after test_waiter_ignores_other_members_and_accepts_the_owner) to keep related coverage together. If the indentation in the snippet is only due to doc formatting, this patch will already do that; otherwise, you may need to re-indent the @session_waiter(timeout=5) line so it remains at the correct scope.
  3. If session_waiter registration semantics differ (e.g., if the first call does not register the waiter), adjust the test to use the correct way of creating and registering a waiter so that USER_SESSIONS is populated for "member_a" and then cleaned up on timeout.

@session_waiter(timeout=5)
async def waiter(controller: SessionController, event: AstrMessageEvent) -> None:
triggered.append(event.get_sender_id())
controller.stop()

waiting = asyncio.create_task(waiter(owner_event, session_filter))
await asyncio.sleep(0)

# A different member of the same group must not reach the waiter.
await SessionWaiter.trigger(session_filter.filter(other_event), other_event)
assert triggered == []

# The owner's own follow-up message must reach the waiter.
follow_up = make_event("member_a", "group_1", text="the real question")
await SessionWaiter.trigger(session_filter.filter(follow_up), follow_up)
await asyncio.wait_for(waiting, timeout=5)

assert triggered == ["member_a"]
assert USER_SESSIONS == {}