diff --git a/app.py b/app.py index c2bb30f..29c3697 100644 --- a/app.py +++ b/app.py @@ -953,6 +953,11 @@ def sitemap(): 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__": db.init_db() app.run(host="127.0.0.1", port=5000, debug=False) diff --git a/sms.py b/sms.py new file mode 100644 index 0000000..57436c7 --- /dev/null +++ b/sms.py @@ -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//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//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) diff --git a/templates/admin.html b/templates/admin.html index 5f646ba..1558432 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -18,6 +18,12 @@
Dedicated IPs{{ dedicated|length }}
+
+

Temporary SMS

+

Manage the temp-number marketplace: pricing, sessions, provider balance.

+ Open SMS admin → +
+

Credit a user

diff --git a/templates/base.html b/templates/base.html index ca8758b..9d55006 100644 --- a/templates/base.html +++ b/templates/base.html @@ -40,6 +40,7 @@ Residential Dedicated IPs HydraProxy + SMS {% if user %}DashboardWalletQualityLogout {% else %}LoginGet proxies{% endif %}
diff --git a/templates/sms.html b/templates/sms.html new file mode 100644 index 0000000..7370ff9 --- /dev/null +++ b/templates/sms.html @@ -0,0 +1,179 @@ +{% extends "base.html" %} +{% block title %}Temp SMS — Clean Proxys{% endblock %} +{% block body %} +
+
+

Temporary SMS

+

Rent a real number for a few minutes to receive a verification code. No outbound. Pay with your wallet balance.

+

Availability depends on the service you're verifying with. Some services reject temporary, VoIP, or recycled numbers.

+
+ + {% if session %} +
+
+
+ NUMBER +
+{{ session.country_cc }} {{ session.number }}
+
{{ session.service }} · {{ session.country_name }}
+
+
+ TIME REMAINING +
--:--
+
+
+ +
+ {{ session.status }} + +
+ +
+
Waiting for incoming message…
+
+ +
+ + + +
+
+ {% endif %} + +
+

Get a number

+
+
+ + +
+
+ + +
+
+ + +
+
+ +
— sats
+
Wallet: {{ balance }} sats
+
+ +
+
+
+ + + + +{% endblock %} diff --git a/templates/sms_admin.html b/templates/sms_admin.html new file mode 100644 index 0000000..22bb1c7 --- /dev/null +++ b/templates/sms_admin.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}SMS Admin — Clean Proxys{% endblock %} +{% block body %} +
+

SMS Admin

+

SMSPool balance: {{ smspool_balance if smspool_balance is not none else 'unavailable' }} USD

+ +
+
ACTIVE
{{ stats.active }}
+
SESSIONS
{{ stats.total_sessions }}
+
SMS RECEIVED
{{ stats.sms_received }}
+
REVENUE
{{ stats.revenue_sats }} sats
+
+ +

Pricing (sats)

+
+ {% for p in pricing %} +
+ + +
+ {% endfor %} + +
+ +

Recent sessions

+ + + + + + {% for s in sessions %} + + + + + + + + + + + {% endfor %} + +
IDUserNumberServiceStatusPriceCostCreated
{{ s.id }}{{ s.username }}{{ s.number }}{{ s.service_name }}{{ s.status }}{{ s.price_sats }}${{ s.provider_cost }}{{ s.created_at }}
+
+{% endblock %}