133 lines
4.6 KiB
Python
133 lines
4.6 KiB
Python
|
|
"""
|
|||
|
|
艾叶 IM Bridge — WhatsApp 通道
|
|||
|
|
对接方式:
|
|||
|
|
1. WhatsApp Business API (Cloud API) — Webhook 回调
|
|||
|
|
2. 可扩展对接 OpenClaw/Moltbot Gateway 的 WebSocket
|
|||
|
|
|
|||
|
|
当前实现:WhatsApp Cloud API Webhook 模式
|
|||
|
|
|
|||
|
|
配置项:
|
|||
|
|
phone_number_id: WhatsApp Business 电话号码 ID
|
|||
|
|
access_token: Meta Graph API 长期令牌
|
|||
|
|
verify_token: Webhook 验证令牌(自定义字符串)
|
|||
|
|
api_version: Graph API 版本(默认 v21.0)
|
|||
|
|
|
|||
|
|
Webhook: POST /webhook/whatsapp
|
|||
|
|
验证: GET /webhook/whatsapp
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
import httpx
|
|||
|
|
from fastapi import Request, Response
|
|||
|
|
|
|||
|
|
from core.channel_base import ChannelBase, InboundMessage, MessageType, OutboundMessage
|
|||
|
|
|
|||
|
|
logger = logging.getLogger("aiye.channel.whatsapp")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class WhatsAppChannel(ChannelBase):
|
|||
|
|
"""WhatsApp Cloud API 通道"""
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def platform(self) -> str:
|
|||
|
|
return "whatsapp"
|
|||
|
|
|
|||
|
|
async def start(self) -> None:
|
|||
|
|
logger.info("WhatsApp 通道已就绪,Webhook: /webhook/whatsapp")
|
|||
|
|
|
|||
|
|
async def stop(self) -> None:
|
|||
|
|
logger.info("WhatsApp 通道已停止")
|
|||
|
|
|
|||
|
|
async def send(self, msg: OutboundMessage) -> bool:
|
|||
|
|
phone_id = self._config.get("phone_number_id", "")
|
|||
|
|
token = self._config.get("access_token", "")
|
|||
|
|
api_ver = self._config.get("api_version", "v21.0")
|
|||
|
|
if not phone_id or not token:
|
|||
|
|
logger.warning("WhatsApp 未配置 phone_number_id / access_token")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
url = f"https://graph.facebook.com/{api_ver}/{phone_id}/messages"
|
|||
|
|
payload = {
|
|||
|
|
"messaging_product": "whatsapp",
|
|||
|
|
"to": msg.chat_id,
|
|||
|
|
"type": "text",
|
|||
|
|
"text": {"body": msg.content},
|
|||
|
|
}
|
|||
|
|
try:
|
|||
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|||
|
|
resp = await client.post(
|
|||
|
|
url,
|
|||
|
|
json=payload,
|
|||
|
|
headers={"Authorization": f"Bearer {token}"},
|
|||
|
|
)
|
|||
|
|
if resp.status_code == 200:
|
|||
|
|
return True
|
|||
|
|
logger.warning("WhatsApp 发送失败: %d %s", resp.status_code, resp.text[:200])
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error("WhatsApp 发送异常: %s", e)
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def register_routes(self, app: Any) -> None:
|
|||
|
|
channel = self
|
|||
|
|
|
|||
|
|
@app.get("/webhook/whatsapp")
|
|||
|
|
async def whatsapp_verify(
|
|||
|
|
request: Request,
|
|||
|
|
):
|
|||
|
|
"""Meta Webhook 验证"""
|
|||
|
|
params = request.query_params
|
|||
|
|
mode = params.get("hub.mode", "")
|
|||
|
|
token = params.get("hub.verify_token", "")
|
|||
|
|
challenge = params.get("hub.challenge", "")
|
|||
|
|
if mode == "subscribe" and token == channel._config.get("verify_token", ""):
|
|||
|
|
return Response(content=challenge, media_type="text/plain")
|
|||
|
|
return Response(content="forbidden", status_code=403)
|
|||
|
|
|
|||
|
|
@app.post("/webhook/whatsapp")
|
|||
|
|
async def whatsapp_webhook(request: Request):
|
|||
|
|
try:
|
|||
|
|
data = await request.json()
|
|||
|
|
except Exception:
|
|||
|
|
return {"status": "error"}
|
|||
|
|
|
|||
|
|
entries = data.get("entry", [])
|
|||
|
|
for entry in entries:
|
|||
|
|
changes = entry.get("changes", [])
|
|||
|
|
for change in changes:
|
|||
|
|
value = change.get("value", {})
|
|||
|
|
messages = value.get("messages", [])
|
|||
|
|
contacts = value.get("contacts", [])
|
|||
|
|
contact_map = {
|
|||
|
|
c.get("wa_id", ""): c.get("profile", {}).get("name", "")
|
|||
|
|
for c in contacts
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
for wa_msg in messages:
|
|||
|
|
msg_type = wa_msg.get("type", "")
|
|||
|
|
from_id = wa_msg.get("from", "")
|
|||
|
|
text = ""
|
|||
|
|
if msg_type == "text":
|
|||
|
|
text = wa_msg.get("text", {}).get("body", "")
|
|||
|
|
|
|||
|
|
if not text.strip():
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
inbound = InboundMessage(
|
|||
|
|
channel_id=channel.channel_id,
|
|||
|
|
platform=channel.platform,
|
|||
|
|
sender_id=from_id,
|
|||
|
|
sender_name=contact_map.get(from_id, ""),
|
|||
|
|
chat_id=from_id,
|
|||
|
|
content=text,
|
|||
|
|
raw=wa_msg,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
reply = await channel.dispatch(inbound)
|
|||
|
|
if reply:
|
|||
|
|
await channel.send(reply)
|
|||
|
|
|
|||
|
|
return {"status": "ok"}
|