from __future__ import annotations import asyncio import io import random from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from googleapiclient.discovery import Resource from yuxi.channels.exceptions import ( ChannelAuthenticationError, ChannelRateLimitError, DeliveryFailedError, ) from yuxi.channels.models import DeliveryResult from yuxi.utils.logging_config import logger _MAX_RETRIES = 5 _MAX_BACKOFF_S = 64 _AUTH_401_MAX_BACKOFF_S = 300 _AUTH_401_COOLDOWN: dict[str, float] = {} def _check_401_cooldown(account_id: str = "default") -> bool: now = __import__("time").monotonic() cooldown_until = _AUTH_401_COOLDOWN.get(account_id, 0) if now < cooldown_until: remaining = cooldown_until - now logger.warning(f"Auth 401 cooldown active for {remaining:.0f}s") return False return True def _apply_401_cooldown(account_id: str = "default", backoff_s: float = 60.0) -> None: now = __import__("time").monotonic() capped = min(backoff_s, _AUTH_401_MAX_BACKOFF_S) _AUTH_401_COOLDOWN[account_id] = now + capped logger.info(f"Auth 401 backoff applied: {capped:.0f}s cooldown") def _extend_401_cooldown(account_id: str = "default") -> None: now = __import__("time").monotonic() existing = _AUTH_401_COOLDOWN.get(account_id, 0) remaining = max(existing - now, 0) extension = min(remaining * 2 + 30, _AUTH_401_MAX_BACKOFF_S) _AUTH_401_COOLDOWN[account_id] = now + extension logger.warning(f"Auth 401 cooldown extended to {extension:.0f}s") def _classify_error(e: Exception, account_id: str = "default") -> None: try: from googleapiclient.errors import HttpError if isinstance(e, HttpError): status = e.resp.status if status == 429: raise ChannelRateLimitError() if status in (401, 403): reason = _extract_rate_limit_reason(e) if reason: raise ChannelRateLimitError() if status == 401: _extend_401_cooldown(account_id) raise ChannelAuthenticationError(str(e)) raise DeliveryFailedError(str(e)) from e except ImportError: raise DeliveryFailedError(str(e)) from None raise DeliveryFailedError(str(e)) from e def _extract_rate_limit_reason(e: Exception) -> bool: try: content = e.content if hasattr(e, "content") else b"" if isinstance(content, bytes): content = content.decode("utf-8", errors="replace") if isinstance(content, str) and ("rateLimitExceeded" in content or "userRateLimitExceeded" in content): return True except Exception: pass return False def _backoff_with_jitter(retry_count: int) -> float: delay = min((2**retry_count) + random.uniform(0, 1), _MAX_BACKOFF_S) return delay async def _retry_with_backoff(func, *args, account_id: str = "default", **kwargs) -> DeliveryResult: last_error = None for attempt in range(_MAX_RETRIES): try: return await func(*args, **kwargs) except ChannelRateLimitError as e: last_error = e delay = _backoff_with_jitter(attempt) logger.warning(f"Rate limited (attempt {attempt + 1}/{_MAX_RETRIES}), backing off {delay:.1f}s") await asyncio.sleep(delay) except (ChannelAuthenticationError, DeliveryFailedError): raise except Exception as e: last_error = e if attempt < _MAX_RETRIES - 1: delay = _backoff_with_jitter(attempt) logger.warning(f"Request failed (attempt {attempt + 1}/{_MAX_RETRIES}): {e}, retrying in {delay:.1f}s") await asyncio.sleep(delay) else: break error_msg = str(last_error) if last_error else "Max retries exceeded" logger.error(f"send failed after {_MAX_RETRIES} retries: {error_msg}") return DeliveryResult(success=False, error=error_msg) async def send_message( chat_service: Resource, chat_id: str, body: dict[str, Any], account_id: str = "default", ) -> DeliveryResult: async def _do_send(): result = chat_service.spaces().messages().create(parent=chat_id, body=body).execute() return DeliveryResult(success=True, message_id=result.get("name", "")) try: return await _retry_with_backoff(_do_send, account_id=account_id) except (ChannelAuthenticationError, DeliveryFailedError, ChannelRateLimitError) as e: return DeliveryResult(success=False, error=str(e)) except Exception as e: _classify_error(e, account_id) return DeliveryResult(success=False, error=str(e)) async def update_message( chat_service: Resource, message_id: str, body: dict[str, Any], update_mask: str = "text", account_id: str = "default", ) -> DeliveryResult: async def _do_send(): result = chat_service.spaces().messages().update(name=message_id, updateMask=update_mask, body=body).execute() return DeliveryResult(success=True, message_id=result.get("name", message_id)) try: return await _retry_with_backoff(_do_send, account_id=account_id) except (ChannelAuthenticationError, DeliveryFailedError, ChannelRateLimitError) as e: return DeliveryResult(success=False, error=str(e)) except Exception as e: _classify_error(e, account_id) return DeliveryResult(success=False, error=str(e)) async def delete_message( chat_service: Resource, message_id: str, account_id: str = "default", ) -> DeliveryResult: async def _do_send(): chat_service.spaces().messages().delete(name=message_id).execute() return DeliveryResult(success=True) try: return await _retry_with_backoff(_do_send, account_id=account_id) except (ChannelAuthenticationError, DeliveryFailedError, ChannelRateLimitError) as e: return DeliveryResult(success=False, error=str(e)) except Exception as e: _classify_error(e, account_id) return DeliveryResult(success=False, error=str(e)) async def send_media( chat_service: Resource, chat_id: str, media_url: str, caption: str = "", account_id: str = "default", ) -> DeliveryResult: async def _do_send(): body: dict[str, Any] = {"text": caption or " "} body["cards_v2"] = [{"card": {"sections": [{"widgets": [{"image": {"imageUrl": media_url}}]}]}}] result = chat_service.spaces().messages().create(parent=chat_id, body=body).execute() return DeliveryResult(success=True, message_id=result.get("name", "")) try: return await _retry_with_backoff(_do_send, account_id=account_id) except (ChannelAuthenticationError, DeliveryFailedError, ChannelRateLimitError) as e: return DeliveryResult(success=False, error=str(e)) except Exception as e: _classify_error(e, account_id) return DeliveryResult(success=False, error=str(e)) async def send_reaction( chat_service: Resource, message_id: str, emoji: str, account_id: str = "default", ) -> DeliveryResult: async def _do_send(): body = {"emoji": {"unicode": emoji}} (chat_service.spaces().messages().reactions().create(parent=message_id, body=body).execute()) return DeliveryResult(success=True) try: return await _retry_with_backoff(_do_send, account_id=account_id) except (ChannelAuthenticationError, DeliveryFailedError, ChannelRateLimitError) as e: return DeliveryResult(success=False, error=str(e)) except Exception as e: _classify_error(e, account_id) return DeliveryResult(success=False, error=str(e)) async def list_reactions( chat_service: Resource, message_id: str, ) -> list[dict[str, Any]]: try: result = chat_service.spaces().messages().reactions().list(parent=message_id).execute() return result.get("reactions", []) except Exception as e: logger.warning(f"list_reactions failed for {message_id}: {e}") return [] async def delete_reaction( chat_service: Resource, reaction_id: str, account_id: str = "default", ) -> DeliveryResult: async def _do_send(): chat_service.spaces().messages().reactions().delete(name=reaction_id).execute() return DeliveryResult(success=True) try: return await _retry_with_backoff(_do_send, account_id=account_id) except (ChannelAuthenticationError, DeliveryFailedError, ChannelRateLimitError) as e: return DeliveryResult(success=False, error=str(e)) except Exception as e: _classify_error(e, account_id) return DeliveryResult(success=False, error=str(e)) async def upload_file_message( chat_service: Resource, chat_id: str, file_data: bytes, filename: str, mime_type: str = "application/octet-stream", caption: str = "", account_id: str = "default", ) -> DeliveryResult: from googleapiclient.http import MediaIoBaseUpload async def _do_send(): media = MediaIoBaseUpload( io.BytesIO(file_data), mimetype=mime_type, resumable=True, ) body: dict[str, Any] = {"text": caption or " "} result = chat_service.spaces().messages().create(parent=chat_id, body=body, media_body=media).execute() return DeliveryResult(success=True, message_id=result.get("name", "")) try: return await _retry_with_backoff(_do_send, account_id=account_id) except (ChannelAuthenticationError, DeliveryFailedError, ChannelRateLimitError) as e: return DeliveryResult(success=False, error=str(e)) except Exception as e: _classify_error(e, account_id) return DeliveryResult(success=False, error=str(e)) async def upload_image_message( chat_service: Resource, chat_id: str, image_data: bytes, filename: str = "image.png", caption: str = "", account_id: str = "default", ) -> DeliveryResult: return await upload_file_message( chat_service, chat_id, image_data, filename, "image/png", caption, account_id=account_id ) async def find_direct_message_space( chat_service: Resource, user_name: str, ) -> str | None: try: result = chat_service.spaces().findDirectMessage(query=user_name).execute() return result.get("name", "") except Exception as e: logger.warning(f"findDirectMessage failed for user {user_name}: {e}") return None async def resolve_outbound_space( chat_service: Resource, target: str, ) -> str | None: if target.startswith("spaces/"): try: result = chat_service.spaces().get(name=target).execute() return result.get("name", "") except Exception: return target dm_result = await find_direct_message_space(chat_service, target) if dm_result: return dm_result return None