新增小红书、XMPP、元宝、Zalo 四个渠道扩展。 小红书渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, media, status, window XMPP 渠道扩展主要模块:plugin, config, gateway, outbound, streaming, pairing, security, dedupe, accounts, commands, muc, rate_limiter, stanza_utils, status, monitor 元宝渠道扩展主要模块:plugin, client, config_schema, gateway, outbound(chunk/queue/transport), inbound(dispatcher), streaming, pairing, security, accounts, actions, commands, codec(biz/conn), session, shared, utils Zalo 渠道扩展主要模块:api, config, gateway, webhook, outbound, pairing, security, session, polling, monitor, status
218 lines
5.9 KiB
Python
218 lines
5.9 KiB
Python
import asyncio
|
|
import logging
|
|
|
|
import httpx
|
|
|
|
from yuxi.channel.extensions.xiaohongshu.format import clean_for_xiaohongshu
|
|
from yuxi.channel.extensions.xiaohongshu.window import XiaohongshuWindowTracker
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SEND_MSG_PATH = "/api/message/send"
|
|
UPLOAD_MEDIA_PATH = "/api/material/upload"
|
|
MAX_TEXT_LEN = 2000
|
|
|
|
_send_context: dict[str, dict] = {}
|
|
_window_tracker = XiaohongshuWindowTracker()
|
|
|
|
|
|
def set_send_context(open_id: str, conversation_id: str, server_message_id: str) -> None:
|
|
_send_context[open_id] = {
|
|
"conversation_id": conversation_id,
|
|
"server_message_id": server_message_id,
|
|
}
|
|
|
|
|
|
def get_window_tracker() -> XiaohongshuWindowTracker:
|
|
return _window_tracker
|
|
|
|
|
|
class XiaohongshuOutbound:
|
|
|
|
def __init__(self, gateway=None):
|
|
self._gateway = gateway
|
|
|
|
def _http(self) -> httpx.AsyncClient | None:
|
|
if self._gateway and hasattr(self._gateway, "_http"):
|
|
return self._gateway._http
|
|
return None
|
|
|
|
async def send_text(
|
|
self,
|
|
to_user_id: str,
|
|
content: str,
|
|
*,
|
|
reply_to_id: str | None = None,
|
|
thread_id: str | None = None,
|
|
account_id: str | None = None,
|
|
) -> bool:
|
|
if not content:
|
|
return False
|
|
|
|
if not _window_tracker.can_reply(to_user_id):
|
|
return False
|
|
|
|
content = clean_for_xiaohongshu(content)
|
|
if len(content) > MAX_TEXT_LEN:
|
|
content = content[:MAX_TEXT_LEN]
|
|
|
|
result = await self._send_msg(to_user_id, {
|
|
"msg_type": "text",
|
|
"content": {"text": content},
|
|
})
|
|
|
|
if result:
|
|
_window_tracker.record_send(to_user_id)
|
|
|
|
return result
|
|
|
|
async def send_image(
|
|
self,
|
|
to_user_id: str,
|
|
media_id: str,
|
|
) -> bool:
|
|
if not media_id:
|
|
return False
|
|
|
|
if not _window_tracker.can_reply(to_user_id):
|
|
return False
|
|
|
|
result = await self._send_msg(to_user_id, {
|
|
"msg_type": "image",
|
|
"content": {"image_url": media_id},
|
|
})
|
|
|
|
if result:
|
|
_window_tracker.record_send(to_user_id)
|
|
|
|
return result
|
|
|
|
async def send_video(
|
|
self,
|
|
to_user_id: str,
|
|
media_id: str,
|
|
) -> bool:
|
|
if not media_id:
|
|
return False
|
|
|
|
if not _window_tracker.can_reply(to_user_id):
|
|
return False
|
|
|
|
result = await self._send_msg(to_user_id, {
|
|
"msg_type": "video",
|
|
"content": {"video_url": media_id},
|
|
})
|
|
|
|
if result:
|
|
_window_tracker.record_send(to_user_id)
|
|
|
|
return result
|
|
|
|
async def send_card(
|
|
self,
|
|
to_user_id: str,
|
|
card_type: str,
|
|
card_data: dict | None = None,
|
|
) -> bool:
|
|
if not card_type:
|
|
return False
|
|
|
|
if not _window_tracker.can_reply(to_user_id):
|
|
return False
|
|
|
|
result = await self._send_msg(to_user_id, {
|
|
"msg_type": "card",
|
|
"content": {
|
|
"card_type": card_type,
|
|
"card_data": card_data or {},
|
|
},
|
|
})
|
|
|
|
if result:
|
|
_window_tracker.record_send(to_user_id)
|
|
|
|
return result
|
|
|
|
async def _send_msg(self, to_user_id: str, content: dict) -> bool:
|
|
token = self._gateway.access_token if self._gateway else None
|
|
if not token:
|
|
logger.error("No valid access_token for xiaohongshu send")
|
|
return False
|
|
|
|
ctx = _send_context.pop(to_user_id, {})
|
|
conversation_id = ctx.get("conversation_id", "")
|
|
server_message_id = ctx.get("server_message_id", "")
|
|
|
|
payload = {
|
|
"access_token": token,
|
|
"open_id": to_user_id,
|
|
**content,
|
|
}
|
|
|
|
if conversation_id:
|
|
payload["conversation_id"] = conversation_id
|
|
if server_message_id:
|
|
payload["msg_id"] = server_message_id
|
|
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
client = self._http()
|
|
if client is None:
|
|
logger.error("No HTTP client available for xiaohongshu send")
|
|
return False
|
|
|
|
for attempt in range(3):
|
|
try:
|
|
resp = await client.post(SEND_MSG_PATH, json=payload, headers=headers)
|
|
data = resp.json()
|
|
if data.get("code") == 0:
|
|
return True
|
|
|
|
logger.warning(
|
|
"Xiaohongshu send failed (attempt %d): code=%s, msg=%s",
|
|
attempt + 1,
|
|
data.get("code"),
|
|
data.get("msg", ""),
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.warning("Xiaohongshu send attempt %d failed: %s", attempt + 1, e)
|
|
|
|
await asyncio.sleep(attempt + 1)
|
|
|
|
return False
|
|
|
|
async def upload_image(self, image_url: str) -> str | None:
|
|
token = self._gateway.access_token if self._gateway else None
|
|
if not token:
|
|
return None
|
|
|
|
client = self._http()
|
|
if client is None:
|
|
logger.error("No HTTP client available for xiaohongshu upload")
|
|
return None
|
|
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
}
|
|
|
|
for attempt in range(3):
|
|
try:
|
|
resp = await client.post(
|
|
UPLOAD_MEDIA_PATH,
|
|
json={"access_token": token, "image_url": image_url},
|
|
headers=headers,
|
|
)
|
|
data = resp.json()
|
|
if data.get("code") == 0:
|
|
return data.get("data", {}).get("media_id")
|
|
logger.warning("Xiaohongshu image upload failed (attempt %d): %s", attempt + 1, data)
|
|
except Exception:
|
|
logger.exception("Xiaohongshu image upload exception (attempt %d)", attempt + 1)
|
|
|
|
await asyncio.sleep(attempt + 1)
|
|
|
|
return None
|