新增小红书、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
113 lines
3.7 KiB
Python
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)
|