7b62c2f8b4
## H5 底部导航修复 (Bug #10) - 精简 App.vue,移除重复 tabbar,仅保留全局样式 - uni-page 设置 height: calc(100% - 50px) + overflow-y: auto - 内容区域精确停在底部导航上方,独立滚动不再叠加 - 恢复 custom-tab-bar 组件 ## 项目进度文档 - PROGRESS.md 更新至 10 个 Bug 修复 - 新增 H5 底部导航修复记录 - 新增历史变更条目
167 lines
5.8 KiB
Python
167 lines
5.8 KiB
Python
from typing import Dict, Any, Optional
|
|
import httpx
|
|
import hashlib
|
|
import hmac
|
|
import logging
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class WhatsAppService:
|
|
def __init__(self):
|
|
self.api_token = settings.WHATSAPP_API_TOKEN
|
|
self.phone_number_id = settings.WHATSAPP_PHONE_NUMBER_ID
|
|
self.api_base = f"https://graph.facebook.com/v18.0/{self.phone_number_id}"
|
|
|
|
def verify_webhook(self, mode: str, token: str, challenge: str) -> Optional[str]:
|
|
if mode == "subscribe" and token == settings.WHATSAPP_WEBHOOK_VERIFY_TOKEN:
|
|
return challenge
|
|
return None
|
|
|
|
def verify_signature(self, body: bytes, signature: str) -> bool:
|
|
if not signature:
|
|
return False
|
|
expected = hmac.new(
|
|
settings.WHATSAPP_API_TOKEN.encode(),
|
|
body,
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
return hmac.compare_digest(f"sha256={expected}", signature)
|
|
|
|
async def send_text(self, to: str, text: str) -> bool:
|
|
if not self.api_token or not self.phone_number_id:
|
|
logger.warning("WhatsApp not configured")
|
|
return False
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.post(
|
|
f"{self.api_base}/messages",
|
|
headers={
|
|
"Authorization": f"Bearer {self.api_token}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"messaging_product": "whatsapp",
|
|
"to": to,
|
|
"type": "text",
|
|
"text": {"body": text},
|
|
},
|
|
timeout=15,
|
|
)
|
|
if resp.status_code != 200:
|
|
logger.error(f"WhatsApp send failed: {resp.text}")
|
|
return False
|
|
return True
|
|
|
|
async def send_template(self, to: str, template_name: str, params: Dict[str, str]) -> bool:
|
|
if not self.api_token or not self.phone_number_id:
|
|
return False
|
|
|
|
components = [
|
|
{
|
|
"type": "body",
|
|
"parameters": [
|
|
{"type": "text", "text": v} for v in params.values()
|
|
],
|
|
}
|
|
]
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.post(
|
|
f"{self.api_base}/messages",
|
|
headers={"Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json"},
|
|
json={
|
|
"messaging_product": "whatsapp",
|
|
"to": to,
|
|
"type": "template",
|
|
"template": {
|
|
"name": template_name,
|
|
"language": {"code": "en"},
|
|
"components": components,
|
|
},
|
|
},
|
|
timeout=15,
|
|
)
|
|
return resp.status_code == 200
|
|
|
|
async def send_media(self, to: str, media_url: str, media_type: str = "image", caption: Optional[str] = None) -> bool:
|
|
if not self.api_token or not self.phone_number_id:
|
|
return False
|
|
|
|
body = {
|
|
"messaging_product": "whatsapp",
|
|
"to": to,
|
|
"type": media_type,
|
|
media_type: {"link": media_url},
|
|
}
|
|
if caption:
|
|
body[media_type]["caption"] = caption
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.post(
|
|
f"{self.api_base}/messages",
|
|
headers={"Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json"},
|
|
json=body,
|
|
timeout=30,
|
|
)
|
|
if resp.status_code != 200:
|
|
logger.error(f"WhatsApp media send failed: {resp.text}")
|
|
return False
|
|
return True
|
|
|
|
async def mark_as_read(self, message_id: str) -> bool:
|
|
if not self.api_token:
|
|
return False
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.post(
|
|
f"{self.api_base}/messages",
|
|
headers={"Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json"},
|
|
json={
|
|
"messaging_product": "whatsapp",
|
|
"status": "read",
|
|
"message_id": message_id,
|
|
},
|
|
timeout=10,
|
|
)
|
|
return resp.status_code == 200
|
|
|
|
def parse_webhook(self, body: Dict) -> Optional[Dict]:
|
|
try:
|
|
entry = body.get("entry", [{}])[0]
|
|
change = entry.get("changes", [{}])[0]
|
|
value = change.get("value", {})
|
|
messages = value.get("messages", [])
|
|
|
|
if not messages:
|
|
return None
|
|
|
|
msg = messages[0]
|
|
msg_type = msg.get("type", "text")
|
|
content = ""
|
|
|
|
if msg_type == "text":
|
|
content = msg.get("text", {}).get("body", "")
|
|
elif msg_type in ("image", "document", "audio", "video"):
|
|
media = msg.get(msg_type, {})
|
|
content = media.get("caption", "") or media.get("filename", "") or f"[{msg_type}]"
|
|
|
|
return {
|
|
"from": msg.get("from"),
|
|
"text": content,
|
|
"msg_id": msg.get("id"),
|
|
"timestamp": msg.get("timestamp"),
|
|
"type": msg_type,
|
|
"profile_name": value.get("contacts", [{}])[0].get("profile", {}).get("name"),
|
|
}
|
|
except Exception as e:
|
|
logger.warning(f"Failed to parse WhatsApp webhook: {e}")
|
|
return None
|
|
|
|
def _build_headers(self) -> Dict[str, str]:
|
|
return {
|
|
"Authorization": f"Bearer {self.api_token}",
|
|
"Content-Type": "application/json",
|
|
}
|