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:
drjones
2026-09-20 19:34:25 -07:00
parent 74268306d0
commit 48305b4ad1
6 changed files with 789 additions and 0 deletions

179
templates/sms.html Normal file
View 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 %}