- leadhunter_crm.py: add update_lead(), delete_lead(); expand VALID_STATUSES to 7-step Kanban with legacy migration map - leadhunter_api.py: add GET/PUT/DELETE /api/leads/<id> endpoints; import update_lead, delete_lead - portal_server.py: add routes for /leadhunter/clients/le-big-ben/ and /formation/ai102 - saas_api_v1.py: register user blueprint (HRT-79/80) and history blueprint (HRT-81) - api_v1/routes/user.py: switch auth import to saas_auth.require_auth - api_v1/routes/history.py: fix auth import + request.current_user fallback - api_v1/routes/ml_feedback.py: fix auth import + request.current_user fallback Co-Authored-By: Paperclip <noreply@paperclip.ing>
944 lines
31 KiB
Python
Executable File
944 lines
31 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
from flask import Flask, send_from_directory, jsonify, request, make_response
|
|
import os
|
|
import json
|
|
import requests
|
|
import subprocess
|
|
import db
|
|
from middleware import rate_limit_middleware, access_log_middleware
|
|
|
|
app = Flask(__name__)
|
|
rate_limit_middleware(app)
|
|
access_log_middleware(app)
|
|
|
|
DASHBOARD_API_URL = "http://localhost:8791"
|
|
COMBINED_API_URL = "http://localhost:8790"
|
|
SAAS_DIR = "/home/h3r7/turf_saas"
|
|
|
|
# ─── SaaS Auth & API v1 blueprints ────────────────────────────────────────────
|
|
try:
|
|
from saas_auth import auth_bp
|
|
from saas_api_v1 import api_v1_bp
|
|
from api_v1.routes.ml_feedback import ml_feedback_bp
|
|
from api_v1.routes.metrics import metrics_bp
|
|
|
|
app.register_blueprint(auth_bp)
|
|
app.register_blueprint(api_v1_bp)
|
|
app.register_blueprint(ml_feedback_bp)
|
|
app.register_blueprint(metrics_bp)
|
|
print("[portal] SaaS auth & API v1 blueprints registered ✅")
|
|
except Exception as e:
|
|
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
|
|
|
|
|
|
# ─── Landing & SaaS pages ─────────────────────────────────────────────────────
|
|
|
|
|
|
@app.route("/health")
|
|
def health():
|
|
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
|
|
return {"status": "ok", "service": "portal"}, 200
|
|
|
|
|
|
@app.route("/")
|
|
def landing():
|
|
"""Marketing landing page."""
|
|
return send_from_directory(SAAS_DIR, "landing.html")
|
|
|
|
|
|
@app.route("/login")
|
|
def login_page():
|
|
return send_from_directory(SAAS_DIR, "login.html")
|
|
|
|
|
|
@app.route("/register")
|
|
def register_page():
|
|
return send_from_directory(SAAS_DIR, "register.html")
|
|
|
|
|
|
@app.route("/dashboard")
|
|
def dashboard_saas():
|
|
return send_from_directory(SAAS_DIR, "dashboard_saas.html")
|
|
|
|
|
|
@app.route("/onboarding")
|
|
def onboarding():
|
|
return send_from_directory(SAAS_DIR, "onboarding.html")
|
|
|
|
|
|
@app.route("/account")
|
|
def account():
|
|
return send_from_directory(SAAS_DIR, "account.html")
|
|
|
|
|
|
@app.route("/portal")
|
|
@app.route("/portail")
|
|
def portal_legacy():
|
|
"""Legacy portal redirect."""
|
|
return send_from_directory(SAAS_DIR, "portail.html")
|
|
|
|
|
|
@app.route("/favicon.ico")
|
|
def favicon():
|
|
return send_from_directory(SAAS_DIR, "favicon.ico")
|
|
|
|
|
|
@app.route("/prompts", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
@app.route("/prompts/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
@app.route("/prompts/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
def proxy_prompts(subpath=""):
|
|
PROMPTS_API_URL = "http://localhost:8781"
|
|
full_url = PROMPTS_API_URL + ("/" + subpath if subpath else "/")
|
|
if request.query_string:
|
|
full_url += "?" + request.query_string.decode()
|
|
try:
|
|
headers = {
|
|
k: v
|
|
for k, v in request.headers
|
|
if k.lower()
|
|
not in ("host", "content-length", "transfer-encoding", "connection")
|
|
}
|
|
raw_body = request.get_data()
|
|
resp = requests.request(
|
|
request.method,
|
|
full_url,
|
|
headers=headers,
|
|
data=raw_body,
|
|
cookies=request.cookies,
|
|
allow_redirects=False,
|
|
timeout=10,
|
|
)
|
|
if resp.status_code in (301, 302, 303, 307, 308):
|
|
location = resp.headers.get("Location", "")
|
|
if location.startswith("/") and not location.startswith("/prompts"):
|
|
location = "/prompts" + location
|
|
r = make_response(b"", resp.status_code)
|
|
r.headers["Location"] = location
|
|
return r
|
|
response = make_response(resp.content, resp.status_code)
|
|
for k, v in resp.headers.items():
|
|
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
|
|
response.headers[k] = v
|
|
return response
|
|
except Exception as e:
|
|
return f"Erreur proxy prompts: {e}", 502
|
|
|
|
|
|
@app.route("/business")
|
|
def business():
|
|
return send_from_directory("/home/h3r7/depenses_trello/templates", "business.html")
|
|
|
|
|
|
@app.route(
|
|
"/depenses", methods=["GET", "POST", "PUT", "DELETE", "PATCH"], strict_slashes=False
|
|
)
|
|
@app.route(
|
|
"/depenses/",
|
|
methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
|
|
strict_slashes=False,
|
|
)
|
|
@app.route(
|
|
"/depenses/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]
|
|
)
|
|
def proxy_depenses(subpath=""):
|
|
backend_url = "http://localhost:8769"
|
|
if subpath:
|
|
full_url = f"{backend_url}/{subpath}"
|
|
else:
|
|
full_url = backend_url
|
|
|
|
if request.query_string:
|
|
full_url += "?" + request.query_string.decode()
|
|
|
|
try:
|
|
print(f"[DEPENSES PROXY] {request.method} {full_url}")
|
|
|
|
# Transmettre les headers bruts sans reparser
|
|
headers = {
|
|
k: v
|
|
for k, v in request.headers
|
|
if k.lower()
|
|
not in ("host", "content-length", "transfer-encoding", "connection")
|
|
}
|
|
|
|
# Body brut transmis tel quel — Content-Type préservé
|
|
raw_body = request.get_data()
|
|
|
|
resp = requests.request(
|
|
request.method,
|
|
full_url,
|
|
headers=headers,
|
|
data=raw_body,
|
|
cookies=request.cookies,
|
|
allow_redirects=False,
|
|
timeout=10,
|
|
)
|
|
print(f"[DEPENSES PROXY] resp={resp.status_code}")
|
|
|
|
if resp.status_code in (301, 302, 303, 307, 308):
|
|
location = resp.headers.get("Location", "")
|
|
if location.startswith("/") and not location.startswith("/depenses"):
|
|
location = "/depenses" + location
|
|
response = make_response(b"", resp.status_code)
|
|
response.headers["Location"] = location
|
|
response.headers["Content-Length"] = "0"
|
|
return response
|
|
|
|
if resp.status_code >= 400:
|
|
return resp.content, resp.status_code
|
|
|
|
response = make_response(resp.content, resp.status_code)
|
|
for k, v in resp.headers.items():
|
|
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
|
|
response.headers[k] = v
|
|
return response
|
|
except Exception as e:
|
|
return f"Erreur proxy depenses: {e}", 502
|
|
|
|
|
|
@app.route("/skills", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
@app.route("/skills/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
@app.route("/skills/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
def proxy_skills(subpath=""):
|
|
SKILLS_API_URL = "http://localhost:8772"
|
|
full_url = SKILLS_API_URL + ("/" + subpath if subpath else "/")
|
|
if request.query_string:
|
|
full_url += "?" + request.query_string.decode()
|
|
try:
|
|
headers = {
|
|
k: v
|
|
for k, v in request.headers
|
|
if k.lower()
|
|
not in ("host", "content-length", "transfer-encoding", "connection")
|
|
}
|
|
raw_body = request.get_data()
|
|
resp = requests.request(
|
|
request.method,
|
|
full_url,
|
|
headers=headers,
|
|
data=raw_body,
|
|
cookies=request.cookies,
|
|
allow_redirects=False,
|
|
timeout=10,
|
|
)
|
|
if resp.status_code in (301, 302, 303, 307, 308):
|
|
location = resp.headers.get("Location", "")
|
|
if location.startswith("/") and not location.startswith("/skills"):
|
|
location = "/skills" + location
|
|
r = make_response(b"", resp.status_code)
|
|
r.headers["Location"] = location
|
|
return r
|
|
response = make_response(resp.content, resp.status_code)
|
|
for k, v in resp.headers.items():
|
|
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
|
|
response.headers[k] = v
|
|
return response
|
|
except Exception as e:
|
|
return f"Erreur proxy skills: {e}", 502
|
|
|
|
|
|
@app.route("/crm", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
@app.route("/crm/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
@app.route("/crm/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
def proxy_crm(subpath=""):
|
|
CRM_API_URL = "http://localhost:8770"
|
|
full_url = CRM_API_URL + ("/" + subpath if subpath else "/")
|
|
if request.query_string:
|
|
full_url += "?" + request.query_string.decode()
|
|
try:
|
|
headers = {
|
|
k: v
|
|
for k, v in request.headers
|
|
if k.lower()
|
|
not in ("host", "content-length", "transfer-encoding", "connection")
|
|
}
|
|
raw_body = request.get_data()
|
|
resp = requests.request(
|
|
request.method,
|
|
full_url,
|
|
headers=headers,
|
|
data=raw_body,
|
|
cookies=request.cookies,
|
|
allow_redirects=False,
|
|
timeout=10,
|
|
)
|
|
if resp.status_code in (301, 302, 303, 307, 308):
|
|
location = resp.headers.get("Location", "")
|
|
if location.startswith("/") and not location.startswith("/crm"):
|
|
location = "/crm" + location
|
|
r = make_response(b"", resp.status_code)
|
|
r.headers["Location"] = location
|
|
return r
|
|
response = make_response(resp.content, resp.status_code)
|
|
for k, v in resp.headers.items():
|
|
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
|
|
response.headers[k] = v
|
|
return response
|
|
except Exception as e:
|
|
return f"Erreur proxy crm: {e}", 502
|
|
|
|
|
|
@app.route("/gitea", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
@app.route("/gitea/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
@app.route("/gitea/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
def proxy_gitea(subpath=""):
|
|
GITEA_API_URL = "http://localhost:3000"
|
|
full_url = GITEA_API_URL + ("/" + subpath if subpath else "/")
|
|
if request.query_string:
|
|
full_url += "?" + request.query_string.decode()
|
|
try:
|
|
headers = {
|
|
k: v
|
|
for k, v in request.headers
|
|
if k.lower()
|
|
not in ("host", "content-length", "transfer-encoding", "connection")
|
|
}
|
|
raw_body = request.get_data()
|
|
resp = requests.request(
|
|
request.method,
|
|
full_url,
|
|
headers=headers,
|
|
data=raw_body,
|
|
cookies=request.cookies,
|
|
allow_redirects=False,
|
|
timeout=10,
|
|
)
|
|
if resp.status_code in (301, 302, 303, 307, 308):
|
|
location = resp.headers.get("Location", "")
|
|
if location.startswith("/") and not location.startswith("/gitea"):
|
|
location = "/gitea" + location
|
|
r = make_response(b"", resp.status_code)
|
|
r.headers["Location"] = location
|
|
return r
|
|
response = make_response(resp.content, resp.status_code)
|
|
for k, v in resp.headers.items():
|
|
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
|
|
response.headers[k] = v
|
|
return response
|
|
except Exception as e:
|
|
return f"Erreur proxy gitea: {e}", 502
|
|
|
|
|
|
@app.route("/boite_a_idees.html")
|
|
def boite_a_idees():
|
|
return send_from_directory("/home/h3r7/depenses_trello", "boite_a_idees.html")
|
|
|
|
|
|
@app.route("/niches_business.html")
|
|
def niches_business():
|
|
return send_from_directory("/home/h3r7/depenses_trello/templates", "business.html")
|
|
|
|
|
|
@app.route("/template_restaurant_json.html")
|
|
def template_restaurant():
|
|
return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_json.html")
|
|
|
|
|
|
@app.route("/template_boulangerie_final.html")
|
|
def template_boulangerie():
|
|
return send_from_directory(
|
|
"/home/h3r7/turf_saas", "template_boulangerie_final.html"
|
|
)
|
|
|
|
|
|
@app.route("/template_artisan_final.html")
|
|
def template_artisan():
|
|
return send_from_directory("/home/h3r7/turf_saas", "template_artisan_final.html")
|
|
|
|
|
|
@app.route("/template_restaurant_final.html")
|
|
def template_restaurant_final():
|
|
return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_final.html")
|
|
|
|
|
|
@app.route("/template_complet.html")
|
|
def template_complet():
|
|
return send_from_directory("/home/h3r7/turf_saas", "template_complet.html")
|
|
|
|
|
|
@app.route("/leadhunter/clients/le-big-ben/")
|
|
@app.route("/leadhunter/clients/le-big-ben")
|
|
def big_ben():
|
|
return send_from_directory(
|
|
"/home/h3r7/turf_saas/templates/leadhunter/clients/le-big-ben", "index.html"
|
|
)
|
|
|
|
|
|
@app.route("/leadhunter/clients/le-big-ben/sitemap.xml")
|
|
def big_ben_sitemap():
|
|
return send_from_directory(
|
|
"/home/h3r7/turf_saas/templates/leadhunter/clients/le-big-ben",
|
|
"sitemap.xml",
|
|
mimetype="application/xml",
|
|
)
|
|
|
|
|
|
@app.route("/formation/ai102")
|
|
@app.route("/formation/ai102/")
|
|
def certif_ai102():
|
|
return send_from_directory("/home/h3r7/turf_saas/pitch", "certif-ai102.html")
|
|
|
|
|
|
@app.route("/boite_a_idees_dashboard")
|
|
def boite_a_idees_dashboard():
|
|
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")
|
|
|
|
|
|
@app.route("/datagouv_explorer.html")
|
|
def datagouv_explorer():
|
|
return send_from_directory("/home/h3r7/turf_saas", "datagouv_explorer.html")
|
|
|
|
|
|
@app.route("/api_datagouv_reference.html")
|
|
def api_datagouv_reference():
|
|
return send_from_directory("/home/h3r7/turf_saas", "api_datagouv_reference.html")
|
|
|
|
|
|
# Agent IA - Page principale
|
|
@app.route("/agent-ia")
|
|
@app.route("/agent-ia/")
|
|
def agent_ia():
|
|
return send_from_directory("/home/h3r7/turf_saas", "agent_ia.html")
|
|
|
|
|
|
# Agent IA - Page de config
|
|
@app.route("/agent-ia/config")
|
|
@app.route("/agent-ia/config/")
|
|
def agent_ia_config():
|
|
return send_from_directory("/home/h3r7/turf_saas", "agent_ia_config.html")
|
|
|
|
|
|
# Ancienne page (compatibilité)
|
|
@app.route("/agent-ia/legacy")
|
|
def agent_ia_legacy():
|
|
return send_from_directory("/home/h3r7/turf_saas", "gemini_agent.html")
|
|
|
|
|
|
# --- API Chat ---
|
|
|
|
|
|
@app.route("/api/chat/workflows", methods=["GET"])
|
|
def api_chat_workflows():
|
|
try:
|
|
workflows = db.get_workflows()
|
|
return jsonify([dict(w) for w in workflows])
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/chat/nvidia-models", methods=["GET"])
|
|
def api_nvidia_models():
|
|
return jsonify(
|
|
[
|
|
{
|
|
"id": k,
|
|
"name": v.split("/")[-1]
|
|
.replace("-instruct", "")
|
|
.replace("-", " ")
|
|
.title(),
|
|
"full_id": v,
|
|
}
|
|
for k, v in NVIDIA_MODELS.items()
|
|
]
|
|
)
|
|
|
|
|
|
@app.route("/api/chat/sessions", methods=["GET"])
|
|
def api_chat_sessions():
|
|
workflow_slug = request.args.get("workflow")
|
|
if not workflow_slug:
|
|
return jsonify({"error": "Paramètre workflow requis"}), 400
|
|
try:
|
|
sessions = db.get_sessions(workflow_slug)
|
|
return jsonify([dict(s) for s in sessions])
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/chat/history", methods=["GET"])
|
|
def api_chat_history():
|
|
session_id = request.args.get("session_id")
|
|
workflow_slug = request.args.get("workflow")
|
|
if not session_id or not workflow_slug:
|
|
return jsonify({"error": "session_id et workflow requis"}), 400
|
|
try:
|
|
messages = db.get_messages(session_id, workflow_slug)
|
|
return jsonify([dict(m) for m in messages])
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/chat/search", methods=["GET"])
|
|
def api_chat_search():
|
|
query = request.args.get("q")
|
|
workflow_slug = request.args.get("workflow")
|
|
if not query or not workflow_slug:
|
|
return jsonify({"error": "q et workflow requis"}), 400
|
|
try:
|
|
results = db.search_messages(query, workflow_slug)
|
|
return jsonify([dict(r) for r in results])
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/chat/session", methods=["POST"])
|
|
def api_chat_session_create():
|
|
data = request.json or {}
|
|
session_id = data.get("session_id")
|
|
workflow_slug = data.get("workflow")
|
|
title = data.get("title")
|
|
if not session_id or not workflow_slug:
|
|
return jsonify({"error": "session_id et workflow requis"}), 400
|
|
try:
|
|
workflows = db.get_workflows()
|
|
wf = next((w for w in workflows if w["slug"] == workflow_slug), None)
|
|
if not wf:
|
|
return jsonify({"error": "Workflow introuvable"}), 404
|
|
db.create_session(session_id, wf["id"], title)
|
|
return jsonify({"status": "ok", "session_id": session_id})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/chat/session", methods=["PUT"])
|
|
def api_chat_session_rename():
|
|
data = request.json or {}
|
|
session_id = request.args.get("session_id")
|
|
workflow_slug = request.args.get("workflow")
|
|
new_title = data.get("title")
|
|
if not session_id or not workflow_slug:
|
|
return jsonify({"error": "session_id et workflow requis"}), 400
|
|
try:
|
|
db.rename_session(session_id, workflow_slug, new_title)
|
|
return jsonify({"status": "ok"})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/chat/session", methods=["DELETE"])
|
|
def api_chat_session_delete():
|
|
session_id = request.args.get("session_id")
|
|
workflow_slug = request.args.get("workflow")
|
|
if not session_id or not workflow_slug:
|
|
return jsonify({"error": "session_id et workflow requis"}), 400
|
|
try:
|
|
deleted = db.delete_session(session_id, workflow_slug)
|
|
return jsonify({"status": "ok", "deleted": deleted})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/api/chat/cleanup", methods=["POST"])
|
|
def api_chat_cleanup():
|
|
data = request.json or {}
|
|
days = data.get("days")
|
|
workflow_slug = data.get("workflow")
|
|
if not days or not workflow_slug:
|
|
return jsonify({"error": "days et workflow requis"}), 400
|
|
try:
|
|
deleted = db.delete_messages_before(days, workflow_slug)
|
|
return jsonify({"status": "ok", "deleted": deleted})
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
# --- Webhook proxy avec persistance ---
|
|
|
|
OPENCLAW_TOKEN = "szDd75yG15ZRADWEhGus3dfNoQve7ZhdxpGq9gudeEzoVhUqB0TomVJd90XcwkUz"
|
|
OPENCLAW_CONTAINER = "openclaw-ow404080wwkkgkgc4oswwssc"
|
|
NVIDIA_API_KEY = (
|
|
"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"
|
|
)
|
|
NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
|
|
NVIDIA_MODEL = "meta/llama-3.1-8b-instruct" # Default model
|
|
NVIDIA_MODELS = {
|
|
"llama-3.1-8b": "meta/llama-3.1-8b-instruct",
|
|
"llama-3.1-70b": "meta/llama-3.1-70b-instruct",
|
|
"llama-3.3-70b": "meta/llama-3.3-70b-instruct",
|
|
"llama-4-scout": "meta/llama-4-scout-17b-16e-instruct",
|
|
"nemotron-mini": "nvidia/nemotron-mini-4b-instruct",
|
|
"nemotron-super": "nvidia/llama-3.3-nemotron-super-49b-v1",
|
|
"mistral-small": "mistralai/mistral-small-3.1-24b-instruct-2503",
|
|
"mistral-medium": "mistralai/mistral-medium-3-instruct",
|
|
"mistral-large": "mistralai/mistral-large-3-675b-instruct-2512",
|
|
"qwen-coder": "qwen/qwen3-coder-480b-a35b-instruct",
|
|
"gemma-3": "google/gemma-3-27b-it",
|
|
"deepseek": "deepseek-ai/deepseek-v3.2",
|
|
}
|
|
|
|
|
|
@app.route("/webhook/telegram", methods=["POST"])
|
|
def telegram_webhook():
|
|
try:
|
|
data = request.get_data()
|
|
resp = requests.post(
|
|
"http://localhost:5003/webhook",
|
|
data=data,
|
|
headers={"Content-Type": "application/json"},
|
|
timeout=30,
|
|
)
|
|
return resp.content, resp.status_code
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/webhook/<workflow_slug>", methods=["POST"])
|
|
def webhook_proxy(workflow_slug):
|
|
try:
|
|
workflows = db.get_workflows()
|
|
wf = next((w for w in workflows if w["slug"] == workflow_slug), None)
|
|
if not wf:
|
|
return jsonify({"error": f'Workflow "{workflow_slug}" introuvable'}), 404
|
|
|
|
session_id = request.headers.get("X-Session-ID", "default")
|
|
user_message = request.json.get("message", "")
|
|
|
|
db.create_session(session_id, wf["id"])
|
|
db.save_message(session_id, wf["id"], "user", user_message)
|
|
|
|
mode = wf.get("mode", "n8n")
|
|
|
|
if mode == "direct":
|
|
# OpenClaw gateway bind sur 127.0.0.1 uniquement → docker exec
|
|
import subprocess
|
|
|
|
escaped_msg = user_message.replace('"', '\\"').replace("\n", "\\n")
|
|
cmd = [
|
|
"docker",
|
|
"exec",
|
|
OPENCLAW_CONTAINER,
|
|
"curl",
|
|
"-s",
|
|
"http://127.0.0.1:18789/v1/chat/completions",
|
|
"-H",
|
|
"Content-Type: application/json",
|
|
"-H",
|
|
f"Authorization: Bearer {OPENCLAW_TOKEN}",
|
|
"-d",
|
|
f'{{"model":"openclaw","messages":[{{"role":"user","content":"{escaped_msg}"}}],"max_tokens":4096,"temperature":0.7}}',
|
|
]
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
if result.returncode != 0:
|
|
raise Exception(f"docker exec failed: {result.stderr}")
|
|
data = json.loads(result.stdout)
|
|
ai_response = (
|
|
data.get("choices", [{}])[0]
|
|
.get("message", {})
|
|
.get("content", str(data))
|
|
)
|
|
elif mode == "nvidia":
|
|
# Appel direct a l API Nvidia NIM
|
|
# Recuperer le modele choisi (par defaut: llama-3.1-8b)
|
|
model_key = request.json.get("model", "llama-3.1-8b")
|
|
model_id = NVIDIA_MODELS.get(model_key, NVIDIA_MODEL)
|
|
resp = requests.post(
|
|
NVIDIA_API_URL,
|
|
headers={
|
|
"Authorization": f"Bearer {NVIDIA_API_KEY}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
json={
|
|
"model": model_id,
|
|
"messages": [{"role": "user", "content": user_message}],
|
|
"max_tokens": 1024,
|
|
"temperature": 0.7,
|
|
},
|
|
timeout=60,
|
|
)
|
|
data = resp.json()
|
|
ai_response = (
|
|
data.get("choices", [{}])[0]
|
|
.get("message", {})
|
|
.get("content", str(data))
|
|
)
|
|
else:
|
|
# Proxy vers webhook n8n
|
|
resp = requests.post(
|
|
wf["webhook_url"],
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"X-Session-ID": session_id,
|
|
},
|
|
json=request.json,
|
|
timeout=60,
|
|
)
|
|
ai_response = resp.text
|
|
|
|
db.save_message(session_id, wf["id"], "ai", ai_response)
|
|
return ai_response, 200, {"Content-Type": "text/plain; charset=utf-8"}
|
|
except Exception as e:
|
|
return str(e), 500
|
|
|
|
|
|
# --- Anciens webhooks (compatibilité) ---
|
|
|
|
|
|
@app.route("/webhook/gemini-agent", methods=["POST"])
|
|
def webhook_proxy_legacy_gemini():
|
|
return webhook_proxy("llm-models")
|
|
|
|
|
|
@app.route("/webhook/openclaw-web", methods=["POST"])
|
|
def webhook_proxy_legacy_openclaw():
|
|
return webhook_proxy("openclaw")
|
|
|
|
|
|
TELEGRAM_TOKEN = "8649773134:AAFqzZVtSHfPPFDadcte1B-1h23nZ8DmdYE"
|
|
|
|
|
|
@app.route("/webhook/telegram-opencode", methods=["POST"])
|
|
def webhook_proxy_telegram():
|
|
"""Handle Telegram messages directly"""
|
|
try:
|
|
data = request.get_json(force=True, silent=True)
|
|
|
|
print(f"[TELEGRAM] Raw: {request.data}")
|
|
print(f"[TELEGRAM] Remote: {request.remote_addr}")
|
|
print(f"[TELEGRAM] Headers: {request.headers.get('X-Forwarded-For', 'none')}")
|
|
|
|
if not data:
|
|
return jsonify({"error": "No data", "raw": request.data.decode()}), 400
|
|
|
|
# Accept both direct message and Telegram update format
|
|
message = data.get("message") or data.get("update", {}).get("message")
|
|
|
|
if not message:
|
|
return jsonify({"data": data, "keys": list(data.keys())}), 400
|
|
|
|
chat_id = message["chat"]["id"]
|
|
text = message.get("text", "")
|
|
user_id = message["from"]["id"]
|
|
|
|
if text.startswith("/start"):
|
|
reply = "🤖 *OpencdPilot Bot*\n\nPilotez OpenCode depuis Telegram!"
|
|
elif text.startswith("/help"):
|
|
reply = "*Commandes:*\n/start - Démarrer\n/help - Aide\n/status - Statut"
|
|
elif text.startswith("/status"):
|
|
reply = "✅ *Système actif*\n- OpenCode: OK\n- Bot: OK"
|
|
else:
|
|
# Call OpenCode API
|
|
opencode_resp = requests.post(
|
|
"http://localhost:8792/api/opencode", json={"prompt": text}, timeout=180
|
|
)
|
|
opencode_data = opencode_resp.json()
|
|
reply = opencode_data.get("output", opencode_data.get("error", "Erreur"))[
|
|
:4000
|
|
]
|
|
|
|
# Send reply
|
|
requests.post(
|
|
f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage",
|
|
json={"chat_id": chat_id, "text": reply, "parse_mode": "Markdown"},
|
|
)
|
|
|
|
return jsonify({"ok": True})
|
|
except Exception as e:
|
|
print(f"[TELEGRAM] Error: {e}")
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
# --- Turf Dashboard ---
|
|
|
|
|
|
@app.route("/dashboard")
|
|
@app.route("/dashboard.html")
|
|
def dashboard():
|
|
return send_from_directory("/home/h3r7/turf_saas", "dashboard_system.html")
|
|
|
|
|
|
@app.route("/turf/")
|
|
@app.route("/turf")
|
|
def turf_index():
|
|
return send_from_directory("/home/h3r7/turf_saas", "dashboard.html")
|
|
|
|
|
|
@app.route("/turf/<path:filename>")
|
|
def turf_static(filename):
|
|
return send_from_directory("/home/h3r7/turf_saas", filename)
|
|
|
|
|
|
# --- POD Routes ---
|
|
@app.route("/pod/")
|
|
@app.route("/pod/<path:filename>")
|
|
def pod_static(filename=""):
|
|
return send_from_directory(
|
|
"/home/h3r7/turf_saas/POD", filename if filename else "pod_manager.html"
|
|
)
|
|
|
|
|
|
@app.route("/turf/api")
|
|
@app.route("/turf/api/")
|
|
@app.route("/turf/api/<path:api_path>")
|
|
def api_proxy(api_path=""):
|
|
# Routes servies par combined_api.py (port 8790) :
|
|
# backtest, stats, paris, parisroi, races, scores, report, ask, brave-search,
|
|
# execute-sql, send-email, vitesse, n8n-proxy, predictions_analysis, ideas
|
|
# Fix HRT-73 : alignement complet avec turf_scraper fix #23
|
|
COMBINED_ROUTES = (
|
|
"backtest",
|
|
"stats",
|
|
"parisroi",
|
|
"paris",
|
|
"predictions_analysis",
|
|
"vitesse",
|
|
"n8n-proxy",
|
|
"races",
|
|
"race/",
|
|
"scores",
|
|
"ask",
|
|
"brave-search",
|
|
"execute-sql",
|
|
"send-email",
|
|
"report",
|
|
"ideas",
|
|
)
|
|
if any(api_path.startswith(r) for r in COMBINED_ROUTES):
|
|
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
|
elif api_path.startswith("scoring"):
|
|
url = f"{DASHBOARD_API_URL}/turf/api/{api_path}"
|
|
elif api_path:
|
|
url = f"{DASHBOARD_API_URL}/turf/api/{api_path}"
|
|
else:
|
|
url = f"{DASHBOARD_API_URL}/turf/api"
|
|
try:
|
|
fwd_method = request.method
|
|
fwd_json = (
|
|
request.get_json(silent=True)
|
|
if fwd_method in ("POST", "PUT", "PATCH")
|
|
else None
|
|
)
|
|
# Forwarder Authorization header (combined_api.py exige Basic h3r7:h3r7 pour parisroi/paris)
|
|
fwd_headers = {"Content-Type": "application/json"}
|
|
incoming_auth = request.headers.get("Authorization")
|
|
if incoming_auth:
|
|
fwd_headers["Authorization"] = incoming_auth
|
|
resp = requests.request(
|
|
method=fwd_method,
|
|
url=url,
|
|
json=fwd_json,
|
|
timeout=30,
|
|
headers=fwd_headers,
|
|
)
|
|
return resp.content, resp.status_code, {"Content-Type": "application/json"}
|
|
except Exception as e:
|
|
return jsonify({"error": str(e), "url": url}), 500
|
|
|
|
|
|
@app.route("/api/opencode", methods=["POST"])
|
|
def opencode_api():
|
|
"""Execute OpenCode commands via API"""
|
|
try:
|
|
data = request.get_json()
|
|
prompt = data.get("prompt", "")
|
|
if not prompt:
|
|
return jsonify({"error": "No prompt provided"}), 400
|
|
|
|
# Execute opencode with the wrapper script
|
|
result = subprocess.run(
|
|
["/home/h3r7/opencode_wrapper.sh", prompt],
|
|
cwd="/home/h3r7",
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=180,
|
|
)
|
|
|
|
return jsonify(
|
|
{
|
|
"output": result.stdout,
|
|
"error": result.stderr,
|
|
"returncode": result.returncode,
|
|
}
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
return jsonify({"error": "Timeout"}), 504
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
@app.route("/candidatures/")
|
|
def candidatures_index():
|
|
return send_from_directory("/home/h3r7/turf_saas", "crm_candidatures.html")
|
|
|
|
|
|
@app.route("/candidatures/<path:filename>")
|
|
def candidatures_static(filename):
|
|
return send_from_directory("/home/h3r7/turf_saas", filename)
|
|
|
|
|
|
@app.route("/map")
|
|
def map_visual():
|
|
return send_from_directory("/home/h3r7/turf_saas", "map_visual.html")
|
|
|
|
|
|
@app.route("/architecture.json")
|
|
def architecture_json():
|
|
return send_from_directory("/home/h3r7/turf_saas", "architecture.json")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host="0.0.0.0", port=8792, debug=False)
|
|
|
|
|
|
# Telegram Bot Webhook Proxy
|
|
def telegram_webhook():
|
|
"""Proxy Telegram webhook to bot service"""
|
|
try:
|
|
data = request.get_data()
|
|
resp = requests.post(
|
|
"http://localhost:5003/webhook",
|
|
data=data,
|
|
headers={"Content-Type": "application/json"},
|
|
timeout=30,
|
|
)
|
|
return resp.content, resp.status_code
|
|
except Exception as e:
|
|
return jsonify({"error": str(e)}), 500
|
|
|
|
|
|
PROMPTS_API_URL = "http://localhost:8781"
|
|
|
|
|
|
@app.route("/testprompts", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
|
def proxy_prompts_test():
|
|
return "TEST_PROMPTS_OK"
|
|
full_url = PROMPTS_API_URL + ("/" + subpath if subpath else "/")
|
|
if request.query_string:
|
|
full_url += "?" + request.query_string.decode()
|
|
try:
|
|
headers = {
|
|
k: v
|
|
for k, v in request.headers
|
|
if k.lower()
|
|
not in (
|
|
"host",
|
|
"content-length",
|
|
"transfer-encoding",
|
|
"connection",
|
|
"authorization",
|
|
)
|
|
}
|
|
raw_body = request.get_data()
|
|
resp = requests.request(
|
|
request.method,
|
|
full_url,
|
|
headers=headers,
|
|
data=raw_body,
|
|
cookies=request.cookies,
|
|
allow_redirects=False,
|
|
timeout=10,
|
|
)
|
|
if resp.status_code in (301, 302, 303, 307, 308):
|
|
location = resp.headers.get("Location", "")
|
|
if location.startswith("/") and not location.startswith("/prompts"):
|
|
location = "/prompts" + location
|
|
r = make_response(b"", resp.status_code)
|
|
r.headers["Location"] = location
|
|
return r
|
|
response = make_response(resp.content, resp.status_code)
|
|
for k, v in resp.headers.items():
|
|
if k.lower() not in ("content-encoding", "transfer-encoding", "connection"):
|
|
response.headers[k] = v
|
|
return response
|
|
except Exception as e:
|
|
return f"Erreur proxy prompts: {e}", 502
|