"""Feishu/Lark channel — connects to Feishu via WebSocket (no public IP needed).""" from __future__ import annotations import asyncio import json import logging import threading from typing import Any from src.channels.base import Channel from src.channels.message_bus import InboundMessageType, MessageBus, OutboundMessage logger = logging.getLogger(__name__) class FeishuChannel(Channel): """Feishu/Lark IM channel using the ``lark-oapi`` WebSocket client. Configuration keys (in ``config.yaml`` under ``channels.feishu``): - ``app_id``: Feishu app ID. - ``app_secret``: Feishu app secret. - ``verification_token``: (optional) Event verification token. The channel uses WebSocket long-connection mode so no public IP is required. Message flow: 1. User sends a message → bot adds "OK" emoji reaction 2. Bot replies in thread: "Working on it......" 3. Agent processes the message and returns a result 4. Bot replies in thread with the result 5. Bot adds "DONE" emoji reaction to the original message """ def __init__(self, bus: MessageBus, config: dict[str, Any]) -> None: super().__init__(name="feishu", bus=bus, config=config) self._thread: threading.Thread | None = None self._main_loop: asyncio.AbstractEventLoop | None = None self._api_client = None self._CreateMessageReactionRequest = None self._CreateMessageReactionRequestBody = None self._Emoji = None async def start(self) -> None: if self._running: return try: import lark_oapi as lark from lark_oapi.api.im.v1 import ( CreateMessageReactionRequest, CreateMessageReactionRequestBody, CreateMessageRequest, CreateMessageRequestBody, Emoji, ReplyMessageRequest, ReplyMessageRequestBody, ) except ImportError: logger.error("lark-oapi is not installed. Install it with: uv add lark-oapi") return self._lark = lark self._CreateMessageRequest = CreateMessageRequest self._CreateMessageRequestBody = CreateMessageRequestBody self._ReplyMessageRequest = ReplyMessageRequest self._ReplyMessageRequestBody = ReplyMessageRequestBody self._CreateMessageReactionRequest = CreateMessageReactionRequest self._CreateMessageReactionRequestBody = CreateMessageReactionRequestBody self._Emoji = Emoji app_id = self.config.get("app_id", "") app_secret = self.config.get("app_secret", "") if not app_id or not app_secret: logger.error("Feishu channel requires app_id and app_secret") return self._api_client = lark.Client.builder().app_id(app_id).app_secret(app_secret).build() self._main_loop = asyncio.get_event_loop() self._running = True self.bus.subscribe_outbound(self._on_outbound) # Both ws.Client construction and start() must happen in a dedicated # thread with its own event loop. lark-oapi caches the running loop # at construction time and later calls loop.run_until_complete(), # which conflicts with an already-running uvloop. self._thread = threading.Thread( target=self._run_ws, args=(app_id, app_secret), daemon=True, ) self._thread.start() logger.info("Feishu channel started") def _run_ws(self, app_id: str, app_secret: str) -> None: """Construct and run the lark WS client in a thread with a fresh event loop. The lark-oapi SDK captures a module-level event loop at import time (``lark_oapi.ws.client.loop``). When uvicorn uses uvloop, that captured loop is the *main* thread's uvloop — which is already running, so ``loop.run_until_complete()`` inside ``Client.start()`` raises ``RuntimeError``. We work around this by creating a plain asyncio event loop for this thread and patching the SDK's module-level reference before calling ``start()``. """ loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: import lark_oapi as lark import lark_oapi.ws.client as _ws_client_mod # Replace the SDK's module-level loop so Client.start() uses # this thread's (non-running) event loop instead of the main # thread's uvloop. _ws_client_mod.loop = loop event_handler = lark.EventDispatcherHandler.builder("", "").register_p2_im_message_receive_v1(self._on_message).build() ws_client = lark.ws.Client( app_id=app_id, app_secret=app_secret, event_handler=event_handler, log_level=lark.LogLevel.INFO, ) ws_client.start() except Exception: if self._running: logger.exception("Feishu WebSocket error") async def stop(self) -> None: self._running = False self.bus.unsubscribe_outbound(self._on_outbound) if self._thread: self._thread.join(timeout=5) self._thread = None logger.info("Feishu channel stopped") async def send(self, msg: OutboundMessage, *, _max_retries: int = 3) -> None: if not self._api_client: logger.warning("[Feishu] send called but no api_client available") return logger.info( "[Feishu] sending reply: chat_id=%s, thread_ts=%s, text_len=%d", msg.chat_id, msg.thread_ts, len(msg.text), ) content = self._build_card_content(msg.text) last_exc: Exception | None = None for attempt in range(_max_retries): try: if msg.thread_ts: # Reply in thread (话题) request = self._ReplyMessageRequest.builder().message_id(msg.thread_ts).request_body(self._ReplyMessageRequestBody.builder().msg_type("interactive").content(content).reply_in_thread(True).build()).build() await asyncio.to_thread(self._api_client.im.v1.message.reply, request) else: # Send new message request = self._CreateMessageRequest.builder().receive_id_type("chat_id").request_body(self._CreateMessageRequestBody.builder().receive_id(msg.chat_id).msg_type("interactive").content(content).build()).build() await asyncio.to_thread(self._api_client.im.v1.message.create, request) # Add "DONE" reaction to the original message on final reply if msg.is_final and msg.thread_ts: await self._add_reaction(msg.thread_ts, "DONE") return # success except Exception as exc: last_exc = exc if attempt < _max_retries - 1: delay = 2**attempt # 1s, 2s logger.warning( "[Feishu] send failed (attempt %d/%d), retrying in %ds: %s", attempt + 1, _max_retries, delay, exc, ) await asyncio.sleep(delay) logger.error("[Feishu] send failed after %d attempts: %s", _max_retries, last_exc) raise last_exc # type: ignore[misc] # -- message formatting ------------------------------------------------ @staticmethod def _build_card_content(text: str) -> str: """Build a Feishu interactive card with markdown content. Feishu's interactive card format natively renders markdown, including headers, bold/italic, code blocks, lists, and links. """ card = { "config": {"wide_screen_mode": True}, "elements": [{"tag": "markdown", "content": text}], } return json.dumps(card) # -- reaction helpers -------------------------------------------------- async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None: """Add an emoji reaction to a message.""" if not self._api_client or not self._CreateMessageReactionRequest: return try: request = self._CreateMessageReactionRequest.builder().message_id(message_id).request_body(self._CreateMessageReactionRequestBody.builder().reaction_type(self._Emoji.builder().emoji_type(emoji_type).build()).build()).build() await asyncio.to_thread(self._api_client.im.v1.message_reaction.create, request) logger.info("[Feishu] reaction '%s' added to message %s", emoji_type, message_id) except Exception: logger.exception("[Feishu] failed to add reaction '%s' to message %s", emoji_type, message_id) async def _send_running_reply(self, message_id: str) -> None: """Reply to a message in-thread with a 'Working on it...' hint.""" if not self._api_client: return try: content = self._build_card_content("Working on it...") request = self._ReplyMessageRequest.builder().message_id(message_id).request_body(self._ReplyMessageRequestBody.builder().msg_type("interactive").content(content).reply_in_thread(True).build()).build() await asyncio.to_thread(self._api_client.im.v1.message.reply, request) logger.info("[Feishu] 'Working on it......' reply sent for message %s", message_id) except Exception: logger.exception("[Feishu] failed to send running reply for message %s", message_id) # -- internal ---------------------------------------------------------- @staticmethod def _log_future_error(fut, name: str, msg_id: str) -> None: """Callback for run_coroutine_threadsafe futures to surface errors.""" try: exc = fut.exception() if exc: logger.error("[Feishu] %s failed for msg_id=%s: %s", name, msg_id, exc) except Exception: pass def _on_message(self, event) -> None: """Called by lark-oapi when a message is received (runs in lark thread).""" try: logger.info("[Feishu] raw event received: type=%s", type(event).__name__) message = event.event.message chat_id = message.chat_id msg_id = message.message_id sender_id = event.event.sender.sender_id.open_id # root_id is set when the message is a reply within a Feishu thread. # Use it as topic_id so all replies share the same DeerFlow thread. root_id = getattr(message, "root_id", None) or None # Parse message content content = json.loads(message.content) text = content.get("text", "").strip() logger.info( "[Feishu] parsed message: chat_id=%s, msg_id=%s, root_id=%s, sender=%s, text=%r", chat_id, msg_id, root_id, sender_id, text[:100] if text else "", ) if not text: logger.info("[Feishu] empty text, ignoring message") return # Check if it's a command if text.startswith("/"): msg_type = InboundMessageType.COMMAND else: msg_type = InboundMessageType.CHAT # topic_id: use root_id for replies (same topic), msg_id for new messages (new topic) topic_id = root_id or msg_id inbound = self._make_inbound( chat_id=chat_id, user_id=sender_id, text=text, msg_type=msg_type, thread_ts=msg_id, metadata={"message_id": msg_id, "root_id": root_id}, ) inbound.topic_id = topic_id # Schedule on the async event loop if self._main_loop and self._main_loop.is_running(): logger.info("[Feishu] publishing inbound message to bus (type=%s, msg_id=%s)", msg_type.value, msg_id) # Schedule all coroutines and attach error logging to futures for name, coro in [ ("add_reaction", self._add_reaction(msg_id, "OK")), ("send_running_reply", self._send_running_reply(msg_id)), ("publish_inbound", self.bus.publish_inbound(inbound)), ]: fut = asyncio.run_coroutine_threadsafe(coro, self._main_loop) fut.add_done_callback(lambda f, n=name, mid=msg_id: self._log_future_error(f, n, mid)) else: logger.warning("[Feishu] main loop not running, cannot publish inbound message") except Exception: logger.exception("[Feishu] error processing message")