/** * session.js — Workshop Session Mode UI (Fund once, ask many) * * Flow: * 1. User clicks "⚡ FUND SESSION" → left panel slides in * 2. Picks amount (200–5000 sats) → POST /api/sessions → deposit invoice * 3. Pays invoice (stub: "Simulate Payment") → 2 s polling until state=active * 4. Macaroon + sessionId stored in localStorage; input bar activates (green border) * 5. Every Enter/Send in the input bar → POST /api/sessions/:id/request * 6. Timmy's response shown in speech bubble, balance ticks down in HUD * 7. Balance < 50 sats → low-balance notice; Top Up button reopens panel topup step * 8. On page reload: localStorage is read, session is validated, UI restored */ import { setSpeechBubble, setMood } from './agents.js'; import { appendSystemMessage, setSessionSendHandler, setInputBarSessionMode } from './ui.js'; import { getOrRefreshToken } from './nostr-identity.js'; import { sentiment } from './edge-worker-client.js'; const API = '/api'; const LS_KEY = 'timmy_session_v1'; const POLL_MS = 2000; const POLL_TIMEOUT = 60_000; const MIN_BALANCE = 50; // ── Module state ────────────────────────────────────────────────────────────── let _panel = null; let _sessionId = null; let _macaroon = null; let _balanceSats = 0; let _sessionState = null; // null | 'awaiting_payment' | 'active' | 'paused' | 'expired' let _pollTimer = null; let _inFlight = false; let _selectedSats = 500; // deposit amount selection let _topupSats = 500; // topup amount selection // ── Public API ──────────────────────────────────────────────────────────────── export function initSessionPanel() { _panel = document.getElementById('session-panel'); if (!_panel) return; // Buttons _on('open-session-btn', 'click', _openPanel); _on('session-close', 'click', _closePanel); _on('session-create-btn', 'click', _createSession); _on('session-pay-btn', 'click', _payDeposit); _on('session-topup-btn', 'click', () => _setStep('topup')); _on('session-topup-create-btn','click', _createTopup); _on('session-topup-pay-btn', 'click', _payTopup); _on('session-back-btn', 'click', () => _setStep('active')); _on('topup-quick-btn', 'click', () => { _openPanel(); _setStep('topup'); }); _on('session-hud-topup', 'click', (e) => { e.preventDefault(); _openPanel(); _setStep('topup'); }); // Amount preset buttons — deposit (quick-fill the number input) _panel.querySelectorAll('[data-session-step="fund"] .session-amount-btn').forEach(btn => { btn.addEventListener('click', () => { _panel.querySelectorAll('[data-session-step="fund"] .session-amount-btn') .forEach(b => b.classList.remove('active')); btn.classList.add('active'); _selectedSats = parseInt(btn.dataset.sats, 10); const inp = document.getElementById('session-amount-input'); if (inp) inp.value = _selectedSats; }); }); // Free-text number input — deposit document.getElementById('session-amount-input')?.addEventListener('input', () => { const v = parseInt(document.getElementById('session-amount-input').value, 10); if (Number.isFinite(v)) { _selectedSats = v; _panel.querySelectorAll('[data-session-step="fund"] .session-amount-btn') .forEach(b => b.classList.toggle('active', parseInt(b.dataset.sats, 10) === v)); } }); // Amount preset buttons — topup (quick-fill the topup number input) _panel.querySelectorAll('[data-session-step="topup"] .session-amount-btn').forEach(btn => { btn.addEventListener('click', () => { _panel.querySelectorAll('[data-session-step="topup"] .session-amount-btn') .forEach(b => b.classList.remove('active')); btn.classList.add('active'); _topupSats = parseInt(btn.dataset.sats, 10); const inp = document.getElementById('session-topup-input'); if (inp) inp.value = _topupSats; }); }); // Free-text number input — topup document.getElementById('session-topup-input')?.addEventListener('input', () => { const v = parseInt(document.getElementById('session-topup-input').value, 10); if (Number.isFinite(v)) { _topupSats = v; _panel.querySelectorAll('[data-session-step="topup"] .session-amount-btn') .forEach(b => b.classList.toggle('active', parseInt(b.dataset.sats, 10) === v)); } }); // Try to restore from localStorage on init _tryRestore(); } export function isSessionActive() { return _sessionState === 'active' || _sessionState === 'paused'; } // Called by ui.js when user submits the input bar while session is active export async function sessionSendHandler(text) { if (!_sessionId || !_macaroon || _inFlight) return; _inFlight = true; _setSendBusy(true); appendSystemMessage(`you: ${text}`); // Attach Nostr token if available const nostrToken = await getOrRefreshToken('/api'); const reqHeaders = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${_macaroon}`, }; if (nostrToken) reqHeaders['X-Nostr-Token'] = nostrToken; try { const res = await fetch(`${API}/sessions/${_sessionId}/request`, { method: 'POST', headers: reqHeaders, body: JSON.stringify({ request: text }), }); const data = await res.json(); if (res.status === 402) { _balanceSats = data.balance ?? 0; _sessionState = 'paused'; _saveToStorage(); _applySessionUI(); setSpeechBubble('My energy is depleted... top me up to continue!'); appendSystemMessage('⚡ Low balance — tap Top Up'); return; } if (res.status === 401) { // Macaroon rejected — stale session, clear and reset _clearSession(); appendSystemMessage('Session expired — fund a new one.'); return; } if (!res.ok) { appendSystemMessage('Session error: ' + (data.error || res.status)); return; } _balanceSats = data.balanceRemaining ?? 0; _sessionState = _balanceSats < MIN_BALANCE ? 'paused' : 'active'; _saveToStorage(); _applySessionUI(); const reply = data.result || data.reason || '…'; setSpeechBubble(reply); appendSystemMessage('Timmy: ' + reply.slice(0, 80)); // Sentiment-driven mood on inbound Timmy reply sentiment(reply).then(s => { setMood(s.label); setTimeout(() => setMood(null), 10_000); }).catch(() => {}); // Update active-step balance if panel is open _updateActiveStep(); } catch (err) { appendSystemMessage('Session error: ' + err.message); } finally { _inFlight = false; _setSendBusy(false); } } // ── Panel open/close ────────────────────────────────────────────────────────── function _openPanel() { if (!_panel) return; _panel.classList.add('open'); _clearError(); if (_sessionState === 'active') { _setStep('active'); _updateActiveStep(); } else if (_sessionState === 'paused') { _setStep('topup'); } else { _setStep('fund'); } } function _closePanel() { _panel?.classList.remove('open'); _stopPolling(); } // ── Create session ──────────────────────────────────────────────────────────── async function _createSession() { _clearError(); // Read from the number input (may differ from the last preset clicked) const inp = document.getElementById('session-amount-input'); if (inp) { const v = parseInt(inp.value, 10); if (Number.isFinite(v)) _selectedSats = v; } if (_selectedSats < 200 || _selectedSats > 10_000) { _setError('Amount must be 200–10,000 sats.'); return; } _setStatus('fund', 'creating session…', '#ffaa00'); _btn('session-create-btn', true); try { const nostrToken = await getOrRefreshToken('/api'); const createHeaders = { 'Content-Type': 'application/json' }; if (nostrToken) createHeaders['X-Nostr-Token'] = nostrToken; const res = await fetch(`${API}/sessions`, { method: 'POST', headers: createHeaders, body: JSON.stringify({ amount_sats: _selectedSats }), }); const data = await res.json(); if (!res.ok) { _setError(data.error || 'Failed to create session.'); return; } _sessionId = data.sessionId; // Show deposit invoice const inv = data.invoice; _el('session-invoice-amount').textContent = inv.amountSats + ' sats'; _el('session-invoice-pr').textContent = inv.paymentRequest || ''; const hashEl = _el('session-invoice-hash'); if (hashEl) hashEl.dataset.hash = inv.paymentHash || ''; _setStep('invoice'); _setStatus('invoice', '⚡ Awaiting deposit…', '#ffaa00'); } catch (err) { _setError('Network error: ' + err.message); } finally { _btn('session-create-btn', false); } } // ── Pay deposit (stub) ──────────────────────────────────────────────────────── async function _payDeposit() { const hash = _el('session-invoice-hash')?.dataset.hash; if (!hash) { _setError('No payment hash — use real Lightning?'); return; } _btn('session-pay-btn', true); _setStatus('invoice', 'Simulating payment…', '#ffaa00'); try { const res = await fetch(`${API}/dev/stub/pay/${hash}`, { method: 'POST' }); const ok = (await res.json()).ok; if (!ok) { _setError('Payment simulation failed.'); return; } _startDepositPolling(); } catch (err) { _setError('Payment error: ' + err.message); } finally { _btn('session-pay-btn', false); } } // ── Deposit polling ─────────────────────────────────────────────────────────── function _startDepositPolling() { _stopPolling(); const deadline = Date.now() + POLL_TIMEOUT; async function poll() { if (!_sessionId) return; try { const nostrToken = await getOrRefreshToken('/api'); const pollHeaders = nostrToken ? { 'X-Nostr-Token': nostrToken } : {}; const res = await fetch(`${API}/sessions/${_sessionId}`, { headers: pollHeaders }); const data = await res.json(); if (data.state === 'active') { _macaroon = data.macaroon; _balanceSats = data.balanceSats; _sessionState = 'active'; _saveToStorage(); _applySessionUI(); _closePanel(); // panel auto-closes; user types in input bar appendSystemMessage(`Session active — ${_balanceSats} sats`); return; } } catch { /* network hiccup */ } if (Date.now() > deadline) { _setError('Timed out waiting for payment.'); return; } _pollTimer = setTimeout(poll, POLL_MS); } _pollTimer = setTimeout(poll, POLL_MS); } // ── Topup ───────────────────────────────────────────────────────────────────── async function _createTopup() { if (!_sessionId || !_macaroon) return; _clearError(); // Read from the topup number input const inp = document.getElementById('session-topup-input'); if (inp) { const v = parseInt(inp.value, 10); if (Number.isFinite(v)) _topupSats = v; } if (_topupSats < 200 || _topupSats > 10_000) { _setError('Topup amount must be 200–10,000 sats.'); return; } _setStatus('topup', 'creating topup invoice…', '#ffaa00'); _btn('session-topup-create-btn', true); try { const nostrToken = await getOrRefreshToken('/api'); const topupHeaders = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${_macaroon}`, }; if (nostrToken) topupHeaders['X-Nostr-Token'] = nostrToken; const res = await fetch(`${API}/sessions/${_sessionId}/topup`, { method: 'POST', headers: topupHeaders, body: JSON.stringify({ amount_sats: _topupSats }), }); const data = await res.json(); if (!res.ok) { // Pending topup already exists — surface it const pending = data.pendingTopup; if (pending) { _el('session-topup-pr').textContent = pending.paymentRequest || ''; const th = _el('session-topup-hash'); if (th) th.dataset.hash = pending.paymentHash || ''; _el('session-topup-pr-row').style.display = ''; _setStatus('topup', '⚡ Previous invoice still pending', '#ffaa00'); return; } _setError(data.error || 'Failed to create topup.'); return; } const inv = data.topup; _el('session-topup-pr').textContent = inv.paymentRequest || ''; const th = _el('session-topup-hash'); if (th) th.dataset.hash = inv.paymentHash || ''; _el('session-topup-pr-row').style.display = ''; _setStatus('topup', '⚡ Awaiting topup payment…', '#ffaa00'); } catch (err) { _setError('Topup error: ' + err.message); } finally { _btn('session-topup-create-btn', false); } } async function _payTopup() { const hash = _el('session-topup-hash')?.dataset.hash; if (!hash) { _setError('No topup hash.'); return; } _btn('session-topup-pay-btn', true); _setStatus('topup', 'Simulating topup payment…', '#ffaa00'); try { const res = await fetch(`${API}/dev/stub/pay/${hash}`, { method: 'POST' }); const ok = (await res.json()).ok; if (!ok) { _setError('Topup simulation failed.'); return; } _startTopupPolling(); } catch (err) { _setError('Topup payment error: ' + err.message); } finally { _btn('session-topup-pay-btn', false); } } function _startTopupPolling() { _stopPolling(); const deadline = Date.now() + POLL_TIMEOUT; const prevBalance = _balanceSats; async function poll() { if (!_sessionId) return; try { const nostrToken = await getOrRefreshToken('/api'); const pollHeaders = nostrToken ? { 'X-Nostr-Token': nostrToken } : {}; const res = await fetch(`${API}/sessions/${_sessionId}`, { headers: pollHeaders }); const data = await res.json(); if (data.balanceSats > prevBalance || data.state === 'active') { _balanceSats = data.balanceSats; _macaroon = data.macaroon || _macaroon; _sessionState = data.state === 'active' ? 'active' : _sessionState; _saveToStorage(); _applySessionUI(); _setStep('active'); _updateActiveStep(); _setStatus('active', `⚡ Topped up! ${_balanceSats} sats`, '#22aa66'); return; } } catch { /* ignore */ } if (Date.now() > deadline) { _setError('Topup timed out.'); return; } _pollTimer = setTimeout(poll, POLL_MS); } _pollTimer = setTimeout(poll, POLL_MS); } // ── Restore from localStorage ───────────────────────────────────────────────── async function _tryRestore() { try { const raw = localStorage.getItem(LS_KEY); if (!raw) return; const { sessionId, macaroon, balanceSats } = JSON.parse(raw); if (!sessionId || !macaroon) return; // Validate the session is still live const nostrToken = await getOrRefreshToken('/api'); const restoreHeaders = nostrToken ? { 'X-Nostr-Token': nostrToken } : {}; const res = await fetch(`${API}/sessions/${sessionId}`, { headers: restoreHeaders }); if (!res.ok) { localStorage.removeItem(LS_KEY); return; } const data = await res.json(); if (data.state !== 'active' && data.state !== 'paused') { localStorage.removeItem(LS_KEY); return; } _sessionId = sessionId; _macaroon = data.macaroon || macaroon; _balanceSats = data.balanceSats ?? balanceSats; _sessionState = data.state; _saveToStorage(); _applySessionUI(); appendSystemMessage(`Session restored (${_balanceSats} sats)`); } catch { // Ignore restore errors silently } } // ── Session UI helpers ──────────────────────────────────────────────────────── function _applySessionUI() { const anyActive = isSessionActive(); // true for both 'active' and 'paused' const canSend = _sessionState === 'active'; // Show low-balance overlay whenever session exists but balance is too low const lowBal = anyActive && _balanceSats < MIN_BALANCE; // HUD balance: "Balance: X sats ⚡ Top Up" const $hud = document.getElementById('session-hud'); if ($hud) { $hud.style.display = anyActive ? '' : 'none'; const $span = document.getElementById('session-hud-balance'); if ($span) $span.textContent = `Balance: ${_balanceSats} sats`; } // Top button label const $btn = document.getElementById('open-session-btn'); if ($btn) { $btn.textContent = anyActive ? `SESSION: ${_balanceSats} ⚡` : '⚡ FUND SESSION'; $btn.style.background = anyActive ? '#1a4a30' : ''; } // Input bar green border + placeholder when session can send setInputBarSessionMode(canSend, 'Ask Timmy (session active)…'); // Route input bar to session handler only when active (not paused) setSessionSendHandler(canSend ? sessionSendHandler : null); // Low balance notice above input bar const $notice = document.getElementById('low-balance-notice'); if ($notice) $notice.style.display = lowBal ? '' : 'none'; } function _updateActiveStep() { const el = document.getElementById('session-active-amount'); if (el) { el.textContent = _balanceSats + ' sats'; el.style.color = _balanceSats < MIN_BALANCE ? '#ff8844' : '#44dd88'; } // Reset topup pr row visibility const prRow = document.getElementById('session-topup-pr-row'); if (prRow) prRow.style.display = 'none'; } function _clearSession() { _sessionId = null; _macaroon = null; _balanceSats = 0; _sessionState = null; localStorage.removeItem(LS_KEY); setInputBarSessionMode(false); setSessionSendHandler(null); const $hud = document.getElementById('session-hud'); if ($hud) $hud.style.display = 'none'; const $btn = document.getElementById('open-session-btn'); if ($btn) { $btn.textContent = '⚡ FUND SESSION'; $btn.style.background = ''; } const $notice = document.getElementById('low-balance-notice'); if ($notice) $notice.style.display = 'none'; } function _saveToStorage() { try { localStorage.setItem(LS_KEY, JSON.stringify({ sessionId: _sessionId, macaroon: _macaroon, balanceSats: _balanceSats, })); } catch { /* storage unavailable */ } } // ── DOM helpers ─────────────────────────────────────────────────────────────── function _el(id) { return document.getElementById(id); } function _on(id, ev, fn) { _el(id)?.addEventListener(ev, fn); } function _btn(id, disabled) { const el = _el(id); if (el) el.disabled = disabled; } function _setSendBusy(busy) { const btn = document.getElementById('send-btn'); if (btn) btn.disabled = busy; } function _setStep(step) { if (!_panel) return; _panel.querySelectorAll('[data-session-step]').forEach(el => { el.style.display = el.dataset.sessionStep === step ? '' : 'none'; }); } function _setStatus(step, msg, color = '#22aa66') { const el = document.getElementById(`session-status-${step}`); if (el) { el.textContent = msg; el.style.color = color; } } function _setError(msg) { const el = _el('session-error'); if (el) el.textContent = msg; } function _clearError() { const el = _el('session-error'); if (el) el.textContent = ''; } function _stopPolling() { clearTimeout(_pollTimer); _pollTimer = null; }