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 <noreply@anthropic.com>
This commit is contained in:
Neha Shivankar 2026-04-28 07:56:23 +00:00
commit 017ddc0848
7 changed files with 1696 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
package-lock.json
.wwebjs_auth/

24
boltic.yaml Normal file
View File

@ -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

17
package.json Normal file
View File

@ -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"
}
}

447
public/app.js Normal file
View File

@ -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 = `<span class="toast-dot"></span><span>${msg}</span>`;
$('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 = `<span class="dot"></span>${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 = `<span class="dot"></span>${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 = '<div class="inbox-empty">No conversations</div>';
return;
}
inboxList.innerHTML = filtered.map(conv => `
<div class="inbox-item ${conv.id === state.selectedChatId ? 'active' : ''}"
data-id="${conv.id}" onclick="selectChat('${conv.id}')">
<div class="avatar">${conv.avatar || conv.contact.charAt(0).toUpperCase()}</div>
<div class="inbox-info">
<div class="inbox-row1">
<span class="inbox-name">${conv.contact}</span>
<span class="inbox-time">${conv.lastActivity ? formatRelative(conv.lastActivity) : ''}</span>
</div>
<div class="inbox-row2">
<span class="inbox-preview">${escHtml(conv.lastPreview || '')}</span>
${conv.unreadCount > 0 ? `<span class="inbox-unread">${conv.unreadCount > 9 ? '9+' : conv.unreadCount}</span>` : ''}
</div>
</div>
</div>
`).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 = '<div style="text-align:center;color:var(--text-dim);padding:2rem;font-size:.85rem">No messages yet</div>';
return;
}
let html = '';
let lastDate = '';
messages.forEach(msg => {
const d = formatDate(msg.timestamp);
if (d !== lastDate) {
html += `<div class="date-divider">${d}</div>`;
lastDate = d;
}
const fromMe = msg.fromMe;
html += `
<div class="msg-wrap ${fromMe ? 'from-me' : 'from-them'}">
<div class="bubble">
${escHtml(msg.body)}
<div class="bubble-meta">
<span>${formatTime(msg.timestamp)}</span>
${fromMe ? `<span class="tick sent">${msg.state === 'sent' ? '✓✓' : '✓'}</span>` : ''}
</div>
</div>
</div>`;
});
messageList.innerHTML = html;
messageList.scrollTop = messageList.scrollHeight;
}
function escHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/* ── 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 = '<div class="loading-spinner small"></div>';
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 = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> 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 = '<span class="dot"></span>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 = '<span class="dot"></span>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);
}
})();

262
public/index.html Normal file
View File

@ -0,0 +1,262 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhatsApp AI Agent</title>
<link rel="stylesheet" href="style.css" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body>
<!-- ── Loading Screen ──────────────────────────────────────────────────── -->
<div id="loading-screen" class="loading-screen">
<div class="loading-logo">
<div class="logo-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2 22l4.832-1.438A9.956 9.956 0 0012 22c5.523 0 10-4.477 10-10S17.523 2 12 2z" fill="currentColor"/>
</svg>
</div>
<span>WhatsApp <strong>AI Agent</strong></span>
</div>
<div class="loading-spinner"></div>
</div>
<!-- ── QR Screen ───────────────────────────────────────────────────────── -->
<div id="qr-screen" class="qr-screen hidden">
<div class="qr-card">
<div class="qr-header">
<div class="logo-lockup">
<div class="logo-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2 22l4.832-1.438A9.956 9.956 0 0012 22c5.523 0 10-4.477 10-10S17.523 2 12 2z" fill="currentColor"/>
</svg>
</div>
<span>WhatsApp <strong>AI Agent</strong></span>
</div>
</div>
<div id="qr-status-badge" class="status-badge status-awaiting">
<span class="dot"></span>Awaiting Scan
</div>
<div class="qr-body">
<div id="qr-image-wrap" class="qr-image-wrap">
<img id="qr-img" src="" alt="QR Code" />
<div id="qr-overlay" class="qr-overlay hidden">
<div class="qr-refresh-msg">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 4v6h6M23 20v-6h-6M20.49 9A9 9 0 005.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 013.51 15"/></svg>
QR Expired
</div>
</div>
</div>
<p class="qr-hint">Open <strong>WhatsApp</strong> on your phone → <strong>Linked Devices</strong> → Scan this QR</p>
<div class="qr-steps">
<div class="step"><span>1</span>Open WhatsApp on mobile</div>
<div class="step"><span>2</span>Tap the three dots → Linked Devices</div>
<div class="step"><span>3</span>Point camera at this screen</div>
</div>
</div>
<div id="qr-connecting" class="qr-connecting hidden">
<div class="loading-spinner small"></div>
<span>Authenticating…</span>
</div>
</div>
</div>
<!-- ── Degraded Screen ─────────────────────────────────────────────────── -->
<div id="degraded-screen" class="qr-screen hidden">
<div class="qr-card" style="max-width:420px">
<div class="qr-header">
<div class="logo-lockup">
<div class="logo-icon"><svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2 22l4.832-1.438A9.956 9.956 0 0012 22c5.523 0 10-4.477 10-10S17.523 2 12 2z" fill="currentColor"/></svg></div>
<span>WhatsApp <strong>AI Agent</strong></span>
</div>
</div>
<div class="status-badge status-failed"><span class="dot"></span>Degraded</div>
<div class="qr-body" style="text-align:center;padding:2rem 1rem">
<p style="color:var(--text-secondary);margin-bottom:1rem">The WhatsApp session could not be started. This is usually because Chromium is not available in this environment.</p>
<p style="color:var(--text-muted);font-size:.85rem">Set <code>DEMO_MODE=true</code> in your environment to explore the dashboard with sample data.</p>
</div>
</div>
</div>
<!-- ── Main Dashboard ──────────────────────────────────────────────────── -->
<div id="dashboard" class="dashboard hidden">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="logo-lockup">
<div class="logo-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2C6.477 2 2 6.477 2 12c0 1.89.525 3.66 1.438 5.168L2 22l4.832-1.438A9.956 9.956 0 0012 22c5.523 0 10-4.477 10-10S17.523 2 12 2z" fill="currentColor"/>
</svg>
</div>
<span>WA <strong>Agent</strong></span>
</div>
<div id="sidebar-session-badge" class="status-badge status-connected">
<span class="dot"></span><span id="sidebar-session-text">Connected</span>
</div>
</div>
<div class="inbox-search-wrap">
<input id="inbox-search" type="text" class="inbox-search" placeholder="Search conversations…" />
</div>
<div class="inbox-label">
<span>Inbox</span>
<span id="total-unread-badge" class="unread-pill hidden">0</span>
</div>
<div id="inbox-list" class="inbox-list">
<div class="inbox-empty">No conversations yet</div>
</div>
</aside>
<!-- Conversation Panel -->
<main class="conv-panel" id="conv-panel">
<div id="conv-placeholder" class="conv-placeholder">
<div class="placeholder-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<h3>Select a conversation</h3>
<p>Choose a chat from the inbox to start messaging</p>
</div>
<div id="conv-view" class="conv-view hidden">
<div class="conv-header">
<div class="conv-header-info">
<div id="conv-avatar" class="avatar avatar-lg">R</div>
<div>
<div id="conv-name" class="conv-name">Contact Name</div>
<div id="conv-phone" class="conv-phone">+91 98765 43210</div>
</div>
</div>
<div class="conv-header-actions">
<button id="btn-ops-toggle" class="icon-btn" title="Operations panel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
</button>
</div>
</div>
<div id="message-list" class="message-list"></div>
<div class="compose-area">
<div id="ai-draft-bar" class="ai-draft-bar hidden">
<div class="ai-draft-inner">
<svg class="ai-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
<span id="ai-draft-status">Generating draft…</span>
</div>
<button id="btn-dismiss-draft" class="icon-btn small"></button>
</div>
<textarea
id="compose-input"
class="compose-input"
placeholder="Type a message…"
rows="1"
></textarea>
<div class="compose-actions">
<button id="btn-ai-draft" class="btn btn-ai" title="Generate AI reply">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
AI Draft
</button>
<div class="compose-right">
<span id="char-count" class="char-count">0</span>
<button id="btn-send" class="btn btn-send" disabled>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
Send
</button>
</div>
</div>
</div>
</div>
</main>
<!-- Operations Panel -->
<aside id="ops-panel" class="ops-panel">
<div class="ops-section">
<h4 class="ops-title">Session Health</h4>
<div class="ops-card">
<div class="ops-row">
<span class="ops-label">Status</span>
<div id="ops-session-badge" class="status-badge status-connected"><span class="dot"></span>Connected</div>
</div>
<div class="ops-row">
<span class="ops-label">Last heartbeat</span>
<span id="ops-heartbeat" class="ops-value"></span>
</div>
<div class="ops-row">
<span class="ops-label">Reconnects</span>
<span id="ops-reconnects" class="ops-value">0</span>
</div>
</div>
</div>
<div class="ops-section">
<h4 class="ops-title">Queue Metrics</h4>
<div class="ops-grid">
<div class="metric-card">
<div id="ops-inbound-depth" class="metric-value">0</div>
<div class="metric-label">Inbound queue</div>
</div>
<div class="metric-card">
<div id="ops-outbound-depth" class="metric-value">0</div>
<div class="metric-label">Outbound queue</div>
</div>
<div class="metric-card success">
<div id="ops-send-success" class="metric-value">0</div>
<div class="metric-label">Sent</div>
</div>
<div class="metric-card danger">
<div id="ops-send-failed" class="metric-value">0</div>
<div class="metric-label">Failed</div>
</div>
</div>
</div>
<div class="ops-section">
<h4 class="ops-title">Worker</h4>
<div class="ops-card">
<div class="ops-row">
<span class="ops-label">Status</span>
<div id="ops-worker-badge" class="status-badge status-idle"><span class="dot"></span>Idle</div>
</div>
</div>
</div>
<div class="ops-section">
<h4 class="ops-title">AI Integration</h4>
<div class="ops-card">
<div class="ops-row">
<span class="ops-label">Model</span>
<span class="ops-value mono">claude-sonnet-4-6</span>
</div>
<div class="ops-row">
<span class="ops-label">Status</span>
<div id="ops-ai-badge" class="status-badge status-idle"><span class="dot"></span>Ready</div>
</div>
</div>
</div>
<div id="demo-banner" class="demo-banner hidden">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<div>
<strong>Demo Mode</strong>
<p>Sample data — no real WhatsApp connection</p>
</div>
</div>
</aside>
</div>
<!-- Toast container -->
<div id="toasts" class="toast-container"></div>
<script src="/socket.io/socket.io.js"></script>
<script src="app.js"></script>
</body>
</html>

520
public/style.css Normal file
View File

@ -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%; }
}

423
server.js Normal file
View File

@ -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}`);
});