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