Files
pleiades/sms.py
drjones d7ce96fba4 Clarify 20-min rental ceiling (verified: SMSPool ignores expiry param)
- sms.py: drop misleading expiry param from /purchase/sms (SMSPool hard-caps
  rentals at 1200s/20min regardless); document MAX_RENTAL_MIN as verified
- surface the ceiling in customer UI + admin panel + feasibility doc
2026-09-20 20:11:08 -07:00

555 lines
20 KiB
Python

#!/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):
# `expiry` is passed for API compatibility but SMSPool ignores it —
# every rental hard-caps at 20 min (see MAX_RENTAL_MIN).
return self._get("purchase/sms", {"service": service, "country": country})
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 # VERIFIED 2026-09-20: SMSPool ignores `expiry` param — rental numbers
# hard-cap at 1200s (20 min) regardless of what's requested. Longer
# sessions require a re-rent loop (not yet implemented).
# --------------------------------------------------------------------------
# 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)