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:
commit
017ddc0848
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
package-lock.json
|
||||
.wwebjs_auth/
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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, '&')
|
||||
.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 = '<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);
|
||||
}
|
||||
})();
|
||||
|
|
@ -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>
|
||||
|
|
@ -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%; }
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user