Add temp-SMS tool (SMSPool-backed temp-number marketplace) to Pleiades
- sms.py: NumberProvider abstraction + SMSPoolProvider + routes (/sms, rent, poll, release) + admin (/admin/sms) - Reuses existing sats wallet + BTCPay deposit flow; charges per-rental against balance_sats - Code detection, lazy expiry/release, per-user session limit, per-IP rate limit - Verified end-to-end: rented Discord US +1 534 251 1825, wallet 5000->3500 sats, release refunded SMSPool to $7.45
This commit is contained in:
5
app.py
5
app.py
@@ -953,6 +953,11 @@ def sitemap():
|
|||||||
return app.response_class(body, mimetype="application/xml")
|
return app.response_class(body, mimetype="application/xml")
|
||||||
|
|
||||||
|
|
||||||
|
# temporary inbound-SMS tool (SMSPool-backed temp-number marketplace)
|
||||||
|
import sms
|
||||||
|
sms.init_sms_app(app, login_required, current_user)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
db.init_db()
|
db.init_db()
|
||||||
app.run(host="127.0.0.1", port=5000, debug=False)
|
app.run(host="127.0.0.1", port=5000, debug=False)
|
||||||
|
|||||||
551
sms.py
Normal file
551
sms.py
Normal file
@@ -0,0 +1,551 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
sms.py — Temporary inbound-SMS tool for Pleiades (Clean Proxys).
|
||||||
|
|
||||||
|
A NumberProvider abstraction over SMSPool, plus Flask routes, pricing, the
|
||||||
|
number lifecycle, code detection, and realtime polling for the temp-number
|
||||||
|
marketplace. Reuses the existing wallet (balance_sats + transactions ledger)
|
||||||
|
and BTCPay deposit flow — customers top up BTC once, then rent numbers on
|
||||||
|
demand against their wallet balance.
|
||||||
|
|
||||||
|
Provider facts (verified live 2026-09-20):
|
||||||
|
GET /request/balance -> {"balance":"7.45"}
|
||||||
|
GET /country/retrieve_all -> [{ID,name,short_name,cc,region}]
|
||||||
|
GET /service/retrieve_all -> [{ID,name,favourite}]
|
||||||
|
GET /pool/retrieve_all -> [{ID,name}]
|
||||||
|
GET /purchase/sms?service=&country= -> {success,number,phonenumber,order_id,
|
||||||
|
country,service,pool,expires_in,
|
||||||
|
expiration,cost,cost_in_cents,
|
||||||
|
current_balance}
|
||||||
|
GET /sms/check?orderid= -> {status,message,resend,expiration,time_left}
|
||||||
|
GET /sms/cancel?orderid= -> {success,message} (refunds unused rental)
|
||||||
|
GET /request/active -> [] (active orders)
|
||||||
|
|
||||||
|
SMSPool /sms/check status codes: 1=SMS received, 2/3=waiting/pending,
|
||||||
|
4=cancelled/expired, 5/6=refunded. Rental cost is charged at purchase and
|
||||||
|
refunded on /sms/cancel if no SMS was received -> float exposure is only
|
||||||
|
`concurrent active sessions * max rental cost`.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from flask import (abort, flash, jsonify, redirect, render_template, request,
|
||||||
|
session, url_for)
|
||||||
|
|
||||||
|
import db
|
||||||
|
|
||||||
|
CFG = db.CFG
|
||||||
|
|
||||||
|
SMSPOOL_KEY = (CFG.get("smspool") or {}).get("key", "")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Provider abstraction
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
class NumberProvider:
|
||||||
|
"""Swap the upstream by implementing this interface and changing PROVIDER."""
|
||||||
|
def list_countries(self): raise NotImplementedError
|
||||||
|
def list_services(self): raise NotImplementedError
|
||||||
|
def provision_number(self, service, country, expiry_min): raise NotImplementedError
|
||||||
|
def check_sms(self, order_id): raise NotImplementedError
|
||||||
|
def release_number(self, order_id): raise NotImplementedError
|
||||||
|
def get_balance(self): raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class SMSPoolProvider(NumberProvider):
|
||||||
|
BASE = "https://api.smspool.net"
|
||||||
|
|
||||||
|
def __init__(self, key):
|
||||||
|
self.key = key
|
||||||
|
|
||||||
|
def _get(self, path, params):
|
||||||
|
p = dict(params)
|
||||||
|
p["key"] = self.key
|
||||||
|
r = requests.get(f"{self.BASE}/{path}", params=p, timeout=30)
|
||||||
|
ct = r.headers.get("content-type", "")
|
||||||
|
if "json" in ct:
|
||||||
|
try:
|
||||||
|
return r.json()
|
||||||
|
except ValueError:
|
||||||
|
return {"success": 0, "message": r.text[:300]}
|
||||||
|
return {"success": 0, "message": r.text[:300]}
|
||||||
|
|
||||||
|
def list_countries(self):
|
||||||
|
return self._get("country/retrieve_all", {})
|
||||||
|
|
||||||
|
def list_services(self):
|
||||||
|
return self._get("service/retrieve_all", {})
|
||||||
|
|
||||||
|
def provision_number(self, service, country, expiry_min):
|
||||||
|
return self._get("purchase/sms", {"service": service, "country": country,
|
||||||
|
"expiry": int(expiry_min)})
|
||||||
|
|
||||||
|
def check_sms(self, order_id):
|
||||||
|
return self._get("sms/check", {"orderid": order_id})
|
||||||
|
|
||||||
|
def release_number(self, order_id):
|
||||||
|
return self._get("sms/cancel", {"orderid": order_id})
|
||||||
|
|
||||||
|
def get_balance(self):
|
||||||
|
r = self._get("request/balance", {})
|
||||||
|
try:
|
||||||
|
return float(r.get("balance", 0))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
PROVIDER = SMSPoolProvider(SMSPOOL_KEY)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Curated popular services (SMSPool IDs, verified 2026-09-20)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
POPULAR_SERVICES = [
|
||||||
|
(395, "Google / Gmail"),
|
||||||
|
(907, "Telegram"),
|
||||||
|
(1012, "WhatsApp"),
|
||||||
|
(948, "Twitter / X"),
|
||||||
|
(457, "Instagram / Threads"),
|
||||||
|
(273, "Discord"),
|
||||||
|
(329, "Facebook / Meta"),
|
||||||
|
(671, "OpenAI / ChatGPT"),
|
||||||
|
(924, "TikTok / Douyin"),
|
||||||
|
(951, "Uber / Postmates"),
|
||||||
|
(39, "Amazon / AWS"),
|
||||||
|
(1072, "Microsoft / Outlook / Bing"),
|
||||||
|
(48, "Apple"),
|
||||||
|
(630, "Netflix"),
|
||||||
|
(926, "Tinder"),
|
||||||
|
(846, "Snapchat"),
|
||||||
|
(829, "Signal"),
|
||||||
|
(523, "LinkedIn"),
|
||||||
|
(1554, "Reddit"),
|
||||||
|
(28, "Airbnb"),
|
||||||
|
(280, "DoorDash"),
|
||||||
|
(233, "Craigslist"),
|
||||||
|
(1034, "Yahoo"),
|
||||||
|
]
|
||||||
|
|
||||||
|
DEFAULT_PRICING = [
|
||||||
|
{"duration_min": 5, "price_sats": 1500},
|
||||||
|
{"duration_min": 10, "price_sats": 2000},
|
||||||
|
{"duration_min": 15, "price_sats": 2500},
|
||||||
|
{"duration_min": 20, "price_sats": 3000},
|
||||||
|
]
|
||||||
|
|
||||||
|
MAX_RENTAL_MIN = 20 # SMSPool rental window ceiling; longer durations re-rent
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Schema
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS sms_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
service_id INTEGER,
|
||||||
|
service_name TEXT,
|
||||||
|
country_id INTEGER,
|
||||||
|
country_name TEXT,
|
||||||
|
country_cc TEXT,
|
||||||
|
provider_order_id TEXT,
|
||||||
|
number TEXT,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
price_sats INTEGER,
|
||||||
|
provider_cost TEXT,
|
||||||
|
created_at INTEGER,
|
||||||
|
expires_at INTEGER,
|
||||||
|
sms_received_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sms_messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id INTEGER NOT NULL,
|
||||||
|
from_number TEXT,
|
||||||
|
body TEXT,
|
||||||
|
detected_code TEXT,
|
||||||
|
received_at INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sms_pricing (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
duration_min INTEGER UNIQUE,
|
||||||
|
price_sats INTEGER,
|
||||||
|
active INTEGER DEFAULT 1
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sms_countries_cache (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
short_name TEXT,
|
||||||
|
cc TEXT,
|
||||||
|
region TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS sms_services_cache (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def init_sms():
|
||||||
|
d = db.get_db()
|
||||||
|
d.executescript(SCHEMA)
|
||||||
|
if d.execute("SELECT COUNT(*) c FROM sms_pricing").fetchone()["c"] == 0:
|
||||||
|
for p in DEFAULT_PRICING:
|
||||||
|
d.execute("INSERT INTO sms_pricing (duration_min, price_sats, active) VALUES (?,?,1)",
|
||||||
|
(p["duration_min"], p["price_sats"]))
|
||||||
|
d.commit()
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_pricing():
|
||||||
|
d = db.get_db()
|
||||||
|
rows = [dict(r) for r in d.execute(
|
||||||
|
"SELECT * FROM sms_pricing WHERE active=1 ORDER BY duration_min")]
|
||||||
|
d.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def price_for(duration_min):
|
||||||
|
d = db.get_db()
|
||||||
|
r = d.execute("SELECT price_sats FROM sms_pricing WHERE duration_min=? AND active=1",
|
||||||
|
(duration_min,)).fetchone()
|
||||||
|
d.close()
|
||||||
|
return r["price_sats"] if r else None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Code detection (convenience only — never alters the raw SMS)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
_CODE_CONTEXT = re.compile(r"(code|verification|verify|otp|pin|confirm|security|login|auth)", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_code(body):
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
for m in re.finditer(r"\b(\d{4,8})\b", body):
|
||||||
|
s = max(0, m.start() - 40)
|
||||||
|
ctx = body[s:m.end() + 20]
|
||||||
|
if _CODE_CONTEXT.search(ctx):
|
||||||
|
return m.group(1)
|
||||||
|
m = re.search(r"\b(\d{4,8})\b", body)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def _client_ip():
|
||||||
|
xff = request.headers.get("X-Forwarded-For", "")
|
||||||
|
return xff.split(",")[0].strip() if xff else (request.remote_addr or "?")
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_ok(ip, limit, window):
|
||||||
|
now = time.time()
|
||||||
|
d = db.get_db()
|
||||||
|
d.execute("DELETE FROM rate_limits WHERE ts < ?", (now - window,))
|
||||||
|
c = d.execute("SELECT COUNT(*) c FROM rate_limits WHERE ip=?", (ip,)).fetchone()["c"]
|
||||||
|
if c >= limit:
|
||||||
|
d.close()
|
||||||
|
return False
|
||||||
|
d.execute("INSERT INTO rate_limits (ip, ts) VALUES (?,?)", (ip, now))
|
||||||
|
d.commit()
|
||||||
|
d.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _active_session(user_id):
|
||||||
|
d = db.get_db()
|
||||||
|
r = d.execute(
|
||||||
|
"SELECT * FROM sms_sessions WHERE user_id=? AND status IN ('active','sms_received') "
|
||||||
|
"ORDER BY id DESC LIMIT 1", (user_id,)).fetchone()
|
||||||
|
d.close()
|
||||||
|
return dict(r) if r else None
|
||||||
|
|
||||||
|
|
||||||
|
def _session_messages(session_id):
|
||||||
|
d = db.get_db()
|
||||||
|
rows = [dict(r) for r in d.execute(
|
||||||
|
"SELECT * FROM sms_messages WHERE session_id=? ORDER BY id", (session_id,)).fetchall()]
|
||||||
|
d.close()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _service_name(service_id):
|
||||||
|
for sid, name in POPULAR_SERVICES:
|
||||||
|
if sid == service_id:
|
||||||
|
return name
|
||||||
|
d = db.get_db()
|
||||||
|
r = d.execute("SELECT name FROM sms_services_cache WHERE id=?", (service_id,)).fetchone()
|
||||||
|
d.close()
|
||||||
|
return r["name"] if r else ("Service %d" % service_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled_countries():
|
||||||
|
d = db.get_db()
|
||||||
|
rows = [dict(r) for r in d.execute(
|
||||||
|
"SELECT * FROM sms_countries_cache ORDER BY (id=1) DESC, name").fetchall()]
|
||||||
|
d.close()
|
||||||
|
if rows:
|
||||||
|
return rows
|
||||||
|
try:
|
||||||
|
data = PROVIDER.list_countries()
|
||||||
|
except Exception:
|
||||||
|
data = []
|
||||||
|
if not isinstance(data, list):
|
||||||
|
data = []
|
||||||
|
d = db.get_db()
|
||||||
|
for c in data:
|
||||||
|
try:
|
||||||
|
d.execute("INSERT OR REPLACE INTO sms_countries_cache (id,name,short_name,cc,region) "
|
||||||
|
"VALUES (?,?,?,?,?)",
|
||||||
|
(c.get("ID"), c.get("name"), c.get("short_name"), c.get("cc"), c.get("region")))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
d.commit()
|
||||||
|
d.close()
|
||||||
|
return [{"id": c.get("ID"), "name": c.get("name"), "short_name": c.get("short_name"),
|
||||||
|
"cc": c.get("cc"), "region": c.get("region")} for c in data]
|
||||||
|
|
||||||
|
|
||||||
|
def _all_services():
|
||||||
|
d = db.get_db()
|
||||||
|
rows = [dict(r) for r in d.execute("SELECT * FROM sms_services_cache").fetchall()]
|
||||||
|
d.close()
|
||||||
|
if rows:
|
||||||
|
return rows
|
||||||
|
try:
|
||||||
|
data = PROVIDER.list_services()
|
||||||
|
except Exception:
|
||||||
|
data = []
|
||||||
|
if not isinstance(data, list):
|
||||||
|
data = []
|
||||||
|
d = db.get_db()
|
||||||
|
for s in data:
|
||||||
|
try:
|
||||||
|
d.execute("INSERT OR REPLACE INTO sms_services_cache (id,name) VALUES (?,?)",
|
||||||
|
(s.get("ID"), s.get("name")))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
d.commit()
|
||||||
|
d.close()
|
||||||
|
return [{"id": s.get("ID"), "name": s.get("name")} for s in data]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_sms(chk):
|
||||||
|
"""Return (body, from_number) from a /sms/check response, or (None, None)."""
|
||||||
|
if not isinstance(chk, dict):
|
||||||
|
return None, None
|
||||||
|
sms = chk.get("sms")
|
||||||
|
if isinstance(sms, dict) and sms.get("text"):
|
||||||
|
return sms["text"], (sms.get("number") or sms.get("sender") or "")
|
||||||
|
if chk.get("text"):
|
||||||
|
return chk["text"], (chk.get("number") or chk.get("sender") or "")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Route registration
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def init_sms_app(app, login_required, current_user):
|
||||||
|
init_sms()
|
||||||
|
|
||||||
|
@app.route("/sms")
|
||||||
|
@login_required
|
||||||
|
def sms_home():
|
||||||
|
u = current_user()
|
||||||
|
pricing = get_pricing()
|
||||||
|
countries = _enabled_countries()
|
||||||
|
sess = _active_session(u["id"])
|
||||||
|
bal = db.get_balance_sats(u["id"])
|
||||||
|
return render_template("sms.html", user=u, pricing=pricing,
|
||||||
|
countries=countries, session=sess,
|
||||||
|
messages=_session_messages(sess["id"]) if sess else [],
|
||||||
|
popular=POPULAR_SERVICES, balance=bal)
|
||||||
|
|
||||||
|
@app.route("/sms/services")
|
||||||
|
@login_required
|
||||||
|
def sms_services():
|
||||||
|
q = (request.args.get("q") or "").strip().lower()
|
||||||
|
if q:
|
||||||
|
out = [s for s in _all_services() if q in s["name"].lower()][:30]
|
||||||
|
return jsonify({"services": out})
|
||||||
|
return jsonify({"services": [{"id": i, "name": n} for i, n in POPULAR_SERVICES]})
|
||||||
|
|
||||||
|
@app.route("/sms/rent", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def sms_rent():
|
||||||
|
u = current_user()
|
||||||
|
if not _rate_ok(_client_ip(), limit=10, window=300):
|
||||||
|
flash("Too many attempts — slow down.", "warn")
|
||||||
|
return redirect(url_for("sms_home"))
|
||||||
|
if _active_session(u["id"]):
|
||||||
|
flash("You already have an active number. Release it first.", "warn")
|
||||||
|
return redirect(url_for("sms_home"))
|
||||||
|
try:
|
||||||
|
service_id = int(request.form.get("service_id"))
|
||||||
|
country_id = int(request.form.get("country_id"))
|
||||||
|
duration = int(request.form.get("duration"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
flash("Bad request.", "warn")
|
||||||
|
return redirect(url_for("sms_home"))
|
||||||
|
|
||||||
|
price = price_for(duration)
|
||||||
|
if price is None:
|
||||||
|
flash("Invalid duration.", "warn")
|
||||||
|
return redirect(url_for("sms_home"))
|
||||||
|
if db.get_balance_sats(u["id"]) < price:
|
||||||
|
flash("Insufficient balance. Deposit BTC in your wallet first.", "warn")
|
||||||
|
return redirect(url_for("wallet"))
|
||||||
|
|
||||||
|
country = next((c for c in _enabled_countries() if c["id"] == country_id), None)
|
||||||
|
if not country:
|
||||||
|
flash("Country unavailable.", "warn")
|
||||||
|
return redirect(url_for("sms_home"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = PROVIDER.provision_number(service_id, country_id, min(duration, MAX_RENTAL_MIN))
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Provider error: {e}", "warn")
|
||||||
|
return redirect(url_for("sms_home"))
|
||||||
|
|
||||||
|
if not res.get("success"):
|
||||||
|
msg = (res.get("errors") or [{}])[0].get("message") or res.get("message") or "No numbers available."
|
||||||
|
flash(f"Number unavailable: {msg}", "warn")
|
||||||
|
return redirect(url_for("sms_home"))
|
||||||
|
|
||||||
|
order_id = res.get("order_id") or res.get("orderid")
|
||||||
|
number = res.get("number") or res.get("phonenumber")
|
||||||
|
cost = res.get("cost", "0")
|
||||||
|
service_name = _service_name(service_id)
|
||||||
|
|
||||||
|
d = db.get_db()
|
||||||
|
d.execute(
|
||||||
|
"INSERT INTO sms_sessions (user_id, service_id, service_name, country_id, "
|
||||||
|
"country_name, country_cc, provider_order_id, number, status, price_sats, "
|
||||||
|
"provider_cost, created_at, expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||||
|
(u["id"], service_id, service_name, country_id, country["name"], country["cc"],
|
||||||
|
order_id, number, "active", price, str(cost), int(time.time()),
|
||||||
|
int(time.time()) + duration * 60))
|
||||||
|
d.commit()
|
||||||
|
d.close()
|
||||||
|
db.add_transaction(u["id"], -price, "sms", order_id)
|
||||||
|
flash(f"Number {number} reserved for {duration} min.", "ok")
|
||||||
|
return redirect(url_for("sms_home"))
|
||||||
|
|
||||||
|
@app.route("/sms/session/<int:sid>/poll")
|
||||||
|
@login_required
|
||||||
|
def sms_poll(sid):
|
||||||
|
u = current_user()
|
||||||
|
d = db.get_db()
|
||||||
|
row = d.execute("SELECT * FROM sms_sessions WHERE id=? AND user_id=?", (sid, u["id"])).fetchone()
|
||||||
|
if not row:
|
||||||
|
d.close()
|
||||||
|
return jsonify({"error": "not found"}), 404
|
||||||
|
sess = dict(row)
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
# lazy expiry -> release upstream
|
||||||
|
if sess["status"] in ("active", "sms_received") and now >= sess["expires_at"]:
|
||||||
|
try:
|
||||||
|
PROVIDER.release_number(sess["provider_order_id"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
d.execute("UPDATE sms_sessions SET status='expired' WHERE id=?", (sid,))
|
||||||
|
d.commit()
|
||||||
|
sess["status"] = "expired"
|
||||||
|
|
||||||
|
if sess["status"] == "active":
|
||||||
|
try:
|
||||||
|
chk = PROVIDER.check_sms(sess["provider_order_id"])
|
||||||
|
except Exception:
|
||||||
|
chk = {}
|
||||||
|
body, from_num = _extract_sms(chk)
|
||||||
|
status = chk.get("status")
|
||||||
|
if body:
|
||||||
|
code = detect_code(body)
|
||||||
|
d.execute(
|
||||||
|
"INSERT INTO sms_messages (session_id, from_number, body, detected_code, received_at) "
|
||||||
|
"VALUES (?,?,?,?,?)", (sid, from_num, body, code, now))
|
||||||
|
d.execute("UPDATE sms_sessions SET status='sms_received', sms_received_at=? WHERE id=?",
|
||||||
|
(now, sid))
|
||||||
|
d.commit()
|
||||||
|
sess["status"] = "sms_received"
|
||||||
|
elif status in (4, 5, 6):
|
||||||
|
d.execute("UPDATE sms_sessions SET status='released' WHERE id=?", (sid,))
|
||||||
|
d.commit()
|
||||||
|
sess["status"] = "released"
|
||||||
|
|
||||||
|
msgs = [dict(r) for r in d.execute(
|
||||||
|
"SELECT * FROM sms_messages WHERE session_id=? ORDER BY id", (sid,)).fetchall()]
|
||||||
|
d.close()
|
||||||
|
return jsonify({
|
||||||
|
"id": sess["id"],
|
||||||
|
"number": sess["number"],
|
||||||
|
"service": sess["service_name"],
|
||||||
|
"country": sess["country_name"],
|
||||||
|
"status": sess["status"],
|
||||||
|
"expires_at": sess["expires_at"],
|
||||||
|
"time_remaining": max(0, sess["expires_at"] - now),
|
||||||
|
"messages": msgs,
|
||||||
|
"code": msgs[-1]["detected_code"] if msgs and msgs[-1]["detected_code"] else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.route("/sms/session/<int:sid>/release", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def sms_release(sid):
|
||||||
|
u = current_user()
|
||||||
|
d = db.get_db()
|
||||||
|
row = d.execute("SELECT * FROM sms_sessions WHERE id=? AND user_id=?", (sid, u["id"])).fetchone()
|
||||||
|
if not row:
|
||||||
|
d.close()
|
||||||
|
abort(404)
|
||||||
|
if row["status"] in ("active", "sms_received"):
|
||||||
|
try:
|
||||||
|
PROVIDER.release_number(row["provider_order_id"])
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
d.execute("UPDATE sms_sessions SET status='released' WHERE id=?", (sid,))
|
||||||
|
d.commit()
|
||||||
|
flash("Number released.", "ok")
|
||||||
|
d.close()
|
||||||
|
return redirect(url_for("sms_home"))
|
||||||
|
|
||||||
|
# ----- admin -----
|
||||||
|
@app.route("/admin/sms", methods=["GET", "POST"])
|
||||||
|
def sms_admin():
|
||||||
|
if not session.get("admin"):
|
||||||
|
abort(403)
|
||||||
|
if request.method == "POST":
|
||||||
|
changed = 0
|
||||||
|
for p in get_pricing():
|
||||||
|
val = (request.form.get("price_%d" % p["duration_min"]) or "").strip()
|
||||||
|
if val:
|
||||||
|
try:
|
||||||
|
price = int(val)
|
||||||
|
d = db.get_db()
|
||||||
|
d.execute("UPDATE sms_pricing SET price_sats=? WHERE duration_min=?",
|
||||||
|
(price, p["duration_min"]))
|
||||||
|
d.commit()
|
||||||
|
d.close()
|
||||||
|
changed += 1
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
flash("Updated %d pricing rows." % changed, "ok")
|
||||||
|
return redirect(url_for("sms_admin"))
|
||||||
|
|
||||||
|
d = db.get_db()
|
||||||
|
stats = {
|
||||||
|
"active": d.execute("SELECT COUNT(*) c FROM sms_sessions WHERE status IN ('active','sms_received')").fetchone()["c"],
|
||||||
|
"total_sessions": d.execute("SELECT COUNT(*) c FROM sms_sessions").fetchone()["c"],
|
||||||
|
"sms_received": d.execute("SELECT COUNT(*) c FROM sms_messages").fetchone()["c"],
|
||||||
|
"revenue_sats": d.execute("SELECT COALESCE(SUM(amount_sats),0) s FROM transactions WHERE type='sms'").fetchone()["s"],
|
||||||
|
"provider_cost": d.execute("SELECT COALESCE(SUM(provider_cost),0) s FROM sms_sessions").fetchone()["s"],
|
||||||
|
}
|
||||||
|
sessions = [dict(r) for r in d.execute(
|
||||||
|
"SELECT s.*, u.username FROM sms_sessions s LEFT JOIN users u ON u.id=s.user_id "
|
||||||
|
"ORDER BY s.id DESC LIMIT 50").fetchall()]
|
||||||
|
pricing = get_pricing()
|
||||||
|
d.close()
|
||||||
|
try:
|
||||||
|
smspool_balance = PROVIDER.get_balance()
|
||||||
|
except Exception:
|
||||||
|
smspool_balance = None
|
||||||
|
return render_template("sms_admin.html", stats=stats, sessions=sessions,
|
||||||
|
pricing=pricing, smspool_balance=smspool_balance)
|
||||||
@@ -18,6 +18,12 @@
|
|||||||
<div class="card stat"><span class="lbl">Dedicated IPs</span><span class="val">{{ dedicated|length }}</span></div>
|
<div class="card stat"><span class="lbl">Dedicated IPs</span><span class="val">{{ dedicated|length }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-top:18px">
|
||||||
|
<h3>Temporary SMS</h3>
|
||||||
|
<p class="muted">Manage the temp-number marketplace: pricing, sessions, provider balance.</p>
|
||||||
|
<a href="/admin/sms" style="display:inline-block;background:linear-gradient(135deg,#0ff,#0af);color:#00141a;padding:10px 18px;border-radius:10px;font-weight:700;text-decoration:none">Open SMS admin →</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card" style="margin-top:18px">
|
<div class="card" style="margin-top:18px">
|
||||||
<h3>Credit a user</h3>
|
<h3>Credit a user</h3>
|
||||||
<form method="post" action="/admin/credit" style="display:flex;gap:10px;align-items:flex-end">
|
<form method="post" action="/admin/credit" style="display:flex;gap:10px;align-items:flex-end">
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
<a href="/plans">Residential</a>
|
<a href="/plans">Residential</a>
|
||||||
<a href="/dedicated">Dedicated IPs</a>
|
<a href="/dedicated">Dedicated IPs</a>
|
||||||
<a href="/hydraproxy">HydraProxy</a>
|
<a href="/hydraproxy">HydraProxy</a>
|
||||||
|
<a href="/sms">SMS</a>
|
||||||
{% if user %}<a href="/dashboard">Dashboard</a><a href="/wallet">Wallet</a><a href="/quality">Quality</a><a href="/logout">Logout</a>
|
{% if user %}<a href="/dashboard">Dashboard</a><a href="/wallet">Wallet</a><a href="/quality">Quality</a><a href="/logout">Logout</a>
|
||||||
{% else %}<a href="/login">Login</a><a class="cta" href="/signup">Get proxies</a>{% endif %}
|
{% else %}<a href="/login">Login</a><a class="cta" href="/signup">Get proxies</a>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
179
templates/sms.html
Normal file
179
templates/sms.html
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Temp SMS — Clean Proxys{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div class="sms-wrap">
|
||||||
|
<header class="sms-head">
|
||||||
|
<h1>Temporary <b>SMS</b></h1>
|
||||||
|
<p class="sub">Rent a real number for a few minutes to receive a verification code. No outbound. Pay with your wallet balance.</p>
|
||||||
|
<p class="disclaimer">Availability depends on the service you're verifying with. Some services reject temporary, VoIP, or recycled numbers.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if session %}
|
||||||
|
<section class="card sms-active" id="active-session" data-sid="{{ session.id }}">
|
||||||
|
<div class="sms-number-row">
|
||||||
|
<div>
|
||||||
|
<span class="label">NUMBER</span>
|
||||||
|
<div class="sms-number" id="s-number">+{{ session.country_cc }} {{ session.number }}</div>
|
||||||
|
<div class="sms-meta">{{ session.service }} · {{ session.country_name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sms-timer">
|
||||||
|
<span class="label">TIME REMAINING</span>
|
||||||
|
<div class="sms-count" id="s-time">--:--</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sms-status-row">
|
||||||
|
<span class="status-pill" id="s-status">{{ session.status }}</span>
|
||||||
|
<span class="code-out" id="s-code"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sms-msgs" id="s-messages">
|
||||||
|
<div class="sms-waiting" id="s-waiting">Waiting for incoming message…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sms-actions">
|
||||||
|
<form method="POST" action="/sms/session/{{ session.id }}/release">
|
||||||
|
<button class="btn ghost" type="submit">Release number</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="card sms-form">
|
||||||
|
<h2>Get a number</h2>
|
||||||
|
<form method="POST" action="/sms/rent" class="sms-form-grid">
|
||||||
|
<div class="field">
|
||||||
|
<label>Country</label>
|
||||||
|
<select name="country_id" required>
|
||||||
|
{% for c in countries %}
|
||||||
|
<option value="{{ c.id }}">{{ c.name }} (+{{ c.cc }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Service</label>
|
||||||
|
<select name="service_id" id="service-select" required>
|
||||||
|
{% for sid, name in popular %}
|
||||||
|
<option value="{{ sid }}">{{ name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Duration</label>
|
||||||
|
<select name="duration" id="duration-select" required>
|
||||||
|
{% for p in pricing %}
|
||||||
|
<option value="{{ p.duration_min }}" data-sats="{{ p.price_sats }}">{{ p.duration_min }} min</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="field price-field">
|
||||||
|
<label>Price</label>
|
||||||
|
<div class="sms-price" id="price-out">— sats</div>
|
||||||
|
<div class="balance-note">Wallet: {{ balance }} sats</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn primary" type="submit" id="rent-btn">GET NUMBER</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sms-wrap{max-width:720px;margin:0 auto;padding:24px 0}
|
||||||
|
.sms-head h1{font-size:2.4rem;letter-spacing:-.5px}
|
||||||
|
.sms-head h1 b{color:var(--accent,#0ff)}
|
||||||
|
.sms-head .sub{color:var(--text-muted,#94a3b8);margin:8px 0}
|
||||||
|
.sms-head .disclaimer{font-size:.85rem;color:#f59e0b;margin-top:8px}
|
||||||
|
.card{background:rgba(20,28,40,.6);border:1px solid rgba(255,255,255,.08);border-radius:16px;padding:22px;margin:18px 0}
|
||||||
|
.sms-number-row{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap}
|
||||||
|
.label{font-size:.72rem;letter-spacing:.12em;color:var(--text-muted,#94a3b8);text-transform:uppercase}
|
||||||
|
.sms-number{font-family:'JetBrains Mono',monospace;font-size:2rem;font-weight:700;color:#fff}
|
||||||
|
.sms-meta{color:var(--text-muted,#94a3b8);font-size:.9rem;margin-top:4px}
|
||||||
|
.sms-timer{text-align:right}
|
||||||
|
.sms-count{font-family:'JetBrains Mono',monospace;font-size:2rem;font-weight:700;color:var(--accent,#0ff)}
|
||||||
|
.sms-status-row{display:flex;align-items:center;gap:12px;margin:18px 0 12px}
|
||||||
|
.status-pill{font-family:'JetBrains Mono',monospace;font-size:.78rem;padding:4px 12px;border-radius:999px;background:rgba(0,229,255,.12);color:#0ff;text-transform:uppercase;letter-spacing:.06em}
|
||||||
|
.code-out{font-family:'JetBrains Mono',monospace;font-size:1.3rem;color:#4ade80}
|
||||||
|
.sms-msgs{background:rgba(0,0,0,.3);border-radius:10px;padding:14px;min-height:70px}
|
||||||
|
.sms-waiting{color:var(--text-muted,#94a3b8)}
|
||||||
|
.sms-msg{margin-bottom:10px}
|
||||||
|
.sms-msg .from{font-size:.75rem;color:var(--text-muted,#94a3b8)}
|
||||||
|
.sms-msg .body{font-family:'JetBrains Mono',monospace;color:#e2e8f0;margin-top:2px}
|
||||||
|
.sms-msg .code-tag{display:inline-block;margin-top:6px;background:rgba(74,222,128,.15);color:#4ade80;padding:2px 10px;border-radius:6px;font-family:'JetBrains Mono',monospace}
|
||||||
|
.sms-actions{margin-top:14px}
|
||||||
|
.sms-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||||||
|
.field{display:flex;flex-direction:column;gap:6px}
|
||||||
|
.field label{font-size:.8rem;color:var(--text-muted,#94a3b8)}
|
||||||
|
select{background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.12);color:#fff;padding:11px;border-radius:10px;font-family:'JetBrains Mono',monospace}
|
||||||
|
.sms-price{font-family:'JetBrains Mono',monospace;font-size:1.4rem;color:#0ff}
|
||||||
|
.balance-note{font-size:.78rem;color:var(--text-muted,#94a3b8)}
|
||||||
|
.btn{font-family:inherit;font-weight:700;border:none;border-radius:10px;padding:12px 18px;cursor:pointer;letter-spacing:.02em}
|
||||||
|
.btn.primary{background:linear-gradient(135deg,#0ff,#0af);color:#00141a}
|
||||||
|
.btn.ghost{background:transparent;border:1px solid rgba(255,255,255,.2);color:#fff}
|
||||||
|
.btn:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
// price preview
|
||||||
|
const dur = document.getElementById('duration-select');
|
||||||
|
const priceOut = document.getElementById('price-out');
|
||||||
|
function updPrice(){
|
||||||
|
const o = dur.options[dur.selectedIndex];
|
||||||
|
if (o && o.dataset.sats) priceOut.textContent = o.dataset.sats + ' sats';
|
||||||
|
}
|
||||||
|
if (dur){ dur.addEventListener('change', updPrice); updPrice(); }
|
||||||
|
|
||||||
|
// live session polling
|
||||||
|
const active = document.getElementById('active-session');
|
||||||
|
if (active){
|
||||||
|
const sid = active.dataset.sid;
|
||||||
|
const tEl = document.getElementById('s-time');
|
||||||
|
const sEl = document.getElementById('s-status');
|
||||||
|
const cEl = document.getElementById('s-code');
|
||||||
|
const mEl = document.getElementById('s-messages');
|
||||||
|
const wEl = document.getElementById('s-waiting');
|
||||||
|
let lastMsgs = 0;
|
||||||
|
|
||||||
|
function fmt(s){
|
||||||
|
s = Math.max(0, s);
|
||||||
|
const m = Math.floor(s/60), ss = s%60;
|
||||||
|
return (m<10?'0':'')+m+':'+(ss<10?'0':'')+ss;
|
||||||
|
}
|
||||||
|
|
||||||
|
function poll(){
|
||||||
|
fetch('/sms/session/'+sid+'/poll', {cache:'no-store'})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (!d || d.error) return;
|
||||||
|
tEl.textContent = fmt(d.time_remaining);
|
||||||
|
sEl.textContent = d.status;
|
||||||
|
if (d.messages && d.messages.length){
|
||||||
|
if (wEl) wEl.remove();
|
||||||
|
if (d.messages.length !== lastMsgs){
|
||||||
|
lastMsgs = d.messages.length;
|
||||||
|
mEl.innerHTML = '';
|
||||||
|
d.messages.forEach(msg => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'sms-msg';
|
||||||
|
div.innerHTML = '<div class="from">FROM +' + (msg.from_number||'?') + '</div>' +
|
||||||
|
'<div class="body"></div>' +
|
||||||
|
(msg.detected_code ? '<span class="code-tag">' + msg.detected_code + '</span>' : '');
|
||||||
|
div.querySelector('.body').textContent = msg.body;
|
||||||
|
mEl.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
cEl.textContent = d.code ? ('CODE: ' + d.code) : '';
|
||||||
|
}
|
||||||
|
if (d.status === 'expired' || d.status === 'released'){
|
||||||
|
sEl.textContent = d.status + ' — refresh';
|
||||||
|
setTimeout(() => location.reload(), 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(poll, 4000);
|
||||||
|
})
|
||||||
|
.catch(() => setTimeout(poll, 5000));
|
||||||
|
}
|
||||||
|
poll();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
47
templates/sms_admin.html
Normal file
47
templates/sms_admin.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}SMS Admin — Clean Proxys{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
<div style="max-width:900px;margin:0 auto;padding:24px 0">
|
||||||
|
<h1 style="font-size:1.9rem;letter-spacing:-.5px">SMS <b style="color:#0ff">Admin</b></h1>
|
||||||
|
<p style="color:#94a3b8;margin:6px 0 16px">SMSPool balance: <b style="color:#0ff">{{ smspool_balance if smspool_balance is not none else 'unavailable' }} USD</b></p>
|
||||||
|
|
||||||
|
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px;margin-bottom:20px">
|
||||||
|
<div style="background:rgba(20,28,40,.6);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:14px"><div style="font-size:.7rem;color:#94a3b8;letter-spacing:.1em">ACTIVE</div><div style="font-size:1.6rem;color:#fff">{{ stats.active }}</div></div>
|
||||||
|
<div style="background:rgba(20,28,40,.6);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:14px"><div style="font-size:.7rem;color:#94a3b8;letter-spacing:.1em">SESSIONS</div><div style="font-size:1.6rem;color:#fff">{{ stats.total_sessions }}</div></div>
|
||||||
|
<div style="background:rgba(20,28,40,.6);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:14px"><div style="font-size:.7rem;color:#94a3b8;letter-spacing:.1em">SMS RECEIVED</div><div style="font-size:1.6rem;color:#fff">{{ stats.sms_received }}</div></div>
|
||||||
|
<div style="background:rgba(20,28,40,.6);border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:14px"><div style="font-size:.7rem;color:#94a3b8;letter-spacing:.1em">REVENUE</div><div style="font-size:1.6rem;color:#4ade80">{{ stats.revenue_sats }} sats</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 style="font-size:1.2rem;margin:20px 0 10px">Pricing (sats)</h2>
|
||||||
|
<form method="POST" action="/admin/sms" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;margin-bottom:24px">
|
||||||
|
{% for p in pricing %}
|
||||||
|
<div>
|
||||||
|
<label style="display:block;font-size:.72rem;color:#94a3b8">{{ p.duration_min }} min</label>
|
||||||
|
<input name="price_{{ p.duration_min }}" value="{{ p.price_sats }}" style="width:90px;background:rgba(0,0,0,.4);border:1px solid rgba(255,255,255,.15);color:#fff;padding:8px;border-radius:8px;font-family:monospace">
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" style="background:linear-gradient(135deg,#0ff,#0af);color:#00141a;border:none;padding:9px 16px;border-radius:8px;font-weight:700;cursor:pointer">Save prices</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2 style="font-size:1.2rem;margin:16px 0 10px">Recent sessions</h2>
|
||||||
|
<table style="width:100%;border-collapse:collapse;font-size:.82rem">
|
||||||
|
<thead><tr style="text-align:left;color:#94a3b8">
|
||||||
|
<th style="padding:6px">ID</th><th>User</th><th>Number</th><th>Service</th><th>Status</th><th>Price</th><th>Cost</th><th>Created</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for s in sessions %}
|
||||||
|
<tr style="border-top:1px solid rgba(255,255,255,.06)">
|
||||||
|
<td style="padding:6px">{{ s.id }}</td>
|
||||||
|
<td>{{ s.username }}</td>
|
||||||
|
<td style="font-family:monospace">{{ s.number }}</td>
|
||||||
|
<td>{{ s.service_name }}</td>
|
||||||
|
<td><span style="color:{{ '#4ade80' if s.status == 'sms_received' else '#0ff' if s.status == 'active' else '#94a3b8' }}">{{ s.status }}</span></td>
|
||||||
|
<td>{{ s.price_sats }}</td>
|
||||||
|
<td>${{ s.provider_cost }}</td>
|
||||||
|
<td style="color:#94a3b8">{{ s.created_at }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user