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.meta import ChannelMeta from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelConnectionError, ) 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 .cards import TEXT_CHUNK_LIMIT from .accounts import FeishuAccountManager from .approval import FeishuApprovalAdapter 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, list_peers as _list_peers_feishu from .dynamic_agent import FeishuDynamicAgentConfig, FeishuDynamicAgentManager from .card_action import decode_card_action, validate_card_context from .formatter import format_outbound from .media import ( download_media as _download_media, upload_file, upload_image, ) from .normalizer import normalize_inbound from .pins import create_pin as _create_pin, list_pins as _list_pins, remove_pin as _remove_pin from .reactions import ( clear_all_bot_reactions, list_reactions as _list_reactions, remove_reaction as _remove_reaction, ) from .reply_dispatcher import dispatch_render, extract_urls from .secret_resolver import resolve_secret from .send import ( is_local_image_path, read_message as _read_message, reply_message as _reply_message, send_card, send_reaction as _send_reaction, send_text, update_card_message, ) 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, 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._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_until: float = 0 self._401_retry_count: int = 0 self._401_max_retries: int = 3 self._401_base_delay: float = 1.0 self._401_max_delay: float = 30.0 async def _handle_401_backoff(self) -> None: now = time.monotonic() if self._401_backoff_until > now: wait = self._401_backoff_until - now logger.warning(f"[Feishu] 401 backoff active, waiting {wait:.1f}s") await asyncio.sleep(wait) async def _on_401_error(self) -> bool: self._401_retry_count += 1 if self._401_retry_count > self._401_max_retries: logger.error(f"[Feishu] 401 retry count exceeded ({self._401_max_retries})") return False delay = min(self._401_base_delay * (2 ** (self._401_retry_count - 1)), self._401_max_delay) self._401_backoff_until = time.monotonic() + delay logger.warning( f"[Feishu] 401 error, refreshing token and retrying in {delay:.1f}s (attempt {self._401_retry_count})" ) try: await self._refresh_token() except Exception as e: logger.error(f"[Feishu] Token refresh after 401 failed: {e}") return False return True def _reset_401_backoff(self) -> None: if self._401_retry_count > 0: logger.info("[Feishu] 401 backoff reset after successful request") self._401_retry_count = 0 self._401_backoff_until = 0 async def _call_with_401_retry(self, callable_fn, *args, **kwargs) -> Any: result = await callable_fn(*args, **kwargs) if isinstance(result, DeliveryResult) and result.error_code == "auth_expired": if await self._on_401_error(): result = await callable_fn(*args, **kwargs) if isinstance(result, DeliveryResult) and not result.success: return result else: return result self._reset_401_backoff() return result 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}") expire = token_resp.data.get("expire", 0) if hasattr(token_resp, "data") else 0 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() 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" ) reply_to_msg_id = response.identity.metadata.get("reply_to_msg_id", "") if response.identity.metadata else "" root_id = response.identity.metadata.get("root_id", "") if response.identity.metadata else "" 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: return DeliveryResult(success=False, error=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 self._check_401_result(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, ) return self._check_401_result(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 self._check_401_result(result) def _check_401_result(self, result: DeliveryResult) -> DeliveryResult: if not result.success and result.error_code == "auth_expired": asyncio.create_task(self._on_401_error()) 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 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, ) 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 already expired, skipping refresh") break delay = max(remaining - self._token_refresh_margin, 10) logger.debug(f"[Feishu] Next token refresh in {delay:.0f}s") try: await asyncio.sleep(delay) await self._refresh_token() except asyncio.CancelledError: logger.info("[Feishu] Token refresh loop cancelled") return except Exception as e: logger.error(f"[Feishu] Token refresh failed: {e}") async def _refresh_token(self) -> None: if not self._lark_client: return 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 if not resp.success(): logger.error(f"[Feishu] Token refresh failed: {resp.msg}") return expire = resp.data.get("expire", 0) if hasattr(resp, "data") else 0 self._token_expire_at = time.time() + expire logger.info(f"[Feishu] Token refreshed, expire in {expire}s") @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", ""), }