新增 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: 媒体资源处理
651 lines
22 KiB
Python
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"}
|