ForcePilot/backend/package/yuxi/channel/extensions/viber/gateway.py
Kris 1c590097be feat(channel): 添加 Twitter 和 Viber 渠道扩展
新增 Twitter 和 Viber 两个渠道扩展。

Twitter 渠道扩展功能模块:
- auth: OAuth 认证管理
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- session: 会话管理
- tweets: 推文管理
- social: 社交互动
- reactions: 表情反应
- media: 媒体资源处理

Viber 渠道扩展功能模块:
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- rate_limiter: 速率限制
- media: 媒体资源处理
2026-05-21 11:57:22 +08:00

271 lines
9.9 KiB
Python

from __future__ import annotations
import json
import logging
import urllib.parse
import httpx
from yuxi.channel.extensions.viber.config import ViberConfigAdapter
from yuxi.channel.extensions.viber.errors import ViberErrorCode
from yuxi.channel.extensions.viber.rate_limiter import ViberRateLimiter
from yuxi.channel.extensions.viber.types import VIBER_API_BASE
from yuxi.channel.extensions.viber.webhook import set_webhook_running
logger = logging.getLogger(__name__)
class ViberGatewayAdapter:
def __init__(self):
self._config = ViberConfigAdapter()
self._running = False
self._rate_limiter = ViberRateLimiter(max_requests=30, per_seconds=1.0)
async def start(self, ctx) -> object:
import asyncio
account_id = getattr(ctx, "account_id", "default")
config = getattr(ctx, "config", {}) or {}
self._config.list_account_ids(config)
account = await self._config.resolve_account(account_id)
auth_token = account.get("auth_token", "")
if not auth_token:
logger.warning("Viber gateway start: auth_token not configured for account %s", account_id)
self._running = True
queue = getattr(ctx, "queue", asyncio.Queue())
return queue
valid = await self._probe_bot(auth_token)
if valid:
logger.info("Viber bot connected for account %s", account_id)
else:
logger.warning("Viber bot probe failed for account %s", account_id)
webhook_url = account.get("webhook_url", "")
if webhook_url:
ok = await self._set_webhook(auth_token, webhook_url)
if ok:
logger.info("Viber webhook set: %s", webhook_url)
else:
logger.error("Viber set_webhook failed for account %s", account_id)
self._running = True
set_webhook_running(True)
queue = getattr(ctx, "queue", asyncio.Queue())
return queue
async def stop(self, ctx) -> None:
account_id = getattr(ctx, "account_id", "default")
if self._running:
account = await self._config.resolve_account(account_id)
auth_token = account.get("auth_token", "")
webhook_url = account.get("webhook_url", "")
if auth_token and webhook_url:
await self._remove_webhook(auth_token)
self._running = False
set_webhook_running(False)
logger.info("Viber gateway stopped")
async def probe_bot(self, account: dict) -> dict | None:
auth_token = account.get("auth_token", "")
if not auth_token:
return None
return await self._probe_bot_detail(auth_token)
async def _probe_bot(self, auth_token: str) -> bool:
try:
result = await self._api_call(auth_token, "get_account_info", {})
return result.get("status") == 0
except Exception:
logger.exception("Viber probe error")
return False
async def _probe_bot_detail(self, auth_token: str) -> dict | None:
try:
result = await self._api_call(auth_token, "get_account_info", {})
if result.get("status") == 0:
return result
return None
except Exception:
logger.exception("Viber probe detail error")
return None
async def _set_webhook(self, auth_token: str, webhook_url: str) -> bool:
payload = {
"url": webhook_url,
"event_types": [
"delivered",
"seen",
"failed",
"subscribed",
"unsubscribed",
"conversation_started",
],
"send_name": True,
"send_photo": True,
}
result = await self._api_call(auth_token, "set_webhook", payload)
ok = result.get("status") == 0
if not ok:
logger.error("Viber set_webhook failed: %s", result.get("status_message"))
return ok
async def _remove_webhook(self, auth_token: str) -> bool:
payload = {"url": ""}
result = await self._api_call(auth_token, "set_webhook", payload)
return result.get("status") == 0
async def get_user_details(self, account: dict, user_id: str) -> dict | None:
auth_token = account.get("auth_token", "")
if not auth_token:
return None
try:
result = await self._api_call(auth_token, "get_user_details", {"id": user_id})
if result.get("status") == 0:
return result.get("result", result)
self._log_error("get_user_details", result)
return None
except Exception:
logger.exception("Viber get_user_details error")
return None
async def broadcast_message(
self,
account: dict,
broadcast_list: list[str],
message: dict,
) -> dict | None:
auth_token = account.get("auth_token", "")
if not auth_token:
return None
if len(broadcast_list) > 300:
logger.warning("Viber broadcast_list exceeds 300 limit: %d", len(broadcast_list))
broadcast_list = broadcast_list[:300]
try:
results = []
for receiver_id in broadcast_list:
msg_copy = json.loads(json.dumps(message))
msg_str = json.dumps(msg_copy)
msg_str = msg_str.replace("replace_me_with_receiver_id", receiver_id)
msg_str = msg_str.replace(
"replace_me_with_url_encoded_receiver_id",
urllib.parse.quote(receiver_id, safe=""),
)
if "replace_me_with_user_name" in msg_str:
user_name = await self._get_user_name(auth_token, receiver_id)
msg_str = msg_str.replace("replace_me_with_user_name", user_name)
msg_copy = json.loads(msg_str)
msg_copy["receiver"] = receiver_id
result = await self._api_call(auth_token, "send_message", msg_copy)
results.append(result)
failed = []
for i, result in enumerate(results):
if result.get("status") != 0:
failed.append({
"receiver": broadcast_list[i],
"status": result.get("status"),
"status_message": result.get("status_message", ""),
})
if failed:
logger.warning(
"Viber broadcast partial failure: %d/%d failed",
len(failed), len(broadcast_list),
)
for f in failed:
logger.warning(
" - receiver=%s status=%s message=%s",
f["receiver"], f["status"], f["status_message"],
)
return {
"status": 0,
"broadcast_count": len(broadcast_list),
"failed_count": len(failed),
"failed_list": failed,
"results": results,
}
except Exception:
logger.exception("Viber broadcast_message error")
return None
async def _get_user_name(self, auth_token: str, user_id: str) -> str:
try:
result = await self._api_call(auth_token, "get_user_details", {"id": user_id})
if result.get("status") == 0:
user_data = result.get("user", {})
name = user_data.get("name", "")
if name:
return name
except Exception:
logger.debug("Viber get_user_name failed for %s", user_id)
return "User"
async def get_online(self, account: dict, user_ids: list[str]) -> dict | None:
auth_token = account.get("auth_token", "")
if not auth_token:
return None
if len(user_ids) > 100:
logger.warning("Viber get_online exceeds 100 user limit: %d", len(user_ids))
user_ids = user_ids[:100]
try:
result = await self._api_call(auth_token, "get_online", {"ids": user_ids})
if result.get("status") == 0:
return result
self._log_error("get_online", result)
return None
except Exception:
logger.exception("Viber get_online error")
return None
async def _api_call(self, auth_token: str, endpoint: str, payload: dict) -> dict:
await self._rate_limiter.acquire()
headers = {
"X-Viber-Auth-Token": auth_token,
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client:
resp = await client.post(
f"{VIBER_API_BASE}/{endpoint}",
json=payload,
headers=headers,
)
resp.raise_for_status()
result = resp.json()
self._log_error(endpoint, result)
billing_status = result.get("billing_status")
if billing_status is not None:
logger.debug(
"Viber billing: endpoint=%s status=%s",
endpoint, billing_status,
)
return result
@staticmethod
def _log_error(endpoint: str, result: dict) -> None:
status = result.get("status")
if status is not None and status != 0:
status_msg = result.get("status_message", "")
retryable = ViberErrorCode.is_retryable(status)
auth_fail = ViberErrorCode.is_auth_error(status)
extra = ""
if retryable:
extra = " [retryable]"
if auth_fail:
extra += " [auth_failure]"
logger.warning(
"Viber API %s failed: status=%s message=%s%s",
endpoint,
status,
status_msg,
extra,
)