ForcePilot/backend/package/yuxi/channel/extensions/viber/outbound.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

651 lines
22 KiB
Python

from __future__ import annotations
import io
import logging
import tempfile
import httpx
from PIL import Image
from yuxi.channel.extensions.viber.config import ViberConfigAdapter
from yuxi.channel.extensions.viber.errors import ViberErrorCode
from yuxi.channel.extensions.viber.format import chunk_text
from yuxi.channel.extensions.viber.rate_limiter import ViberRateLimiter
from yuxi.channel.extensions.viber.types import VIBER_API_BASE, ViberKeyboard, ViberRichMedia
from yuxi.channel.protocols import OutboundDeliveryMode
logger = logging.getLogger(__name__)
VIBER_PICTURE_MAX_BYTES = 1 * 1024 * 1024
class ViberOutboundAdapter:
delivery_mode = OutboundDeliveryMode.DIRECT
chunker_mode = "length"
text_chunk_limit: int = 7000
media_max_mb: int = 50
poll_max_options: int | None = None
supports_poll_duration_seconds = False
supports_anonymous_polls = False
extract_markdown_images = False
presentation_capabilities = None
delivery_capabilities = None
def __init__(self):
self._config = ViberConfigAdapter()
self._rate_limiter = ViberRateLimiter(max_requests=30, per_seconds=1.0)
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,
tracking_data: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
logger.error("Viber send_text: account not resolved")
return
auth_token = account.get("auth_token", "")
if not auth_token:
return
sender_name = account.get("sender_name", "ForcePilot Bot")
sender_avatar = account.get("sender_avatar", "")
limit = account.get("text_chunk_limit", 7000)
chunks = chunk_text(content, limit)
for chunk in chunks:
await self._send_text_api(
auth_token, target_id, chunk, sender_name, sender_avatar,
tracking_data=tracking_data,
)
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,
tracking_data: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
auth_token = account.get("auth_token", "")
if not auth_token:
return
sender_name = account.get("sender_name", "ForcePilot Bot")
sender_avatar = account.get("sender_avatar", "")
match media_type:
case "image":
await self._send_picture_api(
auth_token, target_id, media_url, "", sender_name, sender_avatar,
tracking_data=tracking_data,
)
case "video":
await self._send_video_api(
auth_token, target_id, media_url, 0, sender_name, sender_avatar,
tracking_data=tracking_data,
)
case "file":
filename = media_url.rsplit("/", 1)[-1] if "/" in media_url else "file"
await self._send_file_api(
auth_token, target_id, media_url, 0, filename, sender_name, sender_avatar,
tracking_data=tracking_data,
)
case _:
await self._send_text_api(
auth_token, target_id, media_url, sender_name, sender_avatar,
tracking_data=tracking_data,
)
async def send_picture(
self,
receiver: str,
media_url: str,
text: str = "",
thumbnail_url: str | None = None,
account_id: str | None = None,
tracking_data: str | None = None,
) -> dict:
account = await self._resolve_account(account_id)
if not account:
return {"status": -1, "status_message": "account not resolved"}
auth_token = account.get("auth_token", "")
sender_name = account.get("sender_name", "ForcePilot Bot")
sender_avatar = account.get("sender_avatar", "")
return await self._send_picture_api(
auth_token,
receiver,
media_url,
text,
sender_name,
sender_avatar,
thumbnail_url,
tracking_data=tracking_data,
)
async def send_video(
self,
receiver: str,
media_url: str,
size: int,
duration: int = 0,
thumbnail_url: str | None = None,
account_id: str | None = None,
tracking_data: str | None = None,
) -> dict:
account = await self._resolve_account(account_id)
if not account:
return {"status": -1, "status_message": "account not resolved"}
auth_token = account.get("auth_token", "")
sender_name = account.get("sender_name", "ForcePilot Bot")
sender_avatar = account.get("sender_avatar", "")
return await self._send_video_api(
auth_token,
receiver,
media_url,
size,
sender_name,
sender_avatar,
duration,
thumbnail_url,
tracking_data=tracking_data,
)
async def send_file(
self,
receiver: str,
media_url: str,
size: int,
file_name: str,
account_id: str | None = None,
tracking_data: str | None = None,
) -> dict:
account = await self._resolve_account(account_id)
if not account:
return {"status": -1, "status_message": "account not resolved"}
auth_token = account.get("auth_token", "")
sender_name = account.get("sender_name", "ForcePilot Bot")
sender_avatar = account.get("sender_avatar", "")
return await self._send_file_api(
auth_token, receiver, media_url, size, file_name, sender_name, sender_avatar,
tracking_data=tracking_data,
)
async def send_contact(
self,
receiver: str,
contact_name: str,
phone_number: str,
account_id: str | None = None,
tracking_data: str | None = None,
) -> dict:
account = await self._resolve_account(account_id)
if not account:
return {"status": -1, "status_message": "account not resolved"}
auth_token = account.get("auth_token", "")
sender = self._build_sender(account)
min_api = account.get("min_api_version", 7)
payload = {
"receiver": receiver,
"type": "contact",
"contact": {"name": contact_name, "phone_number": phone_number},
"sender": sender,
"min_api_version": min_api,
}
if tracking_data:
payload["tracking_data"] = tracking_data
return await self._api_call(auth_token, "send_message", payload)
async def send_location(
self,
receiver: str,
lat: float,
lon: float,
account_id: str | None = None,
tracking_data: str | None = None,
) -> dict:
account = await self._resolve_account(account_id)
if not account:
return {"status": -1, "status_message": "account not resolved"}
auth_token = account.get("auth_token", "")
sender = self._build_sender(account)
min_api = account.get("min_api_version", 7)
payload = {
"receiver": receiver,
"type": "location",
"location": {"lat": lat, "lon": lon},
"sender": sender,
"min_api_version": min_api,
}
if tracking_data:
payload["tracking_data"] = tracking_data
return await self._api_call(auth_token, "send_message", payload)
async def send_sticker(
self,
receiver: str,
sticker_id: int,
account_id: str | None = None,
tracking_data: str | None = None,
) -> dict:
account = await self._resolve_account(account_id)
if not account:
return {"status": -1, "status_message": "account not resolved"}
auth_token = account.get("auth_token", "")
sender = self._build_sender(account)
min_api = account.get("min_api_version", 7)
payload = {
"receiver": receiver,
"type": "sticker",
"sticker_id": sticker_id,
"sender": sender,
"min_api_version": min_api,
}
if tracking_data:
payload["tracking_data"] = tracking_data
return await self._api_call(auth_token, "send_message", payload)
async def send_url(
self,
receiver: str,
media_url: str,
account_id: str | None = None,
tracking_data: str | None = None,
) -> dict:
account = await self._resolve_account(account_id)
if not account:
return {"status": -1, "status_message": "account not resolved"}
auth_token = account.get("auth_token", "")
sender = self._build_sender(account)
min_api = account.get("min_api_version", 7)
payload = {
"receiver": receiver,
"type": "url",
"media": media_url,
"sender": sender,
"min_api_version": min_api,
}
if tracking_data:
payload["tracking_data"] = tracking_data
return await self._api_call(auth_token, "send_message", payload)
async def send_picture_auto_convert(
self,
receiver: str,
media_url: str,
text: str = "",
account_id: str | None = None,
tracking_data: str | None = None,
) -> dict:
account = await self._resolve_account(account_id)
if not account:
return {"status": -1, "status_message": "account not resolved"}
auth_token = account.get("auth_token", "")
sender_name = account.get("sender_name", "ForcePilot Bot")
sender_avatar = account.get("sender_avatar", "")
ext = media_url.lower().rsplit(".", 1)[-1] if "." in media_url else ""
if ext in ("png", "gif"):
return await self._send_picture_api(
auth_token, receiver, media_url, text, sender_name, sender_avatar, tracking_data=tracking_data
)
if not ext or ext not in ("jpg", "jpeg"):
logger.warning("Viber picture: unsupported format, auto-converting: %s", media_url)
converted_url = await self._convert_to_jpeg(media_url)
if converted_url:
return await self._send_picture_api(
auth_token, receiver, converted_url, text, sender_name, sender_avatar, tracking_data=tracking_data
)
return {"status": 1, "status_message": "unsupported image format"}
return await self._send_picture_api(
auth_token, receiver, media_url, text, sender_name, sender_avatar, tracking_data=tracking_data
)
async def send_rich_media(
self,
receiver: str,
rich_media: dict,
account_id: str | None = None,
tracking_data: str | None = None,
) -> dict:
account = await self._resolve_account(account_id)
if not account:
return {"status": -1, "status_message": "account not resolved"}
auth_token = account.get("auth_token", "")
sender = self._build_sender(account)
min_api = account.get("min_api_version", 7)
payload = {
"receiver": receiver,
"type": "rich_media",
"rich_media": rich_media,
"sender": sender,
"min_api_version": min_api,
}
if tracking_data:
payload["tracking_data"] = tracking_data
return await self._api_call(auth_token, "send_message", payload)
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
return chunk_text(text, limit)
def sanitize_text(self, text: str, payload: object) -> str:
return text
def should_skip_plain_text_sanitization(self, payload: object) -> bool:
return True
def resolve_target(
self,
to: str | None = None,
*,
config: dict | None = None,
allow_from: list[str] | None = None,
account_id: str | None = None,
mode: str | None = None,
) -> tuple[bool, str]:
if not to:
return False, "target required"
return True, to
@staticmethod
def build_keyboard(keyboard: ViberKeyboard) -> dict:
buttons = []
for btn in keyboard.buttons:
buttons.append(
{
"Columns": btn.columns,
"Rows": btn.rows,
"ActionType": btn.action_type,
"ActionBody": btn.action_body,
"Text": btn.text,
"TextSize": btn.text_size,
"BgColor": btn.bg_color,
"Silent": btn.silent,
}
)
return {
"Type": "keyboard",
"DefaultHeight": keyboard.default_height,
"BgColor": keyboard.bg_color,
"Buttons": buttons,
}
@staticmethod
def build_rich_media(rich_media: ViberRichMedia) -> dict:
buttons = []
for elem in rich_media.buttons:
btn = {
"Columns": elem.columns,
"Rows": elem.rows,
"ActionType": elem.action_type,
"ActionBody": elem.action_body,
"Text": elem.text,
"TextSize": elem.text_size,
"TextHAlign": elem.text_h_align,
"TextVAlign": elem.text_v_align,
"BgColor": elem.bg_color,
}
if elem.image:
btn["Image"] = elem.image
buttons.append(btn)
total_rows = sum(e.rows for e in rich_media.buttons)
if rich_media.buttons_group_rows != total_rows:
logger.warning(
"Viber RichMedia ButtonsGroupRows mismatch: specified=%d actual=%d, auto-correcting",
rich_media.buttons_group_rows,
total_rows,
)
result = {
"Type": "rich_media",
"ButtonsGroupColumns": rich_media.buttons_group_columns,
"ButtonsGroupRows": total_rows,
"BgColor": rich_media.bg_color,
"Buttons": buttons,
}
if rich_media.alt_text:
result["alt_text"] = rich_media.alt_text
return result
async def _resolve_account(self, account_id: str | None) -> dict | None:
if not account_id:
account_id = "default"
try:
return await self._config.resolve_account(account_id)
except Exception:
logger.exception("Viber resolve_account error")
return None
@staticmethod
async def _convert_to_jpeg(media_url: str) -> str | None:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(60.0)) as client:
resp = await client.get(media_url)
resp.raise_for_status()
image_data = resp.content
img = Image.open(io.BytesIO(image_data))
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
compressed = ViberOutboundAdapter._compress_jpeg(img)
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
tmp.write(compressed)
tmp.close()
logger.info("Viber image converted to JPEG: %s%s", media_url, tmp.name)
return tmp.name
except Exception:
logger.exception("Viber image JPEG conversion failed: %s", media_url)
return None
@staticmethod
def _compress_jpeg(img: Image.Image, max_bytes: int = VIBER_PICTURE_MAX_BYTES) -> bytes:
quality = 85
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality)
data = buf.getvalue()
while len(data) > max_bytes and quality > 20:
quality -= 10
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality)
data = buf.getvalue()
if len(data) > max_bytes:
max_dim = 2048
while len(data) > max_bytes and max_dim > 512:
max_dim = int(max_dim * 0.75)
resized = img.copy()
resized.thumbnail((max_dim, max_dim))
buf = io.BytesIO()
resized.save(buf, format="JPEG", quality=quality)
data = buf.getvalue()
return data
@staticmethod
def _build_sender(account: dict) -> dict:
sender = {"name": account.get("sender_name", "ForcePilot Bot")}
avatar = account.get("sender_avatar", "")
if avatar:
sender["avatar"] = avatar
return sender
async def _send_text_api(
self,
auth_token: str,
receiver: str,
text: str,
sender_name: str,
sender_avatar: str,
keyboard: dict | None = None,
tracking_data: str | None = None,
) -> dict:
sender = {"name": sender_name}
if sender_avatar:
sender["avatar"] = sender_avatar
payload = {
"receiver": receiver,
"type": "text",
"text": text,
"sender": sender,
"min_api_version": 7,
}
if keyboard:
payload["keyboard"] = keyboard
if tracking_data:
payload["tracking_data"] = tracking_data
return await self._api_call(auth_token, "send_message", payload)
async def _send_picture_api(
self,
auth_token: str,
receiver: str,
media_url: str,
text: str,
sender_name: str,
sender_avatar: str,
thumbnail_url: str | None = None,
tracking_data: str | None = None,
) -> dict:
sender = {"name": sender_name}
if sender_avatar:
sender["avatar"] = sender_avatar
payload = {
"receiver": receiver,
"type": "picture",
"text": text,
"media": media_url,
"sender": sender,
"min_api_version": 7,
}
if thumbnail_url:
payload["thumbnail"] = thumbnail_url
if tracking_data:
payload["tracking_data"] = tracking_data
return await self._api_call(auth_token, "send_message", payload)
async def _send_video_api(
self,
auth_token: str,
receiver: str,
media_url: str,
size: int,
sender_name: str,
sender_avatar: str,
duration: int = 0,
thumbnail_url: str | None = None,
tracking_data: str | None = None,
) -> dict:
sender = {"name": sender_name}
if sender_avatar:
sender["avatar"] = sender_avatar
payload = {
"receiver": receiver,
"type": "video",
"media": media_url,
"size": size,
"sender": sender,
"min_api_version": 7,
}
if duration:
payload["duration"] = duration
if thumbnail_url:
payload["thumbnail"] = thumbnail_url
if tracking_data:
payload["tracking_data"] = tracking_data
return await self._api_call(auth_token, "send_message", payload)
async def _send_file_api(
self,
auth_token: str,
receiver: str,
media_url: str,
size: int,
file_name: str,
sender_name: str,
sender_avatar: str,
tracking_data: str | None = None,
) -> dict:
sender = {"name": sender_name}
if sender_avatar:
sender["avatar"] = sender_avatar
payload = {
"receiver": receiver,
"type": "file",
"media": media_url,
"size": size,
"file_name": file_name,
"sender": sender,
"min_api_version": 7,
}
if tracking_data:
payload["tracking_data"] = tracking_data
return await self._api_call(auth_token, "send_message", payload)
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",
}
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(30.0)) as client:
resp = await client.post(
f"{VIBER_API_BASE}/{endpoint}",
json=payload,
headers=headers,
)
resp.raise_for_status()
result = resp.json()
status = result.get("status")
if 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,
)
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
except Exception:
logger.exception("Viber API %s error", endpoint)
return {"status": -1, "status_message": "exception"}