Add project files.
This commit is contained in:
commit
eeda32a9b2
1735 changed files with 700598 additions and 0 deletions
1
routes/__init__.py
Normal file
1
routes/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
# Makes 'routes' a package
|
||||
99
routes/helpers.py
Normal file
99
routes/helpers.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import random, time
|
||||
import phonenumbers
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from config import YEASTAR_WEBHOOK_URL, YEASTAR_SECRET
|
||||
|
||||
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
|
||||
|
||||
def generate_message_id():
|
||||
return f"{random.randint(100000, 999999)}{int(time.time())}"
|
||||
|
||||
def parse_bearer_token(auth_header):
|
||||
"""
|
||||
Expect: Authorization: Bearer username|||password
|
||||
"""
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return None, None
|
||||
token = auth_header.split("Bearer ")[1].strip()
|
||||
parts = token.split("|||")
|
||||
if len(parts) == 2:
|
||||
return parts[0], parts[1]
|
||||
return None, None
|
||||
|
||||
def build_soap_envelope(
|
||||
username, password, from_number, to_number,
|
||||
message_text, media_urls=None
|
||||
):
|
||||
media_urls = media_urls or []
|
||||
is_mms = len(media_urls) > 0
|
||||
soap_method = "sendMMS" if is_mms else "sendSMS"
|
||||
|
||||
envelope = f"""
|
||||
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
|
||||
xmlns:wsd="https://voip.ms/api/wsdl"
|
||||
xmlns:xsd1="https://voip.ms/api/schema">
|
||||
<soapenv:Header/>
|
||||
<soapenv:Body>
|
||||
<wsd:{soap_method}>
|
||||
<wsd:params>
|
||||
<xsd1:api_username>{username}</xsd1:api_username>
|
||||
<xsd1:api_password>{password}</xsd1:api_password>
|
||||
<xsd1:did>{from_number}</xsd1:did>
|
||||
<xsd1:dst>{to_number}</xsd1:dst>
|
||||
<xsd1:message>{message_text}</xsd1:message>
|
||||
"""
|
||||
|
||||
# Add media tags if MMS, max 3 allowed
|
||||
if is_mms:
|
||||
for i, url in enumerate(media_urls[:3]):
|
||||
envelope += f" <xsd1:media{i+1}>{url}</xsd1:media{i+1}>\n"
|
||||
|
||||
envelope += f""" </wsd:params>
|
||||
</wsd:{soap_method}>
|
||||
</soapenv:Body>
|
||||
</soapenv:Envelope>"""
|
||||
|
||||
return envelope.strip()
|
||||
|
||||
86
routes/voipms.py
Normal file
86
routes/voipms.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# routes/voipms.py
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify
|
||||
from config import YEASTAR_WEBHOOK_URL, YEASTAR_SECRET, ENDPOINT_OBSCURITY
|
||||
from routes.helpers import to_e164, create_yeastar_payload, generate_signature
|
||||
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
|
||||
121
routes/yeastar.py
Normal file
121
routes/yeastar.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
from flask import Blueprint, request, jsonify
|
||||
import requests
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
|
||||
from config import VOIPMS_ENDPOINT, ENDPOINT_OBSCURITY
|
||||
from routes.helpers import generate_message_id, parse_bearer_token, build_soap_envelope
|
||||
|
||||
bp = Blueprint('yeastar', __name__)
|
||||
|
||||
@bp.route(f"/{ENDPOINT_OBSCURITY}/yeastar-outbound", methods=['POST'])
|
||||
@bp.route(f"/{ENDPOINT_OBSCURITY}/yeastar-outbound/verbose", methods=['POST'])
|
||||
def handle_yeastar_outbound():
|
||||
verbose = request.path.endswith('/verbose')
|
||||
|
||||
ip = request.headers.get('X-Forwarded-For', request.remote_addr)
|
||||
|
||||
try:
|
||||
data = request.get_json(force=True)
|
||||
except Exception:
|
||||
return jsonify({
|
||||
"errors": [{
|
||||
"code": "10002",
|
||||
"title": "Invalid JSON",
|
||||
"detail": "The request body must be valid JSON."
|
||||
}]
|
||||
}), 400
|
||||
|
||||
from_number = data.get('from')
|
||||
to_number = data.get('to')
|
||||
text = data.get('text', '')
|
||||
media_urls = data.get('media_urls', [])
|
||||
|
||||
is_mms = len(media_urls) > 0 or len(text) > 160
|
||||
|
||||
if not media_urls and len(text) > 160:
|
||||
is_mms = True
|
||||
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
username, password = parse_bearer_token(auth_header)
|
||||
if not username or not password:
|
||||
return jsonify({
|
||||
"errors": [{
|
||||
"code": "10004",
|
||||
"title": "Invalid Authorization",
|
||||
"detail": "Bearer token is missing or invalid."
|
||||
}]
|
||||
}), 400
|
||||
|
||||
envelope = build_soap_envelope(username, password, from_number, to_number, text, media_urls if is_mms else [])
|
||||
|
||||
if verbose:
|
||||
print(f"===== Yeastar Verbose Request =====")
|
||||
print(f"IP: {ip}")
|
||||
print(f"Type: {'MMS' if is_mms else 'SMS'}")
|
||||
print(f"From: {from_number} ? To: {to_number}")
|
||||
print(f"Text: {text}")
|
||||
print(f"Media: {media_urls}")
|
||||
print(f"Envelope:\n{envelope}")
|
||||
print("==============================")
|
||||
|
||||
response = requests.post(
|
||||
VOIPMS_ENDPOINT,
|
||||
data=envelope.encode('utf-8'),
|
||||
headers={'Content-Type': 'text/xml'}
|
||||
)
|
||||
|
||||
if verbose:
|
||||
print(f"SOAP Raw Response: {response.text}")
|
||||
|
||||
# Robust SOAP status parse
|
||||
status = None
|
||||
try:
|
||||
root = ET.fromstring(response.text)
|
||||
for item in root.iter():
|
||||
if item.tag.endswith('item'):
|
||||
key = item.find('./key')
|
||||
if key is not None and key.text == 'status':
|
||||
value = item.find('./value')
|
||||
if value is not None:
|
||||
status = value.text
|
||||
break
|
||||
except Exception as e:
|
||||
if verbose:
|
||||
print(f"Failed to parse SOAP response: {e}")
|
||||
|
||||
# === Determine pass/fail ===
|
||||
result = "PASS" if status == "success" else "FAIL"
|
||||
|
||||
if verbose:
|
||||
print(f"Parsed SOAP Status: {status}")
|
||||
print(f"Result: {result}")
|
||||
|
||||
msg_id = generate_message_id()
|
||||
|
||||
# Basic always-on log
|
||||
print(f"[{datetime.now().isoformat()}] [{result}] IP:{ip} From Yeastar | {'MMS' if is_mms else 'SMS'} | From:{from_number} ? To:{to_number} | Images:{len(media_urls)}")
|
||||
|
||||
# === Error code mapping ===
|
||||
error_code_map = {
|
||||
"invalid_number": "10001",
|
||||
"invalid_param": "10002",
|
||||
"unsupported_media": "10003",
|
||||
"auth_fail": "10004",
|
||||
"no_permission": "10005",
|
||||
"too_many_requests": "10006",
|
||||
"service_unavailable": "10007",
|
||||
"media_toolong": "10008",
|
||||
}
|
||||
|
||||
if status == "success":
|
||||
return jsonify({"data": {"id": msg_id}}), 200
|
||||
else:
|
||||
code = error_code_map.get(status, "10002")
|
||||
return jsonify({
|
||||
"errors": [{
|
||||
"code": code,
|
||||
"title": "Message Failed",
|
||||
"detail": f"VOIP.ms returned status: {status or 'unknown'}"
|
||||
}]
|
||||
}), 400
|
||||
Loading…
Add table
Add a link
Reference in a new issue