ForcePilot/backend/package/yuxi/channel/extensions/alipay/outbound.py
Kris 71364ea579 feat(alipay): 新增支付宝渠道插件完整实现
实现了支付宝生活号渠道的完整功能,包括消息接收回调、发送、安全验证、去重、配对绑定、流式回复支持等完整能力。
2026-05-21 10:39:23 +08:00

315 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
)