refactor: unify message channel shutdown convergence

This commit is contained in:
jxxghp
2026-08-24 14:08:06 +08:00
parent 009631b8ee
commit 415335b215
18 changed files with 276 additions and 89 deletions
+2 -2
View File
@@ -93,10 +93,10 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
pass
@abstractmethod
def stop(self) -> None:
def stop(self) -> Optional[bool]:
"""
如果关闭时模块有服务需要停止,需要实现此方法
:return: None,该方法可被多个模块同时处理
:return: False 表示资源尚未收敛;None/True 表示本次停止完成
"""
pass
+15
View File
@@ -73,6 +73,21 @@ class _MessageChannelModuleBase(_ModuleBase, _MessageBase[TService]):
"""
return bool(client.get_state()), ""
def _stop_service_instances(self) -> bool:
"""停止全部渠道实例,并聚合客户端返回的资源收敛结果。"""
converged = True
for client in self.get_instances().values():
stop = getattr(client, "stop", None)
if not callable(stop):
continue
try:
if stop() is False:
converged = False
except Exception as err:
logger.error("停止%s模块实例失败:%s", self.get_name(), err)
converged = False
return converged
def register_commands(self, commands: Dict[str, dict]) -> None:
"""
注册命令,实现这个函数接收系统可用的命令菜单
+3 -7
View File
@@ -96,13 +96,9 @@ class DiscordModule(_MessageChannelModuleBase[Discord]):
"""
return 4
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
try:
client.stop()
except Exception as err:
logger.error(f"停止Discord模块实例失败:{err}")
def stop(self) -> bool:
"""停止全部 Discord 实例,并返回资源是否全部收敛。"""
return self._stop_service_instances()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
+12 -6
View File
@@ -39,6 +39,8 @@ class Discord:
"""
_MAX_SLASH_COMMANDS = 100
_client_close_timeout_seconds = 10
_thread_join_timeout_seconds = 5
def __init__(
self,
@@ -247,10 +249,10 @@ class Discord:
self._thread = threading.Thread(target=runner, daemon=True)
self._thread.start()
def stop(self):
"""停止 Discord 客户端,并在关闭事件循环前收口 typing owner"""
def stop(self) -> bool:
"""停止 Discord 客户端,并返回事件循环线程是否已经终止"""
if not self._client or not self._loop or not self._thread:
return
return True
self._stop_requested.set()
loop = self._loop
thread = self._thread
@@ -264,19 +266,23 @@ class Discord:
try:
asyncio.run_coroutine_threadsafe(
self._client.close(), loop
).result(timeout=10)
).result(timeout=self._client_close_timeout_seconds)
except Exception as err:
logger.error(f"关闭 Discord Bot 失败:{err}")
self._ready_event.clear()
thread.join(timeout=5)
if thread is not threading.current_thread():
thread.join(timeout=self._thread_join_timeout_seconds)
if thread.is_alive():
try:
loop.call_soon_threadsafe(loop.stop)
except Exception as err:
logger.error(f"停止 Discord 事件循环失败:{err}")
thread.join(timeout=5)
if thread is not threading.current_thread():
thread.join(timeout=self._thread_join_timeout_seconds)
if thread.is_alive():
logger.error("Discord Bot 线程未在超时内停止")
return False
return True
def get_state(self) -> bool:
return self._ready_event.is_set() and self._client is not None
+3 -7
View File
@@ -48,13 +48,9 @@ class FeishuModule(_MessageChannelModuleBase[Feishu]):
"""
return False
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
try:
client.stop()
except Exception as err:
logger.error(f"停止飞书模块实例失败:{err}")
def stop(self) -> bool:
"""停止全部飞书实例,并返回资源是否全部收敛。"""
return self._stop_service_instances()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""通知模块通过系统通知配置控制实例化,这里不额外设置环境开关。"""
+16 -5
View File
@@ -129,6 +129,8 @@ class Feishu:
"""飞书通知客户端,负责长连接收消息与主动发送通知。"""
PROCESSING_REACTION_EMOJI = "GLANCE"
_ws_shutdown_timeout_seconds = 5
_ws_join_timeout_seconds = 5
STREAM_CARD_TITLE_ELEMENT_ID = "mp_stream_title"
STREAM_CARD_BODY_ELEMENT_ID = "mp_stream_body"
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".tiff", ".heic"}
@@ -678,12 +680,13 @@ class Feishu:
"""返回飞书客户端是否已就绪。"""
return self._ready.is_set() and self._api_client is not None
def stop(self) -> None:
"""停止飞书客户端并结束长连接线程。"""
def stop(self) -> bool:
"""停止飞书客户端,并返回长连接线程是否已经终止"""
self._stop_event.set()
self._ready.clear()
ws_client = self._ws_client
ws_loop = self._ws_loop
ws_thread = self._ws_thread
if ws_client:
try:
ws_client._auto_reconnect = False
@@ -692,11 +695,19 @@ class Feishu:
self._shutdown_ws_client(),
ws_loop,
)
shutdown_future.result(timeout=5)
shutdown_future.result(timeout=self._ws_shutdown_timeout_seconds)
except Exception as err:
logger.debug(f"停止飞书客户端失败:{err}")
if self._ws_thread and self._ws_thread.is_alive():
self._ws_thread.join(timeout=5)
if (
ws_thread
and ws_thread.is_alive()
and ws_thread is not threading.current_thread()
):
ws_thread.join(timeout=self._ws_join_timeout_seconds)
if ws_thread and ws_thread.is_alive():
logger.error("飞书长连接线程未在关闭预算内退出")
return False
return True
def parse_message(self, body: Any) -> Optional[IncomingMessage]:
"""解析飞书转发到消息入口的 JSON 报文。"""
+3 -7
View File
@@ -90,13 +90,9 @@ class QQBotModule(_MessageChannelModuleBase[QQBot]):
"""
return False
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
try:
client.stop()
except Exception as err:
logger.error(f"停止QQ Bot模块实例失败:{err}")
def stop(self) -> bool:
"""停止全部 QQ Bot 实例,并返回资源是否全部收敛。"""
return self._stop_service_instances()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
+17 -8
View File
@@ -38,6 +38,8 @@ _MAX_IMAGE_SIZE: Tuple[int, int] = (512, 512)
class QQBot:
"""QQ Bot 通知客户端"""
_gateway_join_timeout_seconds = 20
def __init__(
self,
QQ_APP_ID: Optional[str] = None,
@@ -161,8 +163,8 @@ class QQBot:
except Exception as e:
logger.error(f"QQ Bot Gateway 启动失败: {e}")
def stop(self) -> None:
"""停止 Gateway 连接"""
def stop(self) -> bool:
"""停止 Gateway 连接,并返回后台线程是否已经终止。"""
if self._gateway_stop is not None:
self._gateway_stop.set()
try:
@@ -170,12 +172,19 @@ class QQBot:
self._gateway_ws_holder[0].close()
except Exception as e:
logger.debug(f"QQ Bot Gateway WebSocket close: {e}")
if self._gateway_thread is not None and self._gateway_thread.is_alive():
self._gateway_thread.join(timeout=20)
if self._gateway_thread.is_alive():
logger.warning(
"QQ Bot Gateway 线程在 stop 后仍未退出,可能存在重复收消息,请重启进程"
)
gateway_thread = self._gateway_thread
if (
gateway_thread is not None
and gateway_thread.is_alive()
and gateway_thread is not threading.current_thread()
):
gateway_thread.join(timeout=self._gateway_join_timeout_seconds)
if gateway_thread is not None and gateway_thread.is_alive():
logger.warning(
"QQ Bot Gateway 线程在 stop 后仍未退出,可能存在重复收消息,请重启进程"
)
return False
return True
def get_state(self) -> bool:
"""获取就绪状态"""
+3 -7
View File
@@ -77,13 +77,9 @@ class SlackModule(_MessageChannelModuleBase[Slack]):
"""
return 3
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
try:
client.stop()
except Exception as err:
logger.error(f"停止Slack模块实例失败:{err}")
def stop(self) -> bool:
"""停止全部 Slack 实例,并返回资源是否全部收敛。"""
return self._stop_service_instances()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
+4 -1
View File
@@ -117,13 +117,16 @@ class Slack:
timeout=timeout,
)
def stop(self):
def stop(self) -> bool:
"""关闭 Socket Mode 服务,并返回资源是否成功释放。"""
if self._service:
try:
self._service.close()
logger.info("Slack消息接收服务已停止")
except Exception as err:
logger.error("Slack消息接收服务停止失败: %s" % str(err))
return False
return True
def get_state(self) -> bool:
"""
+1 -9
View File
@@ -74,15 +74,7 @@ class TelegramModule(_MessageChannelModuleBase[Telegram]):
def stop(self) -> bool:
"""停止全部 Telegram 实例,并返回资源是否全部收敛。"""
converged = True
for client in self.get_instances().values():
try:
if client.stop() is False:
converged = False
except Exception as err:
logger.error(f"停止Telegram模块实例失败:{err}")
converged = False
return converged
return self._stop_service_instances()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""
+3 -8
View File
@@ -73,14 +73,9 @@ class WechatModule(_MessageChannelModuleBase[WeChat]):
"""
return 1
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
try:
if hasattr(client, "stop"):
client.stop()
except Exception as err:
logger.error(f"停止微信模块实例失败:{err}")
def stop(self) -> bool:
"""停止全部微信实例,并返回长连接资源是否全部收敛。"""
return self._stop_service_instances()
@staticmethod
def _is_bot_mode(config: dict) -> bool:
+25 -5
View File
@@ -38,6 +38,8 @@ class WeChatBot:
_default_ws_url = "wss://openws.work.weixin.qq.com"
_heartbeat_interval = 30
_ack_timeout = 10
_gateway_join_timeout_seconds = 5
_heartbeat_join_timeout_seconds = 2
def __init__(self,
WECHAT_BOT_ID: Optional[str] = None,
@@ -126,7 +128,8 @@ class WeChatBot:
self._heartbeat_thread.start()
logger.info(f"企业微信智能机器人长连接已启动:{self._config_name}")
def stop(self) -> None:
def stop(self) -> bool:
"""停止网关与心跳线程,并返回两个 owner 是否均已终止。"""
self._stop_event.set()
self._authenticated.clear()
if self._ws_app:
@@ -134,10 +137,27 @@ class WeChatBot:
self._ws_app.close()
except Exception as err:
logger.debug(f"关闭企业微信智能机器人连接失败:{err}")
if self._ws_thread and self._ws_thread.is_alive():
self._ws_thread.join(timeout=5)
if self._heartbeat_thread and self._heartbeat_thread.is_alive():
self._heartbeat_thread.join(timeout=2)
ws_thread = self._ws_thread
heartbeat_thread = self._heartbeat_thread
if (
ws_thread
and ws_thread.is_alive()
and ws_thread is not threading.current_thread()
):
ws_thread.join(timeout=self._gateway_join_timeout_seconds)
if (
heartbeat_thread
and heartbeat_thread.is_alive()
and heartbeat_thread is not threading.current_thread()
):
heartbeat_thread.join(timeout=self._heartbeat_join_timeout_seconds)
converged = not any(
thread and thread.is_alive()
for thread in (ws_thread, heartbeat_thread)
)
if not converged:
logger.error("企业微信智能机器人线程未在关闭预算内退出")
return converged
def get_state(self) -> bool:
return self._ready and self._authenticated.is_set()
+3 -7
View File
@@ -69,13 +69,9 @@ class WechatClawBotModule(_MessageChannelModuleBase[WechatClawBot]):
"""
return False
def stop(self) -> None:
"""停止模块"""
for client in self.get_instances().values():
try:
client.stop()
except Exception as err:
logger.error(f"停止微信 ClawBot 模块实例失败:{err}")
def stop(self) -> bool:
"""停止全部微信 ClawBot 实例,并返回资源是否全部收敛。"""
return self._stop_service_instances()
def _test_connection(self, client) -> Tuple[bool, str]:
"""微信 ClawBot 的连接探测返回 (状态, 信息)。"""
+14 -4
View File
@@ -1441,6 +1441,7 @@ class WechatClawBot:
_default_base_url = "https://ilinkai.weixin.qq.com"
_qrcode_ttl_seconds = 240
_active_target_ttl_seconds = 24 * 60 * 60
_poll_join_timeout_seconds = 5
@classmethod
def _build_cache_key(cls, config_name: str) -> str:
@@ -1706,12 +1707,21 @@ class WechatClawBot:
"""获取当前登录状态。"""
return bool(self._state.get("bot_token"))
def stop(self) -> None:
"""停止消息轮询。"""
def stop(self) -> bool:
"""停止消息轮询,并保留超时线程 owner 供后续重试"""
self._stop_event.set()
if self._poll_thread and self._poll_thread.is_alive():
self._poll_thread.join(timeout=5)
poll_thread = self._poll_thread
if (
poll_thread
and poll_thread.is_alive()
and poll_thread is not threading.current_thread()
):
poll_thread.join(timeout=self._poll_join_timeout_seconds)
if poll_thread and poll_thread.is_alive():
logger.error("微信 ClawBot 消息轮询线程未在关闭预算内退出")
return False
self._poll_thread = None
return True
def _start_polling(self) -> None:
"""启动消息轮询线程。"""