ForcePilot/backend/package/yuxi/channels/adapters/googlechat/send.py
Kris 87fc26cd15 refactor(googlechat): 整理并优化Google Chat适配器代码
- 调整依赖导入顺序与清理冗余空行
- 修复setup wizard步骤存储逻辑
- 新增消息编辑/转发标记、卡片控件支持
- 完善目标校验、媒体上传、配对存储功能
- 优化流式响应与错误处理逻辑
- 增强SSRF防护与配置灵活性
- 新增/扩展内置命令与卡片处理能力
- 调整媒体大小限制与类型参数
2026-05-13 16:09:31 +08:00

374 lines
13 KiB
Python

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:
retry_after = _extract_retry_after(e)
if retry_after:
raise ChannelRateLimitError(f"Rate limited, retry after {retry_after}s")
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 _extract_retry_after(e: Exception) -> int | None:
try:
resp = e.resp if hasattr(e, "resp") else None
if resp is None:
return None
retry_after = resp.get("Retry-After") or resp.get("retry-after")
if retry_after:
return int(retry_after)
except (ValueError, TypeError):
pass
return None
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,
custom_emoji_uid: str = "",
account_id: str = "default",
) -> DeliveryResult:
async def _do_send():
if custom_emoji_uid:
body = {"emoji": {"customEmoji": {"uid": custom_emoji_uid}}}
else:
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:
import googleapiclient.errors
if isinstance(e, googleapiclient.errors.HttpError):
status = e.resp.status
logger.warning(f"findDirectMessage HTTP {status} for user {user_name}: {e}")
else:
logger.warning(f"findDirectMessage failed for user {user_name}: {e}")
return None
async def get_message(
chat_service: Resource,
message_id: str,
) -> dict | None:
try:
result = chat_service.spaces().messages().get(name=message_id).execute()
return result
except Exception as e:
logger.warning(f"get_message failed for {message_id}: {e}")
return None
async def list_messages(
chat_service: Resource,
space_name: str,
page_size: int = 50,
page_token: str | None = None,
filter_str: str | None = None,
) -> tuple[list[dict], str | None]:
kwargs: dict[str, Any] = {"parent": space_name, "pageSize": page_size}
if page_token:
kwargs["pageToken"] = page_token
if filter_str:
kwargs["filter"] = filter_str
try:
result = chat_service.spaces().messages().list(**kwargs).execute()
return result.get("messages", []), result.get("nextPageToken")
except Exception as e:
logger.warning(f"list_messages failed for {space_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