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 @@
+