ForcePilot/backend/package/yuxi/channel/extensions/messenger/monitor.py
Kris 895602f774 feat(channel): 添加 Facebook Messenger 渠道扩展
新增 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: 类型定义
2026-05-21 11:25:47 +08:00

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,
)