新增 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: 类型定义
265 lines
8.8 KiB
Python
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)
|