- 调整依赖导入顺序与清理冗余空行 - 修复setup wizard步骤存储逻辑 - 新增消息编辑/转发标记、卡片控件支持 - 完善目标校验、媒体上传、配对存储功能 - 优化流式响应与错误处理逻辑 - 增强SSRF防护与配置灵活性 - 新增/扩展内置命令与卡片处理能力 - 调整媒体大小限制与类型参数
374 lines
13 KiB
Python
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
|