diff --git a/app.py b/app.py index 374d551..0c8fdd2 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask -from routes import yeastar, voipms +from routes import yeastar, voipms, threecx import logging import sys @@ -12,6 +12,7 @@ app = Flask(__name__) # Register blueprints app.register_blueprint(yeastar.bp) app.register_blueprint(voipms.bp) +app.register_blueprint(threecx.bp) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000) diff --git a/config.template.py b/config.template.py index d121556..8d3cff2 100644 --- a/config.template.py +++ b/config.template.py @@ -8,6 +8,7 @@ YEASTAR_SECRET = "your-yeastar-secret" # VOIP.ms SOAP endpoint (Farily static, update this if it changes in the future) VOIPMS_ENDPOINT = "https://voip.ms/api/v1/server.php" +VOIPMS_3CX_ENDPOINT = "https://voip.ms/api/3cx/msg" # Make this something semi-random and URL safe. Adds obscurity and makes your endpoint harder to guess ENDPOINT_OBSCURITY = "abcdefghijklmnopqrstuvwxyz" diff --git a/routes/threecx.py b/routes/threecx.py new file mode 100644 index 0000000..ab5af21 --- /dev/null +++ b/routes/threecx.py @@ -0,0 +1,74 @@ +# routes/threecx.py + +from flask import Blueprint, request, Response +import requests +from datetime import datetime +import logging + +from config import VOIPMS_3CX_ENDPOINT, ENDPOINT_OBSCURITY + +bp = Blueprint("threecx", __name__) + +@bp.route(f"/{ENDPOINT_OBSCURITY}/threecx-outbound", methods=["POST"]) +@bp.route(f"/{ENDPOINT_OBSCURITY}/threecx-outbound/verbose", methods=["POST"]) +def handle_threecx_outbound(): + request_id = datetime.utcnow().strftime("%Y%m%d%H%M%S%f") + verbose = request.path.endswith("/verbose") + + ip = request.headers.get("X-Forwarded-For", request.remote_addr) + + if verbose: + print(f"[{request_id}] ===== 3CX Outbound Verbose =====") + print(f"[{request_id}] IP: {ip}") + print(f"[{request_id}] Headers: {dict(request.headers)}") + + try: + raw_body = request.get_data(as_text=True) + json_data = request.get_json(force=True) + + if verbose: + print(f"[{request_id}] Raw Body: {raw_body}") + print(f"[{request_id}] Parsed JSON: {json_data}") + + from_number = json_data.get("from") + to_number = json_data.get("to") + text = json_data.get("text", "") + media_urls = json_data.get("media_urls", []) + + is_mms = bool(media_urls) or len(text) > 160 + + if is_mms and not media_urls: + json_data["media_urls"] = [""] + if verbose: + print(f"[{request_id}] Upgraded to MMS due to long text; inserted dummy media URL") + + resp = requests.post( + VOIPMS_3CX_ENDPOINT, + json=json_data, + headers={ + 'Content-Type': 'application/json', + 'Authorization': request.headers.get('Authorization', '') + } + ) + + if verbose: + print(f"[{request_id}] VOIP.ms 3CX Response Code: {resp.status_code}") + print(f"[{request_id}] VOIP.ms 3CX Response Body: {resp.text}") + + # Non-verbose single-line log for every request + status_text = "PASS" if resp.ok else "FAIL" + print(f"[{datetime.now().isoformat()}] {status_text} IP:{ip} From 3CX | {'MMS' if is_mms else 'SMS'} | From:{from_number} -> To:{to_number} | Images:{len(media_urls)}") + + return Response( + resp.content, + status=resp.status_code, + content_type=resp.headers.get('Content-Type', 'application/json') + ) + + except Exception as e: + logging.error(f"[{request_id}] Error in 3CX outbound: {e}", exc_info=True) + return Response( + '{"errors":[{"code":"10002","title":"Proxy Error","detail":"%s"}]}' % str(e), + status=500, + content_type='application/json' + ) diff --git a/webhook-proxy.pyproj b/webhook-proxy.pyproj index 01a5bd5..6212a17 100644 --- a/webhook-proxy.pyproj +++ b/webhook-proxy.pyproj @@ -28,6 +28,7 @@ +