新增 Facebook Messenger 渠道扩展,支持在 Yuxi 平台中集成 Messenger 即时通讯渠道。 包含以下功能模块: - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - actions: 动作处理 - template: 消息模板 - quick_reply: 快捷回复 - private_reply: 私密回复 - handover: 转人工切换 - persona: 人设管理 - profile: 主页配置 - user: 用户信息 - insights: 数据洞察 - notification: 通知推送 - media: 媒体资源处理 - types: 类型定义
371 lines
13 KiB
Python
371 lines
13 KiB
Python
import logging
|
|
from datetime import datetime, UTC
|
|
|
|
from yuxi.channel.message.models import (
|
|
MessageType,
|
|
PeerInfo,
|
|
UnifiedMessage,
|
|
)
|
|
from yuxi.channel.routing.models import PeerKind
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class MessengerMonitor:
|
|
def parse_event_to_unified(
|
|
self,
|
|
event: dict,
|
|
account_id: str,
|
|
page_id: str = "",
|
|
) -> UnifiedMessage | None:
|
|
sender_psid = event.get("sender", {}).get("id", "")
|
|
timestamp_ms = event.get("timestamp", 0)
|
|
|
|
if not sender_psid:
|
|
logger.warning("messenger event missing sender PSID")
|
|
return None
|
|
|
|
if "message" in event:
|
|
message = event.get("message", {})
|
|
if message.get("is_echo"):
|
|
return self._parse_echo_event(event, sender_psid, account_id, page_id, timestamp_ms)
|
|
return self._parse_message_event(event, sender_psid, account_id, page_id, timestamp_ms)
|
|
elif "postback" in event:
|
|
return self._parse_postback_event(event, sender_psid, account_id, page_id, timestamp_ms)
|
|
elif "referral" in event:
|
|
return self._parse_referral_event(event, sender_psid, account_id, page_id, timestamp_ms)
|
|
elif "optin" in event:
|
|
return self._parse_optin_event(event, sender_psid, account_id, page_id, timestamp_ms)
|
|
elif "delivery" in event:
|
|
return self._parse_delivery_event(event, sender_psid, account_id, page_id, timestamp_ms)
|
|
elif "read" in event:
|
|
return self._parse_read_event(event, sender_psid, account_id, page_id, timestamp_ms)
|
|
elif "account_linking" in event:
|
|
return self._parse_account_linking_event(event, sender_psid, account_id, page_id, timestamp_ms)
|
|
elif "pass_thread_control" in event or "take_thread_control" in event or "request_thread_control" in event:
|
|
return self._parse_handover_event(event, sender_psid, account_id, page_id, timestamp_ms)
|
|
elif "policy_enforcement" in event:
|
|
return self._parse_policy_event(event, sender_psid, account_id, page_id, timestamp_ms)
|
|
else:
|
|
logger.debug(f"messenger unhandled event type: keys={list(event.keys())}")
|
|
return None
|
|
|
|
def _parse_message_event(
|
|
self, event: dict, psid: str, account_id: str, page_id: str, timestamp_ms: int
|
|
) -> UnifiedMessage | None:
|
|
message = event.get("message", {})
|
|
msg_id = f"messenger:{message.get('mid', '')}"
|
|
|
|
attachments = message.get("attachments", [])
|
|
if attachments:
|
|
return self._parse_attachment_event(attachments, msg_id, psid, account_id, page_id, timestamp_ms, event)
|
|
|
|
quick_reply = message.get("quick_reply", {})
|
|
if quick_reply:
|
|
return self._parse_quick_reply(quick_reply, msg_id, psid, account_id, page_id, timestamp_ms, event)
|
|
|
|
text = message.get("text", "")
|
|
return self._build_unified(
|
|
msg_id=msg_id,
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content=text,
|
|
msg_type=MessageType.TEXT,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=event,
|
|
metadata={"page_id": page_id},
|
|
)
|
|
|
|
def _parse_attachment_event(
|
|
self,
|
|
attachments: list,
|
|
msg_id: str,
|
|
psid: str,
|
|
account_id: str,
|
|
page_id: str,
|
|
timestamp_ms: int,
|
|
raw: dict,
|
|
) -> UnifiedMessage | None:
|
|
attachment = attachments[0]
|
|
att_type = attachment.get("type", "")
|
|
payload = attachment.get("payload", {})
|
|
|
|
type_map = {
|
|
"image": (MessageType.IMAGE, "[图片]"),
|
|
"video": (MessageType.FILE, "[视频]"),
|
|
"audio": (MessageType.VOICE, "[语音消息]"),
|
|
"file": (MessageType.FILE, "[文件]"),
|
|
"location": (MessageType.TEXT, None),
|
|
}
|
|
|
|
if att_type == "location":
|
|
coordinates = payload.get("coordinates", {})
|
|
lat = coordinates.get("lat")
|
|
lon = coordinates.get("long")
|
|
title = payload.get("title", "")
|
|
content = f"[位置] {title}" if title else "[位置]"
|
|
return self._build_unified(
|
|
msg_id=msg_id,
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content=content,
|
|
msg_type=MessageType.TEXT,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=raw,
|
|
metadata={"page_id": page_id},
|
|
location_lat=lat,
|
|
location_lon=lon,
|
|
)
|
|
|
|
msg_type, fallback_text = type_map.get(att_type, (MessageType.FILE, f"[{att_type}]"))
|
|
media_url = payload.get("url", "")
|
|
media_urls = [media_url] if media_url else []
|
|
|
|
return self._build_unified(
|
|
msg_id=msg_id,
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content=fallback_text or att_type,
|
|
msg_type=msg_type,
|
|
media_urls=media_urls,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=raw,
|
|
metadata={"page_id": page_id, "attachment_type": att_type},
|
|
)
|
|
|
|
def _parse_quick_reply(
|
|
self,
|
|
quick_reply: dict,
|
|
msg_id: str,
|
|
psid: str,
|
|
account_id: str,
|
|
page_id: str,
|
|
timestamp_ms: int,
|
|
raw: dict,
|
|
) -> UnifiedMessage:
|
|
payload = quick_reply.get("payload", "")
|
|
return self._build_unified(
|
|
msg_id=msg_id,
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content=payload,
|
|
msg_type=MessageType.TEXT,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=raw,
|
|
metadata={"page_id": page_id, "is_quick_reply": True},
|
|
)
|
|
|
|
def _parse_postback_event(
|
|
self, event: dict, psid: str, account_id: str, page_id: str, timestamp_ms: int
|
|
) -> UnifiedMessage:
|
|
postback = event.get("postback", {})
|
|
payload = postback.get("payload", "")
|
|
title = postback.get("title", "")
|
|
content = payload or title
|
|
return self._build_unified(
|
|
msg_id=f"messenger:pb:{payload}",
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content=content,
|
|
msg_type=MessageType.EVENT,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=event,
|
|
metadata={"page_id": page_id, "event_type": "postback", "title": title},
|
|
)
|
|
|
|
def _parse_referral_event(
|
|
self, event: dict, psid: str, account_id: str, page_id: str, timestamp_ms: int
|
|
) -> UnifiedMessage:
|
|
referral = event.get("referral", {})
|
|
ref = referral.get("ref", "")
|
|
return self._build_unified(
|
|
msg_id=f"messenger:ref:{ref}",
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content=f"[referral] {ref}",
|
|
msg_type=MessageType.EVENT,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=event,
|
|
metadata={"page_id": page_id, "event_type": "referral", "ref": ref},
|
|
)
|
|
|
|
def _parse_optin_event(
|
|
self, event: dict, psid: str, account_id: str, page_id: str, timestamp_ms: int
|
|
) -> UnifiedMessage:
|
|
optin = event.get("optin", {})
|
|
return self._build_unified(
|
|
msg_id=f"messenger:optin:{optin.get('one_time_notif_token', '')}",
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content="[optin]",
|
|
msg_type=MessageType.EVENT,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=event,
|
|
metadata={"page_id": page_id, "event_type": "optin", "optin_payload": optin.get("payload")},
|
|
)
|
|
|
|
def _parse_delivery_event(
|
|
self, event: dict, psid: str, account_id: str, page_id: str, timestamp_ms: int
|
|
) -> UnifiedMessage:
|
|
delivery = event.get("delivery", {})
|
|
mids = delivery.get("mids", [])
|
|
mid = mids[0] if mids else ""
|
|
return self._build_unified(
|
|
msg_id=f"messenger:delivery:{mid}",
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content="[delivery]",
|
|
msg_type=MessageType.EVENT,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=event,
|
|
metadata={"page_id": page_id, "event_type": "delivery", "mids": mids},
|
|
)
|
|
|
|
def _parse_read_event(
|
|
self, event: dict, psid: str, account_id: str, page_id: str, timestamp_ms: int
|
|
) -> UnifiedMessage:
|
|
read = event.get("read", {})
|
|
return self._build_unified(
|
|
msg_id=f"messenger:read:{read.get('watermark', '')}",
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content="[read]",
|
|
msg_type=MessageType.EVENT,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=event,
|
|
metadata={"page_id": page_id, "event_type": "read", "watermark": read.get("watermark")},
|
|
)
|
|
|
|
def _parse_echo_event(
|
|
self, event: dict, psid: str, account_id: str, page_id: str, timestamp_ms: int
|
|
) -> UnifiedMessage:
|
|
message = event.get("message", {})
|
|
mid = message.get("mid", "")
|
|
return self._build_unified(
|
|
msg_id=f"messenger:echo:{mid}",
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content=message.get("text", "[echo]"),
|
|
msg_type=MessageType.EVENT,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=event,
|
|
metadata={"page_id": page_id, "event_type": "echo", "mid": mid},
|
|
)
|
|
|
|
def _parse_account_linking_event(
|
|
self, event: dict, psid: str, account_id: str, page_id: str, timestamp_ms: int
|
|
) -> UnifiedMessage:
|
|
linking = event.get("account_linking", {})
|
|
status = linking.get("status", "")
|
|
return self._build_unified(
|
|
msg_id=f"messenger:linking:{status}",
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content=f"[account_linking] {status}",
|
|
msg_type=MessageType.EVENT,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=event,
|
|
metadata={
|
|
"page_id": page_id,
|
|
"event_type": "account_linking",
|
|
"status": status,
|
|
"authorization_code": linking.get("authorization_code"),
|
|
},
|
|
)
|
|
|
|
def _parse_handover_event(
|
|
self, event: dict, psid: str, account_id: str, page_id: str, timestamp_ms: int
|
|
) -> UnifiedMessage:
|
|
if "pass_thread_control" in event:
|
|
handover = event.get("pass_thread_control", {})
|
|
event_type = "pass_thread_control"
|
|
elif "take_thread_control" in event:
|
|
handover = event.get("take_thread_control", {})
|
|
event_type = "take_thread_control"
|
|
else:
|
|
handover = event.get("request_thread_control", {})
|
|
event_type = "request_thread_control"
|
|
return self._build_unified(
|
|
msg_id=f"messenger:{event_type}:{handover.get('metadata', '')}",
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content=f"[{event_type}]",
|
|
msg_type=MessageType.EVENT,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=event,
|
|
metadata={
|
|
"page_id": page_id,
|
|
"event_type": event_type,
|
|
"new_owner_app_id": handover.get("new_owner_app_id"),
|
|
"previous_owner_app_id": handover.get("previous_owner_app_id"),
|
|
"metadata": handover.get("metadata"),
|
|
},
|
|
)
|
|
|
|
def _parse_policy_event(
|
|
self, event: dict, psid: str, account_id: str, page_id: str, timestamp_ms: int
|
|
) -> UnifiedMessage:
|
|
policy = event.get("policy_enforcement", {})
|
|
action = policy.get("action", "")
|
|
reason = policy.get("reason", "")
|
|
return self._build_unified(
|
|
msg_id=f"messenger:policy:{action}",
|
|
psid=psid,
|
|
account_id=account_id,
|
|
content=f"[policy_enforcement] {action}: {reason}",
|
|
msg_type=MessageType.EVENT,
|
|
timestamp_ms=timestamp_ms,
|
|
raw=event,
|
|
metadata={
|
|
"page_id": page_id,
|
|
"event_type": "policy_enforcement",
|
|
"action": action,
|
|
"reason": reason,
|
|
},
|
|
)
|
|
|
|
@staticmethod
|
|
def _build_unified(
|
|
msg_id: str,
|
|
psid: str,
|
|
account_id: str,
|
|
content: str,
|
|
msg_type: MessageType = MessageType.TEXT,
|
|
media_urls: list[str] | None = None,
|
|
timestamp_ms: int = 0,
|
|
raw: dict | None = None,
|
|
metadata: dict | None = None,
|
|
location_lat: float | None = None,
|
|
location_lon: float | None = None,
|
|
) -> UnifiedMessage:
|
|
timestamp = None
|
|
if timestamp_ms:
|
|
try:
|
|
timestamp = datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC)
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
sender = PeerInfo(
|
|
kind=PeerKind.DIRECT,
|
|
id=psid,
|
|
display_name=None,
|
|
is_bot=False,
|
|
is_self=False,
|
|
)
|
|
|
|
return UnifiedMessage(
|
|
msg_id=msg_id,
|
|
channel_type="messenger",
|
|
account_id=account_id,
|
|
content=content,
|
|
sender=sender,
|
|
message_type=msg_type,
|
|
media_urls=media_urls or [],
|
|
timestamp=timestamp,
|
|
raw_payload=raw or {},
|
|
metadata=metadata or {},
|
|
reply_to_id=None,
|
|
location_lat=location_lat,
|
|
location_lon=location_lon,
|
|
)
|