2025-07-13 22:10:11 -04:00
|
|
|
# routes/voipms.py
|
|
|
|
|
|
|
|
|
|
import json
|
2025-07-15 23:49:54 -04:00
|
|
|
import hmac, hashlib
|
|
|
|
|
import phonenumbers
|
2025-07-13 22:10:11 -04:00
|
|
|
from datetime import datetime
|
|
|
|
|
from flask import Blueprint, request, jsonify
|
|
|
|
|
from config import YEASTAR_WEBHOOK_URL, YEASTAR_SECRET, ENDPOINT_OBSCURITY
|
|
|
|
|
import requests
|
|
|
|
|
import logging
|
|
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
bp = Blueprint("voipms", __name__)
|
|
|
|
|
|
|
|
|
|
@bp.route(f"/{ENDPOINT_OBSCURITY}/voipms-inbound/verbose", methods=["POST"])
|
|
|
|
|
@bp.route(f"/{ENDPOINT_OBSCURITY}/voipms-inbound", methods=["POST"])
|
|
|
|
|
def voipms_inbound():
|
|
|
|
|
request_id = uuid.uuid4().hex[:8]
|
|
|
|
|
verbose = request.path.endswith("/verbose")
|
|
|
|
|
|
|
|
|
|
def get_client_ip():
|
|
|
|
|
for header in ["X-Forwarded-For", "X-Real-IP"]:
|
|
|
|
|
ip = request.headers.get(header)
|
|
|
|
|
if ip:
|
|
|
|
|
return ip.split(",")[0].strip()
|
|
|
|
|
return request.remote_addr or "unknown"
|
|
|
|
|
|
|
|
|
|
client_ip = get_client_ip()
|
|
|
|
|
|
|
|
|
|
if verbose: print(f"[{request_id}] voipms_inbound called{' [VERBOSE]' if verbose else ''}")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
if verbose:
|
|
|
|
|
print(f"[{request_id}] Headers: {dict(request.headers)}")
|
|
|
|
|
|
|
|
|
|
raw_data = request.get_data(as_text=True)
|
|
|
|
|
if verbose:
|
|
|
|
|
print(f"[{request_id}] Raw Body: {raw_data}")
|
|
|
|
|
|
|
|
|
|
data = request.get_json(force=True)
|
|
|
|
|
|
|
|
|
|
payload = data.get("data", {}).get("payload", {})
|
|
|
|
|
from_number = payload.get("from", {}).get("phone_number")
|
|
|
|
|
to_list = payload.get("to", [])
|
|
|
|
|
text = payload.get("text", "")
|
|
|
|
|
media_urls = [m["url"] for m in payload.get("media", [])] if payload.get("media") else []
|
|
|
|
|
|
|
|
|
|
from_number = to_e164(from_number) if from_number else ""
|
|
|
|
|
to_numbers = [to_e164(entry.get("phone_number")) for entry in to_list if entry.get("phone_number")]
|
|
|
|
|
|
|
|
|
|
if verbose:
|
|
|
|
|
print(f"[{request_id}] Normalized From: {from_number}")
|
|
|
|
|
print(f"[{request_id}] Normalized To: {to_numbers}")
|
|
|
|
|
print(f"[{request_id}] Text: {text}")
|
|
|
|
|
print(f"[{request_id}] Media URLs: {media_urls}")
|
|
|
|
|
else:
|
|
|
|
|
msg_type = "MMS" if media_urls else "SMS"
|
|
|
|
|
now_str = datetime.now().isoformat()
|
|
|
|
|
print(f"[{now_str}] IP:{client_ip} From Voip.ms | {msg_type} | From:{from_number} ? To:{', '.join(to_numbers)} | Images:{len(media_urls)}")
|
|
|
|
|
|
|
|
|
|
ys_payload = create_yeastar_payload({
|
|
|
|
|
"from": from_number,
|
|
|
|
|
"to": to_numbers,
|
|
|
|
|
"text": text,
|
|
|
|
|
"media_urls": media_urls
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if verbose:
|
|
|
|
|
print(f"[{request_id}] Outbound Payload: {json.dumps(ys_payload, indent=2)}")
|
|
|
|
|
|
|
|
|
|
body_str = json.dumps(ys_payload)
|
|
|
|
|
signature = generate_signature(body_str)
|
|
|
|
|
headers = {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
"X-Signature-256": signature
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
response = requests.post(YEASTAR_WEBHOOK_URL, data=body_str, headers=headers)
|
|
|
|
|
if verbose: print(f"[{request_id}] Yeastar Response Code: {response.status_code}")
|
|
|
|
|
if verbose:
|
|
|
|
|
print(f"[{request_id}] Yeastar Response Body: {response.text}")
|
|
|
|
|
|
|
|
|
|
return jsonify({"status": "ok"}), 200
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logging.error(f"[{request_id}] Error in voipms_inbound: {e}", exc_info=True)
|
|
|
|
|
return jsonify({"error": "internal server error"}), 500
|
2025-07-15 23:49:54 -04:00
|
|
|
|
|
|
|
|
def generate_signature(body: str) -> str:
|
|
|
|
|
signature = hmac.new(YEASTAR_SECRET.encode(), body.encode(), hashlib.sha256).hexdigest()
|
|
|
|
|
return f"sha256={signature}"
|
|
|
|
|
|
|
|
|
|
def to_e164(number):
|
|
|
|
|
try:
|
|
|
|
|
parsed = phonenumbers.parse(number, "US")
|
|
|
|
|
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logging.warning(f"Failed to normalize phone number {number}: {e}")
|
|
|
|
|
return number
|
|
|
|
|
|
|
|
|
|
def create_yeastar_payload(data):
|
|
|
|
|
now_iso = datetime.now().astimezone().isoformat()
|
|
|
|
|
message_id = str(int(datetime.utcnow().timestamp()))
|
|
|
|
|
payload = {
|
|
|
|
|
"data": {
|
|
|
|
|
"event_type": "message.received",
|
|
|
|
|
"payload": {
|
|
|
|
|
"id": message_id,
|
|
|
|
|
"from": {
|
|
|
|
|
"phone_number": data["from"]
|
|
|
|
|
},
|
|
|
|
|
"to": [
|
|
|
|
|
{"phone_number": num} for num in data["to"]
|
|
|
|
|
],
|
|
|
|
|
"text": data["text"],
|
|
|
|
|
"record_type": "message",
|
|
|
|
|
"received_at": now_iso
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
media_urls = data.get("media_urls", [])
|
|
|
|
|
if media_urls:
|
|
|
|
|
payload["data"]["payload"].pop("text", None)
|
|
|
|
|
payload["data"]["payload"]["media"] = [{"url": url} for url in media_urls]
|
|
|
|
|
|
|
|
|
|
return payload
|