from __future__ import annotations import asyncio import json import os import time from typing import Any, ClassVar from yuxi.channels.base import BaseChannelAdapter from yuxi.channels.capabilities import ChannelCapabilities from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelConnectionError, ) from yuxi.channels.infra.circuit_breaker import CircuitBreaker, CircuitBreakerOpenError from yuxi.channels.meta import ChannelMeta from yuxi.channels.models import ( ChannelMessage, ChannelResponse, ChannelStatus, ChannelType, ChatType, DeliveryResult, HealthStatus, ) from yuxi.channels.registry import register_builtin_adapter from yuxi.utils.datetime_utils import utc_now_naive from yuxi.utils.logging_config import logger from .accounts import FeishuAccountManager from .approval import FeishuApprovalAdapter from .card_action import decode_card_action, validate_card_context from .cards import TEXT_CHUNK_LIMIT from .chat_cache import ChatNameCache from .client import FeishuClientCache from .commands import build_synthetic_message, parse_bot_menu_event from .comment_handler import handle_comment_event from .dedup import FeishuDedupStore from .directory import list_groups as _list_groups_feishu from .directory import list_peers as _list_peers_feishu from .dynamic_agent import FeishuDynamicAgentConfig, FeishuDynamicAgentManager from .formatter import format_outbound from .media import ( download_media as _download_media, ) from .media import ( upload_file, upload_image, ) from .normalizer import normalize_inbound from .oauth import TokenBackoffManager from .pins import create_pin as _create_pin from .pins import list_pins as _list_pins from .pins import remove_pin as _remove_pin from .reactions import ( clear_all_bot_reactions, ) from .reactions import ( list_reactions as _list_reactions, ) from .reactions import ( remove_reaction as _remove_reaction, ) from .reply_dispatcher import dispatch_render, extract_urls from .secret_resolver import resolve_secret from .send import ( forward_message as _forward_message, ) from .send import ( get_message_read_status as _get_message_read_status, ) from .send import ( is_local_image_path, send_card, send_text, update_card_message, ) from .send import ( list_messages as _list_messages, ) from .send import ( read_message as _read_message, ) from .send import ( reply_message as _reply_message, ) from .send import ( send_reaction as _send_reaction, ) from .sequential import FeishuSequentialQueue from .session import CHAT_ID_PREFIX_DM, CHAT_ID_PREFIX_GROUP from .stream import STREAM_START_BACKOFF_S, FeishuStreamingSession from .thread_bindings import ThreadBindingManager from .tts import FeishuTTSConfig, send_tts_audio from .typing import TypingIndicator from .verify import verify_and_decrypt_webhook, verify_feishu_signature try: import lark_oapi HAS_LARK_SDK = True except ImportError: HAS_LARK_SDK = False lark_oapi = None # type: ignore @register_builtin_adapter class FeishuAdapter(BaseChannelAdapter): channel_id = "feishu" channel_type = ChannelType.FEISHU text_chunk_limit = TEXT_CHUNK_LIMIT supports_markdown = True supports_streaming = True streaming_modes = ["off", "block"] max_media_size_mb = 50 capabilities = ChannelCapabilities( chat_types=["direct", "group", "topic_group"], delivery_mode="direct", reactions=True, edit=True, unsend=True, reply=True, forward=True, media=True, threads=True, pin=True, unpin=True, list_pins=True, supports_markdown=True, supports_streaming=True, streaming_modes=["off", "block"], text_chunk_limit=TEXT_CHUNK_LIMIT, max_media_size_mb=50, ) meta = ChannelMeta(id="feishu", label="Feishu", aliases=["lark"]) webhook_path = "/api/channels/feishu/events" _token_refresh_margin = 300 _http_timeout_ms: ClassVar[int] = 30000 _ws_ping_interval_s: ClassVar[int] = 30 def __init__(self, config: dict[str, Any] | None = None): super().__init__(config) self._status = ChannelStatus.DISCONNECTED self._app_id: str = "" self._app_secret: str = "" self._bot_open_id: str = "" self._platform: str = "feishu" self._verify_token: str = "" self._encrypt_key: str = "" self._domain: str = "" self._lark_client: Any = None self._ws_client: Any = None self._ws_task: asyncio.Task | None = None self._ws_clients: dict[str, Any] = {} self._ws_tasks: dict[str, asyncio.Task] = {} self._account_clients: dict[str, Any] = {} self._multi_account_mode: bool = False self._circuit_breaker = CircuitBreaker(failure_threshold=5, recovery_timeout=60, channel_id="feishu") self._token_refresh_task: asyncio.Task | None = None self._connected_at: float | None = None self._token_expire_at: float = 0 self._pending_streams: dict[str, str] = {} self._stream_lock = asyncio.Lock() self._stream_sessions: dict[str, FeishuStreamingSession] = {} self._stream_backoff_until: dict[str, float] = {} self._stream_context: dict[str, dict[str, str | None]] = {} self._typing: TypingIndicator | None = None self._ws_retry_count = 0 self._ws_base_delay = 1.0 self._ws_max_delay = 60.0 self._ws_jitter = 0.3 self._dedup = FeishuDedupStore( persist_path=self.config.get("dedup_persist_path", ""), ) self._chat_cache = ChatNameCache() self._sequential = FeishuSequentialQueue() self._reply_in_thread: bool = False self._reaction_notifications: str = "all" self._render_mode: str = "auto" self._tts_config: FeishuTTSConfig = FeishuTTSConfig() self._health_cache: tuple[float, Any] | None = None self._health_cache_healthy_ttl = 600.0 self._health_cache_failed_ttl = 60.0 self._accounts = FeishuAccountManager(config) self._client_cache = FeishuClientCache() self._thread_bindings = ThreadBindingManager() self._approval_adapter: FeishuApprovalAdapter | None = None self._dynamic_agent = FeishuDynamicAgentManager() self._group_session_scope: str = "group" self._reactions_enabled: bool = True self._tool_enabled: dict[str, bool] = {} self._401_backoff = TokenBackoffManager(max_retries=3, base_delay=1.0, max_delay=30.0) self._token_refresh_lock = asyncio.Lock() async def _handle_401_backoff(self) -> None: await self._401_backoff.wait_if_backing_off() async def _on_401_error(self) -> bool: if not self._401_backoff.record_error(): return False async with self._token_refresh_lock: if self._token_expire_at > time.time() + self._token_refresh_margin: return True try: return await self._refresh_token() except Exception as e: logger.error(f"[Feishu] Token refresh after 401 failed: {e}") return False def _reset_401_backoff(self) -> None: self._401_backoff.reset() async def connect(self) -> None: if self._status == ChannelStatus.CONNECTED: return if not HAS_LARK_SDK: raise ChannelAuthenticationError() self._status = ChannelStatus.CONNECTING logger.info(f"[Feishu] Starting channel '{self.config.get('name', self.channel_id)}'") self._app_id = resolve_secret(self.config, "app_id", "FEISHU_APP_ID") self._app_secret = resolve_secret(self.config, "app_secret", "FEISHU_APP_SECRET") self._bot_open_id = self.config.get("bot_open_id", "") self._platform = self.config.get("platform", "feishu") self._verify_token = resolve_secret(self.config, "verify_token", "FEISHU_VERIFY_TOKEN") self._encrypt_key = resolve_secret(self.config, "encrypt_key", "FEISHU_ENCRYPT_KEY") self._reply_in_thread = self.config.get("replyInThread", False) self._reaction_notifications = self.config.get("reactionNotifications", "all") self._render_mode = self.config.get("renderMode", "auto") self._tts_config = FeishuTTSConfig.from_config(self.config) self._approval_adapter = FeishuApprovalAdapter.from_config(self.config) self._dynamic_agent = FeishuDynamicAgentManager(FeishuDynamicAgentConfig.from_config(self.config)) self._group_session_scope = self.config.get("groupSessionScope", "group") self._reactions_enabled = self.config.get("reactionsEnabled", True) self._tool_enabled = { "doc": self.config.get("tools", {}).get("doc", True), "chat": self.config.get("tools", {}).get("chat", True), "wiki": self.config.get("tools", {}).get("wiki", True), "drive": self.config.get("tools", {}).get("drive", True), "perm": self.config.get("tools", {}).get("perm", False), } self._ws_ping_interval_s = int(self.config.get("wsPingIntervalS", self._ws_ping_interval_s)) if not self._app_id or not self._app_secret: raise ChannelAuthenticationError("app_id and app_secret are required") self._webhook_path = self.config.get("webhookPath", self.webhook_path) self._token_refresh_margin = self.config.get("tokenRefreshMargin", self._token_refresh_margin) domain = self.config.get("domain", "") if domain: self._domain = domain elif self._platform == "lark": self._domain = "open.larksuite.com" else: self._domain = "open.feishu.cn" timeout_ms_str = os.environ.get("FEISHU_HTTP_TIMEOUT_MS", "") if timeout_ms_str and timeout_ms_str.isdigit(): self._http_timeout_ms = int(timeout_ms_str) self._webhook_max_body_bytes = int(os.environ.get("FEISHU_WEBHOOK_MAX_BODY_BYTES", str(10 * 1024 * 1024))) self._webhook_body_timeout_ms = int(os.environ.get("FEISHU_WEBHOOK_BODY_TIMEOUT_MS", "30000")) http_proxy = self.config.get("httpProxy", "") or os.environ.get("FEISHU_HTTP_PROXY", "") builder = lark_oapi.Client.builder().app_id(self._app_id).app_secret(self._app_secret).domain(self._domain) if http_proxy: builder.http_proxy(http_proxy) self._lark_client = builder.build() try: token_resp = await asyncio.to_thread(self._lark_client.auth.tenant_access_token_internal) except Exception as e: raise ChannelAuthenticationError(str(e)) from e if not token_resp.success(): raise ChannelAuthenticationError(f"Token acquisition failed: {token_resp.msg}") data = token_resp.data if hasattr(token_resp, "data") else {} token_value = data.get("tenant_access_token", "") if not token_value: raise ChannelAuthenticationError("Token acquisition succeeded but returned empty token value") expire = data.get("expire", 0) if expire <= 0: raise ChannelAuthenticationError(f"Token acquisition returned invalid expire: {expire}") self._token_expire_at = time.time() + expire logger.info(f"[Feishu] Token acquired, expire in {expire}s") self._start_token_refresh_loop() enabled_accounts = self._accounts.list_enabled() if len(enabled_accounts) > 1: self._multi_account_mode = True await self._start_multi_account_monitoring(enabled_accounts) else: await self._start_long_poll() self._status = ChannelStatus.CONNECTED self._connected_at = time.time() logger.info(f"[Feishu] Channel started, platform: {self._platform}") async def disconnect(self) -> None: if self._status == ChannelStatus.DISCONNECTED: return logger.info(f"[Feishu] Stopping channel '{self.config.get('name', self.channel_id)}'") if self._token_refresh_task and not self._token_refresh_task.done(): self._token_refresh_task.cancel() try: await self._token_refresh_task except asyncio.CancelledError: pass self._token_refresh_task = None if self._ws_task and not self._ws_task.done(): self._ws_task.cancel() try: await self._ws_task except asyncio.CancelledError: pass self._ws_task = None for name, task in list(self._ws_tasks.items()): if not task.done(): task.cancel() try: await task except asyncio.CancelledError: pass self._ws_tasks.pop(name, None) self._ws_client = None self._ws_clients.clear() self._account_clients.clear() self._multi_account_mode = False self._lark_client = None self._connected_at = None self._token_expire_at = 0 async with self._stream_lock: self._pending_streams.clear() self._stream_context.clear() for session in self._stream_sessions.values(): try: await session.close() except Exception: pass self._stream_sessions.clear() self._stream_backoff_until.clear() self._reset_401_backoff() self._status = ChannelStatus.DISCONNECTED def normalize_inbound(self, raw: dict[str, Any]) -> ChannelMessage: return normalize_inbound(self.channel_id, self.channel_type, raw, self._bot_open_id) def format_outbound(self, response: ChannelResponse) -> dict[str, Any]: content = response.content chat_type = response.identity.metadata.get("chat_type", "private") if response.identity.metadata else "private" return format_outbound(content, chat_type=chat_type, metadata=response.metadata) async def send(self, response: ChannelResponse) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") await self._handle_401_backoff() async def _do_send() -> DeliveryResult: payload = self.format_outbound(response) chat_id = response.identity.channel_chat_id chat_type = ( response.identity.chat_type.value if isinstance(response.identity.chat_type, ChatType) else "private" ) identity_meta = response.identity.metadata or {} reply_to_msg_id = identity_meta.get("reply_to_msg_id", "") root_id = identity_meta.get("root_id", "") if reply_to_msg_id: self._stream_context[chat_id] = { "reply_to_msg_id": reply_to_msg_id, "root_id": root_id or reply_to_msg_id, } content = payload.get("content", "") buttons = payload.get("buttons") thread_id = payload.get("thread_id") template = payload.get("template") tone = payload.get("tone") use_post_format = payload.get("use_post_format", False) if image_path := is_local_image_path(content): try: with open(image_path, "rb") as f: image_data = f.read() except OSError as e: raise ChannelConnectionError(f"Failed to read image file: {e}") image_key = await upload_image(self._lark_client, image_data) result = await self._send_image_msg(chat_id, image_key) return result if chat_type in ("group", "thread") and reply_to_msg_id: result = await _reply_message( self._lark_client, reply_to_msg_id, content, buttons=buttons, thread_id=thread_id or (reply_to_msg_id if self._reply_in_thread else None), use_post_format=use_post_format, chat_type=chat_type, chat_id=chat_id, ) return result render_mode = dispatch_render(content, buttons=buttons, render_mode=self._render_mode) if render_mode == "card": url_unfurl = extract_urls(content) result = await send_card( self._lark_client, chat_id, content, chat_type=chat_type, buttons=buttons, thread_id=thread_id, template=template, tone=tone, url_unfurl=url_unfurl if url_unfurl else None, ) else: result = await send_text( self._lark_client, chat_id, content, chat_type=chat_type, thread_id=thread_id, use_post_format=use_post_format, ) return result try: result = await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") if not result.success and result.error_code == "auth_expired": if await self._on_401_error(): try: result = await self._circuit_breaker.call(_do_send) except CircuitBreakerOpenError: return DeliveryResult(success=False, error="Circuit breaker open") else: self._reset_401_backoff() return result async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") account_id = self.config.get("account_id", "default") now = time.monotonic() backoff_until = self._stream_backoff_until.get(account_id, 0) if backoff_until > now: return DeliveryResult(success=True, message_id=msg_id or "pending") async with self._stream_lock: session = self._stream_sessions.get(chat_id) if session is None and not finished: ctx = self._stream_context.get(chat_id, {}) reply_to_msg_id = ctx.get("reply_to_msg_id") or None root_id = ctx.get("root_id") or None session = FeishuStreamingSession( _client=self._lark_client, _chat_id=chat_id, _reply_to_msg_id=reply_to_msg_id, _root_id=root_id, _send_mode="reply" if reply_to_msg_id else ("root_create" if root_id else "create"), ) self._stream_sessions[chat_id] = session card_id = await session.start() if not card_id: self._stream_sessions.pop(chat_id, None) self._stream_backoff_until[account_id] = now + STREAM_START_BACKOFF_S fallback_result = await self._send_stream_fallback(chat_id, chunk, reply_to_msg_id, root_id) logger.warning(f"[Feishu] Stream start failed for {chat_id}, fell back to static card") return fallback_result if session is None: return DeliveryResult(success=True, message_id=msg_id or "pending") if finished: await session.close() self._stream_context.pop(chat_id, None) return DeliveryResult(success=True, message_id=session._message_id or msg_id) await session.update(chunk) return DeliveryResult(success=True, message_id=session._message_id or msg_id or "pending") async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") return await _send_reaction(self._lark_client, msg_id, emoji) async def remove_reaction(self, chat_id: str, msg_id: str, reaction_id: str) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") return await _remove_reaction(self._lark_client, msg_id, reaction_id) async def clear_all_reactions(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") return await clear_all_bot_reactions(self._lark_client, msg_id, self._bot_open_id) async def list_reactions(self, chat_id: str, msg_id: str) -> dict[str, Any]: if not self._lark_client: return {"reactions": [], "total": 0} return await _list_reactions(self._lark_client, msg_id) async def read_message(self, msg_id: str) -> dict: if not self._lark_client: return {} return await _read_message(self._lark_client, msg_id) async def list_messages( self, chat_id: str, page_token: str = "", page_size: int = 20, start_time: str = "", end_time: str = "", ) -> dict[str, Any]: if not self._lark_client: return {"messages": [], "has_more": False, "page_token": ""} return await _list_messages( self._lark_client, chat_id, page_token=page_token, page_size=page_size, start_time=start_time, end_time=end_time, ) async def forward_message( self, message_id: str, target_chat_id: str, target_type: str = "chat", ) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") return await _forward_message( self._lark_client, message_id, target_chat_id, target_type=target_type, ) async def get_message_read_status( self, message_id: str, page_token: str = "", page_size: int = 20, ) -> dict[str, Any]: if not self._lark_client: return {"users": [], "has_more": False, "page_token": ""} return await _get_message_read_status( self._lark_client, message_id, page_token=page_token, page_size=page_size, ) async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") try: if isinstance(data, bytes): size_mb = len(data) / (1024 * 1024) if size_mb > self.max_media_size_mb: return DeliveryResult( success=False, error=f"Media size {size_mb:.1f}MB exceeds limit {self.max_media_size_mb}MB", ) if media_type == "image": image_key = await upload_image(self._lark_client, data) return await self._send_image_msg(chat_id, image_key) elif media_type in ("file", "audio", "video"): filename = getattr(data, "filename", "file") if hasattr(data, "filename") else "file" file_key = await upload_file(self._lark_client, data, filename) return await self._send_media_msg(chat_id, file_key, media_type) else: return DeliveryResult(success=False, error=f"Unsupported media type: {media_type}") except Exception as e: logger.error(f"[Feishu] send_media failed: {e}") return DeliveryResult(success=False, error=str(e)) async def download_media(self, file_id: str) -> bytes: if not self._lark_client: raise RuntimeError("Not connected") try: info = json.loads(file_id) except (json.JSONDecodeError, TypeError) as e: raise ValueError(f"Invalid file_id format: {file_id}") from e return await _download_media( self._lark_client, info["message_id"], info["file_key"], info["file_type"], ) async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") try: content_json = json.dumps({"text": content}, ensure_ascii=False) request = ( lark_oapi.api.im.v1.PatchMessageRequest.builder() .message_id(msg_id) .request_body(lark_oapi.api.im.v1.PatchMessageRequestBody.builder().content(content_json).build()) .build() ) resp = await self._lark_client.im.v1.message.patch(request) if resp.success(): return DeliveryResult(success=True, message_id=msg_id) return DeliveryResult(success=False, error=f"Edit failed: {resp.msg}") except Exception as e: logger.error(f"[Feishu] edit_message failed: {e}") return DeliveryResult(success=False, error=str(e)) async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") try: request = lark_oapi.api.im.v1.DeleteMessageRequest.builder().message_id(msg_id).build() resp = await self._lark_client.im.v1.message.delete(request) if resp.success(): return DeliveryResult(success=True, message_id=msg_id) return DeliveryResult(success=False, error=f"Delete failed: {resp.msg}") except Exception as e: logger.error(f"[Feishu] delete_message failed: {e}") return DeliveryResult(success=False, error=str(e)) async def update_card(self, msg_id: str, content: str) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") return await update_card_message(self._lark_client, msg_id, content) async def _send_image_msg(self, chat_id: str, image_key: str) -> DeliveryResult: content = json.dumps({"image_key": image_key}, ensure_ascii=False) chat_type = "private" if chat_id.startswith(CHAT_ID_PREFIX_GROUP): chat_type = "group" receive_id = chat_id.replace(CHAT_ID_PREFIX_DM, "").replace(CHAT_ID_PREFIX_GROUP, "") receive_id_type = "open_id" if chat_type == "private" else "chat_id" request = ( lark_oapi.api.im.v1.CreateMessageRequest.builder() .receive_id_type(receive_id_type) .request_body( lark_oapi.api.im.v1.CreateMessageRequestBody.builder() .receive_id(receive_id) .msg_type("image") .content(content) .build() ) .build() ) resp = await self._lark_client.im.message.create(request) if resp.success(): return DeliveryResult(success=True, message_id=resp.data.get("message_id", "")) return DeliveryResult(success=False, error=f"Send image failed: {resp.msg}") async def _send_stream_fallback( self, chat_id: str, content: str, reply_to_msg_id: str | None, root_id: str | None ) -> DeliveryResult: chat_type = "group" if chat_id.startswith(CHAT_ID_PREFIX_GROUP) else "private" receive_id = chat_id.replace(CHAT_ID_PREFIX_DM, "").replace(CHAT_ID_PREFIX_GROUP, "") if reply_to_msg_id: return await _reply_message( self._lark_client, reply_to_msg_id, content, thread_id=root_id, chat_type=chat_type, chat_id=receive_id, ) return await send_card( self._lark_client, receive_id, content, chat_type=chat_type, thread_id=root_id, ) async def _send_media_msg(self, chat_id: str, file_key: str, media_type: str) -> DeliveryResult: msg_type_map = {"audio": "audio", "video": "media", "file": "file"} feishu_msg_type = msg_type_map.get(media_type, "file") content = json.dumps({"file_key": file_key}, ensure_ascii=False) chat_type = "private" if chat_id.startswith(CHAT_ID_PREFIX_GROUP): chat_type = "group" receive_id = chat_id.replace(CHAT_ID_PREFIX_DM, "").replace(CHAT_ID_PREFIX_GROUP, "") receive_id_type = "open_id" if chat_type == "private" else "chat_id" request = ( lark_oapi.api.im.v1.CreateMessageRequest.builder() .receive_id_type(receive_id_type) .request_body( lark_oapi.api.im.v1.CreateMessageRequestBody.builder() .receive_id(receive_id) .msg_type(feishu_msg_type) .content(content) .build() ) .build() ) resp = await self._lark_client.im.message.create(request) if resp.success(): return DeliveryResult(success=True, message_id=resp.data.get("message_id", "")) return DeliveryResult(success=False, error=f"Send {media_type} failed: {resp.msg}") async def health_check(self) -> HealthStatus: if not self._lark_client: return HealthStatus(status="unhealthy", last_error="Not connected") now = time.monotonic() if self._health_cache is not None: cached_at, cached_result = self._health_cache ttl = self._health_cache_healthy_ttl if cached_result.status == "healthy" else self._health_cache_failed_ttl if now - cached_at < ttl: return cached_result try: resp = self._lark_client.bot.v3.info() if not resp.success(): result = HealthStatus(status="degraded", last_error=f"Bot probe: {resp.msg}") self._health_cache = (now, result) return result result = HealthStatus( status="healthy", metadata={ "app_id": self._app_id, "platform": self._platform, "adapter_status": self._status.value, }, last_connected_at=utc_now_naive(), ) self._health_cache = (now, result) return result except Exception as e: result = HealthStatus(status="unhealthy", last_error=str(e)) self._health_cache = (now, result) return result async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool: return verify_feishu_signature(headers, body, self._encrypt_key) async def verify_and_decrypt_webhook( self, headers: dict, body: bytes, source_ip: str = "default" ) -> tuple[bool, bytes | None, str]: return verify_and_decrypt_webhook(headers, body, self._encrypt_key, source_ip) async def get_user_info(self, channel_user_id: str) -> dict[str, Any]: if not self._lark_client: return {} try: request = ( lark_oapi.api.contact.v3.GetUserRequest.builder() .user_id(channel_user_id) .user_id_type("open_id") .build() ) resp = self._lark_client.contact.v3.user.get(request) if resp.success(): user = resp.data.user if hasattr(resp.data, "user") else {} avatar_info = getattr(user, "avatar", {}) or {} return { "open_id": getattr(user, "open_id", ""), "user_id": getattr(user, "user_id", ""), "union_id": getattr(user, "union_id", ""), "name": getattr(user, "name", ""), "en_name": getattr(user, "en_name", ""), "nickname": getattr(user, "nickname", ""), "email": getattr(user, "email", ""), "enterprise_email": getattr(user, "enterprise_email", ""), "mobile": getattr(user, "mobile", ""), "mobile_visible": getattr(user, "mobile_visible", False), "avatar_url": avatar_info.get("avatar_240", ""), "department_ids": getattr(user, "department_ids", []), "job_title": getattr(user, "job_title", ""), "employee_no": getattr(user, "employee_no", ""), "status": getattr(user, "status", None), "leader_user_id": getattr(user, "leader_user_id", ""), "city": getattr(user, "city", ""), "country": getattr(user, "country", ""), "work_station": getattr(user, "work_station", ""), } return {} except Exception as e: logger.warning(f"[Feishu] get_user_info failed for {channel_user_id}: {e}") return {} async def get_channel_info(self, chat_id: str, *, include_members: bool = False) -> dict: if not self._lark_client: return {} try: request = lark_oapi.api.im.v1.GetChatRequest.builder().chat_id(chat_id).build() resp = self._lark_client.im.v1.chat.get(request) if resp.success(): chat = resp.data if hasattr(resp.data, "chat") else resp.data return { "chat_id": getattr(chat, "chat_id", chat_id), "name": getattr(chat, "name", ""), "description": getattr(chat, "description", ""), "owner_id": getattr(chat, "owner_id", ""), "owner_id_type": getattr(chat, "owner_id_type", ""), "member_count": getattr(chat, "member_count", 0), "chat_type": getattr(chat, "chat_type", ""), } return {} except Exception as e: logger.warning(f"[Feishu] get_channel_info failed for {chat_id}: {e}") return {} async def list_channels(self, *, scope: str = "all") -> list[dict]: return await _list_groups_feishu(self._lark_client) async def pin_message(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") return await _create_pin(self._lark_client, msg_id) async def unpin_message(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") return await _remove_pin(self._lark_client, msg_id) async def list_pins(self, chat_id: str) -> dict[str, Any]: if not self._lark_client: return {"pins": [], "total": 0} return await _list_pins(self._lark_client, chat_id) async def start_typing(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._lark_client: return DeliveryResult(success=False, error="Not connected") if self._typing is None: self._typing = TypingIndicator(_client=self._lark_client) account_id = self.config.get("account_id", "default") ok = await self._typing.start(msg_id, account_id) return DeliveryResult(success=ok) async def stop_typing(self, chat_id: str, msg_id: str) -> DeliveryResult: if not self._lark_client or self._typing is None: return DeliveryResult(success=False, error="Not connected") ok = await self._typing.stop(msg_id) return DeliveryResult(success=ok) async def list_peers(self) -> list[dict]: if not self._lark_client: return [] return await _list_peers_feishu(self._lark_client) async def ping(self) -> dict[str, Any]: if not self._lark_client: return {"status": "disconnected", "latency_ms": None} t0 = time.monotonic() try: resp = self._lark_client.bot.v3.info() latency_ms = round((time.monotonic() - t0) * 1000, 1) if resp.success(): return { "status": "healthy", "latency_ms": latency_ms, "app_id": self._app_id, "platform": self._platform, "adapter_status": self._status.value, } return { "status": "degraded", "latency_ms": latency_ms, "error": resp.msg if hasattr(resp, "msg") else str(resp), } except Exception as e: latency_ms = round((time.monotonic() - t0) * 1000, 1) return {"status": "unreachable", "latency_ms": latency_ms, "error": str(e)} async def send_tts(self, chat_id: str, text: str) -> bool: if not self._lark_client: return False return await send_tts_audio( self._lark_client, chat_id, text, tts_config=self._tts_config, ) async def _start_multi_account_monitoring(self, accounts: list) -> None: for account in accounts: account_client = ( lark_oapi.Client.builder() .app_id(account.app_id) .app_secret(account.app_secret) .domain(account.domain or self._domain) .build() ) self._account_clients[account.name] = account_client try: ws_client = lark_oapi.ws.Client( app_id=account.app_id, app_secret=account.app_secret, event_handler=self._make_account_event_handler(account.name), log_level=30, ) except Exception as e: logger.error(f"[Feishu] Failed to init WS client for account '{account.name}': {e}") continue self._ws_clients[account.name] = ws_client self._ws_tasks[account.name] = asyncio.create_task( self._account_ws_runner(account.name, ws_client, account) ) logger.info(f"[Feishu] Started WS monitoring for account '{account.name}'") def _make_account_event_handler(self, account_name: str): async def handler(event: dict) -> None: await self._handle_ws_event_for_account(event, account_name) return handler async def _handle_ws_event_for_account(self, event: dict, account_name: str) -> None: account = self._accounts.get_account(account_name) if account is None or self._message_handler is None: return event_type = event.get("type", "") if self._reaction_notifications != "all": if event_type in ("im.message.reaction.created_v1", "im.message.reaction.deleted_v1"): if self._should_skip_reaction_event(event): return supported = { "im.message.receive_v1", "im.message.message_read_v1", "im.message.updated_v1", "im.message.deleted_v1", "card.action.trigger", "card.action.trigger_v1", "im.message.reaction.created_v1", "im.message.reaction.deleted_v1", "im.chat.member.bot.added_v1", "im.chat.member.bot.deleted_v1", "application.bot.menu_v6", "drive.notice.comment_add_v1", } if event_type not in supported: return raw_payload = { "event": event.get("event", {}), "event_type": event_type, } if self._dedup.has_processed(raw_payload): return self._dedup.record_processed(raw_payload) try: if event_type == "application.bot.menu_v6": channel_msg = self._build_menu_message(event) elif event_type == "drive.notice.comment_add_v1": channel_msg = handle_comment_event( self.channel_id, self.channel_type, raw_payload, account.bot_open_id, ) if channel_msg is None: return elif event_type in ("card.action.trigger", "card.action.trigger_v1"): channel_msg = self._handle_card_action_event(raw_payload, account.bot_open_id) if channel_msg is None: return else: channel_msg = normalize_inbound( self.channel_id, self.channel_type, raw_payload, account.bot_open_id, ) try: loop = asyncio.get_running_loop() except RuntimeError: loop = asyncio.get_event_loop() key = channel_msg.identity.channel_chat_id or "default" asyncio.run_coroutine_threadsafe( self._sequential.run_sequential(key, self._message_handler(channel_msg)), loop, ) except Exception as e: logger.error(f"[Feishu:{account_name}] Event [{event_type}] error: {e}") finally: self._dedup.finalize_processing(raw_payload) async def _account_ws_runner(self, name: str, ws_client: Any, account: Any) -> None: retry_count = 0 while self._status == ChannelStatus.CONNECTED: try: await asyncio.to_thread(ws_client.start) except Exception as e: retry_count += 1 delay = min(self._ws_base_delay * (2 ** (retry_count - 1)), self._ws_max_delay) logger.warning( f"[Feishu:{name}] WS disconnected (attempt {retry_count}), reconnecting in {delay:.1f}s: {e}" ) if self._status != ChannelStatus.CONNECTED: break await asyncio.sleep(delay) try: ws_client = lark_oapi.ws.Client( app_id=account.app_id, app_secret=account.app_secret, event_handler=self._make_account_event_handler(name), log_level=30, ) self._ws_clients[name] = ws_client except Exception as init_err: logger.error(f"[Feishu:{name}] WS re-init failed: {init_err}") async def _start_long_poll(self) -> None: if not HAS_LARK_SDK: raise ChannelConnectionError("lark-oapi SDK not installed") self._inject_event_loop_proxy() try: self._ws_client = lark_oapi.ws.Client( app_id=self._app_id, app_secret=self._app_secret, event_handler=self._handle_ws_event, log_level=30, ) except Exception as e: raise ChannelConnectionError(f"Failed to init WS client: {e}") from e if hasattr(self._ws_client, "start"): self._ws_task = asyncio.create_task(self._ws_runner()) logger.info("[Feishu] Long Poll started with auto-reconnect") else: raise ChannelConnectionError("WS client has no 'start' method") async def _ws_runner(self) -> None: while self._status == ChannelStatus.CONNECTED: try: await asyncio.to_thread(self._ws_client.start) except Exception as e: self._ws_retry_count += 1 delay = min( self._ws_base_delay * (2 ** (self._ws_retry_count - 1)), self._ws_max_delay, ) jitter = delay * self._ws_jitter * (0.5 - asyncio.get_event_loop().time() % 1) delay += jitter logger.warning( f"[Feishu] WS disconnected (attempt {self._ws_retry_count}), reconnecting in {delay:.1f}s: {e}" ) if self._status != ChannelStatus.CONNECTED: break await asyncio.sleep(delay) try: self._ws_client = lark_oapi.ws.Client( app_id=self._app_id, app_secret=self._app_secret, event_handler=self._handle_ws_event, log_level=30, ) except Exception as init_err: logger.error(f"[Feishu] WS re-init failed: {init_err}") self._ws_retry_count = 0 def _start_token_refresh_loop(self) -> None: self._token_refresh_task = asyncio.create_task(self._token_refresh_loop()) async def _token_refresh_loop(self) -> None: logger.info("[Feishu] Token refresh loop started") while self._status == ChannelStatus.CONNECTED: remaining = self._token_expire_at - time.time() if remaining <= 0: logger.warning("[Feishu] Token expired, attempting immediate refresh") else: delay = max(remaining - self._token_refresh_margin, 10) logger.debug(f"[Feishu] Next token refresh in {delay:.0f}s") try: await asyncio.sleep(delay) except asyncio.CancelledError: logger.info("[Feishu] Token refresh loop cancelled") return try: if not await self._refresh_token(): logger.warning("[Feishu] Token refresh failed, retrying in 30s") await asyncio.sleep(30) except asyncio.CancelledError: logger.info("[Feishu] Token refresh loop cancelled") return except Exception as e: logger.error(f"[Feishu] Token refresh unexpected error: {e}, retrying in 30s") await asyncio.sleep(30) async def _refresh_token(self) -> bool: if not self._lark_client: return False try: resp = await asyncio.to_thread(self._lark_client.auth.tenant_access_token_internal) except Exception as e: logger.error(f"[Feishu] Token refresh call failed: {e}") return False if not resp.success(): logger.error(f"[Feishu] Token refresh failed: {resp.msg}") return False data = resp.data if hasattr(resp, "data") else {} token_value = data.get("tenant_access_token", "") if not token_value: logger.error("[Feishu] Token refresh returned empty token value") return False expire = data.get("expire", 0) if expire <= 0: logger.error(f"[Feishu] Token refresh returned invalid expire: {expire}") return False self._token_expire_at = time.time() + expire logger.info(f"[Feishu] Token refreshed, expire in {expire}s") return True @staticmethod def _inject_event_loop_proxy() -> None: import lark_oapi.ws.client as _ws_mod class _EventLoopProxy: def __getattr__(self, name): loop = asyncio.get_running_loop() return getattr(loop, name) if not isinstance(getattr(_ws_mod, "loop", None), _EventLoopProxy): _ws_mod.loop = _EventLoopProxy() async def _handle_ws_event(self, event: dict) -> None: if self._message_handler is None: return event_type = event.get("type", "") if self._reaction_notifications != "all": if event_type in ("im.message.reaction.created_v1", "im.message.reaction.deleted_v1"): if self._should_skip_reaction_event(event): return supported = { "im.message.receive_v1", "im.message.message_read_v1", "im.message.updated_v1", "im.message.deleted_v1", "card.action.trigger", "card.action.trigger_v1", "im.message.reaction.created_v1", "im.message.reaction.deleted_v1", "im.chat.member.bot.added_v1", "im.chat.member.bot.deleted_v1", "application.bot.menu_v6", "drive.notice.comment_add_v1", } if event_type not in supported: return raw_payload = { "event": event.get("event", {}), "event_type": event_type, } if self._dedup.has_processed(raw_payload): logger.debug(f"[Feishu] Dedup: skipping event_id={event.get('event_id', '')}") return self._dedup.record_processed(raw_payload) try: if event_type == "application.bot.menu_v6": channel_msg = self._build_menu_message(event) elif event_type == "drive.notice.comment_add_v1": channel_msg = handle_comment_event( self.channel_id, self.channel_type, raw_payload, self._bot_open_id, ) if channel_msg is None: return elif event_type in ("card.action.trigger", "card.action.trigger_v1"): channel_msg = self._handle_card_action_event(raw_payload, self._bot_open_id) if channel_msg is None: return else: channel_msg = self.normalize_inbound(raw_payload) try: loop = asyncio.get_running_loop() except RuntimeError: loop = asyncio.get_event_loop() key = channel_msg.identity.channel_chat_id or "default" asyncio.run_coroutine_threadsafe( self._sequential.run_sequential(key, self._message_handler(channel_msg)), loop, ) except Exception as e: logger.error(f"[Feishu] Event [{event_type}] error: {e}") finally: self._dedup.finalize_processing(raw_payload) def _should_skip_reaction_event(self, event: dict) -> bool: if not self._reactions_enabled: return True if self._reaction_notifications == "off": return True if self._reaction_notifications == "own": event_data = event.get("event", {}) user_id = event_data.get("user_id", {}) or {} operator_id = user_id.get("open_id", "") return operator_id != self._bot_open_id return False def _handle_card_action_event(self, raw_payload: dict, bot_open_id: str) -> Any: event = raw_payload.get("event", {}) action_value = event.get("action", {}).get("value", "") open_id = event.get("operator", {}).get("open_id", "") or event.get("open_id", "") action_type, envelope = decode_card_action(action_value) if action_type == "invalid:malformed": logger.warning("[Feishu] Card action decode failed: malformed value") return None if action_type == "legacy": logger.warning("[Feishu] Legacy card command detected, ignoring") return None if envelope is not None: chat_id = event.get("open_chat_id", "") or event.get("chat_id", "") is_valid = validate_card_context( envelope, open_id, chat_id, "group" if chat_id.startswith("oc_") else "private" ) if not is_valid: logger.warning("[Feishu] Card action context validation failed") return None return normalize_inbound( self.channel_id, self.channel_type, raw_payload, bot_open_id, ) def is_tool_enabled(self, tool_name: str) -> bool: return self._tool_enabled.get(tool_name, True) def _build_menu_message(self, event: dict) -> Any: parsed = parse_bot_menu_event(event) if parsed is None: return normalize_inbound( self.channel_id, self.channel_type, {"event": event.get("event", {}), "event_type": "application.bot.menu_v6"}, self._bot_open_id, ) open_id = parsed["open_id"] chat_id = f"{CHAT_ID_PREFIX_DM}{open_id}" return build_synthetic_message( self.channel_id, self.channel_type, open_id, chat_id, parsed["command"], ) async def auth_login(self, ctx: dict[str, Any] | None = None) -> dict[str, Any]: if not HAS_LARK_SDK: return {"success": False, "error": "lark-oapi SDK not available"} if not self._app_id or not self._app_secret: return {"success": False, "error": "app_id and app_secret not configured"} from .oauth import FeishuOAuthClient redirect_uri = self.config.get("oauth_redirect_uri", "") oauth_client = FeishuOAuthClient( app_id=self._app_id, app_secret=self._app_secret, redirect_uri=redirect_uri, ) ctx = ctx or {} state = ctx.get("state", "") scope = ctx.get("scope", "user:read") auth_url = oauth_client.get_authorization_url(state=state, scope=scope) return { "success": True, "auth_url": auth_url, "provider": "feishu", "channel_id": self.channel_id, } async def handle_auth_callback(self, code: str) -> dict[str, Any]: if not HAS_LARK_SDK: return {"success": False, "error": "lark-oapi SDK not available"} from .oauth import FeishuOAuthClient oauth_client = FeishuOAuthClient( app_id=self._app_id, app_secret=self._app_secret, redirect_uri=self.config.get("oauth_redirect_uri", ""), ) token_data = await oauth_client.exchange_code_for_token(code) if not token_data: return {"success": False, "error": "Token exchange failed"} return { "success": True, "provider": "feishu", "channel_id": self.channel_id, "access_token": token_data.get("access_token", ""), "refresh_token": token_data.get("refresh_token", ""), "expires_in": token_data.get("expires_in", 0), "open_id": token_data.get("open_id", ""), "name": token_data.get("name", ""), } def list_account_ids(self) -> list[str]: return self._accounts.list_account_ids() def resolve_account(self, account_id: str | None = None) -> dict | None: account = self._accounts.resolve_account(account_id) if account is None: return None return {"account_id": account.account_id, "label": account.label, "enabled": account.enabled} def default_account_id(self) -> str: return self._accounts.default_account_id or "" async def add_account(self, account_id: str, config: dict[str, Any]) -> bool: if account_id in self._accounts.accounts: logger.warning(f"[Feishu] Account '{account_id}' already exists") return False from .accounts import FeishuAccount new_account = FeishuAccount( account_id=account_id, label=config.get("label", account_id), app_id=config.get("app_id", ""), app_secret=config.get("app_secret", ""), verification_token=config.get("verification_token", ""), encrypt_key=config.get("encrypt_key", ""), bot_open_id=config.get("bot_open_id", ""), enabled=True, config=config, ) self._accounts.accounts[account_id] = new_account if not self._accounts.default_account_id: self._accounts.default_account_id = account_id logger.info(f"[Feishu] Added account '{account_id}'") return True async def remove_account(self, account_id: str) -> bool: return self._accounts.delete_account(account_id) async def delete_account(self, account_id: str) -> bool: return self._accounts.delete_account(account_id) async def update_account(self, account_id: str, config: dict[str, Any]) -> bool: account = self._accounts.accounts.get(account_id) if account is None: logger.warning(f"[Feishu] Account '{account_id}' not found") return False account.label = config.get("label", account.label) account.app_id = config.get("app_id", account.app_id) account.app_secret = config.get("app_secret", account.app_secret) account.verification_token = config.get("verification_token", account.verification_token) account.encrypt_key = config.get("encrypt_key", account.encrypt_key) account.bot_open_id = config.get("bot_open_id", account.bot_open_id) account.enabled = config.get("enabled", account.enabled) account.config = config logger.info(f"[Feishu] Updated account '{account_id}'") return True def build_channel_summary(self) -> dict[str, Any]: enabled = self._accounts.list_enabled() return { "channel_id": self.channel_id, "channel_type": self.channel_type, "account_count": len(self._accounts.accounts), "enabled_count": len(enabled), "connected": self._status == ChannelStatus.CONNECTED, } def build_account_snapshot(self, account_id: str) -> dict[str, Any] | None: account = self._accounts.accounts.get(account_id) if account is None: return None return { "account_id": account.account_id, "label": account.label, "enabled": account.enabled, "has_app_id": bool(account.app_id), "has_secret": bool(account.app_secret), "has_verification_token": bool(account.verification_token), }