openclaw-neo-bot / server.py
Razka Bot
feat: elegant growth dashboard with traffic metrics and oauth/make controls
9c73ca9
Raw
History Blame
23.3 kB
import os
import time
from datetime import datetime, timezone
from urllib.parse import urlencode
from flask import Flask, request, jsonify, render_template_string, redirect
import requests
app = Flask(__name__)
SUPABASE_URL = os.getenv("SUPABASE_URL", "").rstrip("/")
SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "")
APP_BASE_URL = os.getenv("APP_BASE_URL", "").rstrip("/")
NEXT_AGENT = {
"trend_hunter": "offer_scout",
"offer_scout": "content_strategist",
"content_strategist": "seo_writer",
"seo_writer": "creative_generator",
"creative_generator": "publisher",
"publisher": "tracker",
"tracker": "analytics",
"analytics": "cro_optimizer",
"cro_optimizer": "compliance_guard",
}
# ---------- Infra helpers ----------
def now_iso():
return datetime.now(timezone.utc).isoformat()
def provider_config(provider: str):
p = provider.lower()
if p == "facebook":
return {
"client_id": os.getenv("FB_CLIENT_ID", ""),
"client_secret": os.getenv("FB_CLIENT_SECRET", ""),
"auth_url": "https://www.facebook.com/v19.0/dialog/oauth",
"token_url": "https://graph.facebook.com/v19.0/oauth/access_token",
"scope": os.getenv("FB_SCOPE", "pages_manage_posts,pages_read_engagement"),
}
if p == "x":
return {
"client_id": os.getenv("X_CLIENT_ID", ""),
"client_secret": os.getenv("X_CLIENT_SECRET", ""),
"auth_url": "https://twitter.com/i/oauth2/authorize",
"token_url": "https://api.twitter.com/2/oauth2/token",
"scope": os.getenv("X_SCOPE", "tweet.read tweet.write users.read offline.access"),
}
if p == "reddit":
return {
"client_id": os.getenv("REDDIT_CLIENT_ID", ""),
"client_secret": os.getenv("REDDIT_CLIENT_SECRET", ""),
"auth_url": "https://www.reddit.com/api/v1/authorize",
"token_url": "https://www.reddit.com/api/v1/access_token",
"scope": os.getenv("REDDIT_SCOPE", "identity submit read"),
}
raise RuntimeError(f"Unsupported provider: {provider}")
def sb(path, method="GET", params=None, body=None):
if not SUPABASE_URL or not SUPABASE_ANON_KEY:
raise RuntimeError("Missing SUPABASE_URL / SUPABASE_ANON_KEY")
url = f"{SUPABASE_URL}/rest/v1/{path}"
headers = {
"apikey": SUPABASE_ANON_KEY,
"Authorization": f"Bearer {SUPABASE_ANON_KEY}",
"Content-Type": "application/json",
}
if method in ("POST", "PATCH", "DELETE"):
headers["Prefer"] = "return=representation"
r = requests.request(method, url, headers=headers, params=params, json=body, timeout=25)
if r.status_code >= 400:
raise RuntimeError(f"Supabase {r.status_code}: {r.text[:250]}")
if not r.text:
return None
return r.json()
# ---------- Connection store ----------
def upsert_connection(provider: str, row: dict):
existing = sb("social_connections", params={"provider": f"eq.{provider}", "limit": 1}) or []
if existing:
sb(f"social_connections?provider=eq.{provider}", method="PATCH", body=row)
return {"provider": provider, "status": "updated"}
sb("social_connections", method="POST", body=row)
return {"provider": provider, "status": "inserted"}
def save_connection(provider: str, token_payload: dict):
expires_at = None
if token_payload.get("expires_in"):
try:
expires_at = datetime.fromtimestamp(
time.time() + int(token_payload.get("expires_in")), tz=timezone.utc
).isoformat()
except Exception:
expires_at = None
row = {
"provider": provider,
"access_token": token_payload.get("access_token"),
"refresh_token": token_payload.get("refresh_token"),
"scope": token_payload.get("scope"),
"token_type": token_payload.get("token_type"),
"expires_at": expires_at,
"raw": token_payload,
"updated_at": now_iso(),
}
return upsert_connection(provider, row)
def save_make_webhook(webhook_url: str):
row = {
"provider": "make",
"access_token": None,
"refresh_token": None,
"scope": "webhook",
"token_type": "webhook",
"expires_at": None,
"raw": {"webhook_url": webhook_url},
"updated_at": now_iso(),
}
return upsert_connection("make", row)
# ---------- Affiliate agent logic ----------
def run_agent(agent_code, input_data):
prev = (input_data or {}).get("prev_output", {})
if agent_code == "trend_hunter":
niche = (input_data or {}).get("niche", "affiliate marketing")
base = ["best", "review", "promo", "diskon", "vs"]
return {
"niche": niche,
"keywords": [f"{niche} {b}" for b in base],
"search_intent": "commercial+transactional",
}
if agent_code == "offer_scout":
kws = prev.get("keywords", [])
return {
"offers": [
{"keyword": k, "offer_id": f"offer_{i+1}", "score": 80 - i * 7}
for i, k in enumerate(kws[:3])
]
}
if agent_code == "content_strategist":
offers = prev.get("offers", [])
return {
"content_plan": [
{
"stage": "BOFU" if i == 0 else ("MOFU" if i == 1 else "TOFU"),
"title_angle": f"Solusi terbaik untuk {o.get('keyword', 'offer')}",
}
for i, o in enumerate(offers)
],
"affiliate_link": (input_data or {}).get("affiliate_link"),
"short_url": (input_data or {}).get("short_url"),
}
if agent_code == "seo_writer":
title = prev.get("content_plan", [{}])[0].get("title_angle", "Panduan affiliate")
slug = "-".join([x for x in "".join([c.lower() if c.isalnum() else " " for c in title]).split() if x])
return {"title": title, "slug": slug, "article_html": f"<h1>{title}</h1><p>Draft siap publish.</p>"}
if agent_code == "creative_generator":
title = prev.get("title", "Promo affiliate")
return {
"creatives": [
{"channel": "facebook", "hook": f"{title} - cek sekarang"},
{"channel": "instagram", "hook": f"🔥 {title}"},
]
}
if agent_code == "publisher":
return {
"publish_refs": [{"channel": "blog", "ref": f"post_{int(time.time())}"}],
"short_url": (input_data or {}).get("short_url"),
}
if agent_code == "tracker":
return {
"tracking_map": {
"utm_source": "hf-space",
"utm_medium": "affiliate",
"utm_campaign": f"campaign_{int(time.time())}",
}
}
if agent_code == "analytics":
return {"kpi_snapshot": {"ctr": 0.07, "cvr": 0.02, "epc": 0.4}}
if agent_code == "cro_optimizer":
return {"experiments": [{"name": "headline-variant-a", "priority": "high"}]}
if agent_code == "compliance_guard":
return {"compliance_status": "pass-with-notes", "fixes": ["Tambahkan disclosure affiliate"]}
raise RuntimeError(f"No runner for {agent_code}")
def run_once():
task_rows = sb("aff_tasks", params={"status": "eq.queued", "order": "scheduled_at.asc", "limit": 1}) or []
if not task_rows:
return {"ok": True, "message": "No queued tasks"}
task = task_rows[0]
task_id = task["id"]
attempts = int(task.get("attempts") or 0) + 1
sb(
f"aff_tasks?id=eq.{task_id}",
method="PATCH",
body={"status": "running", "started_at": now_iso(), "attempts": attempts},
)
try:
output = run_agent(task["agent_code"], task.get("input") or {})
sb(
f"aff_tasks?id=eq.{task_id}",
method="PATCH",
body={"status": "done", "output": output, "finished_at": now_iso()},
)
next_agent = NEXT_AGENT.get(task["agent_code"])
if next_agent:
sb(
"aff_tasks",
method="POST",
body={
"agent_code": next_agent,
"status": "queued",
"input": {"prev_agent": task["agent_code"], "prev_output": output},
},
)
return {"ok": True, "task_id": task_id, "agent": task["agent_code"], "status": "done"}
except Exception as e:
sb(
f"aff_tasks?id=eq.{task_id}",
method="PATCH",
body={"status": "error", "error": str(e), "finished_at": now_iso()},
)
return {"ok": False, "task_id": task_id, "agent": task["agent_code"], "error": str(e)}
# ---------- Dashboard data ----------
def stats_today():
start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
rows = (
sb(
"aff_tasks",
params={"select": "status,agent_code,created_at,output", "created_at": f"gte.{start}", "limit": 2000},
)
or []
)
agg = {"queued": 0, "running": 0, "done": 0, "error": 0}
by_agent = {}
publish_count = 0
latest_kpi = {"ctr": 0.0, "cvr": 0.0, "epc": 0.0}
for row in rows:
st = row.get("status", "queued")
agg[st] = agg.get(st, 0) + 1
code = row.get("agent_code", "unknown")
by_agent[code] = by_agent.get(code, 0) + 1
out = row.get("output") or {}
if code == "publisher":
refs = out.get("publish_refs") or []
publish_count += len(refs)
if code == "analytics" and out.get("kpi_snapshot"):
k = out.get("kpi_snapshot")
latest_kpi = {
"ctr": float(k.get("ctr") or 0.0),
"cvr": float(k.get("cvr") or 0.0),
"epc": float(k.get("epc") or 0.0),
}
est_clicks = int(publish_count * 55)
est_leads = int(est_clicks * max(latest_kpi["cvr"], 0.01))
return {
"total": len(rows),
"agg": agg,
"by_agent": by_agent,
"traffic": {
"published_items": publish_count,
"estimated_clicks": est_clicks,
"estimated_leads": est_leads,
"ctr": latest_kpi["ctr"],
"cvr": latest_kpi["cvr"],
"epc": latest_kpi["epc"],
"note": "Estimated from pipeline outputs (publisher + latest analytics snapshot).",
},
}
def latest_tasks(limit=12):
rows = (
sb(
"aff_tasks",
params={
"select": "id,agent_code,status,created_at,finished_at,error",
"order": "created_at.desc",
"limit": limit,
},
)
or []
)
return rows
def connection_overview():
rows = (
sb(
"social_connections",
params={"select": "provider,scope,token_type,expires_at,updated_at", "order": "updated_at.desc", "limit": 30},
)
or []
)
return rows
PAGE = """
<!doctype html>
<html>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>OpenClaw Growth Console</title>
<style>
:root{--bg:#0a1022;--panel:#121a33;--panel2:#1a2342;--txt:#eaf0ff;--muted:#9fb0e6;--ok:#3ddc97;--warn:#ffca57;--bad:#ff6b6b;--blue:#4ea1ff}
*{box-sizing:border-box}
body{margin:0;background:linear-gradient(180deg,#070c1a,#0a1022);color:var(--txt);font-family:Inter,Arial,sans-serif}
.wrap{max-width:1140px;margin:24px auto;padding:0 14px}
.hero{background:linear-gradient(135deg,#18264a,#121a33);border:1px solid #2a3a66;border-radius:16px;padding:18px}
.hero h1{margin:0 0 8px;font-size:24px}
.muted{color:var(--muted);font-size:13px}
.grid{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:12px}
.card{background:var(--panel);border:1px solid #2a3a66;border-radius:14px;padding:12px}
.k{font-size:12px;color:var(--muted)}
.v{font-size:24px;font-weight:700;margin-top:4px}
.v.ok{color:var(--ok)} .v.warn{color:var(--warn)} .v.bad{color:var(--bad)}
.section{background:var(--panel);border:1px solid #2a3a66;border-radius:14px;padding:14px;margin-top:12px}
.actions a,.actions button{margin:4px}
button{background:#28407a;color:white;border:none;border-radius:10px;padding:8px 12px;cursor:pointer}
button:hover{filter:brightness(1.08)}
input{width:100%;background:var(--panel2);border:1px solid #38508a;color:white;border-radius:10px;padding:9px}
.two{display:grid;grid-template-columns:1fr 1fr;gap:12px}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{padding:8px;border-bottom:1px solid #26365f;text-align:left}
th{color:#baceff}
.pill{display:inline-block;padding:3px 8px;border-radius:999px;font-size:11px}
.p-ok{background:#103d2a;color:#7ef0b9}.p-run{background:#3a2c00;color:#ffd86b}.p-err{background:#4a1e1e;color:#ff9b9b}.p-q{background:#1a2d4f;color:#9fc2ff}
@media (max-width:960px){.grid{grid-template-columns:repeat(2,1fr)}.two{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class='wrap'>
<div class='hero'>
<h1>⚡ OpenClaw Growth Console</h1>
<div class='muted'>Status: {{supabase_ok}} · Last refresh: {{refreshed_at}}</div>
<div class='grid'>
<div class='card'><div class='k'>Total Tasks Today</div><div class='v'>{{stats.total}}</div></div>
<div class='card'><div class='k'>Done</div><div class='v ok'>{{stats.agg.done}}</div></div>
<div class='card'><div class='k'>Running + Queued</div><div class='v warn'>{{stats.agg.running + stats.agg.queued}}</div></div>
<div class='card'><div class='k'>Errors</div><div class='v bad'>{{stats.agg.error}}</div></div>
</div>
<div class='grid'>
<div class='card'><div class='k'>Published Items</div><div class='v'>{{stats.traffic.published_items}}</div></div>
<div class='card'><div class='k'>Estimated Clicks</div><div class='v'>{{stats.traffic.estimated_clicks}}</div></div>
<div class='card'><div class='k'>Estimated Leads</div><div class='v'>{{stats.traffic.estimated_leads}}</div></div>
<div class='card'><div class='k'>CTR / CVR / EPC</div><div class='v'>{{'%.2f' % (stats.traffic.ctr*100)}}% · {{'%.2f' % (stats.traffic.cvr*100)}}% · {{'%.2f' % stats.traffic.epc}}</div></div>
</div>
<div class='muted'>{{stats.traffic.note}}</div>
</div>
<div class='section actions'>
<a href='/oauth/start/facebook'><button>Connect Facebook</button></a>
<a href='/oauth/start/x'><button>Connect X</button></a>
<a href='/oauth/start/reddit'><button>Connect Reddit</button></a>
<a href='/connections'><button>View Connections JSON</button></a>
<a href='/run-once'><button>Run Once</button></a>
<a href='/run-batch?count=5'><button>Run Batch x5</button></a>
<a href='/dashboard-data'><button>Dashboard API</button></a>
</div>
<div class='two'>
<div class='section'>
<h3>Connect Make.com</h3>
<form method='post' action='/make/connect'>
<label class='muted'>Webhook URL</label>
<input name='webhook_url' placeholder='https://hook.eu2.make.com/xxxx'>
<p><button type='submit'>Save Webhook</button> <a href='/make/test'><button type='button'>Test Webhook</button></a></p>
</form>
</div>
<div class='section'>
<h3>Enqueue Campaign</h3>
<form method='post' action='/enqueue'>
<label class='muted'>Affiliate Link</label>
<input name='affiliate_link' required>
<label class='muted'>Short URL (optional)</label>
<input name='short_url'>
<p><button type='submit'>Enqueue</button></p>
</form>
</div>
</div>
<div class='section'>
<h3>Connected Platforms</h3>
<table>
<thead><tr><th>Provider</th><th>Scope</th><th>Type</th><th>Expires</th><th>Updated</th></tr></thead>
<tbody>
{% for c in connections %}
<tr>
<td>{{c.provider}}</td>
<td>{{c.scope or '-'}}</td>
<td>{{c.token_type or '-'}}</td>
<td>{{c.expires_at or '-'}}</td>
<td>{{c.updated_at}}</td>
</tr>
{% endfor %}
{% if not connections %}
<tr><td colspan='5' class='muted'>Belum ada koneksi. Connect dulu via tombol di atas.</td></tr>
{% endif %}
</tbody>
</table>
</div>
<div class='section'>
<h3>Latest Tasks</h3>
<table>
<thead><tr><th>Created</th><th>Agent</th><th>Status</th><th>Task ID</th><th>Error</th></tr></thead>
<tbody>
{% for t in latest %}
<tr>
<td>{{t.created_at}}</td>
<td>{{t.agent_code}}</td>
<td>
{% if t.status == 'done' %}<span class='pill p-ok'>done</span>
{% elif t.status == 'running' %}<span class='pill p-run'>running</span>
{% elif t.status == 'error' %}<span class='pill p-err'>error</span>
{% else %}<span class='pill p-q'>queued</span>{% endif %}
</td>
<td>{{t.id}}</td>
<td>{{(t.error or '')[:80]}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</body>
</html>
"""
# ---------- Routes ----------
@app.get("/")
def home():
try:
stats = stats_today()
conns = connection_overview()
latest = latest_tasks(15)
return render_template_string(
PAGE,
supabase_ok="connected",
refreshed_at=now_iso(),
stats=stats,
connections=conns,
latest=latest,
)
except Exception as e:
return render_template_string(
PAGE,
supabase_ok=f"error: {e}",
refreshed_at=now_iso(),
stats={"total": 0, "agg": {"done": 0, "running": 0, "queued": 0, "error": 0}, "traffic": {"published_items": 0, "estimated_clicks": 0, "estimated_leads": 0, "ctr": 0, "cvr": 0, "epc": 0, "note": "No data"}},
connections=[],
latest=[],
)
@app.get("/healthz")
def healthz():
return jsonify({"ok": True, "time": now_iso()})
@app.get("/dashboard-data")
def dashboard_data():
return jsonify({
"ok": True,
"time": now_iso(),
"stats": stats_today(),
"connections": connection_overview(),
"latest": latest_tasks(15),
})
@app.get("/oauth/start/<provider>")
def oauth_start(provider):
try:
conf = provider_config(provider)
if not APP_BASE_URL:
return jsonify({"ok": False, "error": "Missing APP_BASE_URL secret"}), 500
if not conf["client_id"] or not conf["client_secret"]:
return jsonify({"ok": False, "error": f"Missing client credentials for {provider}"}), 400
redirect_uri = f"{APP_BASE_URL}/oauth/callback/{provider}"
state = f"{provider}-{int(time.time())}"
params = {
"client_id": conf["client_id"],
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": conf["scope"],
"state": state,
}
auth_url = f"{conf['auth_url']}?{urlencode(params)}"
return redirect(auth_url)
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/oauth/callback/<provider>")
def oauth_callback(provider):
code = request.args.get("code")
if not code:
return jsonify({"ok": False, "error": "Missing code"}), 400
conf = provider_config(provider)
redirect_uri = f"{APP_BASE_URL}/oauth/callback/{provider}"
data = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": conf["client_id"],
"client_secret": conf["client_secret"],
}
try:
token_res = requests.post(conf["token_url"], data=data, timeout=25)
payload = token_res.json()
if token_res.status_code >= 400:
return jsonify({"ok": False, "error": payload}), 400
saved = save_connection(provider, payload)
return jsonify({"ok": True, "provider": provider, "saved": saved})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.get("/connections")
def connections():
rows = connection_overview()
return jsonify({"ok": True, "connections": rows})
@app.post("/make/connect")
def make_connect():
webhook_url = request.form.get("webhook_url") or (request.json or {}).get("webhook_url")
if not webhook_url:
return jsonify({"ok": False, "error": "webhook_url required"}), 400
if not webhook_url.startswith("https://hook."):
return jsonify({"ok": False, "error": "invalid make webhook url"}), 400
saved = save_make_webhook(webhook_url)
return jsonify({"ok": True, "saved": saved})
@app.get("/make/test")
def make_test():
rows = sb("social_connections", params={"provider": "eq.make", "limit": 1}) or []
if not rows:
return jsonify({"ok": False, "error": "make webhook not connected"}), 400
webhook_url = ((rows[0] or {}).get("raw") or {}).get("webhook_url")
if not webhook_url:
return jsonify({"ok": False, "error": "stored webhook missing"}), 400
payload = {
"event": "openclaw_make_test",
"timestamp": now_iso(),
"source": "hf-space",
"message": "Make webhook test OK",
}
try:
r = requests.post(webhook_url, json=payload, timeout=20)
return jsonify({"ok": r.status_code < 400, "status": r.status_code, "text": r.text[:180]})
except Exception as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.post("/enqueue")
def enqueue():
link = request.form.get("affiliate_link") or (request.json or {}).get("affiliate_link")
short = request.form.get("short_url") or (request.json or {}).get("short_url")
if not link:
return jsonify({"ok": False, "error": "affiliate_link required"}), 400
row = sb(
"aff_tasks",
method="POST",
body={
"agent_code": "content_strategist",
"status": "queued",
"input": {
"niche": "web hosting",
"offer": "Hostinger",
"affiliate_link": link,
"short_url": short,
"source": "hf-space",
},
},
)
return jsonify({"ok": True, "task": row[0] if isinstance(row, list) and row else row})
@app.get("/run-once")
def run_once_route():
return jsonify(run_once())
@app.get("/run-batch")
def run_batch():
count = int(request.args.get("count", 5))
out = []
for _ in range(max(1, min(count, 20))):
result = run_once()
out.append(result)
if result.get("message") == "No queued tasks":
break
return jsonify({"ok": True, "results": out})
@app.get("/stats")
def stats_route():
return jsonify(stats_today())
if __name__ == "__main__":
port = int(os.getenv("PORT", "7860"))
app.run(host="0.0.0.0", port=port)