import logging import time from datetime import date from yuxi.channel.extensions.alipay.constants import ( ALIPAY_DAILY_MSG_LIMIT_PER_USER, ALIPAY_IMAGE_TEXT_ARTICLE_LIMIT, ALIPAY_INTERACTION_WINDOW_HOURS, ALIPAY_TEXT_LIMIT, ) from yuxi.channel.extensions.alipay.errors import AlipayError, AlipayErrorCode from yuxi.channel.extensions.alipay.format import split_utf8_safe from yuxi.channel.extensions.alipay.gateway import AlipayGateway from yuxi.channel.extensions.alipay.types import AlipayAccount, AlipayOutboundResult logger = logging.getLogger(__name__) class AlipayOutbound: delivery_mode = "direct" chunker_mode = "length" text_chunk_limit = ALIPAY_TEXT_LIMIT poll_max_options = None supports_poll_duration_seconds = False supports_anonymous_polls = False extract_markdown_images = True presentation_capabilities = None delivery_capabilities = None def __init__(self, gateway: AlipayGateway): self._gateway = gateway self._daily_counters: dict[str, int] = {} self._last_interaction: dict[str, float] = {} def _get_counter_key(self, account_id: str, user_id: str) -> str: today = date.today().isoformat() return f"{account_id}:{user_id}:{today}" def _check_daily_limit(self, user_id: str, account_id: str) -> bool: key = self._get_counter_key(account_id, user_id) count = self._daily_counters.get(key, 0) return count < ALIPAY_DAILY_MSG_LIMIT_PER_USER def _increment_daily_counter(self, user_id: str, account_id: str): key = self._get_counter_key(account_id, user_id) self._daily_counters[key] = self._daily_counters.get(key, 0) + 1 today = date.today().isoformat() stale = [k for k in self._daily_counters if not k.endswith(f":{today}")] for k in stale: del self._daily_counters[k] def record_interaction(self, user_id: str, account_id: str): key = f"{account_id}:{user_id}" self._last_interaction[key] = time.time() def _check_window(self, user_id: str, account_id: str) -> bool: key = f"{account_id}:{user_id}" last = self._last_interaction.get(key, 0) return (time.time() - last) < ALIPAY_INTERACTION_WINDOW_HOURS * 3600 async def send_text( self, target_id: str, content: str, account: AlipayAccount, reply_to_id: str | None = None, thread_id: str | None = None, ) -> AlipayOutboundResult: if not account.is_configured(): raise AlipayError(code=AlipayErrorCode.UNKNOWN, message="缺少账户配置") if not self._check_window(target_id, account.account_id): return AlipayOutboundResult( msg_id=None, success=False, error="超过 48 小时交互窗口,无法发送消息", ) if not self._check_daily_limit(target_id, account.account_id): return AlipayOutboundResult( msg_id=None, success=False, error="单用户每日消息数已达上限(100条)", ) if len(content) > ALIPAY_TEXT_LIMIT: content = content[:ALIPAY_TEXT_LIMIT] try: result = await self._gateway.request( account=account, method="alipay.open.public.message.custom.send", biz_content={ "to_user_id": target_id, "msg_type": "text", "text": {"content": content}, }, ) self._increment_daily_counter(target_id, account.account_id) return AlipayOutboundResult( msg_id=result.get("msg_id"), success=True, result=result, ) except AlipayError as e: return AlipayOutboundResult( msg_id=None, success=False, error=str(e), ) async def send_template( self, account: AlipayAccount, to_user_id: str, template_id: str, context: dict, url: str | None = None, ) -> AlipayOutboundResult: if not account.is_configured(): raise AlipayError(code=AlipayErrorCode.UNKNOWN, message="缺少账户配置") biz_content: dict = { "to_user_id": to_user_id, "template": { "template_id": template_id, "context": context, }, } if url: biz_content["url"] = url try: result = await self._gateway.request( account=account, method="alipay.open.public.message.single.send", biz_content=biz_content, ) return AlipayOutboundResult( msg_id=result.get("msg_id"), success=True, result=result, ) except AlipayError as e: return AlipayOutboundResult( msg_id=None, success=False, error=str(e), ) async def recall_message( self, account: AlipayAccount, msg_id: str, ) -> AlipayOutboundResult: if not account.is_configured(): raise AlipayError(code=AlipayErrorCode.UNKNOWN, message="缺少账户配置") try: result = await self._gateway.request( account=account, method="alipay.open.public.life.msg.recall", biz_content={"msg_id": msg_id}, ) return AlipayOutboundResult( msg_id=msg_id, success=True, result=result, ) except AlipayError as e: return AlipayOutboundResult( msg_id=None, success=False, error=str(e), ) async def query_followers( self, account: AlipayAccount, next_token: str = "", ) -> AlipayOutboundResult: if not account.is_configured(): raise AlipayError(code=AlipayErrorCode.UNKNOWN, message="缺少账户配置") biz_content: dict = {} if next_token: biz_content["next_token"] = next_token try: result = await self._gateway.request( account=account, method="alipay.open.public.follow.batchquery", biz_content=biz_content, ) return AlipayOutboundResult( msg_id=None, success=True, result=result, ) except AlipayError as e: return AlipayOutboundResult( msg_id=None, success=False, error=str(e), ) def sanitize_text(self, text: str, payload: dict | None = None) -> str: return text def should_skip_plain_text_sanitization(self, payload: dict | None = None) -> bool: return False def normalize_payload(self, payload: dict, config: dict, account_id: str | None = None) -> dict: return payload def resolve_effective_text_chunk_limit( self, config: dict, account_id: str | None = None, fallback_limit: int | None = None ) -> int: return fallback_limit or ALIPAY_TEXT_LIMIT def chunker(self, text: str, limit: int, ctx=None) -> list[str]: return split_utf8_safe(text, limit) async def send_payload(self, ctx) -> None: return None async def send_poll(self, ctx) -> None: return None async def send_media( self, target_id: str, media_url: str, media_type: str, account: AlipayAccount, reply_to_id: str | None = None, thread_id: str | None = None, ) -> AlipayOutboundResult: if not account.is_configured(): raise AlipayError(code=AlipayErrorCode.UNKNOWN, message="缺少账户配置") if not self._check_window(target_id, account.account_id): return AlipayOutboundResult( msg_id=None, success=False, error="超过 48 小时交互窗口,无法发送消息", ) try: result = await self._gateway.request( account=account, method="alipay.open.public.message.custom.send", biz_content={ "to_user_id": target_id, "msg_type": "image-text", "articles": [ { "title": "", "desc": "", "image_url": media_url, "url": media_url, } ], }, ) return AlipayOutboundResult( msg_id=result.get("msg_id"), success=True, result=result, ) except AlipayError as e: return AlipayOutboundResult( msg_id=None, success=False, error=str(e), ) async def send_image_text( self, account: AlipayAccount, to_user_id: str, articles: list[dict], ) -> AlipayOutboundResult: if not self._check_window(to_user_id, account.account_id): return AlipayOutboundResult( msg_id=None, success=False, error="超过 48 小时交互窗口,无法发送消息", ) if len(articles) > ALIPAY_IMAGE_TEXT_ARTICLE_LIMIT: articles = articles[:ALIPAY_IMAGE_TEXT_ARTICLE_LIMIT] try: result = await self._gateway.request( account=account, method="alipay.open.public.message.custom.send", biz_content={ "to_user_id": to_user_id, "msg_type": "image-text", "articles": articles, }, ) return AlipayOutboundResult( msg_id=result.get("msg_id"), success=True, result=result, ) except AlipayError as e: return AlipayOutboundResult( msg_id=None, success=False, error=str(e), )