ForcePilot/backend/package/yuxi/channel/extensions/whatsapp/outbound.py
Kris 7215418610 feat(channel): 添加企业微信、微博、WhatsApp 和 Workplace 渠道扩展
新增企业微信、微博、WhatsApp、Workplace 四个渠道扩展。

企业微信渠道扩展主要模块:config, gateway, webhook, webhook_bot, outbound, streaming, pairing, security, crypto, dedupe, persistent_dedupe, card, directory, events, externalcontact, media, mentions, menu, message, oauth, status

微博渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, passive_reply, broadcast, message, menu, media, subscription, template, status

WhatsApp 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, actions, monitor, status

Workplace 渠道扩展主要模块:config, gateway, webhook, outbound, streaming, pairing, security, dedupe, actions, challenge, groups, media, mentions, menu, monitor, persona, quick_reply, signature, subscriptions, template, threading, users, status
2026-05-21 12:01:56 +08:00

762 lines
25 KiB
Python

"""WhatsApp Outbound 适配器 — 消息发送 (Cloud API HTTPS REST)"""
import asyncio
import aiohttp
import logging
from typing import Any
logger = logging.getLogger(__name__)
WHATSAPP_API_BASE = "https://graph.facebook.com/v22.0"
MAX_RETRIES = 3
INITIAL_BACKOFF_SECONDS = 1.0
def _mime_for_type(media_type: str) -> str:
mapping = {
"image": "image/jpeg",
"video": "video/mp4",
"audio": "audio/mp3",
"voice": "audio/ogg",
"document": "application/octet-stream",
}
return mapping.get(media_type, "application/octet-stream")
class WhatsAppOutbound:
delivery_mode = "direct"
chunker_mode = "length"
text_chunk_limit: int = 2000
poll_max_options: int = 12
supports_poll_duration_seconds = False
supports_anonymous_polls = False
extract_markdown_images = True
presentation_capabilities = None
delivery_capabilities = None
def __init__(self, config_adapter=None):
self._config_adapter = config_adapter
@staticmethod
def _build_url(phone_number_id: str) -> str:
return f"{WHATSAPP_API_BASE}/{phone_number_id}/messages"
@staticmethod
def _headers(access_token: str) -> dict:
return {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
async def _resolve_account(self, account_id: str | None) -> dict | None:
from yuxi.channel.extensions.whatsapp.config import WhatsAppConfigAdapter
adapter = self._config_adapter or WhatsAppConfigAdapter()
aid = account_id or adapter.default_account_id({})
return await adapter.resolve_account(aid)
async def _send_with_retry(
self,
phone_number_id: str,
access_token: str,
payload: dict,
*,
max_retries: int = MAX_RETRIES,
) -> tuple[int, dict]:
backoff = INITIAL_BACKOFF_SECONDS
last_result: dict = {}
for attempt in range(max_retries + 1):
async with aiohttp.ClientSession() as session:
async with session.post(
self._build_url(phone_number_id),
json=payload,
headers=self._headers(access_token),
) as resp:
result = await resp.json()
if resp.status == 200:
return resp.status, result
if resp.status == 429 and attempt < max_retries:
retry_after = resp.headers.get("Retry-After")
wait = float(retry_after) if retry_after else backoff
logger.warning(
"WhatsApp rate limited, retrying in %.1fs (attempt %d/%d)",
wait, attempt + 1, max_retries,
)
await asyncio.sleep(wait)
backoff *= 2
last_result = result
continue
return resp.status, result
return 429, last_result
@staticmethod
def _inject_biz_opaque(payload: dict, callback_data: str | None) -> None:
if callback_data:
payload["biz_opaque_callback_data"] = callback_data[:512]
# ── 基础消息发送 ──────────────────────────────────────────
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,
recipient_type: str = "individual",
biz_opaque_data: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
logger.error("WhatsApp send_text: account not resolved")
return
payload: dict[str, Any] = {
"messaging_product": "whatsapp",
"recipient_type": recipient_type,
"to": target_id,
"type": "text",
"text": {"body": content},
}
if reply_to_id:
payload["context"] = {"message_id": reply_to_id}
self._inject_biz_opaque(payload, biz_opaque_data)
status, result = await self._send_with_retry(
account.get("phone_number_id", ""),
account.get("access_token", ""),
payload,
)
if status != 200:
logger.error(f"WhatsApp send_text failed: {result}")
else:
logger.debug(f"WhatsApp send_text OK: msg_id={result.get('messages', [{}])[0].get('id')}")
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,
recipient_type: str = "individual",
*,
use_link: bool = False,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
if not media_url:
logger.error("WhatsApp send_media: media_url is empty")
return
media_ref: dict[str, str]
if use_link:
media_ref = {"link": media_url}
else:
media_id = await self._upload_media(media_url, media_type, account)
if not media_id:
logger.error("WhatsApp send_media: media upload failed, aborting")
return
media_ref = {"id": media_id}
payload: dict[str, Any] = {
"messaging_product": "whatsapp",
"recipient_type": recipient_type,
"to": target_id,
"type": media_type,
media_type: media_ref,
}
if reply_to_id:
payload["context"] = {"message_id": reply_to_id}
status, result = await self._send_with_retry(
account.get("phone_number_id", ""),
account.get("access_token", ""),
payload,
)
if status != 200:
logger.error(f"WhatsApp send_media failed: {result}")
async def send_reaction(
self,
target_id: str,
message_id: str,
emoji: str,
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
payload = {
"messaging_product": "whatsapp",
"to": target_id,
"type": "reaction",
"reaction": {"message_id": message_id, "emoji": emoji},
}
status, result = await self._send_with_retry(
account.get("phone_number_id", ""),
account.get("access_token", ""),
payload,
)
if status != 200:
logger.error(f"WhatsApp send_reaction failed: {result}")
async def send_sticker(
self,
target_id: str,
media_url: str,
*,
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
if not media_url:
logger.error("WhatsApp send_sticker: media_url is empty")
return
media_id = await self._upload_media(media_url, "image", account)
if not media_id:
logger.error("WhatsApp send_sticker: media upload failed, aborting")
return
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": target_id,
"type": "sticker",
"sticker": {"id": media_id},
}
status, result = await self._send_with_retry(
account.get("phone_number_id", ""),
account.get("access_token", ""),
payload,
)
if status != 200:
logger.error(f"WhatsApp send_sticker failed: {result}")
async def send_location(
self,
target_id: str,
latitude: float,
longitude: float,
*,
name: str | None = None,
address: str | None = None,
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
location: dict[str, Any] = {"latitude": str(latitude), "longitude": str(longitude)}
if name:
location["name"] = name
if address:
location["address"] = address
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": target_id,
"type": "location",
"location": location,
}
status, result = await self._send_with_retry(
account.get("phone_number_id", ""),
account.get("access_token", ""),
payload,
)
if status != 200:
logger.error(f"WhatsApp send_location failed: {result}")
async def send_contacts(
self,
target_id: str,
contacts: list[dict],
*,
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": target_id,
"type": "contacts",
"contacts": contacts,
}
status, result = await self._send_with_retry(
account.get("phone_number_id", ""),
account.get("access_token", ""),
payload,
)
if status != 200:
logger.error(f"WhatsApp send_contacts failed: {result}")
# ── 请求/回复类交互式消息 ─────────────────────────────
async def request_location(
self,
target_id: str,
body_text: str,
*,
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": target_id,
"type": "interactive",
"interactive": {
"type": "location_request_message",
"body": {"text": body_text[:1024]},
},
}
status, result = await self._send_with_retry(
account.get("phone_number_id", ""),
account.get("access_token", ""),
payload,
)
if status != 200:
logger.error(f"WhatsApp request_location failed: {result}")
async def request_address(
self,
target_id: str,
body_text: str,
country: str,
*,
values: list[str] | None = None,
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
interactive: dict[str, Any] = {
"type": "address_message",
"body": {"text": body_text[:1024]},
}
if values:
interactive["action"] = {
"name": "address_message",
"parameters": {
"country": country,
"values": values,
},
}
else:
interactive["action"] = {
"name": "address_message",
"parameters": {"country": country},
}
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": target_id,
"type": "interactive",
"interactive": interactive,
}
status, result = await self._send_with_retry(
account.get("phone_number_id", ""),
account.get("access_token", ""),
payload,
)
if status != 200:
logger.error(f"WhatsApp request_address failed: {result}")
async def send_cta_url_button(
self,
target_id: str,
body_text: str,
button_text: str,
url: str,
*,
header_text: str | None = None,
footer_text: str | None = None,
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
interactive: dict[str, Any] = {
"type": "cta_url",
"body": {"text": body_text[:1024]},
"action": {
"name": "cta_url",
"parameters": {"display_text": button_text[:20], "url": url},
},
}
if header_text:
interactive["header"] = {"type": "text", "text": header_text[:60]}
if footer_text:
interactive["footer"] = {"text": footer_text[:60]}
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": target_id,
"type": "interactive",
"interactive": interactive,
}
status, result = await self._send_with_retry(
account.get("phone_number_id", ""),
account.get("access_token", ""),
payload,
)
if status != 200:
logger.error(f"WhatsApp send_cta_url_button failed: {result}")
async def send_text_to_group(
self,
group_id: str,
content: str,
*,
reply_to_id: str | None = None,
account_id: str | None = None,
) -> None:
await self.send_text(
group_id, content,
reply_to_id=reply_to_id,
account_id=account_id,
recipient_type="group",
)
# ── 用户管理 ─────────────────────────────────────────────
async def block_user(
self,
phone_numbers: list[str],
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
phone_number_id = account.get("phone_number_id", "")
access_token = account.get("access_token", "")
if not phone_number_id or not access_token:
logger.error("WhatsApp block_user: missing credentials")
return
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
}
payload = {"messaging_product": "whatsapp", "blocked_numbers": phone_numbers}
try:
async with aiohttp.ClientSession() as session:
url = f"{WHATSAPP_API_BASE}/{phone_number_id}/block_users"
async with session.post(url, json=payload, headers=headers) as resp:
result = await resp.json()
if resp.status == 200:
logger.info(f"Blocked users: {phone_numbers}")
else:
logger.error(f"Block users failed: {result}")
except Exception:
logger.exception("WhatsApp block_user failed")
# ── 模板消息 ─────────────────────────────────────────────
async def send_template(
self,
target_id: str,
template_name: str,
language_code: str,
*,
header_params: list[dict] | None = None,
body_params: list[dict] | None = None,
button_params: list[dict] | None = None,
account_id: str | None = None,
) -> str | None:
account = await self._resolve_account(account_id)
if not account:
logger.error("WhatsApp send_template: account not resolved")
return None
components: list[dict] = []
if header_params:
components.append({"type": "header", "parameters": header_params})
if body_params:
components.append({"type": "body", "parameters": body_params})
if button_params:
components.append({"type": "button", "sub_type": "url", "parameters": button_params})
payload: dict[str, Any] = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": target_id,
"type": "template",
"template": {
"name": template_name,
"language": {"code": language_code},
},
}
if components:
payload["template"]["components"] = components
status, result = await self._send_with_retry(
account.get("phone_number_id", ""),
account.get("access_token", ""),
payload,
)
if status != 200:
logger.error(f"WhatsApp send_template failed: {result}")
return None
msg_id = result.get("messages", [{}])[0].get("id")
logger.debug(f"WhatsApp send_template OK: msg_id={msg_id}")
return msg_id
# ── 交互式消息 ───────────────────────────────────────────
async def send_interactive_buttons(
self,
target_id: str,
body_text: str,
buttons: list[dict],
*,
header_text: str | None = None,
footer_text: str | None = None,
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
if len(buttons) > 3:
logger.warning("WhatsApp supports max 3 reply buttons, truncating")
buttons = buttons[:3]
interactive: dict[str, Any] = {
"type": "button",
"body": {"text": body_text[:1024]},
"action": {"buttons": [{"type": "reply", "reply": b} for b in buttons]},
}
if header_text:
interactive["header"] = {"type": "text", "text": header_text[:60]}
if footer_text:
interactive["footer"] = {"text": footer_text[:60]}
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": target_id,
"type": "interactive",
"interactive": interactive,
}
status, result = await self._send_with_retry(
account.get("phone_number_id", ""),
account.get("access_token", ""),
payload,
)
if status != 200:
logger.error(f"WhatsApp send_interactive_buttons failed: {result}")
async def send_interactive_list(
self,
target_id: str,
body_text: str,
button_text: str,
sections: list[dict],
*,
header_text: str | None = None,
footer_text: str | None = None,
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
interactive: dict[str, Any] = {
"type": "list",
"body": {"text": body_text[:1024]},
"action": {
"button": button_text[:20],
"sections": [
{
"title": s.get("title", "")[:24],
"rows": [
{"id": r["id"], "title": r["title"][:24]}
for r in s.get("rows", [])[:10]
],
}
for s in sections
],
},
}
if header_text:
interactive["header"] = {"type": "text", "text": header_text[:60]}
if footer_text:
interactive["footer"] = {"text": footer_text[:60]}
payload = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": target_id,
"type": "interactive",
"interactive": interactive,
}
status, result = await self._send_with_retry(
account.get("phone_number_id", ""),
account.get("access_token", ""),
payload,
)
if status != 200:
logger.error(f"WhatsApp send_interactive_list failed: {result}")
# ── 已读回执 ─────────────────────────────────────────────
async def mark_as_read(
self,
message_id: str,
account_id: str | None = None,
) -> None:
account = await self._resolve_account(account_id)
if not account:
return
payload = {
"messaging_product": "whatsapp",
"status": "read",
"message_id": message_id,
}
async with aiohttp.ClientSession() as session:
async with session.post(
self._build_url(account.get("phone_number_id", "")),
json=payload,
headers=self._headers(account.get("access_token", "")),
) as resp:
if resp.status != 200:
logger.error(f"WhatsApp mark_as_read failed: {await resp.json()}")
# ── 媒体管理 ─────────────────────────────────────────────
async def _upload_media(self, media_url: str, media_type: str, account: dict) -> str:
phone_number_id = account.get("phone_number_id", "")
access_token = account.get("access_token", "")
if not phone_number_id or not access_token:
logger.error("WhatsApp _upload_media: missing credentials")
return ""
upload_url = f"{WHATSAPP_API_BASE}/{phone_number_id}/media"
headers = {"Authorization": f"Bearer {access_token}"}
try:
if media_url.startswith(("http://", "https://")):
async with aiohttp.ClientSession() as session:
async with session.get(media_url) as resp:
if resp.status != 200:
logger.error(f"Failed to download media from {media_url}: HTTP {resp.status}")
return ""
media_data = await resp.read()
content_type = _mime_for_type(media_type)
form_data = aiohttp.FormData()
form_data.add_field("file", media_data, content_type=content_type)
form_data.add_field("messaging_product", "whatsapp")
async with aiohttp.ClientSession() as session:
async with session.post(upload_url, data=form_data, headers=headers) as resp:
result = await resp.json()
if resp.status == 200:
media_id = result.get("id", "")
logger.debug(f"Media uploaded: media_id={media_id}")
return media_id
logger.error(f"Media upload failed: {result}")
return ""
else:
logger.error(f"Unsupported media URL scheme: {media_url}")
return ""
except Exception:
logger.exception("WhatsApp _upload_media failed")
return ""
async def download_media(
self,
media_id: str,
*,
account_id: str | None = None,
) -> bytes | None:
account = await self._resolve_account(account_id)
if not account:
return None
download_url = f"{WHATSAPP_API_BASE}/{media_id}"
headers = {"Authorization": f"Bearer {account.get('access_token', '')}"}
try:
async with aiohttp.ClientSession() as session:
async with session.get(download_url, headers=headers) as resp:
if resp.status == 200:
return await resp.read()
logger.error(f"Media download failed: HTTP {resp.status}")
return None
except Exception:
logger.exception("WhatsApp download_media failed")
return None
# ── 文本处理 ─────────────────────────────────────────────
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:
from yuxi.channel.extensions.whatsapp.format import markdown_to_whatsapp
return markdown_to_whatsapp(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