184 lines
6.5 KiB
Python

# pyright: reportMissingTypeStubs=false, reportPrivateUsage=false
"""Shared Feishu/Lark WebSocket runtime.
The official lark_oapi websocket client stores an asyncio loop in a module-level
variable. Running one blocking ``Client.start()`` per assistant would make
multiple Feishu instances fragile, so this module centralizes the loop patch and
starts each client through the SDK's async primitives on one dedicated loop.
"""
from __future__ import annotations
import asyncio
import threading
from collections.abc import Awaitable, Callable
from contextlib import suppress
from dataclasses import dataclass, field
from typing import Any, Protocol
from loguru import logger
class _LarkWsClient(Protocol):
"""Private SDK surface isolated behind the Feishu runtime adapter."""
_auto_reconnect: bool
_receive_message_loop: Callable[[], Awaitable[None]]
async def _connect(self) -> None: ...
async def _disconnect(self) -> None: ...
async def _ping_loop(self) -> None: ...
@dataclass
class _ClientRuntime:
client: _LarkWsClient
stop_event: asyncio.Event
task: asyncio.Task[Any] | None
receive_loop: Callable[[], Awaitable[None]]
auto_reconnect: bool
receive_tasks: set[asyncio.Task[Any]] = field(default_factory=set)
class FeishuWsRunner:
"""Run multiple lark_oapi websocket clients on one dedicated event loop."""
def __init__(self) -> None:
self._thread: threading.Thread | None = None
self._loop: asyncio.AbstractEventLoop | None = None
self._ready = threading.Event()
self._lock = threading.Lock()
self._clients: dict[str, _ClientRuntime] = {}
async def start_client(self, key: str, client: _LarkWsClient) -> None:
"""Start or replace one client runtime."""
loop = self._ensure_loop()
await asyncio.wrap_future(
asyncio.run_coroutine_threadsafe(self._start_client(key, client), loop)
)
async def stop_client(self, key: str) -> None:
"""Stop one client runtime if it is active."""
loop = self._loop
if loop is None or loop.is_closed():
return
await asyncio.wrap_future(asyncio.run_coroutine_threadsafe(self._stop_client(key), loop))
def _ensure_loop(self) -> asyncio.AbstractEventLoop:
with self._lock:
if self._loop is not None and not self._loop.is_closed():
return self._loop
self._ready.clear()
self._thread = threading.Thread(target=self._run_loop, name="feishu-ws", daemon=True)
self._thread.start()
if not self._ready.wait(timeout=10) or self._loop is None:
raise RuntimeError("Feishu WebSocket runner did not start")
return self._loop
def _run_loop(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
import lark_oapi.ws.client as lark_ws_client
lark_ws_client.loop = loop
self._loop = loop
self._ready.set()
loop.run_forever()
finally:
with suppress(Exception):
loop.run_until_complete(loop.shutdown_asyncgens())
loop.close()
async def _start_client(self, key: str, client: _LarkWsClient) -> None:
await self._stop_client(key)
stop_event = asyncio.Event()
receive_loop = client._receive_message_loop
runtime = _ClientRuntime(
client=client,
stop_event=stop_event,
task=None,
receive_loop=receive_loop,
auto_reconnect=client._auto_reconnect,
)
# The SDK discards this task handle. Track it at the adapter boundary so
# an intentional stop can cancel recv() before closing the socket; otherwise
# the SDK logs close code 1000 as an error and starts an unwanted reconnect.
async def tracked_receive_loop() -> None:
if stop_event.is_set():
return
task = asyncio.current_task()
if task is not None:
runtime.receive_tasks.add(task)
try:
await receive_loop()
finally:
if task is not None:
runtime.receive_tasks.discard(task)
client._receive_message_loop = tracked_receive_loop
runtime.task = asyncio.create_task(self._client_main(key, client, stop_event))
self._clients[key] = runtime
async def _stop_client(self, key: str) -> None:
runtime = self._clients.pop(key, None)
if runtime is None:
return
runtime.stop_event.set()
runtime.client._auto_reconnect = False
try:
receive_tasks = tuple(runtime.receive_tasks)
for task in receive_tasks:
task.cancel()
if receive_tasks:
await asyncio.gather(*receive_tasks, return_exceptions=True)
if runtime.task is not None:
runtime.task.cancel()
with suppress(asyncio.CancelledError):
await runtime.task
with suppress(Exception):
await runtime.client._disconnect()
finally:
runtime.client._receive_message_loop = runtime.receive_loop
runtime.client._auto_reconnect = runtime.auto_reconnect
async def _client_main(
self, key: str, client: _LarkWsClient, stop_event: asyncio.Event
) -> None:
ping_task: asyncio.Task[None] | None = None
while not stop_event.is_set():
try:
await client._connect()
ping_task = asyncio.create_task(client._ping_loop())
await stop_event.wait()
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning("Feishu WebSocket client '{}' failed: {}", key, exc)
with suppress(Exception):
await client._disconnect()
if not stop_event.is_set():
await asyncio.sleep(5)
finally:
if ping_task is not None:
ping_task.cancel()
with suppress(asyncio.CancelledError):
await ping_task
with suppress(Exception):
await client._disconnect()
_runner: FeishuWsRunner | None = None
def get_feishu_ws_runner() -> FeishuWsRunner:
"""Return the process-wide Feishu WebSocket runner."""
global _runner
if _runner is None:
_runner = FeishuWsRunner()
return _runner