315 lines
10 KiB
Python
315 lines
10 KiB
Python
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),
|
||
)
|