ForcePilot/backend/package/yuxi/channel/extensions/zalo/outbound.py
Kris 5946478772 feat(channel): 添加小红书、XMPP、元宝和 Zalo 渠道扩展
新增小红书、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
2026-05-21 12:04:05 +08:00

113 lines
3.7 KiB
Python

import asyncio
import logging
from yuxi.channel.extensions.zalo.api import ZaloBotApi
from yuxi.channel.extensions.zalo.config import ZaloConfigAdapter
logger = logging.getLogger(__name__)
class ZaloOutbound:
delivery_mode = "direct"
chunker_mode = "length"
text_chunk_limit: int = 2000
def __init__(self, config_adapter: ZaloConfigAdapter | None = None):
self._config_adapter = config_adapter or ZaloConfigAdapter()
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account or not account.get("bot_token"):
logger.error("Zalo send_text: account not resolved or missing token")
return
prefix = account.get("response_prefix", "")
if prefix:
content = f"{prefix} {content}"
api = ZaloBotApi(account["bot_token"], proxy=account.get("proxy"))
try:
chunks = self._chunk_text(content, self.text_chunk_limit)
for chunk in chunks:
await api.send_message(target_id, chunk)
finally:
await api.close()
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> None:
if media_type not in ("image", "sticker"):
logger.warning("Zalo send_media: unsupported type %s", media_type)
return
account = await self._resolve_account(account_id)
if not account or not account.get("bot_token"):
logger.error("Zalo send_media: account not resolved")
return
api = ZaloBotApi(account["bot_token"], proxy=account.get("proxy"))
try:
if media_type == "image":
if not media_url.startswith(("http://", "https://")):
logger.error("Zalo photo URL must be absolute HTTP/HTTPS")
return
await self.send_chat_action(target_id, "upload_photo", account_id=account_id)
await api.send_photo(target_id, media_url)
elif media_type == "sticker":
await api.send_sticker(target_id, media_url)
finally:
await api.close()
async def send_chat_action(
self,
chat_id: str,
action: str = "typing",
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account or not account.get("bot_token"):
return
api = ZaloBotApi(account["bot_token"], proxy=account.get("proxy"))
try:
await asyncio.wait_for(
api.send_chat_action(chat_id, action),
timeout=5.0,
)
except TimeoutError:
pass
except Exception:
logger.debug("Zalo send_chat_action failed, ignoring")
finally:
await api.close()
@staticmethod
def _chunk_text(text: str, limit: int) -> list[str]:
if len(text) <= limit:
return [text]
chunks = []
while len(text) > limit:
chunks.append(text[:limit])
text = text[limit:]
if text:
chunks.append(text)
return chunks
async def _resolve_account(self, account_id: str | None) -> dict | None:
aid = account_id or self._config_adapter.default_account_id({})
return await self._config_adapter.resolve_account(aid)