Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ def _commit(self, abm: AstrBotMessage) -> None:

@register_platform_adapter("qq_official", "QQ 机器人官方 API 适配器")
class QQOfficialPlatformAdapter(Platform):
STARTUP_RETRY_DELAY_SECONDS = 5

def __init__(
self,
platform_config: dict,
Expand All @@ -123,18 +125,47 @@ def __init__(
public_guild_messages=True,
direct_message=guild_dm,
)
self.client = botClient(
self._shutdown_event = asyncio.Event()
self.client = self._create_client()

self._session_last_message_id: dict[str, str] = {}
self._session_scene: dict[str, str] = {}

self.test_mode = os.environ.get("TEST_MODE", "off") == "on"

def _create_client(self) -> botClient:
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
client = botClient(
intents=self.intents,
bot_log=False,
timeout=20,
)
client.set_platform(self)
return client

self.client.set_platform(self)

self._session_last_message_id: dict[str, str] = {}
self._session_scene: dict[str, str] = {}
@staticmethod
def _should_retry_startup_error(error: Exception) -> bool:
if isinstance(error, (asyncio.TimeoutError, ConnectionError, OSError)):
return True
if isinstance(error, TypeError):
error_msg = str(error)
return "NoneType" in error_msg and "subscriptable" in error_msg
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.

medium

Checking for a specific TypeError by inspecting its message string is fragile. If the botpy library changes this error message in a future version, the retry logic will break.

While this may be a necessary workaround, it would be beneficial to add a comment explaining why this specific error message is being checked. This will provide crucial context for future maintainers. For example:

# The botpy library may raise a TypeError("'NoneType' object is not subscriptable")
# on a transient gateway timeout. We catch this specific error to enable retries.
# See issue: [link to botpy issue if any]

return False
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated

async def _close_client(self) -> None:
try:
await self.client.close()
except asyncio.CancelledError:
raise
except Exception as e:
logger.warning(
"qq_official(%s): close client failed during recovery: %s",
self.meta().id,
e,
)

self.test_mode = os.environ.get("TEST_MODE", "off") == "on"
async def _recreate_client(self) -> None:
await self._close_client()
self.client = self._create_client()

async def send_by_session(
self,
Expand Down Expand Up @@ -500,12 +531,48 @@ def _parse_from_qqofficial(
abm.self_id = "qq_official"
return abm

def run(self):
return self.client.start(appid=self.appid, secret=self.secret)
async def run(self) -> None:
try:
while not self._shutdown_event.is_set():
try:
await self.client.start(appid=self.appid, secret=self.secret)
if self._shutdown_event.is_set():
break
logger.warning(
"qq_official(%s): client stopped unexpectedly, restarting in %ss",
self.meta().id,
self.STARTUP_RETRY_DELAY_SECONDS,
)
except asyncio.CancelledError:
raise
except Exception as e:
if not self._should_retry_startup_error(e):
raise
if self._shutdown_event.is_set():
break
logger.warning(
"qq_official(%s): startup failed, retrying in %ss: %s",
self.meta().id,
self.STARTUP_RETRY_DELAY_SECONDS,
e,
)

await self._recreate_client()

try:
await asyncio.wait_for(
self._shutdown_event.wait(),
timeout=self.STARTUP_RETRY_DELAY_SECONDS,
)
except asyncio.TimeoutError:
continue
finally:
await self._close_client()

def get_client(self) -> botClient:
return self.client

async def terminate(self) -> None:
await self.client.close()
self._shutdown_event.set()
await self._close_client()
logger.info("QQ 官方机器人接口 适配器已被优雅地关闭")
97 changes: 97 additions & 0 deletions tests/test_qqofficial_platform_adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock

import pytest

from astrbot.core.platform.sources.qqofficial.qqofficial_platform_adapter import (
QQOfficialPlatformAdapter,
)


def _platform_config() -> dict:
return {
"id": "qq-official-test",
"appid": "appid",
"secret": "secret",
"enable_group_c2c": True,
"enable_guild_direct_message": True,
}


@pytest.mark.asyncio
async def test_qqofficial_run_retries_after_gateway_timeout(monkeypatch):
first_client = SimpleNamespace(
start=AsyncMock(
side_effect=TypeError("'NoneType' object is not subscriptable")
),
close=AsyncMock(),
)
adapter_holder: dict[str, QQOfficialPlatformAdapter] = {}

async def second_start(*args, **kwargs):
adapter_holder["adapter"]._shutdown_event.set()
return None

second_client = SimpleNamespace(
start=AsyncMock(side_effect=second_start),
close=AsyncMock(),
)
clients = iter([first_client, second_client])
monkeypatch.setattr(
QQOfficialPlatformAdapter,
"_create_client",
lambda self: next(clients),
)

adapter = QQOfficialPlatformAdapter(_platform_config(), {}, asyncio.Queue())
adapter_holder["adapter"] = adapter
adapter.STARTUP_RETRY_DELAY_SECONDS = 0

await adapter.run()

first_client.start.assert_awaited_once_with(appid="appid", secret="secret")
first_client.close.assert_awaited_once()
second_client.start.assert_awaited_once_with(appid="appid", secret="secret")
second_client.close.assert_awaited_once()


@pytest.mark.asyncio
async def test_qqofficial_run_reraises_non_retryable_error(monkeypatch):
client = SimpleNamespace(
start=AsyncMock(side_effect=ValueError("invalid credentials")),
close=AsyncMock(),
)
monkeypatch.setattr(
QQOfficialPlatformAdapter,
"_create_client",
lambda self: client,
)

adapter = QQOfficialPlatformAdapter(_platform_config(), {}, asyncio.Queue())

with pytest.raises(ValueError, match="invalid credentials"):
await adapter.run()

client.start.assert_awaited_once_with(appid="appid", secret="secret")
client.close.assert_awaited_once()


@pytest.mark.asyncio
async def test_qqofficial_run_propagates_cancelled_error(monkeypatch):
client = SimpleNamespace(
start=AsyncMock(side_effect=asyncio.CancelledError()),
close=AsyncMock(),
)
monkeypatch.setattr(
QQOfficialPlatformAdapter,
"_create_client",
lambda self: client,
)

adapter = QQOfficialPlatformAdapter(_platform_config(), {}, asyncio.Queue())

with pytest.raises(asyncio.CancelledError):
await adapter.run()

client.close.assert_awaited_once()
Loading