commit 017ddc0848b466dc9ca1c8cac4ceafa68387755f Author: nehashivankar@gofynd.com Date: Tue Apr 28 07:56:23 2026 +0000 Add WhatsApp AI Agent — full-stack MVP Backend: Express + Socket.io server with whatsapp-web.js session management, in-memory inbound/outbound queues with idempotency, exponential-backoff retry, and Claude AI draft generation via Anthropic SDK. Frontend: Modern dark-theme single-page dashboard with session status card, real-time inbox, conversation view, AI compose box, and operations panel. Demo mode (DEMO_MODE=true) runs with sample data, no real WhatsApp needed. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..996dacb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +package-lock.json +.wwebjs_auth/ diff --git a/boltic.yaml b/boltic.yaml new file mode 100644 index 0000000..dff7f36 --- /dev/null +++ b/boltic.yaml @@ -0,0 +1,24 @@ +app: whatsapp-ai-agent +region: asia-south1 +entrypoint: server.js +build: + builtin: nodejs +serverlessConfig: + env: + DEMO_MODE: "true" + ANTHROPIC_API_KEY: "your-api-key-here" + portMap: + - Name: http + Port: 8080 + Protocol: http + resources: + CPU: 1 + MemoryMB: 2048 + MemoryMaxMB: 3072 + DiskMB: 4096 + timeout: 3600 + scaling: + AutoStop: false + Min: 1 + Max: 1 + MaxIdleTime: 3600 diff --git a/package.json b/package.json new file mode 100644 index 0000000..19657d0 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "whatsapp-ai-agent", + "version": "1.0.0", + "description": "WhatsApp Web AI Messaging Agent", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.40.0", + "express": "^4.18.2", + "socket.io": "^4.7.4", + "qrcode": "^1.5.3", + "whatsapp-web.js": "^1.23.0", + "uuid": "^9.0.0" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..4c545a4 --- /dev/null +++ b/public/app.js @@ -0,0 +1,447 @@ +/* ── Socket ──────────────────────────────────────────────────────────────── */ +const socket = io(); + +/* ── State ───────────────────────────────────────────────────────────────── */ +let state = { + session: 'disconnected', + conversations: [], + selectedChatId: null, + stats: {}, + sending: false, + aiLoading: false, + demoMode: false +}; + +/* ── DOM Refs ─────────────────────────────────────────────────────────────── */ +const $ = id => document.getElementById(id); +const loadingScreen = $('loading-screen'); +const qrScreen = $('qr-screen'); +const degradedScreen = $('degraded-screen'); +const dashboard = $('dashboard'); +const qrImg = $('qr-img'); +const qrOverlay = $('qr-overlay'); +const qrConnecting = $('qr-connecting'); +const inboxList = $('inbox-list'); +const convPlaceholder= $('conv-placeholder'); +const convView = $('conv-view'); +const messageList = $('message-list'); +const composeInput = $('compose-input'); +const btnSend = $('btn-send'); +const btnAiDraft = $('btn-ai-draft'); +const aiDraftBar = $('ai-draft-bar'); +const aiDraftStatus = $('ai-draft-status'); +const charCount = $('char-count'); +const opsPanel = $('ops-panel'); +const demoBanner = $('demo-banner'); + +/* ── Utilities ────────────────────────────────────────────────────────────── */ +function formatTime(iso) { + const d = new Date(iso); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} +function formatDate(iso) { + const d = new Date(iso); + const today = new Date(); + const yesterday = new Date(today); yesterday.setDate(today.getDate() - 1); + if (d.toDateString() === today.toDateString()) return 'Today'; + if (d.toDateString() === yesterday.toDateString()) return 'Yesterday'; + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} +function formatRelative(iso) { + const diff = Date.now() - new Date(iso).getTime(); + if (diff < 60000) return 'just now'; + if (diff < 3600000) return `${Math.floor(diff/60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff/3600000)}h ago`; + return new Date(iso).toLocaleDateString([], { month: 'short', day: 'numeric' }); +} +function toast(msg, type = 'green') { + const el = document.createElement('div'); + el.className = `toast ${type}`; + el.innerHTML = `${msg}`; + $('toasts').appendChild(el); + setTimeout(() => el.remove(), 3200); +} + +/* ── Session Badge Helper ────────────────────────────────────────────────── */ +const STATE_BADGE = { + connected: { cls: 'status-connected', text: 'Connected' }, + awaiting_scan:{ cls: 'status-awaiting', text: 'Awaiting Scan' }, + connecting: { cls: 'status-reconnecting', text: 'Connecting' }, + reconnecting: { cls: 'status-reconnecting', text: 'Reconnecting' }, + degraded: { cls: 'status-degraded', text: 'Degraded' }, + disconnected: { cls: 'status-disconnected', text: 'Disconnected' } +}; +function setBadge(el, sessionStr) { + const info = STATE_BADGE[sessionStr] || STATE_BADGE.disconnected; + el.className = `status-badge ${info.cls}`; + el.innerHTML = `${info.text}`; +} + +/* ── Screen Router ────────────────────────────────────────────────────────── */ +function route(sessionStr, qrDataUrl) { + loadingScreen.classList.add('hidden'); + qrScreen.classList.add('hidden'); + degradedScreen.classList.add('hidden'); + dashboard.classList.add('hidden'); + qrConnecting.classList.add('hidden'); + + if (sessionStr === 'awaiting_scan' || sessionStr === 'connecting') { + qrScreen.classList.remove('hidden'); + if (qrDataUrl) { + qrImg.src = qrDataUrl; + qrOverlay.classList.add('hidden'); + } else { + qrOverlay.classList.remove('hidden'); + } + if (sessionStr === 'connecting') { + qrConnecting.classList.remove('hidden'); + } + setBadge($('qr-status-badge'), sessionStr); + } else if (sessionStr === 'degraded') { + degradedScreen.classList.remove('hidden'); + } else if (sessionStr === 'connected') { + dashboard.classList.remove('hidden'); + } else { + // disconnected / reconnecting — show QR screen with no QR + qrScreen.classList.remove('hidden'); + qrOverlay.classList.remove('hidden'); + setBadge($('qr-status-badge'), sessionStr); + } +} + +/* ── Stats Update ─────────────────────────────────────────────────────────── */ +function updateStats(stats) { + state.stats = stats; + if (!stats) return; + + $('ops-inbound-depth').textContent = stats.queueDepth ?? 0; + $('ops-outbound-depth').textContent = stats.outboundDepth ?? 0; + $('ops-send-success').textContent = stats.sendSuccess ?? 0; + $('ops-send-failed').textContent = stats.sendFailed ?? 0; + $('ops-reconnects').textContent = stats.reconnectCount ?? 0; + $('ops-heartbeat').textContent = stats.lastHeartbeat + ? formatRelative(stats.lastHeartbeat) : '—'; + + const workerBusy = stats.workerStatus === 'busy'; + const workerBadge = $('ops-worker-badge'); + workerBadge.className = `status-badge ${workerBusy ? 'status-busy' : 'status-idle'}`; + workerBadge.innerHTML = `${workerBusy ? 'Processing' : 'Idle'}`; +} + +/* ── Inbox Render ─────────────────────────────────────────────────────────── */ +function renderInbox(conversations) { + state.conversations = conversations; + const q = $('inbox-search').value.toLowerCase(); + const filtered = q + ? conversations.filter(c => c.contact.toLowerCase().includes(q) || c.lastPreview?.toLowerCase().includes(q)) + : conversations; + + const totalUnread = conversations.reduce((s, c) => s + (c.unreadCount || 0), 0); + const badge = $('total-unread-badge'); + if (totalUnread > 0) { + badge.textContent = totalUnread; + badge.classList.remove('hidden'); + } else { + badge.classList.add('hidden'); + } + + if (!filtered.length) { + inboxList.innerHTML = '
No conversations
'; + return; + } + + inboxList.innerHTML = filtered.map(conv => ` +
+
${conv.avatar || conv.contact.charAt(0).toUpperCase()}
+
+
+ ${conv.contact} + ${conv.lastActivity ? formatRelative(conv.lastActivity) : ''} +
+
+ ${escHtml(conv.lastPreview || '')} + ${conv.unreadCount > 0 ? `${conv.unreadCount > 9 ? '9+' : conv.unreadCount}` : ''} +
+
+
+ `).join(''); +} + +/* ── Select Chat ──────────────────────────────────────────────────────────── */ +async function selectChat(chatId) { + state.selectedChatId = chatId; + renderInbox(state.conversations); + convPlaceholder.classList.add('hidden'); + convView.classList.remove('hidden'); + + const conv = state.conversations.find(c => c.id === chatId); + if (!conv) return; + + $('conv-avatar').textContent = conv.avatar || conv.contact.charAt(0).toUpperCase(); + $('conv-name').textContent = conv.contact; + $('conv-phone').textContent = conv.phone?.replace('@c.us', '').replace(/(\d{2})(\d{5})(\d{5})/, '+$1 $2 $3'); + + renderMessages(conv.messages); + + // Mark read + fetch(`/api/mark-read/${encodeURIComponent(chatId)}`, { method: 'POST' }); + if (conv.unreadCount > 0) { + conv.unreadCount = 0; + renderInbox(state.conversations); + } + + composeInput.focus(); +} + +/* ── Message Render ───────────────────────────────────────────────────────── */ +function renderMessages(messages) { + if (!messages || !messages.length) { + messageList.innerHTML = '
No messages yet
'; + return; + } + + let html = ''; + let lastDate = ''; + messages.forEach(msg => { + const d = formatDate(msg.timestamp); + if (d !== lastDate) { + html += `
${d}
`; + lastDate = d; + } + const fromMe = msg.fromMe; + html += ` +
+
+ ${escHtml(msg.body)} +
+ ${formatTime(msg.timestamp)} + ${fromMe ? `${msg.state === 'sent' ? '✓✓' : '✓'}` : ''} +
+
+
`; + }); + messageList.innerHTML = html; + messageList.scrollTop = messageList.scrollHeight; +} + +function escHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +/* ── Compose Handlers ─────────────────────────────────────────────────────── */ +composeInput.addEventListener('input', () => { + composeInput.style.height = 'auto'; + composeInput.style.height = Math.min(composeInput.scrollHeight, 140) + 'px'; + const len = composeInput.value.length; + charCount.textContent = len; + btnSend.disabled = len === 0 || state.sending; +}); + +composeInput.addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); } +}); + +btnSend.addEventListener('click', doSend); + +async function doSend() { + const body = composeInput.value.trim(); + if (!body || !state.selectedChatId || state.sending) return; + + state.sending = true; + btnSend.disabled = true; + btnSend.innerHTML = '
'; + + const idempotencyKey = `${state.selectedChatId}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + + try { + const res = await fetch('/api/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chatId: state.selectedChatId, body, idempotencyKey }) + }); + const data = await res.json(); + + if (data.queued) { + // Optimistic UI — add message immediately + const conv = state.conversations.find(c => c.id === state.selectedChatId); + if (conv) { + const msg = { id: idempotencyKey, body, fromMe: true, timestamp: new Date().toISOString(), state: 'sending' }; + conv.messages = conv.messages || []; + conv.messages.push(msg); + conv.lastPreview = body; + conv.lastActivity = msg.timestamp; + renderMessages(conv.messages); + renderInbox(state.conversations); + } + composeInput.value = ''; + composeInput.style.height = 'auto'; + charCount.textContent = '0'; + aiDraftBar.classList.add('hidden'); + toast('Message queued for sending'); + } else if (data.duplicate) { + toast('Duplicate send prevented', 'purple'); + } + } catch { + toast('Failed to queue message', 'red'); + } + + state.sending = false; + btnSend.innerHTML = ' Send'; + btnSend.disabled = composeInput.value.length === 0; +} + +/* ── AI Draft ─────────────────────────────────────────────────────────────── */ +btnAiDraft.addEventListener('click', doAiDraft); + +async function doAiDraft() { + if (!state.selectedChatId || state.aiLoading) return; + + state.aiLoading = true; + btnAiDraft.disabled = true; + aiDraftBar.classList.remove('hidden'); + aiDraftStatus.textContent = 'Generating draft…'; + $('ops-ai-badge').className = 'status-badge status-busy'; + $('ops-ai-badge').innerHTML = 'Generating'; + + try { + const res = await fetch('/api/ai-draft', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chatId: state.selectedChatId }) + }); + const data = await res.json(); + + if (data.draft) { + composeInput.value = data.draft; + composeInput.style.height = 'auto'; + composeInput.style.height = Math.min(composeInput.scrollHeight, 140) + 'px'; + charCount.textContent = data.draft.length; + btnSend.disabled = false; + aiDraftStatus.textContent = 'Draft ready — review before sending'; + composeInput.focus(); + toast('AI draft generated', 'purple'); + } else { + aiDraftStatus.textContent = data.error || 'Failed to generate draft'; + toast(data.error || 'AI draft failed', 'red'); + } + } catch { + aiDraftStatus.textContent = 'AI service unavailable'; + toast('AI service unavailable', 'red'); + } + + state.aiLoading = false; + btnAiDraft.disabled = false; + $('ops-ai-badge').className = 'status-badge status-idle'; + $('ops-ai-badge').innerHTML = 'Ready'; +} + +$('btn-dismiss-draft').addEventListener('click', () => { + aiDraftBar.classList.add('hidden'); +}); + +/* ── Socket Events ────────────────────────────────────────────────────────── */ +socket.on('session_state', ({ state: s, qrDataUrl, stats }) => { + setBadge($('sidebar-session-badge'), s); + setBadge($('ops-session-badge'), s); + $('sidebar-session-text').textContent = STATE_BADGE[s]?.text || s; + updateStats(stats); + route(s, qrDataUrl); + + if (s === 'connected' && state.session !== 'connected') { + toast('WhatsApp session connected'); + } + state.session = s; +}); + +socket.on('inbox_update', (inbox) => { + renderInbox(inbox); + // If current chat got new messages, refresh view + if (state.selectedChatId) { + const conv = inbox.find(c => c.id === state.selectedChatId); + if (conv) renderMessages(conv.messages); + } +}); + +socket.on('new_message', ({ chatId, conversation }) => { + // Update conversation in local state + const idx = state.conversations.findIndex(c => c.id === chatId); + if (idx >= 0) { + state.conversations[idx] = conversation; + } else { + state.conversations.unshift(conversation); + } + + if (chatId === state.selectedChatId) { + renderMessages(conversation.messages); + } + + toast(`New message from ${conversation.contact}`, 'green'); +}); + +socket.on('message_state', (job) => { + // Update optimistic message state + const conv = state.conversations.find(c => c.id === job.chatId); + if (!conv) return; + const msg = conv.messages.find(m => m.id === job.idempotencyKey); + if (msg) { + msg.state = job.state; + if (job.state === 'sent') msg.timestamp = job.sentAt || msg.timestamp; + if (job.chatId === state.selectedChatId) renderMessages(conv.messages); + } + if (job.state === 'failed') toast(`Message failed: ${job.error}`, 'red'); +}); + +socket.on('stats_update', updateStats); + +/* ── Inbox Search ─────────────────────────────────────────────────────────── */ +$('inbox-search').addEventListener('input', () => renderInbox(state.conversations)); + +/* ── Ops Panel Toggle ─────────────────────────────────────────────────────── */ +$('btn-ops-toggle').addEventListener('click', () => { + opsPanel.classList.toggle('open'); +}); + +/* ── Auto-resize compose on load ─────────────────────────────────────────── */ +window.addEventListener('resize', () => { + if (composeInput.value) { + composeInput.style.height = 'auto'; + composeInput.style.height = Math.min(composeInput.scrollHeight, 140) + 'px'; + } +}); + +/* ── Initial Data Load ────────────────────────────────────────────────────── */ +(async () => { + try { + const [sessionRes, inboxRes] = await Promise.all([ + fetch('/api/session'), + fetch('/api/inbox') + ]); + const sessionData = await sessionRes.json(); + const inbox = await inboxRes.json(); + + // Detect demo mode + if (sessionData.demoMode || (sessionData.state === 'connected' && inbox.length > 0)) { + const firstConv = inbox[0]; + if (firstConv?.phone?.includes('@c.us')) { + demoBanner.classList.remove('hidden'); + state.demoMode = true; + } + } + + updateStats(sessionData.stats); + setBadge($('sidebar-session-badge'), sessionData.state); + setBadge($('ops-session-badge'), sessionData.state); + state.session = sessionData.state; + + renderInbox(inbox); + route(sessionData.state, sessionData.qrDataUrl); + } catch (err) { + console.error('Init error', err); + route('disconnected', null); + } +})(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..31b8115 --- /dev/null +++ b/public/index.html @@ -0,0 +1,262 @@ + + + + + + WhatsApp AI Agent + + + + + + + +
+ +
+
+ + + + + + + + + + + +
+ + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..87ad7b7 --- /dev/null +++ b/public/style.css @@ -0,0 +1,520 @@ +/* ── Variables ──────────────────────────────────────────────────────────── */ +:root { + --bg-0: #0d1117; + --bg-1: #161b22; + --bg-2: #21262d; + --bg-hover: #2d333b; + --border: #30363d; + --border-subtle: #21262d; + + --text: #e6edf3; + --text-2: #c9d1d9; + --text-muted: #8b949e; + --text-dim: #6e7681; + + --green: #00d4aa; + --green-dim: rgba(0, 212, 170, 0.12); + --green-border: rgba(0, 212, 170, 0.3); + --purple: #a78bfa; + --purple-dim: rgba(167, 139, 250, 0.12); + --purple-border: rgba(167, 139, 250, 0.3); + --blue: #58a6ff; + --success: #3fb950; + --warning: #d29922; + --danger: #f85149; + --danger-dim: rgba(248, 81, 73, 0.12); + + --radius-sm: 6px; + --radius: 10px; + --radius-lg: 14px; + --radius-xl: 20px; + + --shadow: 0 8px 32px rgba(0,0,0,0.6); + --shadow-sm: 0 2px 8px rgba(0,0,0,0.4); + + --sidebar-w: 280px; + --ops-w: 270px; + --transition: 180ms ease; +} + +/* ── Reset ──────────────────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +html, body { height: 100%; overflow: hidden; } +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + background: var(--bg-0); + color: var(--text); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} +button { cursor: pointer; border: none; background: none; font-family: inherit; } +input, textarea { font-family: inherit; color: var(--text); } +code { font-family: 'SF Mono', 'Fira Code', monospace; font-size: .85em; background: var(--bg-2); padding: 2px 5px; border-radius: 4px; } +.hidden { display: none !important; } + +/* ── Loading Screen ──────────────────────────────────────────────────────── */ +.loading-screen { + position: fixed; inset: 0; + background: var(--bg-0); + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 2rem; z-index: 100; +} + +/* ── Logo ────────────────────────────────────────────────────────────────── */ +.logo-lockup { + display: flex; align-items: center; gap: .6rem; + font-size: 1rem; font-weight: 500; color: var(--text); + white-space: nowrap; +} +.logo-lockup strong { font-weight: 700; } +.logo-icon { + width: 32px; height: 32px; + background: var(--green); + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + color: #000; +} +.logo-icon svg { width: 18px; height: 18px; } +.loading-logo { display: flex; align-items: center; gap: .75rem; font-size: 1.25rem; font-weight: 600; } + +/* ── Spinner ──────────────────────────────────────────────────────────────── */ +.loading-spinner { + width: 36px; height: 36px; + border: 3px solid var(--border); + border-top-color: var(--green); + border-radius: 50%; + animation: spin .8s linear infinite; +} +.loading-spinner.small { width: 18px; height: 18px; border-width: 2px; } +@keyframes spin { to { transform: rotate(360deg); } } + +/* ── Status Badges ───────────────────────────────────────────────────────── */ +.status-badge { + display: inline-flex; align-items: center; gap: .4rem; + padding: .25rem .7rem; + border-radius: 999px; + font-size: .75rem; font-weight: 500; + border: 1px solid transparent; +} +.dot { + width: 6px; height: 6px; border-radius: 50%; + flex-shrink: 0; +} +.status-connected { background: var(--green-dim); border-color: var(--green-border); color: var(--green); } +.status-connected .dot { background: var(--green); animation: pulse-green 2s ease-in-out infinite; } +.status-awaiting { background: rgba(210, 153, 34, .12); border-color: rgba(210, 153, 34, .3); color: var(--warning); } +.status-awaiting .dot { background: var(--warning); } +.status-reconnecting { background: rgba(88, 166, 255, .1); border-color: rgba(88, 166, 255, .25); color: var(--blue); } +.status-reconnecting .dot { background: var(--blue); animation: pulse-blue 1.5s ease-in-out infinite; } +.status-disconnected, .status-degraded { background: var(--danger-dim); border-color: rgba(248,81,73,.25); color: var(--danger); } +.status-disconnected .dot, .status-degraded .dot { background: var(--danger); } +.status-failed { background: var(--danger-dim); border-color: rgba(248,81,73,.25); color: var(--danger); } +.status-failed .dot { background: var(--danger); } +.status-idle { background: var(--bg-2); border-color: var(--border); color: var(--text-muted); } +.status-idle .dot { background: var(--text-muted); } +.status-busy { background: var(--purple-dim); border-color: var(--purple-border); color: var(--purple); } +.status-busy .dot { background: var(--purple); animation: pulse-purple 1s ease-in-out infinite; } + +@keyframes pulse-green { + 0%, 100% { box-shadow: 0 0 0 0 rgba(0,212,170,.4); } + 50% { box-shadow: 0 0 0 4px rgba(0,212,170,0); } +} +@keyframes pulse-blue { + 0%, 100% { box-shadow: 0 0 0 0 rgba(88,166,255,.4); } + 50% { box-shadow: 0 0 0 4px rgba(88,166,255,0); } +} +@keyframes pulse-purple { + 0%, 100% { box-shadow: 0 0 0 0 rgba(167,139,250,.4); } + 50% { box-shadow: 0 0 0 4px rgba(167,139,250,0); } +} + +/* ── QR Screen ───────────────────────────────────────────────────────────── */ +.qr-screen { + position: fixed; inset: 0; + background: var(--bg-0); + display: flex; align-items: center; justify-content: center; + padding: 1.5rem; +} +.qr-card { + background: var(--bg-1); + border: 1px solid var(--border); + border-radius: var(--radius-xl); + padding: 2rem; + width: 100%; max-width: 360px; + box-shadow: var(--shadow); + display: flex; flex-direction: column; gap: 1.25rem; +} +.qr-header { display: flex; align-items: center; justify-content: space-between; } +.qr-body { display: flex; flex-direction: column; align-items: center; gap: 1rem; } +.qr-image-wrap { + position: relative; + width: 240px; height: 240px; + border-radius: var(--radius-lg); + overflow: hidden; + background: white; + padding: 12px; + box-shadow: 0 0 0 1px var(--border), var(--shadow-sm); +} +.qr-image-wrap img { width: 100%; height: 100%; display: block; } +.qr-overlay { + position: absolute; inset: 0; + background: rgba(13,17,23,.92); + display: flex; align-items: center; justify-content: center; + backdrop-filter: blur(4px); +} +.qr-refresh-msg { + display: flex; flex-direction: column; align-items: center; gap: .5rem; + color: var(--text-muted); font-size: .85rem; +} +.qr-refresh-msg svg { width: 28px; height: 28px; color: var(--warning); } +.qr-hint { color: var(--text-muted); font-size: .85rem; text-align: center; line-height: 1.6; } +.qr-hint strong { color: var(--text); } +.qr-steps { display: flex; flex-direction: column; gap: .5rem; width: 100%; } +.step { + display: flex; align-items: center; gap: .75rem; + color: var(--text-muted); font-size: .8rem; +} +.step span { + width: 22px; height: 22px; border-radius: 50%; + background: var(--green-dim); border: 1px solid var(--green-border); + color: var(--green); font-size: .7rem; font-weight: 600; + display: flex; align-items: center; justify-content: center; flex-shrink: 0; +} +.qr-connecting { + display: flex; align-items: center; gap: .75rem; + color: var(--text-muted); font-size: .85rem; + padding: .5rem; +} + +/* ── Dashboard Layout ────────────────────────────────────────────────────── */ +.dashboard { + display: grid; + grid-template-columns: var(--sidebar-w) 1fr var(--ops-w); + height: 100vh; + overflow: hidden; +} + +/* ── Sidebar ──────────────────────────────────────────────────────────────── */ +.sidebar { + background: var(--bg-1); + border-right: 1px solid var(--border); + display: flex; flex-direction: column; + overflow: hidden; +} +.sidebar-header { + padding: 1rem; + border-bottom: 1px solid var(--border); + display: flex; align-items: center; justify-content: space-between; + gap: .5rem; flex-shrink: 0; +} +.inbox-search-wrap { padding: .75rem 1rem .5rem; flex-shrink: 0; } +.inbox-search { + width: 100%; padding: .5rem .75rem; + background: var(--bg-2); border: 1px solid var(--border); + border-radius: var(--radius); font-size: .85rem; + outline: none; transition: border-color var(--transition); +} +.inbox-search::placeholder { color: var(--text-dim); } +.inbox-search:focus { border-color: var(--green-border); } +.inbox-label { + padding: .25rem 1rem .5rem; + display: flex; align-items: center; justify-content: space-between; + font-size: .7rem; font-weight: 600; text-transform: uppercase; + letter-spacing: .06em; color: var(--text-dim); + flex-shrink: 0; +} +.unread-pill { + background: var(--green); color: #000; + font-size: .65rem; font-weight: 700; + padding: .1rem .4rem; border-radius: 999px; + min-width: 18px; text-align: center; +} +.inbox-list { flex: 1; overflow-y: auto; } +.inbox-list::-webkit-scrollbar { width: 4px; } +.inbox-list::-webkit-scrollbar-track { background: transparent; } +.inbox-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } +.inbox-empty { padding: 2rem 1rem; color: var(--text-dim); font-size: .85rem; text-align: center; } + +/* Inbox Item */ +.inbox-item { + padding: .75rem 1rem; + display: flex; align-items: center; gap: .75rem; + cursor: pointer; transition: background var(--transition); + border-bottom: 1px solid var(--border-subtle); + position: relative; +} +.inbox-item:hover { background: var(--bg-hover); } +.inbox-item.active { background: var(--green-dim); } +.inbox-item.active::before { + content: ''; + position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: var(--green); border-radius: 0 3px 3px 0; +} +.avatar { + width: 40px; height: 40px; border-radius: 50%; + background: linear-gradient(135deg, var(--green-dim), var(--green-border)); + border: 1px solid var(--green-border); + color: var(--green); font-weight: 600; font-size: .95rem; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; +} +.avatar-lg { width: 38px; height: 38px; font-size: .9rem; } +.inbox-info { flex: 1; min-width: 0; } +.inbox-row1 { display: flex; justify-content: space-between; align-items: center; } +.inbox-name { font-weight: 500; font-size: .9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.inbox-time { font-size: .7rem; color: var(--text-dim); flex-shrink: 0; margin-left: .5rem; } +.inbox-row2 { display: flex; justify-content: space-between; align-items: center; margin-top: .2rem; } +.inbox-preview { font-size: .8rem; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; } +.inbox-unread { + background: var(--green); color: #000; + font-size: .65rem; font-weight: 700; + width: 18px; height: 18px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; margin-left: .5rem; +} + +/* ── Conversation Panel ──────────────────────────────────────────────────── */ +.conv-panel { + display: flex; flex-direction: column; + background: var(--bg-0); + overflow: hidden; min-width: 0; +} +.conv-placeholder { + flex: 1; + display: flex; flex-direction: column; + align-items: center; justify-content: center; + gap: 1rem; color: var(--text-muted); + padding: 2rem; +} +.placeholder-icon { + width: 64px; height: 64px; + background: var(--bg-2); border-radius: 50%; + display: flex; align-items: center; justify-content: center; + color: var(--text-dim); +} +.placeholder-icon svg { width: 28px; height: 28px; } +.conv-placeholder h3 { font-size: 1rem; font-weight: 600; color: var(--text-2); } +.conv-placeholder p { font-size: .85rem; text-align: center; } +.conv-view { flex: 1; display: flex; flex-direction: column; overflow: hidden; } + +.conv-header { + padding: .875rem 1.25rem; + border-bottom: 1px solid var(--border); + background: var(--bg-1); + display: flex; align-items: center; justify-content: space-between; + flex-shrink: 0; +} +.conv-header-info { display: flex; align-items: center; gap: .75rem; } +.conv-name { font-weight: 600; font-size: .95rem; } +.conv-phone { font-size: .75rem; color: var(--text-muted); } +.conv-header-actions { display: flex; gap: .5rem; } + +/* Messages */ +.message-list { + flex: 1; overflow-y: auto; padding: 1.25rem; + display: flex; flex-direction: column; gap: .6rem; +} +.message-list::-webkit-scrollbar { width: 4px; } +.message-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } + +.msg-wrap { display: flex; animation: fadeUp .2s ease; } +.msg-wrap.from-me { justify-content: flex-end; } +@keyframes fadeUp { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +.bubble { + max-width: 72%; padding: .6rem .9rem; + border-radius: var(--radius-lg); + font-size: .88rem; line-height: 1.55; + word-break: break-word; +} +.from-them .bubble { + background: var(--bg-2); + border: 1px solid var(--border); + border-bottom-left-radius: var(--radius-sm); + color: var(--text); +} +.from-me .bubble { + background: linear-gradient(135deg, #00b894, var(--green)); + color: #000; + border-bottom-right-radius: var(--radius-sm); +} +.bubble-meta { + display: flex; align-items: center; justify-content: flex-end; + gap: .3rem; margin-top: .3rem; + font-size: .67rem; opacity: .65; +} +.tick { font-size: .75rem; } +.tick.sent { color: #000; } +.date-divider { + text-align: center; color: var(--text-dim); + font-size: .7rem; padding: .5rem 0; + display: flex; align-items: center; gap: .75rem; +} +.date-divider::before, .date-divider::after { + content: ''; flex: 1; height: 1px; background: var(--border); +} + +/* ── Compose Area ─────────────────────────────────────────────────────────── */ +.compose-area { + border-top: 1px solid var(--border); + background: var(--bg-1); + padding: .875rem 1.25rem; + display: flex; flex-direction: column; gap: .625rem; + flex-shrink: 0; +} +.ai-draft-bar { + background: var(--purple-dim); + border: 1px solid var(--purple-border); + border-radius: var(--radius); + padding: .5rem .75rem; + display: flex; align-items: center; justify-content: space-between; + animation: fadeUp .2s ease; +} +.ai-draft-inner { display: flex; align-items: center; gap: .5rem; color: var(--purple); font-size: .8rem; } +.ai-icon { width: 14px; height: 14px; flex-shrink: 0; } +.compose-input { + width: 100%; resize: none; + background: var(--bg-2); border: 1px solid var(--border); + border-radius: var(--radius); padding: .6rem .875rem; + font-size: .9rem; line-height: 1.5; + outline: none; transition: border-color var(--transition); + max-height: 140px; overflow-y: auto; +} +.compose-input::placeholder { color: var(--text-dim); } +.compose-input:focus { border-color: var(--green-border); } +.compose-actions { + display: flex; align-items: center; justify-content: space-between; gap: .75rem; +} +.compose-right { display: flex; align-items: center; gap: .75rem; } +.char-count { font-size: .75rem; color: var(--text-dim); } + +/* ── Buttons ──────────────────────────────────────────────────────────────── */ +.btn { + display: inline-flex; align-items: center; gap: .4rem; + padding: .5rem 1rem; border-radius: var(--radius); + font-size: .85rem; font-weight: 500; + transition: all var(--transition); +} +.btn svg { width: 14px; height: 14px; flex-shrink: 0; } +.btn-ai { + background: var(--purple-dim); + border: 1px solid var(--purple-border); + color: var(--purple); +} +.btn-ai:hover { background: rgba(167,139,250,.2); border-color: rgba(167,139,250,.5); } +.btn-ai:disabled { opacity: .4; cursor: not-allowed; } +.btn-send { + background: var(--green); + color: #000; font-weight: 600; +} +.btn-send:hover { background: #00e5b8; } +.btn-send:disabled { opacity: .4; cursor: not-allowed; } +.icon-btn { + width: 32px; height: 32px; + border-radius: var(--radius-sm); + background: var(--bg-2); border: 1px solid var(--border); + color: var(--text-muted); + display: flex; align-items: center; justify-content: center; + transition: all var(--transition); +} +.icon-btn:hover { background: var(--bg-hover); color: var(--text); } +.icon-btn svg { width: 15px; height: 15px; } +.icon-btn.small { width: 24px; height: 24px; background: transparent; border: none; font-size: .7rem; } + +/* ── Operations Panel ────────────────────────────────────────────────────── */ +.ops-panel { + background: var(--bg-1); + border-left: 1px solid var(--border); + overflow-y: auto; padding: 1.25rem 1rem; + display: flex; flex-direction: column; gap: 1.25rem; +} +.ops-panel::-webkit-scrollbar { width: 4px; } +.ops-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } +.ops-title { + font-size: .7rem; font-weight: 600; text-transform: uppercase; + letter-spacing: .07em; color: var(--text-dim); + margin-bottom: .625rem; +} +.ops-card { + background: var(--bg-2); border: 1px solid var(--border); + border-radius: var(--radius); overflow: hidden; +} +.ops-row { + display: flex; align-items: center; justify-content: space-between; + padding: .625rem .875rem; + border-bottom: 1px solid var(--border-subtle); +} +.ops-row:last-child { border-bottom: none; } +.ops-label { font-size: .8rem; color: var(--text-muted); } +.ops-value { font-size: .8rem; color: var(--text-2); } +.ops-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: .75rem; } +.ops-grid { + display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; +} +.metric-card { + background: var(--bg-2); border: 1px solid var(--border); + border-radius: var(--radius); padding: .75rem; + text-align: center; +} +.metric-card.success { border-color: rgba(63,185,80,.25); background: rgba(63,185,80,.07); } +.metric-card.danger { border-color: rgba(248,81,73,.2); background: rgba(248,81,73,.07); } +.metric-value { font-size: 1.5rem; font-weight: 700; color: var(--text); line-height: 1; } +.metric-card.success .metric-value { color: var(--success); } +.metric-card.danger .metric-value { color: var(--danger); } +.metric-label { font-size: .7rem; color: var(--text-muted); margin-top: .25rem; } + +.demo-banner { + display: flex; align-items: flex-start; gap: .625rem; + background: rgba(210,153,34,.1); border: 1px solid rgba(210,153,34,.3); + border-radius: var(--radius); padding: .75rem; + color: var(--warning); font-size: .8rem; +} +.demo-banner svg { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; } +.demo-banner strong { display: block; margin-bottom: .15rem; font-size: .82rem; } +.demo-banner p { color: rgba(210,153,34,.8); font-size: .75rem; } + +/* ── Toasts ───────────────────────────────────────────────────────────────── */ +.toast-container { + position: fixed; bottom: 1.25rem; right: 1.25rem; + display: flex; flex-direction: column; gap: .5rem; + z-index: 200; pointer-events: none; +} +.toast { + background: var(--bg-2); border: 1px solid var(--border); + border-radius: var(--radius); padding: .75rem 1rem; + font-size: .85rem; color: var(--text); + box-shadow: var(--shadow); max-width: 300px; + animation: toastIn .25s ease, toastOut .25s ease 2.75s forwards; + display: flex; align-items: center; gap: .6rem; + pointer-events: auto; +} +.toast-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } +.toast.green { border-left: 3px solid var(--green); } +.toast.green .toast-dot { background: var(--green); } +.toast.purple { border-left: 3px solid var(--purple); } +.toast.purple .toast-dot { background: var(--purple); } +.toast.red { border-left: 3px solid var(--danger); } +.toast.red .toast-dot { background: var(--danger); } + +@keyframes toastIn { from { opacity: 0; transform: translateY(12px); } to { opacity: 1; transform: translateY(0); } } +@keyframes toastOut { from { opacity: 1; } to { opacity: 0; } } + +/* ── Responsive ───────────────────────────────────────────────────────────── */ +@media (max-width: 900px) { + .dashboard { grid-template-columns: var(--sidebar-w) 1fr; } + .ops-panel { display: none; } + .ops-panel.open { + display: flex; position: fixed; right: 0; top: 0; bottom: 0; + width: var(--ops-w); z-index: 50; box-shadow: var(--shadow); + } +} +@media (max-width: 600px) { + :root { --sidebar-w: 200px; } + .inbox-time { display: none; } + .bubble { max-width: 88%; } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..b6fbf88 --- /dev/null +++ b/server.js @@ -0,0 +1,423 @@ +const express = require('express'); +const http = require('http'); +const { Server } = require('socket.io'); +const QRCode = require('qrcode'); +const Anthropic = require('@anthropic-ai/sdk'); +const { v4: uuidv4 } = require('uuid'); +const path = require('path'); + +const app = express(); +const server = http.createServer(app); +const io = new Server(server, { cors: { origin: '*' } }); + +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +const anthropic = process.env.ANTHROPIC_API_KEY + ? new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }) + : null; + +// ── State ──────────────────────────────────────────────────────────────────── +let sessionState = 'disconnected'; +let qrDataUrl = null; +let waClient = null; + +const conversations = new Map(); // chatId -> ConversationObject +const inboundQueue = []; +const outboundQueue = []; +const seenMessageIds = new Set(); +const processedIdempotencyKeys = new Set(); +let outboundBusy = false; + +const stats = { + queueDepth: 0, + outboundDepth: 0, + sendSuccess: 0, + sendFailed: 0, + reconnectCount: 0, + lastHeartbeat: null, + workerStatus: 'idle' +}; + +// ── Helpers ────────────────────────────────────────────────────────────────── +function broadcastState() { + io.emit('session_state', { state: sessionState, qrDataUrl, stats }); +} + +function broadcastInbox() { + const inbox = Array.from(conversations.values()).sort( + (a, b) => new Date(b.lastActivity) - new Date(a.lastActivity) + ); + io.emit('inbox_update', inbox); +} + +function addMessage(chatId, msg) { + if (!conversations.has(chatId)) { + conversations.set(chatId, { + id: chatId, + contact: msg.contact || chatId, + phone: chatId, + avatar: (msg.contact || chatId).charAt(0).toUpperCase(), + messages: [], + unreadCount: 0, + lastActivity: msg.timestamp, + lastPreview: msg.body + }); + } + const conv = conversations.get(chatId); + conv.messages.push(msg); + conv.lastActivity = msg.timestamp; + conv.lastPreview = msg.body; + if (!msg.fromMe) conv.unreadCount++; + return conv; +} + +// ── WhatsApp Client ─────────────────────────────────────────────────────────── +function initWhatsApp() { + let Client, LocalAuth; + try { + ({ Client, LocalAuth } = require('whatsapp-web.js')); + } catch { + console.warn('whatsapp-web.js not available, staying disconnected'); + sessionState = 'degraded'; + broadcastState(); + return; + } + + waClient = new Client({ + authStrategy: new LocalAuth({ dataPath: './.wwebjs_auth' }), + puppeteer: { + args: [ + '--no-sandbox', '--disable-setuid-sandbox', + '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', + '--no-first-run', '--no-zygote', '--single-process', '--disable-gpu' + ] + } + }); + + waClient.on('qr', async (qr) => { + sessionState = 'awaiting_scan'; + qrDataUrl = await QRCode.toDataURL(qr, { width: 280, margin: 2 }); + broadcastState(); + }); + + waClient.on('authenticated', () => { + sessionState = 'connecting'; + broadcastState(); + }); + + waClient.on('ready', () => { + sessionState = 'connected'; + qrDataUrl = null; + stats.lastHeartbeat = new Date().toISOString(); + broadcastState(); + console.log('WhatsApp ready'); + }); + + waClient.on('disconnected', (reason) => { + console.log('Disconnected:', reason); + sessionState = 'reconnecting'; + stats.reconnectCount++; + broadcastState(); + setTimeout(() => waClient.initialize().catch(console.error), 5000); + }); + + waClient.on('message', async (msg) => { + if (msg.fromMe) return; + const mid = msg.id._serialized; + if (seenMessageIds.has(mid)) return; + seenMessageIds.add(mid); + + let contactName = msg.from; + try { + const contact = await msg.getContact(); + contactName = contact.name || contact.pushname || msg.from; + } catch {} + + const inboundMsg = { + id: mid, + body: msg.body, + fromMe: false, + contact: contactName, + timestamp: new Date(msg.timestamp * 1000).toISOString(), + state: 'received' + }; + + const conv = addMessage(msg.from, inboundMsg); + inboundQueue.push({ ...inboundMsg, chatId: msg.from }); + stats.queueDepth = inboundQueue.length; + + io.emit('new_message', { chatId: msg.from, conversation: conv, message: inboundMsg }); + io.emit('stats_update', stats); + broadcastInbox(); + }); + + waClient.initialize().catch((err) => { + console.error('WhatsApp init failed:', err.message); + sessionState = 'degraded'; + broadcastState(); + }); +} + +// ── Outbound Worker ─────────────────────────────────────────────────────────── +async function processOutbound() { + if (outboundBusy || outboundQueue.length === 0) return; + outboundBusy = true; + stats.workerStatus = 'busy'; + io.emit('stats_update', stats); + + const job = outboundQueue.shift(); + stats.outboundDepth = outboundQueue.length; + + try { + job.state = 'sending'; + io.emit('message_state', job); + + if (sessionState !== 'connected' || !waClient) { + throw new Error('Session not connected'); + } + await waClient.sendMessage(job.chatId, job.body); + + job.state = 'sent'; + job.sentAt = new Date().toISOString(); + stats.sendSuccess++; + + const sentMsg = { + id: job.idempotencyKey, + body: job.body, + fromMe: true, + timestamp: job.sentAt, + state: 'sent' + }; + addMessage(job.chatId, sentMsg); + // Don't increment unread for own messages + const conv = conversations.get(job.chatId); + if (conv) { conv.unreadCount = Math.max(0, conv.unreadCount - 1); conv.unreadCount = 0; } + broadcastInbox(); + + } catch (err) { + job.retries = (job.retries || 0) + 1; + stats.sendFailed++; + + if (job.retries < 3) { + job.state = 'retrying'; + setTimeout(() => { + outboundQueue.unshift(job); + stats.outboundDepth = outboundQueue.length; + processOutbound(); + }, Math.pow(2, job.retries) * 1500); + } else { + job.state = 'failed'; + job.error = err.message; + } + } + + io.emit('message_state', job); + io.emit('stats_update', stats); + outboundBusy = false; + stats.workerStatus = 'idle'; + + if (outboundQueue.length > 0) setTimeout(processOutbound, 400); +} + +// ── Heartbeat ───────────────────────────────────────────────────────────────── +setInterval(() => { + if (sessionState === 'connected') { + stats.lastHeartbeat = new Date().toISOString(); + io.emit('stats_update', stats); + } +}, 30000); + +// ── REST API ────────────────────────────────────────────────────────────────── +app.get('/api/session', (req, res) => { + res.json({ state: sessionState, qrDataUrl, stats }); +}); + +app.get('/api/inbox', (req, res) => { + const inbox = Array.from(conversations.values()).sort( + (a, b) => new Date(b.lastActivity) - new Date(a.lastActivity) + ); + res.json(inbox); +}); + +app.get('/api/conversation/:chatId', (req, res) => { + const conv = conversations.get(decodeURIComponent(req.params.chatId)); + if (!conv) return res.status(404).json({ error: 'Not found' }); + res.json(conv); +}); + +app.post('/api/send', (req, res) => { + const { chatId, body, idempotencyKey } = req.body; + if (!chatId || !body) return res.status(400).json({ error: 'chatId and body required' }); + + const key = idempotencyKey || uuidv4(); + if (processedIdempotencyKeys.has(key)) { + return res.json({ queued: false, duplicate: true, key }); + } + processedIdempotencyKeys.add(key); + + const job = { + id: uuidv4(), + idempotencyKey: key, + chatId, + body, + state: 'queued', + createdAt: new Date().toISOString(), + retries: 0 + }; + + outboundQueue.push(job); + stats.outboundDepth = outboundQueue.length; + io.emit('stats_update', stats); + processOutbound(); + + res.json({ queued: true, job }); +}); + +app.post('/api/ai-draft', async (req, res) => { + if (!anthropic) { + return res.status(503).json({ error: 'ANTHROPIC_API_KEY not configured' }); + } + + const { chatId, hint } = req.body; + const conv = conversations.get(chatId); + const recent = conv ? conv.messages.slice(-12) : []; + const transcript = recent + .map(m => `${m.fromMe ? 'Agent' : 'Customer'}: ${m.body}`) + .join('\n'); + + try { + const response = await anthropic.messages.create({ + model: 'claude-sonnet-4-6', + max_tokens: 400, + system: [ + { + type: 'text', + text: 'You are a professional customer service agent. Write a concise, friendly reply to the customer\'s latest message. Output only the reply text — no preamble, no quotation marks, no signature.', + cache_control: { type: 'ephemeral' } + } + ], + messages: [ + { + role: 'user', + content: `Conversation so far:\n${transcript}\n${hint ? `\nAdditional context: ${hint}` : ''}\n\nWrite a reply to the customer's last message.` + } + ] + }); + res.json({ draft: response.content[0].text }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +app.post('/api/mark-read/:chatId', (req, res) => { + const conv = conversations.get(decodeURIComponent(req.params.chatId)); + if (conv) { conv.unreadCount = 0; broadcastInbox(); } + res.json({ ok: true }); +}); + +app.get('/api/stats', (req, res) => res.json(stats)); + +// Demo mode: inject simulated inbound message +app.post('/api/demo/inject', (req, res) => { + if (process.env.DEMO_MODE !== 'true') return res.status(403).json({ error: 'Demo mode off' }); + const { chatId, body } = req.body; + const conv = conversations.get(chatId); + if (!conv) return res.status(404).json({ error: 'Chat not found' }); + + const msg = { + id: uuidv4(), + body: body || 'Hey, any update?', + fromMe: false, + contact: conv.contact, + timestamp: new Date().toISOString(), + state: 'received' + }; + addMessage(chatId, msg); + io.emit('new_message', { chatId, conversation: conversations.get(chatId), message: msg }); + broadcastInbox(); + res.json({ ok: true }); +}); + +// ── Socket.io ───────────────────────────────────────────────────────────────── +io.on('connection', (socket) => { + socket.emit('session_state', { state: sessionState, qrDataUrl, stats }); + const inbox = Array.from(conversations.values()).sort( + (a, b) => new Date(b.lastActivity) - new Date(a.lastActivity) + ); + socket.emit('inbox_update', inbox); +}); + +// ── Demo Mode ───────────────────────────────────────────────────────────────── +if (process.env.DEMO_MODE === 'true') { + sessionState = 'connected'; + stats.lastHeartbeat = new Date().toISOString(); + + const demoChats = [ + { id: '919876543210@c.us', contact: 'Rahul Sharma' }, + { id: '918765432109@c.us', contact: 'Priya Patel' }, + { id: '917654321098@c.us', contact: 'Amit Kumar' }, + { id: '916543210987@c.us', contact: 'Sneha Joshi' } + ]; + + const demoThreads = [ + [ + { from: false, body: 'Hi, I placed an order #12345 but haven\'t received a confirmation yet.' }, + { from: true, body: 'Hello Rahul! Let me check that for you right away.' }, + { from: false, body: 'It\'s been 2 days already. Should I be worried?' }, + { from: false, body: 'Also, can I change the delivery address?' } + ], + [ + { from: false, body: 'I want to return a product. The size doesn\'t fit.' }, + { from: true, body: 'Hi Priya! I\'d be happy to help with the return. Could you share your order ID?' }, + { from: false, body: 'It\'s ORDER-78923' } + ], + [ + { from: false, body: 'Is COD available for my area - 411001?' }, + { from: true, body: 'Yes, COD is available in Pune 411001!' }, + { from: false, body: 'Great! What\'s the maximum COD order value?' } + ], + [ + { from: false, body: 'My payment failed but amount was deducted. Please help!' } + ] + ]; + + demoChats.forEach((chat, i) => { + const thread = demoThreads[i]; + conversations.set(chat.id, { + id: chat.id, + contact: chat.contact, + phone: chat.id, + avatar: chat.contact.charAt(0), + messages: thread.map((m, j) => ({ + id: uuidv4(), + body: m.body, + fromMe: m.from, + contact: m.from ? 'Agent' : chat.contact, + timestamp: new Date(Date.now() - (thread.length - j) * 900000 - i * 3600000).toISOString(), + state: 'received' + })), + unreadCount: thread.filter(m => !m.from).length - Math.min(thread.filter(m => m.from).length, thread.filter(m => !m.from).length), + lastActivity: new Date(Date.now() - i * 3600000).toISOString(), + lastPreview: thread[thread.length - 1].body + }); + // Fix unreadCount to just be trailing customer messages + const conv = conversations.get(chat.id); + let unread = 0; + for (let k = conv.messages.length - 1; k >= 0; k--) { + if (!conv.messages[k].fromMe) unread++; + else break; + } + conv.unreadCount = unread; + }); + + console.log('Running in DEMO MODE — no real WhatsApp connection'); +} else { + initWhatsApp(); +} + +// ── Start ───────────────────────────────────────────────────────────────────── +const PORT = process.env.PORT || 8080; +server.listen(PORT, () => { + console.log(`WhatsApp AI Agent on port ${PORT} | Demo=${process.env.DEMO_MODE}`); +});