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

244 lines
8.8 KiB
Python

from __future__ import annotations
import asyncio
import json
import logging
import time
from collections.abc import Awaitable, Callable
from datetime import datetime, UTC
from yuxi.channel.extensions.viber.dedupe import ViberEventDeduplicator, build_event_dedupe_key
from yuxi.channel.extensions.viber.types import ViberCallbackEvent, ViberSender
from yuxi.channel.message.models import (
MessageType,
PeerInfo,
UnifiedMessage,
)
from yuxi.channel.routing.models import PeerKind
logger = logging.getLogger(__name__)
class ViberSubscriptionTracker:
def __init__(self):
self._subscribers: dict[str, float] = {}
def mark_subscribed(self, user_id: str) -> None:
if not user_id:
return
self._subscribers[user_id] = time.monotonic()
logger.info("Viber subscription tracked: user=%s", user_id)
def mark_unsubscribed(self, user_id: str) -> None:
if not user_id:
return
self._subscribers.pop(user_id, None)
logger.info("Viber unsubscription tracked: user=%s", user_id)
def is_subscribed(self, user_id: str) -> bool:
return user_id in self._subscribers
def get_subscribers(self) -> set[str]:
return set(self._subscribers.keys())
class ViberMonitor:
delivery_mode = "direct"
def __init__(self):
self._dedupe = ViberEventDeduplicator()
self._subscription_tracker = ViberSubscriptionTracker()
self._on_conversation_started: Callable[[str, str], Awaitable[None]] | None = None
def parse_webhook_body(self, raw_body: bytes) -> dict:
try:
return json.loads(raw_body)
except json.JSONDecodeError:
logger.warning("Viber webhook: invalid JSON body")
return {}
def parse_event(self, callback: dict) -> ViberCallbackEvent:
sender_data = callback.get("sender", {})
sender = None
if sender_data:
sender = ViberSender(
id=sender_data.get("id", ""),
name=sender_data.get("name", ""),
avatar=sender_data.get("avatar", ""),
country=sender_data.get("country", ""),
language=sender_data.get("language", ""),
api_version=sender_data.get("api_version", 7),
)
return ViberCallbackEvent(
event=callback.get("event", ""),
timestamp=callback.get("timestamp", 0),
message_token=callback.get("message_token"),
sender=sender,
message=callback.get("message"),
user=callback.get("user"),
user_id=callback.get("user_id"),
silent=callback.get("silent", False),
context=callback.get("context"),
)
def parse_event_to_unified(
self,
callback: dict,
account_id: str,
) -> UnifiedMessage | None:
event = self.parse_event(callback)
dedupe_key = build_event_dedupe_key(account_id, callback)
if self._dedupe.is_duplicate(dedupe_key):
logger.debug("Viber duplicate event: key=%s", dedupe_key)
return None
if event.event == "webhook":
logger.info("Viber webhook verification callback received")
return None
if event.event == "subscribed":
user_id = event.user_id or (event.sender.id if event.sender else "")
self._subscription_tracker.mark_subscribed(user_id)
return None
if event.event == "unsubscribed":
user_id = event.user_id or ""
self._subscription_tracker.mark_unsubscribed(user_id)
return None
if event.event == "conversation_started":
user_id = event.user_id or (event.sender.id if event.sender else "")
self._subscription_tracker.mark_subscribed(user_id)
if self._on_conversation_started:
asyncio.ensure_future(self._on_conversation_started(user_id, account_id))
return self._build_conversation_started_message(event, account_id)
if event.event in ("delivered", "seen", "failed"):
logger.debug("Viber message status: event=%s token=%s", event.event, event.message_token)
return None
if event.event != "message" or event.message is None:
logger.debug("Viber non-message event: event=%s", event.event)
return None
message = event.message
msg_type, content, media_urls = self._extract_content(message)
sender = PeerInfo(
kind=PeerKind.DIRECT,
id=event.sender.id if event.sender else "",
display_name=event.sender.name if event.sender else None,
is_bot=False,
is_self=False,
)
timestamp = None
if event.timestamp:
try:
timestamp = datetime.fromtimestamp(event.timestamp / 1000, tz=UTC)
except (OSError, ValueError):
pass
return UnifiedMessage(
msg_id=f"viber:{event.message_token}",
channel_type="viber",
account_id=account_id,
content=content,
sender=sender,
message_type=msg_type,
media_urls=media_urls,
group=None,
timestamp=timestamp,
raw_payload=callback,
metadata={
"message_token": event.message_token,
"sender_country": event.sender.country if event.sender else "",
"sender_language": event.sender.language if event.sender else "",
"sender_api_version": event.sender.api_version if event.sender else 7,
"context": event.context,
},
)
def _build_conversation_started_message(
self,
event: ViberCallbackEvent,
account_id: str,
) -> UnifiedMessage:
user_id = event.user_id or (event.sender.id if event.sender else "")
sender = PeerInfo(
kind=PeerKind.DIRECT,
id=user_id,
display_name=event.sender.name if event.sender else None,
is_bot=False,
is_self=False,
)
timestamp = None
if event.timestamp:
try:
timestamp = datetime.fromtimestamp(event.timestamp / 1000, tz=UTC)
except (OSError, ValueError):
pass
return UnifiedMessage(
msg_id=f"viber:conv_start:{event.message_token or user_id}",
channel_type="viber",
account_id=account_id,
content="[会话开始]",
sender=sender,
message_type=MessageType.EVENT,
media_urls=[],
group=None,
timestamp=timestamp,
raw_payload={"event": event.event, "user_id": user_id},
metadata={
"message_token": event.message_token,
"context": event.context,
},
)
def is_subscribed(self, user_id: str) -> bool:
return self._subscription_tracker.is_subscribed(user_id)
@staticmethod
def _extract_content(message: dict) -> tuple[MessageType, str, list[str]]:
msg_type_str = message.get("type", "text")
media_urls: list[str] = []
match msg_type_str:
case "text":
return MessageType.TEXT, message.get("text", ""), media_urls
case "picture":
url = message.get("media", "")
if url:
media_urls.append(url)
return MessageType.IMAGE, message.get("text", "") or "[图片]", media_urls
case "video":
url = message.get("media", "")
if url:
media_urls.append(url)
return MessageType.FILE, "[视频]", media_urls
case "file":
url = message.get("media", "")
if url:
media_urls.append(url)
filename = message.get("file_name", "unknown")
return MessageType.FILE, f"[文件] {filename}", media_urls
case "contact":
contact = message.get("contact", {})
name = contact.get("name", "")
phone = contact.get("phone_number", "")
return MessageType.TEXT, f"[联系人] {name} ({phone})", media_urls
case "location":
loc = message.get("location", {})
lat = loc.get("lat", 0)
lon = loc.get("lon", 0)
return MessageType.TEXT, f"[位置] {lat}, {lon}", media_urls
case "sticker":
sticker_id = message.get("sticker_id", "")
return MessageType.EVENT, f"[贴纸] {sticker_id}", media_urls
case "url":
url = message.get("media", "")
return MessageType.TEXT, url or "[URL]", media_urls
case _:
return MessageType.TEXT, f"[{msg_type_str}]", media_urls