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

265 lines
8.8 KiB
Python

import logging
from typing import Any
import httpx
from yuxi.channel.extensions.messenger.errors import MessengerErrorKind, classify_error
from yuxi.channel.extensions.messenger.types import MessengerOutboundResult
logger = logging.getLogger(__name__)
MESSENGER_API_BASE = "https://graph.facebook.com/v22.0"
TEXT_MAX_CHARS = 2000
QUICK_REPLY_MAX = 13
QUICK_REPLY_TITLE_MAX = 20
BUTTON_TITLE_MAX = 20
GENERIC_CARD_MAX = 10
class MessengerOutbound:
delivery_mode = "direct"
chunker_mode = "length"
text_chunk_limit: int = TEXT_MAX_CHARS
poll_max_options: int | None = None
supports_poll_duration_seconds = False
supports_anonymous_polls = False
extract_markdown_images = False
presentation_capabilities = None
delivery_capabilities = None
def __init__(self):
self._client: httpx.AsyncClient | None = None
async def _ensure_client(self):
if self._client is None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(30.0))
async def _close_client(self):
if self._client:
await self._client.aclose()
self._client = None
@staticmethod
def _build_url(page_id: str) -> str:
return f"{MESSENGER_API_BASE}/{page_id}/messages"
@staticmethod
def _build_send_payload(account: dict, target_id: str, message_payload: dict, tag: str | None = None) -> dict:
messaging_type = account.get("default_messaging_type", "RESPONSE")
notification_type = account.get("notification_type", "REGULAR")
persona_id = account.get("persona_id")
payload: dict[str, Any] = {
"messaging_type": messaging_type,
"recipient": {"id": target_id},
"message": message_payload,
}
if messaging_type == "MESSAGE_TAG" and tag:
payload["tag"] = tag
if notification_type and notification_type != "REGULAR":
payload["notification_type"] = notification_type
if persona_id:
payload["persona_id"] = persona_id
return payload
async def send_text(
self,
target_id: str,
content: str,
*,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> MessengerOutboundResult:
account = await self._resolve_account(account_id)
if not account:
return MessengerOutboundResult(success=False, error_message="account not resolved")
text = content[:TEXT_MAX_CHARS]
message_payload: dict[str, Any] = {"text": text}
if reply_to_id:
message_payload["reply_to"] = {"mid": reply_to_id}
payload = self._build_send_payload(account, target_id, message_payload)
return await self._post(account, payload)
async def send_media(
self,
target_id: str,
media_url: str,
media_type: str,
reply_to_id: str | None = None,
thread_id: str | None = None,
account_id: str | None = None,
) -> MessengerOutboundResult:
account = await self._resolve_account(account_id)
if not account:
return MessengerOutboundResult(success=False, error_message="account not resolved")
messenger_types = {"image": "image", "video": "video", "voice": "audio", "file": "file"}
fb_type = messenger_types.get(media_type, "file")
message_payload = {
"attachment": {
"type": fb_type,
"payload": {"url": media_url, "is_reusable": True},
}
}
if reply_to_id:
message_payload["reply_to"] = {"mid": reply_to_id}
payload = self._build_send_payload(account, target_id, message_payload)
return await self._post(account, payload)
async def send_sender_action(
self,
target_id: str,
action: str,
account_id: str | None = None,
) -> MessengerOutboundResult:
account = await self._resolve_account(account_id)
if not account:
return MessengerOutboundResult(success=False, error_message="account not resolved")
payload = {
"recipient": {"id": target_id},
"sender_action": action,
}
return await self._post(account, payload)
async def send_mark_seen(
self,
target_id: str,
account_id: str | None = None,
) -> MessengerOutboundResult:
account = await self._resolve_account(account_id)
if not account:
return MessengerOutboundResult(success=False, error_message="account not resolved")
payload = {
"recipient": {"id": target_id},
"sender_action": "mark_seen",
}
return await self._post(account, payload)
async def send_template(
self,
target_id: str,
template_type: str,
payload: dict,
account_id: str | None = None,
) -> MessengerOutboundResult:
account = await self._resolve_account(account_id)
if not account:
return MessengerOutboundResult(success=False, error_message="account not resolved")
message_payload = {
"attachment": {
"type": "template",
"payload": {"template_type": template_type, **payload},
}
}
body = self._build_send_payload(account, target_id, message_payload)
return await self._post(account, body)
async def send_quick_replies(
self,
target_id: str,
text: str,
items: list[dict],
account_id: str | None = None,
) -> MessengerOutboundResult:
account = await self._resolve_account(account_id)
if not account:
return MessengerOutboundResult(success=False, error_message="account not resolved")
message_payload = {
"text": text[:640],
"quick_replies": items[:QUICK_REPLY_MAX],
}
body = self._build_send_payload(account, target_id, message_payload)
return await self._post(account, body)
async def _post(self, account: dict, payload: dict) -> MessengerOutboundResult:
await self._ensure_client()
page_id = account.get("page_id", "")
access_token = account.get("page_access_token", "")
url = f"{self._build_url(page_id)}?access_token={access_token}"
try:
resp = await self._client.post(url, json=payload)
result = resp.json()
if resp.status_code == 200:
msg_id = result.get("message_id", "")
logger.debug(f"messenger send OK: msg_id={msg_id}")
return MessengerOutboundResult(message_id=msg_id, recipient_id=payload["recipient"]["id"], success=True)
error = result.get("error", {})
error_code = error.get("code", 0)
error_subcode = error.get("error_subcode", 0)
error_msg = error.get("message", "")
error_kind = classify_error(error_code, error_subcode)
logger.error(f"messenger send failed: code={error_code} subcode={error_subcode} msg={error_msg}")
return MessengerOutboundResult(
success=False,
error_code=error_code,
error_message=error_msg,
recipient_id=payload["recipient"]["id"],
is_window_expired=(error_kind == MessengerErrorKind.WINDOW_EXPIRED),
is_user_blocked=(error_kind == MessengerErrorKind.USER_BLOCKED),
is_rate_limited=(error_kind == MessengerErrorKind.RATE_LIMITED),
)
except Exception:
logger.exception("messenger send exception")
return MessengerOutboundResult(success=False, error_message="network error")
def chunker(self, text: str, limit: int, ctx: object | None = None) -> list[str]:
if len(text) <= limit:
return [text]
chunks = []
while len(text) > limit:
chunks.append(text[:limit])
text = text[limit:]
if text:
chunks.append(text)
return chunks
def sanitize_text(self, text: str, payload: object) -> str:
return text
def should_skip_plain_text_sanitization(self, payload: object) -> bool:
return False
def resolve_target(
self,
to: str | None = None,
*,
config: dict | None = None,
allow_from: list[str] | None = None,
account_id: str | None = None,
mode: str | None = None,
) -> tuple[bool, str]:
if not to:
return False, "target required"
return True, to
@staticmethod
async def _resolve_account(account_id: str | None) -> dict | None:
from yuxi.channel.extensions.messenger.config import MessengerConfigAdapter
adapter = MessengerConfigAdapter()
aid = account_id or adapter.default_account_id()
return adapter.resolve_account(aid)