新增 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: 媒体资源处理
244 lines
8.8 KiB
Python
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
|