feat: Whatsapp Integration

This commit is contained in:
Sagnik
2026-04-28 13:41:14 +05:30
parent 7ee51543d9
commit 3623bacbac
15 changed files with 2549 additions and 3 deletions

View File

@@ -0,0 +1,265 @@
# Velocity Comms Integration — Handoff Document
## 1. Architecture Recommendation
### Goal
Add a native **Conversations** module to Project Velocity so brokers/agents can manage WhatsApp (and future SMS/call) threads without leaving the WebOS.
### Design Philosophy
- **Native Velocity UI**: Dark glass panels, compact density, blue accent, no iframe embeds.
- **Provider-agnostic backend**: Abstract `CommsProvider` class with adapter pattern.
- **CRM-first**: Every thread attempts to link to `crm_people` by `primary_phone`. Unresolved numbers are surfaced for manual linking.
- **Mock-first development**: The module renders fully without real credentials.
### Provider Comparison
| Provider | Best For | Velocity Fit | 72-Hour Viability |
|----------|----------|--------------|-------------------|
| **Chatwoot** | Full support suite (email, SMS, WA) | Too heavy to embed; good UX reference | Low — would require stripping UI |
| **WAHA** | Lightweight WhatsApp Web gateway | Good adapter candidate | High — simple REST, easy webhooks |
| **Evolution API** | Modern WA gateway with groups, status, typing | Best adapter candidate | **High** — active community, clean webhooks |
| **Meta Cloud API** | Official WABA; template-based outbound | Required for production scale at large builders | Medium — needs Meta Business verification |
**Recommended 72-hour route:**
1. **Day 1**: Merge schema + backend routes + mock provider. Frontend compiles with mock data.
2. **Day 2**: Connect Evolution API or WAHA in a staging environment. Test inbound webhook → thread creation.
3. **Day 3**: CRM linking, settings UI, call-log upload placeholder, and smoke tests.
For production, plan a **dual-provider** setup:
- **Evolution/WAHA** for quick conversational messaging (no Meta approval needed).
- **Meta Cloud API** for template-based broadcast/re-engagement once Business Manager is verified.
---
## 2. Exact Files Created
```
app/src/types/commsTypes.ts
app/src/lib/commsApi.ts
app/src/components/modules/Comms.tsx
backend/db/schema_comms.sql
backend/services/comms_provider.py
backend/services/comms_waha_provider.py
backend/services/comms_evolution_provider.py
backend/services/comms_ingest.py
backend/api/routes_comms.py
COMMS_INTEGRATION_HANDOFF.md
```
---
## 3. Patch Instructions for Existing Files
### A. `app/src/types/index.ts`
Add `'comms'` to the `ModuleId` union:
```typescript
export type ModuleId = 'dashboard' | 'oracle' | 'sentinel' | 'inventory' | 'settings' | 'catalyst' | 'admin' | 'crm' | 'comms';
```
### B. `app/src/App.tsx`
1. Import the new component:
```typescript
import { Comms } from '@/components/modules/Comms';
```
2. Insert the route into `MODULE_ROUTES` **just before** `settings`:
```typescript
{ id: 'comms', path: '/comms', title: 'Conversations', component: Comms },
```
### C. `app/src/components/layout/Sidebar.tsx`
1. Import a new icon:
```typescript
import { MessageCircle } from 'lucide-react';
```
2. Add to `NAV_ICONS`:
```typescript
const NAV_ICONS: Record<string, LucideIcon> = {
'/dashboard': LayoutGrid,
'/oracle': MessageSquarePlus,
'/sentinel': ScanFace,
'/inventory': Building2,
'/catalyst': Megaphone,
'/comms': MessageCircle, // ← NEW
'/settings': Sliders,
'/admin': Shield,
'/crm': Users,
};
```
### D. `backend/main.py`
1. Import the router near the other imports:
```python
from backend.api.routes_comms import router as comms_router
```
2. Include it after the other routers:
```python
app.include_router(comms_router, prefix="/api/comms", tags=["Comms"])
```
---
## 4. Environment Variables
Add these to your `.env` or systemd environment:
```bash
# Provider selection: mock | waha | evolution | meta_cloud
COMMS_PROVIDER=mock
# Provider connectivity
COMMS_PROVIDER_BASE_URL=
COMMS_PROVIDER_API_KEY=
COMMS_INSTANCE_ID=default
# Webhook security
COMMS_WEBHOOK_SECRET=
# Phone normalization
COMMS_DEFAULT_COUNTRY_CODE=91
# Media storage
COMMS_MEDIA_STORAGE_DIR=/opt/dlami/nvme/assets/comms
# Transcription (none | openai | local)
COMMS_TRANSCRIPTION_PROVIDER=none
```
**No secrets are hardcoded in source.**
---
## 5. Database Migration
Run the SQL file against your Postgres database:
```bash
psql $DATABASE_URL -f backend/db/schema_comms.sql
```
Tables created:
- `comms_threads` — conversation headers with CRM link
- `comms_messages` — individual messages (inbound/outbound/system)
- `comms_call_logs` — call records with optional transcript
- `comms_settings` — key-value config store
---
## 6. API Routes
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/comms/threads` | List threads (search, status, pagination) |
| GET | `/api/comms/threads/{id}` | Get single thread with CRM enrichment |
| GET | `/api/comms/threads/{id}/messages` | Chronological messages |
| POST | `/api/comms/threads/{id}/messages` | Send outbound message via provider |
| POST | `/api/comms/threads/{id}/link-person` | Link thread to `crm_people.id` |
| POST | `/api/comms/threads/{id}/notes` | Add system note |
| POST | `/api/comms/threads/{id}/tasks` | Add system task |
| POST | `/api/comms/webhooks/{provider}` | Public webhook endpoint |
| GET | `/api/comms/settings` | Get comms configuration |
| PATCH | `/api/comms/settings` | Update configuration |
| POST | `/api/comms/provider/test` | Test provider connectivity |
| POST | `/api/comms/recordings/transcribe` | Queue transcription job |
---
## 7. Frontend Route Changes
- New sidebar item: **Conversations** (icon: `MessageCircle`)
- Position: directly above **Settings**
- Route: `/comms`
- Component: `Comms.tsx` with three-pane layout (Inbox | Chat | CRM Rail)
---
## 8. Settings Changes
A new **Communications** subsection should be added inside your existing Settings module (or as a standalone card). Fields:
| Field | Type | Description |
|-------|------|-------------|
| Provider | select | mock / waha / evolution / meta_cloud |
| Provider Base URL | text | e.g. `http://localhost:3000` |
| API Key | password | masked after save |
| Instance ID | text | WA/Evolution session name |
| Phone Number ID | text | Meta Cloud API only |
| Webhook Callback URL | text | Auto-populated or custom |
| Webhook Secret | password | Sets `webhook_secret_set` flag |
| Default Assignment User | select | User dropdown from `/api/auth/users` |
| Auto-link by Phone | toggle | Match `crm_people.primary_phone` automatically |
| Create CRM Interaction on Inbound | toggle | Write to `intel_interactions` if table exists |
| Default Country Code | text | e.g. `91` for India |
| Transcription Provider | select | none / openai / local |
| Connection Test | button | Calls `POST /api/comms/provider/test` |
---
## 9. Smoke Test Steps
1. **DB**: Run `schema_comms.sql`. Verify tables exist.
2. **Backend**: Start FastAPI. Confirm `/health` returns `db_pool: connected`.
3. **Backend**: `curl -X POST http://localhost:8000/api/comms/provider/test` → should return mock success.
4. **Frontend**: Load Velocity. Sidebar should show **Conversations**.
5. **Frontend**: Click Conversations. Mock mode should render 3 threads and messages.
6. **Frontend**: Send a message in mock thread. Optimistic update → mock delivery checkmark.
7. **Backend**: Post sample webhook:
```bash
curl -X POST http://localhost:8000/api/comms/webhooks/evolution -H "Content-Type: application/json" -d '{"event":"messages.upsert","instance":"default","data":{"key":{"remoteJid":"919876543210@s.whatsapp.net","fromMe":false,"id":"test-1"},"message":{"conversation":"Hello from webhook"},"messageTimestamp":1710000000}}'
```
8. **Backend**: Verify thread + message inserted. Check `comms_threads` for new row.
9. **Frontend**: Refresh inbox. New thread should appear.
10. **CRM Link**: Click "Link to Contact" (or call `POST /api/comms/threads/{id}/link-person`) and verify `person_id` is set.
---
## 10. Known Limitations
- **Call recording via WhatsApp API**: Neither WAHA nor Evolution supports native WhatsApp call recording. Call logs are designed for **external telephony intake** (manual upload or webhook from a PBX/VoIP system). Recording file + transcript workflow is scaffolded but needs a real transcription provider (OpenAI Whisper, AWS Transcribe, or faster-whisper) wired in.
- **Media downloads**: `get_media()` is stubbed for WAHA/Evolution. Production needs signed URL handling or local file download.
- **Meta Cloud API adapter**: Not yet implemented. Add `comms_meta_provider.py` when Meta Business verification is complete.
- **Template messages**: Only placeholder methods exist. Template approval flow (Meta) or local template storage must be built for outbound campaigns.
- **Webhook auth**: Currently accepts any payload. Add HMAC/signature verification per provider before production.
- **Rate limiting**: Not implemented. Add FastAPI rate-limit middleware on `/api/comms/webhooks/{provider}`.
- **phonenumbers library**: `comms_ingest.py` gracefully degrades to regex if `phonenumbers` is not installed. Install it for robust E.164 normalization:
```bash
pip install phonenumbers
```
---
## 11. What Still Needs Real Credentials
| Item | What You Need |
|------|---------------|
| **Evolution API** | A running Evolution instance (Docker), API key, and a paired WhatsApp number. |
| **WAHA** | A running WAHA container, session QR-scan, and API key. |
| **Meta Cloud API** | Meta Business Manager, verified business, WhatsApp Business Account, permanent access token, phone number ID. |
| **Transcription** | OpenAI API key (for Whisper) or local faster-whisper model path. |
| **CRM enrichment** | Ensure `crm_people` table exists with `primary_phone` indexed. |
---
## 12. What to Verify Before Production
- [ ] Webhook endpoint is exposed via HTTPS (ngrok/cloudflare tunnel for local dev).
- [ ] `COMMS_WEBHOOK_SECRET` is set and signature verification is enabled in `routes_comms.py`.
- [ ] Database has indexes on `comms_threads(phone_e164)` and `comms_messages(thread_id, created_at)`.
- [ ] `crm_people.primary_phone` is normalized to E.164 before comms matching.
- [ ] Media storage directory exists and is writable (`COMMS_MEDIA_STORAGE_DIR`).
- [ ] Outbound message queue / retry logic is added (currently synchronous).
- [ ] GDPR/opt-out handling is implemented if targeting EU markets.
- [ ] Backup strategy for `comms_messages` (contains legal conversation records).
---
## 13. Next Iteration Ideas
- **Bulk broadcast**: Template-based outbound to filtered CRM segments.
- **AI reply suggestions**: Integrate Oracle / local LLM to draft replies based on CRM context.
- **Voice notes**: Upload `.ogg` audio, transcribe, store transcript as message.
- **Read receipts**: Poll provider for delivery/read status and update `comms_messages`.
- **Assignment rules**: Round-robin or load-based auto-assignment to agents.
---
*Document generated for Project Velocity v1.1 — Comms Module Integration*

View File

@@ -11,6 +11,7 @@ import { Inventory } from '@/components/modules/Inventory';
import { Settings } from '@/components/modules/Settings';
import { Catalyst } from '@/components/modules/Catalyst';
import { CRM } from '@/components/modules/CRM';
import { Comms } from '@/components/modules/Comms';
import { NotificationCenter } from '@/components/layout/NotificationCenter';
import { useCrmBootstrap } from '@/hooks/useCrmBootstrap';
import type { ModuleId } from '@/types';
@@ -53,6 +54,7 @@ export const MODULE_ROUTES: Array<{
{ id: 'inventory', path: '/inventory', title: 'Inventory', component: Inventory },
{ id: 'catalyst', path: '/catalyst', title: 'The Catalyst', component: Catalyst },
{ id: 'crm', path: '/crm', title: 'CRM', component: CRM },
{ id: 'comms', path: '/comms', title: 'Conversations', component: Comms },
{ id: 'settings', path: '/settings', title: 'Settings', component: Settings },
{ id: 'admin', path: '/admin', title: 'Admin', component: AdminPage, adminOnly: true },
];

View File

@@ -9,6 +9,7 @@ import {
Megaphone,
Shield,
Users,
MessageCircle,
type LucideIcon,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
@@ -21,6 +22,7 @@ const NAV_ICONS: Record<string, LucideIcon> = {
'/sentinel': ScanFace,
'/inventory': Building2,
'/catalyst': Megaphone,
'/comms': MessageCircle,
'/settings': Sliders,
'/admin': Shield,
'/crm': Users,

View File

@@ -0,0 +1,728 @@
import { useEffect, useRef, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
MessageCircle,
Search,
Send,
Phone,
PhoneCall,
Paperclip,
MoreVertical,
Link as LinkIcon,
User,
Settings,
Bot,
AlertCircle,
CheckCheck,
Clock,
ChevronLeft,
Inbox,
Voicemail,
Plus,
Hash,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import {
fetchCommsThreads,
fetchCommsMessages,
sendCommsMessage,
linkCommsThreadToPerson,
fetchCommsSettings,
} from '@/lib/commsApi';
import type {
CommsThread,
CommsMessage,
CommsSettings,
SendMessagePayload,
} from '@/types/commsTypes';
/* ── Mock generators for demo / offline mode ─────────────────────────────── */
function generateMockThreads(): CommsThread[] {
const now = new Date().toISOString();
return [
{
threadId: 'mock-1',
phoneE164: '+919876543210',
displayName: 'Rahul Sharma',
channel: 'whatsapp',
status: 'open',
unreadCount: 2,
lastMessageAt: now,
lastMessagePreview: 'Is the 3BHK still available?',
provider: 'mock',
createdAt: now,
updatedAt: now,
crmPerson: {
id: 'p1',
fullName: 'Rahul Sharma',
primaryPhone: '+919876543210',
leadStatus: 'hot',
projectName: 'Atri Aqua',
},
},
{
threadId: 'mock-2',
phoneE164: '+919988776655',
displayName: 'Unknown Number',
channel: 'whatsapp',
status: 'open',
unreadCount: 1,
lastMessageAt: now,
lastMessagePreview: 'Send me the brochure please',
provider: 'mock',
createdAt: now,
updatedAt: now,
},
{
threadId: 'mock-3',
phoneE164: '+911122334455',
displayName: 'Priya Patel',
channel: 'call',
status: 'resolved',
unreadCount: 0,
lastMessageAt: now,
provider: 'mock',
createdAt: now,
updatedAt: now,
crmPerson: {
id: 'p2',
fullName: 'Priya Patel',
primaryPhone: '+911122334455',
leadStatus: 'qualified',
projectName: 'Godrej Elevate',
},
},
];
}
function generateMockMessages(threadId: string): CommsMessage[] {
const now = new Date();
const t1 = new Date(now.getTime() - 1000 * 60 * 60 * 2).toISOString();
const t2 = new Date(now.getTime() - 1000 * 60 * 30).toISOString();
const t3 = new Date(now.getTime() - 1000 * 60 * 5).toISOString();
if (threadId === 'mock-1') {
return [
{
messageId: 'm1',
threadId,
direction: 'inbound',
messageType: 'text',
body: 'Hi, I saw your listing for Atri Aqua. Is the 3BHK still available?',
deliveryStatus: 'read',
createdAt: t1,
provider: 'mock',
senderName: 'Rahul Sharma',
},
{
messageId: 'm2',
threadId,
direction: 'outbound',
messageType: 'text',
body: 'Yes sir, absolutely. We have a premium corner unit on the 12th floor with marina view.',
deliveryStatus: 'read',
createdAt: t2,
provider: 'mock',
},
{
messageId: 'm3',
threadId,
direction: 'inbound',
messageType: 'text',
body: 'What is the final price and can I schedule a visit this weekend?',
deliveryStatus: 'delivered',
createdAt: t3,
provider: 'mock',
senderName: 'Rahul Sharma',
},
];
}
if (threadId === 'mock-2') {
return [
{
messageId: 'm4',
threadId,
direction: 'inbound',
messageType: 'text',
body: 'Send me the brochure please',
deliveryStatus: 'delivered',
createdAt: t3,
provider: 'mock',
senderName: 'Unknown',
},
];
}
return [];
}
/* ── Component ───────────────────────────────────────────────────────────── */
export function Comms() {
useStore();
const [threads, setThreads] = useState<CommsThread[]>([]);
const [activeThreadId, setActiveThreadId] = useState<string | null>(null);
const [messages, setMessages] = useState<CommsMessage[]>([]);
const [settings, setSettings] = useState<CommsSettings | null>(null);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [composerText, setComposerText] = useState('');
const [sending, setSending] = useState(false);
const [mockMode, setMockMode] = useState(false);
const [showCrmRail, setShowCrmRail] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const activeThread = threads.find((t) => t.threadId === activeThreadId) || null;
useEffect(() => { loadInitial(); }, []);
useEffect(() => { if (activeThreadId) loadMessages(activeThreadId); }, [activeThreadId]);
useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]);
async function loadInitial() {
try {
setLoading(true);
const [threadsRes, settingsRes] = await Promise.all([
fetchCommsThreads({ limit: 50 }),
fetchCommsSettings().catch(() => null),
]);
setThreads(threadsRes.threads || []);
setSettings(settingsRes);
setMockMode(false);
} catch (e) {
console.warn('Comms backend unavailable, switching to mock mode', e);
setMockMode(true);
setThreads(generateMockThreads());
setSettings({
provider: 'mock',
webhookSecretSet: false,
autoLinkByPhone: false,
createCrmInteractionOnInbound: false,
defaultCountryCode: '91',
});
} finally {
setLoading(false);
}
}
async function loadMessages(threadId: string) {
try {
if (mockMode) {
setMessages(generateMockMessages(threadId));
return;
}
const res = await fetchCommsMessages(threadId, { limit: 100 });
setMessages(res.messages || []);
} catch (e) {
setMessages([]);
}
}
async function handleSend(e?: React.FormEvent) {
e?.preventDefault();
if (!composerText.trim() || !activeThreadId) return;
const payload: SendMessagePayload = {
messageType: 'text',
body: composerText.trim(),
};
const optimistic: CommsMessage = {
messageId: `opt-${Date.now()}`,
threadId: activeThreadId,
direction: 'outbound',
messageType: 'text',
body: payload.body,
deliveryStatus: 'pending',
createdAt: new Date().toISOString(),
provider: activeThread?.provider || 'mock',
};
setMessages((prev) => [...prev, optimistic]);
setComposerText('');
setSending(true);
try {
if (mockMode) {
await new Promise((r) => setTimeout(r, 800));
setMessages((prev) =>
prev.map((m) =>
m.messageId === optimistic.messageId
? { ...m, deliveryStatus: 'sent', messageId: `mock-${Date.now()}` }
: m
)
);
} else {
await sendCommsMessage(activeThreadId, payload);
await loadMessages(activeThreadId);
}
} catch (err) {
setMessages((prev) =>
prev.map((m) =>
m.messageId === optimistic.messageId ? { ...m, deliveryStatus: 'failed' } : m
)
);
} finally {
setSending(false);
}
}
async function handleLinkPerson(personId: string) {
if (!activeThreadId || mockMode) return;
try {
await linkCommsThreadToPerson(activeThreadId, { personId });
await loadInitial();
if (activeThreadId) await loadMessages(activeThreadId);
} catch (e) {
console.error(e);
}
}
const filteredThreads = threads.filter((t) => {
const q = searchQuery.toLowerCase();
return (
(t.displayName || '').toLowerCase().includes(q) ||
t.phoneE164.includes(q) ||
(t.lastMessagePreview || '').toLowerCase().includes(q)
);
});
if (loading) {
return (
<div className="h-full flex items-center justify-center text-zinc-400">
<div className="flex items-center gap-3">
<div className="w-5 h-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
Loading conversations
</div>
</div>
);
}
/* ── Empty state: provider not configured ──────────────────────────────── */
if (!mockMode && (!settings || settings.provider === 'mock')) {
return (
<div className="h-full flex items-center justify-center">
<div
className="max-w-md w-full p-8 rounded-2xl text-center"
style={{
background: 'hsl(var(--surface))',
border: '1px solid hsl(var(--border-subtle))',
}}
>
<div
className="w-14 h-14 rounded-2xl flex items-center justify-center mx-auto mb-5"
style={{ background: 'hsl(var(--accent) / 0.1)' }}
>
<MessageCircle className="w-7 h-7 text-blue-400" />
</div>
<h2 className="text-lg font-semibold text-white mb-2">Conversations</h2>
<p className="text-sm text-zinc-400 mb-6">
Connect a WhatsApp provider to start receiving and sending messages.
Until then, preview the interface with mock data.
</p>
<div className="flex gap-3 justify-center">
<button
onClick={() => setMockMode(true)}
className="px-4 py-2 rounded-xl text-sm font-medium text-white transition-colors"
style={{ background: 'hsl(var(--surface-2))', border: '1px solid hsl(var(--border-subtle))' }}
>
<Bot className="w-4 h-4 inline mr-2" />
Preview Mock Data
</button>
<button
onClick={() => { /* router push to settings */ }}
className="px-4 py-2 rounded-xl text-sm font-medium text-white transition-colors"
style={{ background: 'hsl(var(--accent))' }}
>
<Settings className="w-4 h-4 inline mr-2" />
Configure Provider
</button>
</div>
</div>
</div>
);
}
return (
<div className="h-full flex gap-4" style={{ minHeight: 0 }}>
{/* ════════════════════════════════════════════════════════════════════
LEFT: Inbox
════════════════════════════════════════════════════════════════════ */}
<aside
className="flex-none flex flex-col rounded-2xl overflow-hidden"
style={{
width: 320,
background: 'hsl(var(--surface))',
border: '1px solid hsl(var(--border-subtle))',
}}
>
<div className="p-4 border-b" style={{ borderColor: 'hsl(var(--border-subtle))' }}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-white">Inbox</h2>
{mockMode && (
<span className="tag text-amber-400 border-amber-500/20 bg-amber-500/10">
Mock Mode
</span>
)}
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
<input
type="text"
placeholder="Search threads…"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 rounded-xl text-sm text-white placeholder-zinc-500 outline-none"
style={{
background: 'hsl(var(--surface-2))',
border: '1px solid hsl(var(--border-subtle))',
}}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{filteredThreads.length === 0 ? (
<div className="p-8 text-center text-zinc-500 text-sm">No threads found</div>
) : (
filteredThreads.map((thread) => (
<button
key={thread.threadId}
onClick={() => setActiveThreadId(thread.threadId)}
className="w-full text-left px-4 py-3 transition-colors relative"
style={{
background: activeThreadId === thread.threadId ? 'hsl(var(--accent) / 0.08)' : 'transparent',
borderLeft: activeThreadId === thread.threadId ? '2px solid hsl(var(--accent))' : '2px solid transparent',
}}
>
<div className="flex items-start gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0"
style={{
background: thread.crmPerson ? 'hsl(var(--accent) / 0.15)' : 'hsl(var(--surface-3))',
color: thread.crmPerson ? 'hsl(var(--accent))' : 'hsl(var(--muted-fg))',
}}
>
{thread.displayName?.[0]?.toUpperCase() || <User className="w-4 h-4" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white truncate">
{thread.displayName || thread.phoneE164}
</span>
{thread.unreadCount > 0 && (
<span className="ml-2 px-1.5 py-0.5 rounded-md text-[10px] font-bold bg-blue-500 text-white">
{thread.unreadCount}
</span>
)}
</div>
<p className="text-xs text-zinc-400 truncate mt-0.5">{thread.lastMessagePreview || 'No messages'}</p>
<div className="flex items-center gap-2 mt-1">
<span className="text-[10px] text-zinc-500">{thread.phoneE164}</span>
{!thread.personId && (
<span className="text-[10px] px-1.5 rounded bg-amber-500/10 text-amber-400 border border-amber-500/20">
Unresolved
</span>
)}
{thread.channel === 'call' && <Phone className="w-3 h-3 text-zinc-500" />}
</div>
</div>
</div>
</button>
))
)}
</div>
</aside>
{/* ════════════════════════════════════════════════════════════════════
CENTER: Conversation Timeline
════════════════════════════════════════════════════════════════════ */}
<section
className="flex-1 flex flex-col rounded-2xl overflow-hidden"
style={{
background: 'hsl(var(--surface))',
border: '1px solid hsl(var(--border-subtle))',
}}
>
{activeThread ? (
<>
{/* ── Client Identity Strip ── */}
<div
className="flex-none px-5 py-3 flex items-center justify-between border-b"
style={{ borderColor: 'hsl(var(--border-subtle))' }}
>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
style={{
background: activeThread.crmPerson ? 'hsl(var(--accent) / 0.15)' : 'hsl(var(--surface-3))',
color: activeThread.crmPerson ? 'hsl(var(--accent))' : 'hsl(var(--muted-fg))',
}}
>
{activeThread.displayName?.[0]?.toUpperCase() || <User className="w-4 h-4" />}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">
{activeThread.displayName || activeThread.phoneE164}
</h3>
{activeThread.channel === 'whatsapp' && (
<span className="tag text-emerald-400 border-emerald-500/20 bg-emerald-500/10 text-[10px]">
WhatsApp
</span>
)}
{activeThread.channel === 'call' && (
<span className="tag text-blue-300 border-blue-400/20 bg-blue-400/10 text-[10px]">
Call
</span>
)}
</div>
<p className="text-xs text-zinc-400">{activeThread.phoneE164}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button className="p-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white transition-colors">
<PhoneCall className="w-4 h-4" />
</button>
<button className="p-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white transition-colors">
<MoreVertical className="w-4 h-4" />
</button>
<button
onClick={() => setShowCrmRail((v) => !v)}
className="p-2 rounded-lg hover:bg-white/5 text-zinc-400 hover:text-white transition-colors"
title="Toggle CRM rail"
>
<ChevronLeft className={`w-4 h-4 transition-transform ${showCrmRail ? '' : 'rotate-180'}`} />
</button>
</div>
</div>
{/* ── Messages ── */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-5 py-4 space-y-4">
<AnimatePresence initial={false}>
{messages.map((msg) => (
<motion.div
key={msg.messageId}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
className={`flex ${msg.direction === 'outbound' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[70%] px-4 py-2.5 rounded-2xl text-sm ${
msg.direction === 'outbound' ? 'rounded-br-md' : 'rounded-bl-md'
}`}
style={{
background:
msg.direction === 'outbound'
? 'hsl(var(--accent) / 0.9)'
: 'hsl(var(--surface-3))',
color: msg.direction === 'outbound' ? '#fff' : 'hsl(var(--foreground))',
}}
>
{msg.messageType === 'text' && <p className="leading-relaxed">{msg.body}</p>}
{msg.mediaUrl && (
<div className="mt-2 rounded-lg overflow-hidden">
<img src={msg.mediaUrl} alt="media" className="max-w-full max-h-48 object-cover" />
</div>
)}
<div className="flex items-center justify-end gap-1.5 mt-1.5">
<span className="text-[10px] opacity-60">
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
{msg.direction === 'outbound' && (
<>
{msg.deliveryStatus === 'pending' && <Clock className="w-3 h-3 opacity-60" />}
{msg.deliveryStatus === 'sent' && <CheckCheck className="w-3 h-3 opacity-60" />}
{msg.deliveryStatus === 'delivered' && <CheckCheck className="w-3 h-3 text-blue-200" />}
{msg.deliveryStatus === 'read' && <CheckCheck className="w-3 h-3 text-emerald-300" />}
{msg.deliveryStatus === 'failed' && <AlertCircle className="w-3 h-3 text-red-300" />}
</>
)}
</div>
</div>
</motion.div>
))}
</AnimatePresence>
<div ref={messagesEndRef} />
</div>
{/* ── Composer ── */}
<div
className="flex-none px-4 py-3 border-t"
style={{ borderColor: 'hsl(var(--border-subtle))' }}
>
{!activeThread.personId && (
<div
className="mb-3 px-3 py-2 rounded-xl flex items-center gap-2 text-xs"
style={{
background: 'hsl(38 92% 50% / 0.08)',
border: '1px solid hsl(38 92% 50% / 0.2)',
color: 'hsl(38 92% 65%)',
}}
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
<span className="flex-1">This number is not linked to a CRM contact.</span>
<button
onClick={() => handleLinkPerson('demo-person-id')}
className="px-2 py-1 rounded-md bg-amber-500/10 hover:bg-amber-500/20 transition-colors font-medium"
>
Link
</button>
</div>
)}
<form onSubmit={handleSend} className="flex items-end gap-2">
<button
type="button"
className="p-2.5 rounded-xl text-zinc-400 hover:text-white hover:bg-white/5 transition-colors"
>
<Paperclip className="w-5 h-5" />
</button>
<div className="flex-1 relative">
<textarea
rows={1}
value={composerText}
onChange={(e) => setComposerText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
placeholder="Type a message…"
className="w-full px-4 py-2.5 rounded-xl text-sm text-white placeholder-zinc-500 outline-none resize-none max-h-32"
style={{
background: 'hsl(var(--surface-2))',
border: '1px solid hsl(var(--border-subtle))',
}}
/>
</div>
<button
type="submit"
disabled={!composerText.trim() || sending}
className="p-2.5 rounded-xl text-white transition-all disabled:opacity-40 disabled:cursor-not-allowed"
style={{ background: 'hsl(var(--accent))' }}
>
<Send className="w-5 h-5" />
</button>
</form>
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-zinc-500">
<Inbox className="w-12 h-12 mb-4 opacity-20" />
<p className="text-sm">Select a conversation to start</p>
</div>
)}
</section>
{/* ════════════════════════════════════════════════════════════════════
RIGHT: CRM Intelligence Rail
════════════════════════════════════════════════════════════════════ */}
<AnimatePresence>
{showCrmRail && activeThread && (
<motion.aside
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="flex-none flex flex-col rounded-2xl overflow-hidden"
style={{
width: 300,
background: 'hsl(var(--surface))',
border: '1px solid hsl(var(--border-subtle))',
}}
>
<div className="p-4 border-b" style={{ borderColor: 'hsl(var(--border-subtle))' }}>
<h3 className="text-sm font-semibold text-white">CRM Intelligence</h3>
<p className="text-xs text-zinc-500 mt-0.5">Velocity Lens · {activeThread.phoneE164}</p>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 space-y-4">
{activeThread.crmPerson ? (
<>
<div
className="p-4 rounded-xl"
style={{ background: 'hsl(var(--surface-2))', border: '1px solid hsl(var(--border-subtle))' }}
>
<div className="flex items-center gap-3 mb-3">
<div
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-semibold"
style={{ background: 'hsl(var(--accent) / 0.15)', color: 'hsl(var(--accent))' }}
>
{activeThread.crmPerson.fullName?.[0] || 'C'}
</div>
<div>
<p className="text-sm font-medium text-white">{activeThread.crmPerson.fullName || 'Unknown'}</p>
<p className="text-xs text-zinc-400">{activeThread.crmPerson.primaryEmail || 'No email'}</p>
</div>
</div>
<div className="space-y-2">
<div className="flex justify-between text-xs">
<span className="text-zinc-500">Status</span>
<span className="text-white capitalize">{activeThread.crmPerson.leadStatus || 'New'}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-zinc-500">Project</span>
<span className="text-white">{activeThread.crmPerson.projectName || '-'}</span>
</div>
<div className="flex justify-between text-xs">
<span className="text-zinc-500">Buyer Type</span>
<span className="text-white">{activeThread.crmPerson.buyerType || '-'}</span>
</div>
</div>
</div>
<div
className="p-4 rounded-xl"
style={{ background: 'hsl(var(--surface-2))', border: '1px solid hsl(var(--border-subtle))' }}
>
<h4 className="text-xs font-semibold text-zinc-300 uppercase tracking-wider mb-3">Next Best Action</h4>
<p className="text-sm text-white font-medium">Schedule site visit</p>
<p className="text-xs text-zinc-400 mt-1">High intent signal detected</p>
</div>
<div
className="p-4 rounded-xl"
style={{ background: 'hsl(var(--surface-2))', border: '1px solid hsl(var(--border-subtle))' }}
>
<h4 className="text-xs font-semibold text-zinc-300 uppercase tracking-wider mb-3">Quick Actions</h4>
<div className="space-y-2">
<button className="w-full text-left px-3 py-2 rounded-lg text-xs text-white hover:bg-white/5 transition-colors flex items-center gap-2">
<Phone className="w-3.5 h-3.5 text-zinc-400" />
Log Call
</button>
<button className="w-full text-left px-3 py-2 rounded-lg text-xs text-white hover:bg-white/5 transition-colors flex items-center gap-2">
<Voicemail className="w-3.5 h-3.5 text-zinc-400" />
Add Recording
</button>
<button className="w-full text-left px-3 py-2 rounded-lg text-xs text-white hover:bg-white/5 transition-colors flex items-center gap-2">
<Plus className="w-3.5 h-3.5 text-zinc-400" />
Create Task
</button>
</div>
</div>
</>
) : (
<div
className="p-4 rounded-xl text-center"
style={{ background: 'hsl(var(--surface-2))', border: '1px solid hsl(var(--border-subtle))' }}
>
<Hash className="w-8 h-8 text-amber-500/60 mx-auto mb-3" />
<p className="text-sm text-white font-medium mb-1">Unresolved Number</p>
<p className="text-xs text-zinc-400 mb-4">
No CRM match found for {activeThread.phoneE164}
</p>
<button
onClick={() => handleLinkPerson('demo-person-id')}
className="w-full px-3 py-2 rounded-lg text-xs font-medium text-white transition-colors"
style={{ background: 'hsl(var(--accent))' }}
>
<LinkIcon className="w-3.5 h-3.5 inline mr-1.5" />
Link to Contact
</button>
</div>
)}
</div>
</motion.aside>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useRef, useState, type ChangeEvent } from 'react';
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
User,
@@ -16,12 +16,15 @@ import {
ChevronDown,
LogOut,
Pencil,
MessageCircle,
type LucideIcon,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import { useCurrency, CURRENCY_OPTIONS } from '@/store/useCurrencyStore';
import type { CurrencyCode } from '@/store/useCurrencyStore';
import { API_URL } from '@/lib/api';
import { fetchCommsSettings, testCommsProviderConnection, updateCommsSettings } from '@/lib/commsApi';
import type { CommsProvider, CommsSettings } from '@/types/commsTypes';
import {
clearVelocityToken,
getVelocityToken,
@@ -613,6 +616,160 @@ function DisplaySettings() {
}
// ── Data & Privacy ───────────────────────────────────────────────────────────
function CommunicationsSettings() {
const [settings, setSettings] = useState<CommsSettings | null>(null);
const [draft, setDraft] = useState<Partial<CommsSettings>>({});
const [statusText, setStatusText] = useState('Loading provider settings...');
const [saving, setSaving] = useState(false);
const current: CommsSettings = {
provider: 'mock',
providerBaseUrl: '',
providerApiKey: '',
instanceId: '',
phoneNumberId: '',
webhookCallbackUrl: '/api/comms/webhooks/{provider}',
webhookSecretSet: false,
autoLinkByPhone: true,
createCrmInteractionOnInbound: true,
defaultCountryCode: '91',
transcriptionProvider: 'none',
...(settings ?? {}),
...draft,
};
useEffect(() => {
let cancelled = false;
void fetchCommsSettings()
.then((value) => {
if (cancelled) return;
setSettings(value);
setStatusText('Settings loaded from backend.');
})
.catch((error) => {
if (cancelled) return;
setStatusText(error instanceof Error ? error.message : 'Unable to load comms settings.');
});
return () => {
cancelled = true;
};
}, []);
const update = <K extends keyof CommsSettings>(key: K, value: CommsSettings[K]) => {
setDraft((prev) => ({ ...prev, [key]: value }));
};
const save = async () => {
setSaving(true);
try {
await updateCommsSettings(draft);
const latest = await fetchCommsSettings();
setSettings(latest);
setDraft({});
setStatusText('Communications settings saved.');
} catch (error) {
setStatusText(error instanceof Error ? error.message : 'Failed to save communications settings.');
} finally {
setSaving(false);
}
};
const test = async () => {
try {
const result = await testCommsProviderConnection();
setStatusText(result.message || (result.success ? 'Provider connection succeeded.' : 'Provider connection failed.'));
} catch (error) {
setStatusText(error instanceof Error ? error.message : 'Provider test failed.');
}
};
const fieldClass = "w-64 rounded-xl px-3 py-2 text-sm text-white placeholder-zinc-500 outline-none";
return (
<GlassCard delay={0.3}>
<SectionHeader icon={MessageCircle} title="Communications" accent="#22d3ee" />
<div>
<SettingsRow label="Provider" description="Mock is local preview. WAHA and Evolution require a running provider service.">
<DarkSelect
value={current.provider}
onChange={(v) => update('provider', v as CommsProvider)}
options={[
{ value: 'mock', label: 'Mock' },
{ value: 'waha', label: 'WAHA' },
{ value: 'evolution', label: 'Evolution API' },
{ value: 'meta_cloud', label: 'Meta Cloud API' },
]}
/>
</SettingsRow>
<SettingsRow label="Provider Base URL" description="Internal or public base URL for WAHA/Evolution.">
<input
className={fieldClass}
style={INNER_SURFACE}
value={current.providerBaseUrl ?? ''}
onChange={(event) => update('providerBaseUrl', event.target.value)}
placeholder="http://127.0.0.1:3000"
/>
</SettingsRow>
<SettingsRow label="API Key" description="Stored in backend comms settings. Masked when read back.">
<input
className={fieldClass}
style={INNER_SURFACE}
type="password"
value={current.providerApiKey ?? ''}
onChange={(event) => update('providerApiKey', event.target.value)}
placeholder="Provider API key"
/>
</SettingsRow>
<SettingsRow label="Instance / Session" description="WAHA session name or Evolution instance name.">
<input
className={fieldClass}
style={INNER_SURFACE}
value={current.instanceId ?? ''}
onChange={(event) => update('instanceId', event.target.value)}
placeholder="default"
/>
</SettingsRow>
<SettingsRow label="Webhook URL" description="Point provider inbound webhooks here.">
<span className="text-xs font-mono text-zinc-300">{current.webhookCallbackUrl || '/api/comms/webhooks/{provider}'}</span>
</SettingsRow>
<SettingsRow label="Auto-link by Phone" description="Match inbound numbers to crm_people.primary_phone.">
<Toggle enabled={Boolean(current.autoLinkByPhone)} onChange={(v) => update('autoLinkByPhone', v)} />
</SettingsRow>
<SettingsRow label="Create CRM Interaction" description="Mirror inbound messages into canonical intelligence tables.">
<Toggle enabled={Boolean(current.createCrmInteractionOnInbound)} onChange={(v) => update('createCrmInteractionOnInbound', v)} />
</SettingsRow>
<SettingsRow label="Transcription Provider" description="Recording intake is stored now; transcription worker can be added later.">
<DarkSelect
value={current.transcriptionProvider ?? 'none'}
onChange={(v) => update('transcriptionProvider', v as CommsSettings['transcriptionProvider'])}
options={[
{ value: 'none', label: 'None' },
{ value: 'local', label: 'Local Whisper' },
{ value: 'openai', label: 'OpenAI' },
]}
/>
</SettingsRow>
<div className="px-6 py-4 flex items-center justify-between gap-3">
<p className="text-xs text-zinc-400">{statusText}</p>
<div className="flex items-center gap-2">
<GhostButton onClick={test}>Test</GhostButton>
<motion.button
type="button"
onClick={save}
disabled={saving || Object.keys(draft).length === 0}
className="px-4 py-2 rounded-xl text-sm font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
style={{ background: 'hsl(var(--accent))', color: 'hsl(var(--accent-fg))' }}
whileHover={{ scale: saving ? 1 : 1.02 }}
whileTap={{ scale: saving ? 1 : 0.97 }}
>
{saving ? 'Saving...' : 'Save'}
</motion.button>
</div>
</div>
</div>
</GlassCard>
);
}
function DataSettings() {
const [retention, setRetention] = useState('90');
const { leads, messages, units, status } = useStore();
@@ -728,9 +885,14 @@ export function Settings() {
<DisplaySettings />
</div>
{/* Row 4: Data + About */}
{/* Row 4: Communications + Data */}
<div className="grid grid-cols-2 gap-4 relative z-10">
<CommunicationsSettings />
<DataSettings />
</div>
{/* Row 5: About */}
<div className="grid grid-cols-1 gap-4 relative z-0">
<AboutSection />
</div>
</div>

89
app/src/lib/commsApi.ts Normal file
View File

@@ -0,0 +1,89 @@
import { getVelocityToken } from './velocityPlatformClient';
import type {
CommsThread,
CommsSettings,
CommsProviderTestResult,
SendMessagePayload,
CommsThreadListResponse,
CommsMessageListResponse,
ThreadLinkPayload,
} from '@/types/commsTypes';
const API_BASE = import.meta.env.VITE_API_BASE_URL || '';
async function commsFetch(path: string, options?: RequestInit) {
const token = getVelocityToken();
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options?.headers,
},
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${res.status}`);
}
return res.json();
}
function toQuery(params?: Record<string, string | number | undefined>) {
const query = new URLSearchParams();
Object.entries(params ?? {}).forEach(([key, value]) => {
if (value !== undefined && value !== '') query.set(key, String(value));
});
const serialized = query.toString();
return serialized ? `?${serialized}` : '';
}
export const fetchCommsThreads = (params?: { status?: string; search?: string; limit?: number; offset?: number }) =>
commsFetch(`/api/comms/threads${toQuery(params)}`) as Promise<CommsThreadListResponse>;
export const fetchCommsThread = (threadId: string) =>
commsFetch(`/api/comms/threads/${threadId}`) as Promise<CommsThread>;
export const fetchCommsMessages = (threadId: string, params?: { limit?: number; offset?: number }) =>
commsFetch(`/api/comms/threads/${threadId}/messages${toQuery(params)}`) as Promise<CommsMessageListResponse>;
export const sendCommsMessage = (threadId: string, payload: SendMessagePayload) =>
commsFetch(`/api/comms/threads/${threadId}/messages`, {
method: 'POST',
body: JSON.stringify(payload),
});
export const linkCommsThreadToPerson = (threadId: string, payload: ThreadLinkPayload) =>
commsFetch(`/api/comms/threads/${threadId}/link-person`, {
method: 'POST',
body: JSON.stringify(payload),
});
export const addCommsThreadNote = (threadId: string, body: { content: string }) =>
commsFetch(`/api/comms/threads/${threadId}/notes`, {
method: 'POST',
body: JSON.stringify(body),
});
export const addCommsThreadTask = (threadId: string, body: { title: string; dueAt?: string }) =>
commsFetch(`/api/comms/threads/${threadId}/tasks`, {
method: 'POST',
body: JSON.stringify(body),
});
export const fetchCommsSettings = () =>
commsFetch(`/api/comms/settings`) as Promise<CommsSettings>;
export const updateCommsSettings = (payload: Partial<CommsSettings>) =>
commsFetch(`/api/comms/settings`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
export const testCommsProviderConnection = () =>
commsFetch(`/api/comms/provider/test`, { method: 'POST' }) as Promise<CommsProviderTestResult>;
export const transcribeCommsRecording = (callId: string) =>
commsFetch(`/api/comms/recordings/transcribe`, {
method: 'POST',
body: JSON.stringify({ callId }),
});

121
app/src/types/commsTypes.ts Normal file
View File

@@ -0,0 +1,121 @@
// Velocity Comms Types
// Native types for the Conversations module
export type CommsChannel = 'whatsapp' | 'sms' | 'call';
export type CommsThreadStatus = 'open' | 'resolved' | 'spam' | 'archived';
export type CommsMessageDirection = 'inbound' | 'outbound' | 'system';
export type CommsMessageType = 'text' | 'image' | 'video' | 'audio' | 'document' | 'location' | 'template';
export type CommsProvider = 'mock' | 'waha' | 'evolution' | 'meta_cloud';
export type CommsDeliveryStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'failed';
export interface CommsThread {
threadId: string;
provider: CommsProvider;
externalThreadId?: string;
personId?: string;
phoneE164: string;
displayName?: string;
channel: CommsChannel;
status: CommsThreadStatus;
assignedUserId?: string;
lastMessageAt?: string;
unreadCount: number;
metadataJson?: Record<string, unknown>;
createdAt: string;
updatedAt: string;
crmPerson?: {
id: string;
fullName?: string;
primaryPhone?: string;
primaryEmail?: string;
buyerType?: string;
leadStatus?: string;
projectName?: string;
};
lastMessagePreview?: string;
}
export interface CommsMessage {
messageId: string;
threadId: string;
provider: CommsProvider;
externalMessageId?: string;
direction: CommsMessageDirection;
messageType: CommsMessageType;
body: string;
mediaUrl?: string;
mediaMimeType?: string;
deliveryStatus: CommsDeliveryStatus;
sentAt?: string;
deliveredAt?: string;
readAt?: string;
rawPayload?: Record<string, unknown>;
createdAt: string;
senderName?: string;
senderAvatar?: string;
}
export interface CommsCallLog {
callId: string;
threadId?: string;
personId?: string;
provider: CommsProvider;
externalCallId?: string;
phoneE164: string;
direction: 'inbound' | 'outbound';
status: 'ringing' | 'answered' | 'missed' | 'voicemail' | 'completed';
startedAt: string;
endedAt?: string;
durationSeconds?: number;
recordingUrl?: string;
transcriptId?: string;
transcriptText?: string;
rawPayload?: Record<string, unknown>;
createdAt: string;
}
export interface CommsSettings {
provider: CommsProvider;
providerBaseUrl?: string;
providerApiKey?: string;
instanceId?: string;
phoneNumberId?: string;
webhookCallbackUrl?: string;
webhookSecretSet: boolean;
defaultAssignmentUserId?: string;
autoLinkByPhone: boolean;
createCrmInteractionOnInbound: boolean;
defaultCountryCode: string;
mediaStorageDir?: string;
transcriptionProvider?: 'none' | 'openai' | 'local';
updatedAt?: string;
}
export interface ThreadLinkPayload {
personId: string;
}
export interface SendMessagePayload {
messageType: CommsMessageType;
body: string;
mediaUrl?: string;
templateName?: string;
templateLanguage?: string;
}
export interface CommsProviderTestResult {
success: boolean;
message: string;
accountInfo?: Record<string, unknown>;
}
export interface CommsThreadListResponse {
threads: CommsThread[];
total: number;
unreadTotal: number;
}
export interface CommsMessageListResponse {
messages: CommsMessage[];
thread: CommsThread;
}

View File

@@ -1,5 +1,5 @@
// Navigation Module Types
export type ModuleId = 'dashboard' | 'oracle' | 'sentinel' | 'inventory' | 'settings' | 'catalyst' | 'admin' | 'crm';
export type ModuleId = 'dashboard' | 'oracle' | 'sentinel' | 'inventory' | 'settings' | 'catalyst' | 'admin' | 'crm' | 'comms';
export type SentinelSubTab = 'overview' | 'live-session';

588
backend/api/routes_comms.py Normal file
View File

@@ -0,0 +1,588 @@
"""
Velocity Conversations API.
Native WhatsApp-first communications surface for Velocity WebOS. The routes are
provider-abstracted and CRM-aware, while remaining safe to run in mock mode.
"""
from __future__ import annotations
import hashlib
import hmac
import json
import os
from datetime import datetime
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from backend.auth.dependencies import UserPrincipal, get_current_user
from backend.services.comms_evolution_provider import EvolutionProvider
from backend.services.comms_ingest import ingest_inbound_message
from backend.services.comms_provider import MockProvider
from backend.services.comms_waha_provider import WahaProvider
router = APIRouter()
_SCHEMA_READY = False
class SendMessageBody(BaseModel):
messageType: str = "text"
body: str
mediaUrl: str | None = None
templateName: str | None = None
templateLanguage: str | None = None
class LinkPersonBody(BaseModel):
personId: str
class NoteBody(BaseModel):
content: str
class TaskBody(BaseModel):
title: str
dueAt: str | None = None
class SettingsPatch(BaseModel):
provider: str | None = None
providerBaseUrl: str | None = None
providerApiKey: str | None = None
instanceId: str | None = None
phoneNumberId: str | None = None
webhookCallbackUrl: str | None = None
webhookSecret: str | None = None
defaultAssignmentUserId: str | None = None
autoLinkByPhone: bool | None = None
createCrmInteractionOnInbound: bool | None = None
defaultCountryCode: str | None = None
transcriptionProvider: str | None = None
class TranscribeBody(BaseModel):
callId: str | None = None
recordingUrl: str | None = None
def _get_provider():
return _provider_from_config({})
def _provider_from_config(config: dict[str, Any], provider_override: str | None = None):
provider = (provider_override or config.get("provider") or os.getenv("COMMS_PROVIDER", "mock")).strip().lower()
base_url = (config.get("provider_base_url") or os.getenv("COMMS_PROVIDER_BASE_URL", "")).strip()
api_key = (config.get("provider_api_key") or os.getenv("COMMS_PROVIDER_API_KEY", "")).strip()
instance_id = (config.get("instance_id") or os.getenv("COMMS_INSTANCE_ID", "")).strip() or None
if provider == "waha":
return WahaProvider(base_url, api_key, instance_id)
if provider == "evolution":
return EvolutionProvider(base_url, api_key, instance_id)
return MockProvider("", "", "mock")
async def _load_config(pool) -> dict[str, Any]:
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT value_json FROM comms_settings WHERE key = 'config'")
return _json_obj(row["value_json"]) if row else {}
async def _get_provider_for_pool(pool, provider_override: str | None = None):
return _provider_from_config(await _load_config(pool), provider_override)
def _camel_settings(config: dict[str, Any], updated_at: datetime | None = None) -> dict[str, Any]:
return {
"provider": config.get("provider", os.getenv("COMMS_PROVIDER", "mock")),
"providerBaseUrl": config.get("provider_base_url", os.getenv("COMMS_PROVIDER_BASE_URL", "")),
"providerApiKey": config.get("provider_api_key", ""),
"instanceId": config.get("instance_id", os.getenv("COMMS_INSTANCE_ID", "")),
"phoneNumberId": config.get("phone_number_id", ""),
"webhookCallbackUrl": config.get("webhook_callback_url", "/api/comms/webhooks/{provider}"),
"webhookSecretSet": bool(config.get("webhook_secret_hash") or config.get("webhook_secret_set")),
"defaultAssignmentUserId": config.get("default_assignment_user_id"),
"autoLinkByPhone": bool(config.get("auto_link_by_phone", True)),
"createCrmInteractionOnInbound": bool(config.get("create_crm_interaction_on_inbound", True)),
"defaultCountryCode": str(config.get("default_country_code", os.getenv("COMMS_DEFAULT_COUNTRY_CODE", "91"))),
"mediaStorageDir": config.get("media_storage_dir", os.getenv("COMMS_MEDIA_STORAGE_DIR", "/opt/dlami/nvme/assets/comms")),
"transcriptionProvider": config.get("transcription_provider", os.getenv("COMMS_TRANSCRIPTION_PROVIDER", "none")),
**({"updatedAt": updated_at.isoformat()} if updated_at else {}),
}
def _snake_settings(body: SettingsPatch) -> dict[str, Any]:
mapping = {
"provider": "provider",
"providerBaseUrl": "provider_base_url",
"providerApiKey": "provider_api_key",
"instanceId": "instance_id",
"phoneNumberId": "phone_number_id",
"webhookCallbackUrl": "webhook_callback_url",
"defaultAssignmentUserId": "default_assignment_user_id",
"autoLinkByPhone": "auto_link_by_phone",
"createCrmInteractionOnInbound": "create_crm_interaction_on_inbound",
"defaultCountryCode": "default_country_code",
"transcriptionProvider": "transcription_provider",
}
raw = body.model_dump(exclude_unset=True)
updates: dict[str, Any] = {}
for src, dst in mapping.items():
if src in raw:
updates[dst] = raw[src]
if body.webhookSecret is not None:
updates["webhook_secret_hash"] = hashlib.sha256(body.webhookSecret.encode()).hexdigest() if body.webhookSecret else ""
updates["webhook_secret_set"] = bool(body.webhookSecret)
return updates
def _json_obj(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _record_value(row: Any, key: str, default: Any = None) -> Any:
try:
return row[key]
except (KeyError, IndexError, TypeError):
return default
async def _ensure_schema(pool) -> None:
global _SCHEMA_READY
if _SCHEMA_READY:
return
async with pool.acquire() as conn:
await conn.execute(
"""
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS comms_threads (
thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider TEXT NOT NULL DEFAULT 'mock',
external_thread_id TEXT,
person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL,
phone_e164 TEXT NOT NULL,
display_name TEXT,
channel TEXT NOT NULL DEFAULT 'whatsapp',
status TEXT NOT NULL DEFAULT 'open',
assigned_user_id UUID NULL,
last_message_at TIMESTAMPTZ,
unread_count INT NOT NULL DEFAULT 0,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comms_threads_phone_provider ON comms_threads(provider, phone_e164);
CREATE INDEX IF NOT EXISTS idx_comms_threads_person ON comms_threads(person_id) WHERE person_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_comms_threads_status ON comms_threads(status);
CREATE INDEX IF NOT EXISTS idx_comms_threads_last_message ON comms_threads(last_message_at DESC NULLS LAST);
CREATE TABLE IF NOT EXISTS comms_messages (
message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL REFERENCES comms_threads(thread_id) ON DELETE CASCADE,
provider TEXT NOT NULL DEFAULT 'mock',
external_message_id TEXT,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound', 'system')),
message_type TEXT NOT NULL DEFAULT 'text',
body TEXT NOT NULL DEFAULT '',
media_url TEXT,
media_mime_type TEXT,
delivery_status TEXT NOT NULL DEFAULT 'pending',
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comms_messages_thread ON comms_messages(thread_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_comms_messages_external ON comms_messages(external_message_id) WHERE external_message_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS comms_call_logs (
call_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NULL REFERENCES comms_threads(thread_id) ON DELETE SET NULL,
person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL,
provider TEXT NOT NULL DEFAULT 'mock',
external_call_id TEXT,
phone_e164 TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
status TEXT NOT NULL DEFAULT 'completed',
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
duration_seconds INT,
recording_url TEXT,
transcript_id UUID,
transcript_text TEXT,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comms_call_logs_phone ON comms_call_logs(phone_e164);
CREATE INDEX IF NOT EXISTS idx_comms_call_logs_thread ON comms_call_logs(thread_id) WHERE thread_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS comms_settings (
key TEXT PRIMARY KEY,
value_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO comms_settings (key, value_json)
VALUES ('config', '{"provider":"mock","auto_link_by_phone":true,"create_crm_interaction_on_inbound":true,"default_country_code":"91","transcription_provider":"none"}'::jsonb)
ON CONFLICT (key) DO NOTHING;
"""
)
_SCHEMA_READY = True
async def _pool(request: Request):
pool = request.app.state.db_pool
if pool is None:
raise HTTPException(status_code=503, detail="Database unavailable")
await _ensure_schema(pool)
return pool
def _row_thread(row) -> dict[str, Any]:
return {
"threadId": str(row["thread_id"]),
"provider": row["provider"],
"externalThreadId": row["external_thread_id"],
"personId": str(row["person_id"]) if row["person_id"] else None,
"phoneE164": row["phone_e164"],
"displayName": row["display_name"],
"channel": row["channel"],
"status": row["status"],
"assignedUserId": str(row["assigned_user_id"]) if row["assigned_user_id"] else None,
"lastMessageAt": row["last_message_at"].isoformat() if row["last_message_at"] else None,
"unreadCount": row["unread_count"],
"metadataJson": _json_obj(row["metadata_json"]),
"createdAt": row["created_at"].isoformat(),
"updatedAt": row["updated_at"].isoformat(),
"lastMessagePreview": _record_value(row, "last_message_preview"),
"crmPerson": {
"id": str(row["person_id"]),
"fullName": row["crm_full_name"],
"primaryPhone": row["crm_primary_phone"],
"primaryEmail": row["crm_primary_email"],
"buyerType": row["crm_buyer_type"],
"leadStatus": row["crm_lead_status"],
"projectName": row["crm_project_name"],
} if row["person_id"] else None,
}
@router.get("/threads")
async def list_threads(
request: Request,
status: str | None = None,
search: str | None = None,
limit: int = 50,
offset: int = 0,
_: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
limit = max(1, min(limit, 100))
offset = max(0, offset)
conditions = ["1=1"]
values: list[Any] = []
if status:
values.append(status)
conditions.append(f"t.status = ${len(values)}")
if search:
values.append(f"%{search}%")
conditions.append(f"(t.phone_e164 ILIKE ${len(values)} OR t.display_name ILIKE ${len(values)} OR p.full_name ILIKE ${len(values)} OR p.primary_email ILIKE ${len(values)})")
where_clause = " AND ".join(conditions)
async with pool.acquire() as conn:
rows = await conn.fetch(
f"""
SELECT t.*,
p.full_name AS crm_full_name,
p.primary_email AS crm_primary_email,
p.primary_phone AS crm_primary_phone,
p.buyer_type AS crm_buyer_type,
COALESCE(l.status, '') AS crm_lead_status,
(
SELECT pi.project_name FROM crm_property_interests pi
WHERE pi.person_id = p.person_id
ORDER BY pi.priority ASC NULLS LAST, pi.created_at DESC NULLS LAST
LIMIT 1
) AS crm_project_name,
(
SELECT m.body FROM comms_messages m
WHERE m.thread_id = t.thread_id
ORDER BY m.created_at DESC
LIMIT 1
) AS last_message_preview
FROM comms_threads t
LEFT JOIN crm_people p ON t.person_id = p.person_id
LEFT JOIN LATERAL (
SELECT status FROM crm_leads l
WHERE l.person_id = p.person_id
ORDER BY l.updated_at DESC NULLS LAST
LIMIT 1
) l ON TRUE
WHERE {where_clause}
ORDER BY t.last_message_at DESC NULLS LAST, t.updated_at DESC
LIMIT ${len(values)+1} OFFSET ${len(values)+2}
""",
*values,
limit,
offset,
)
total = await conn.fetchval(
f"SELECT COUNT(*) FROM comms_threads t LEFT JOIN crm_people p ON t.person_id = p.person_id WHERE {where_clause}",
*values,
)
unread = await conn.fetchval("SELECT COALESCE(SUM(unread_count),0)::int FROM comms_threads WHERE status = 'open'")
return {"threads": [_row_thread(row) for row in rows], "total": total or 0, "unreadTotal": unread or 0}
@router.get("/threads/{thread_id}")
async def get_thread(thread_id: str, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT t.*, p.full_name AS crm_full_name, p.primary_email AS crm_primary_email,
p.primary_phone AS crm_primary_phone, p.buyer_type AS crm_buyer_type,
COALESCE(l.status, '') AS crm_lead_status,
(
SELECT pi.project_name FROM crm_property_interests pi
WHERE pi.person_id = p.person_id
ORDER BY pi.priority ASC NULLS LAST, pi.created_at DESC NULLS LAST
LIMIT 1
) AS crm_project_name,
NULL::text AS last_message_preview
FROM comms_threads t
LEFT JOIN crm_people p ON t.person_id = p.person_id
LEFT JOIN LATERAL (
SELECT status FROM crm_leads l
WHERE l.person_id = p.person_id
ORDER BY l.updated_at DESC NULLS LAST
LIMIT 1
) l ON TRUE
WHERE t.thread_id = $1::uuid
""",
thread_id,
)
if not row:
raise HTTPException(status_code=404, detail="Thread not found")
return _row_thread(row)
@router.get("/threads/{thread_id}/messages")
async def list_messages(
thread_id: str,
request: Request,
limit: int = 100,
offset: int = 0,
_: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
limit = max(1, min(limit, 200))
offset = max(0, offset)
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT * FROM comms_messages
WHERE thread_id = $1::uuid
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
""",
thread_id,
limit,
offset,
)
messages = [
{
"messageId": str(row["message_id"]),
"threadId": str(row["thread_id"]),
"provider": row["provider"],
"externalMessageId": row["external_message_id"],
"direction": row["direction"],
"messageType": row["message_type"],
"body": row["body"],
"mediaUrl": row["media_url"],
"mediaMimeType": row["media_mime_type"],
"deliveryStatus": row["delivery_status"],
"sentAt": row["sent_at"].isoformat() if row["sent_at"] else None,
"deliveredAt": row["delivered_at"].isoformat() if row["delivered_at"] else None,
"readAt": row["read_at"].isoformat() if row["read_at"] else None,
"rawPayload": _json_obj(row["raw_payload"]),
"createdAt": row["created_at"].isoformat(),
}
for row in reversed(rows)
]
return {"messages": messages, "thread": await get_thread(thread_id, request)}
@router.post("/threads/{thread_id}/messages")
async def send_message(
thread_id: str,
body: SendMessageBody,
request: Request,
_: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
async with pool.acquire() as conn:
thread = await conn.fetchrow("SELECT * FROM comms_threads WHERE thread_id = $1::uuid", thread_id)
if not thread:
raise HTTPException(status_code=404, detail="Thread not found")
provider = await _get_provider_for_pool(pool)
result = await provider.send_message(
phone=thread["phone_e164"],
message=body.body,
message_type=body.messageType,
media_url=body.mediaUrl,
)
async with pool.acquire() as conn:
msg_id = await conn.fetchval(
"""
INSERT INTO comms_messages
(thread_id, provider, external_message_id, direction, message_type, body, media_url, delivery_status, sent_at)
VALUES ($1::uuid, $2, $3, 'outbound', $4, $5, $6, 'sent', NOW())
RETURNING message_id
""",
thread_id,
os.getenv("COMMS_PROVIDER", "mock").lower(),
result.get("external_message_id"),
body.messageType,
body.body,
body.mediaUrl,
)
await conn.execute("UPDATE comms_threads SET last_message_at = NOW(), updated_at = NOW() WHERE thread_id = $1::uuid", thread_id)
return {"messageId": str(msg_id), "providerResult": result}
@router.post("/threads/{thread_id}/link-person")
async def link_person(
thread_id: str,
body: LinkPersonBody,
request: Request,
_: UserPrincipal = Depends(get_current_user),
):
pool = await _pool(request)
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT EXISTS (SELECT 1 FROM crm_people WHERE person_id = $1::uuid)", body.personId)
if not exists:
raise HTTPException(status_code=404, detail="CRM person not found")
updated = await conn.execute(
"UPDATE comms_threads SET person_id = $1::uuid, updated_at = NOW() WHERE thread_id = $2::uuid",
body.personId,
thread_id,
)
return {"success": updated.endswith("1"), "threadId": thread_id, "personId": body.personId}
@router.post("/threads/{thread_id}/notes")
async def add_note(thread_id: str, body: NoteBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
async with pool.acquire() as conn:
msg_id = await conn.fetchval(
"""
INSERT INTO comms_messages (thread_id, provider, direction, message_type, body, delivery_status)
VALUES ($1::uuid, 'system', 'system', 'text', $2, 'delivered')
RETURNING message_id
""",
thread_id,
f"Note: {body.content}",
)
return {"messageId": str(msg_id)}
@router.post("/threads/{thread_id}/tasks")
async def add_task(thread_id: str, body: TaskBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
text = f"Task: {body.title}" + (f" (Due: {body.dueAt})" if body.dueAt else "")
async with pool.acquire() as conn:
msg_id = await conn.fetchval(
"""
INSERT INTO comms_messages (thread_id, provider, direction, message_type, body, delivery_status)
VALUES ($1::uuid, 'system', 'system', 'text', $2, 'delivered')
RETURNING message_id
""",
thread_id,
text,
)
return {"messageId": str(msg_id)}
@router.post("/webhooks/{provider}")
async def receive_webhook(provider: str, request: Request):
pool = await _pool(request)
raw_body = await request.body()
secret = os.getenv("COMMS_WEBHOOK_SECRET", "").strip()
if secret:
signature = request.headers.get("x-velocity-signature", "")
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=401, detail="Invalid comms webhook signature")
payload = await request.json()
provider_impl = await _get_provider_for_pool(pool, provider)
normalized = await provider_impl.normalize_webhook(payload)
normalized["provider"] = provider
return {"received": True, "ingest": await ingest_inbound_message(pool, normalized)}
@router.get("/settings")
async def get_settings(request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT value_json, updated_at FROM comms_settings WHERE key = 'config'")
config = _json_obj(row["value_json"]) if row else {}
result = _camel_settings(config, row["updated_at"] if row else None)
if result.get("providerApiKey"):
result["providerApiKey"] = "********" + str(result["providerApiKey"])[-4:]
return result
@router.patch("/settings")
async def patch_settings(body: SettingsPatch, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
updates = _snake_settings(body)
if updates.get("provider_api_key", "").startswith("*"):
updates.pop("provider_api_key", None)
async with pool.acquire() as conn:
row = await conn.fetchrow("SELECT value_json FROM comms_settings WHERE key = 'config'")
config = _json_obj(row["value_json"]) if row else {}
config.update(updates)
await conn.execute(
"""
INSERT INTO comms_settings (key, value_json, updated_at)
VALUES ('config', $1::jsonb, NOW())
ON CONFLICT (key) DO UPDATE SET value_json = EXCLUDED.value_json, updated_at = NOW()
""",
json.dumps(config),
)
return {"success": True}
@router.post("/provider/test")
async def test_provider(request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
return await (await _get_provider_for_pool(pool)).test_connection()
@router.post("/recordings/transcribe")
async def transcribe_recording(body: TranscribeBody, request: Request, _: UserPrincipal = Depends(get_current_user)):
pool = await _pool(request)
if body.callId:
async with pool.acquire() as conn:
await conn.execute(
"UPDATE comms_call_logs SET transcript_text = $1 WHERE call_id = $2::uuid",
"Transcription pending. Configure COMMS_TRANSCRIPTION_PROVIDER to enable processing.",
body.callId,
)
return {
"success": True,
"status": "pending",
"message": "Transcription intake recorded. A real transcription worker/provider is still required.",
"callId": body.callId,
"recordingUrl": body.recordingUrl,
}

100
backend/db/schema_comms.sql Normal file
View File

@@ -0,0 +1,100 @@
-- Velocity Comms Schema
-- Run this migration against your asyncpg pool database.
BEGIN;
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Threads (conversations)
CREATE TABLE IF NOT EXISTS comms_threads (
thread_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider TEXT NOT NULL DEFAULT 'mock',
external_thread_id TEXT,
person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL,
phone_e164 TEXT NOT NULL,
display_name TEXT,
channel TEXT NOT NULL DEFAULT 'whatsapp',
status TEXT NOT NULL DEFAULT 'open',
assigned_user_id UUID NULL,
last_message_at TIMESTAMPTZ,
unread_count INT NOT NULL DEFAULT 0,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comms_threads_phone ON comms_threads(phone_e164);
CREATE INDEX IF NOT EXISTS idx_comms_threads_person ON comms_threads(person_id) WHERE person_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_comms_threads_status ON comms_threads(status);
CREATE INDEX IF NOT EXISTS idx_comms_threads_last_message ON comms_threads(last_message_at DESC NULLS LAST);
-- Messages
CREATE TABLE IF NOT EXISTS comms_messages (
message_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NOT NULL REFERENCES comms_threads(thread_id) ON DELETE CASCADE,
provider TEXT NOT NULL DEFAULT 'mock',
external_message_id TEXT,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound', 'system')),
message_type TEXT NOT NULL DEFAULT 'text',
body TEXT NOT NULL DEFAULT '',
media_url TEXT,
media_mime_type TEXT,
delivery_status TEXT NOT NULL DEFAULT 'pending',
sent_at TIMESTAMPTZ,
delivered_at TIMESTAMPTZ,
read_at TIMESTAMPTZ,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comms_messages_thread ON comms_messages(thread_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_comms_messages_external ON comms_messages(external_message_id) WHERE external_message_id IS NOT NULL;
-- Call logs
CREATE TABLE IF NOT EXISTS comms_call_logs (
call_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id UUID NULL REFERENCES comms_threads(thread_id) ON DELETE SET NULL,
person_id UUID NULL REFERENCES crm_people(person_id) ON DELETE SET NULL,
provider TEXT NOT NULL DEFAULT 'mock',
external_call_id TEXT,
phone_e164 TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
status TEXT NOT NULL DEFAULT 'completed',
started_at TIMESTAMPTZ NOT NULL,
ended_at TIMESTAMPTZ,
duration_seconds INT,
recording_url TEXT,
transcript_id UUID,
transcript_text TEXT,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_comms_call_logs_phone ON comms_call_logs(phone_e164);
CREATE INDEX IF NOT EXISTS idx_comms_call_logs_thread ON comms_call_logs(thread_id) WHERE thread_id IS NOT NULL;
-- Settings (key-value JSON)
CREATE TABLE IF NOT EXISTS comms_settings (
key TEXT PRIMARY KEY,
value_json JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Insert default settings
INSERT INTO comms_settings (key, value_json) VALUES ('config', '{
"provider": "mock",
"provider_base_url": "",
"provider_api_key": "",
"instance_id": "",
"phone_number_id": "",
"webhook_callback_url": "",
"webhook_secret_set": false,
"default_assignment_user_id": null,
"auto_link_by_phone": true,
"create_crm_interaction_on_inbound": true,
"default_country_code": "91",
"media_storage_dir": "/opt/dlami/nvme/assets/comms",
"transcription_provider": "none"
}'::jsonb) ON CONFLICT (key) DO NOTHING;
COMMIT;

View File

@@ -63,6 +63,7 @@ from backend.api.routes_admin_surface import router as admin_surface_router
from backend.api.routes_oracle_templates import router as oracle_templates_router
from backend.api.routes_observability import router as observability_router
from backend.api.routes_crm_imports import router as crm_imports_router
from backend.api.routes_comms import router as comms_router
from backend.api.routes_runtime_llm import router as runtime_llm_router
from backend.auth.routes import router as auth_router
from backend.auth.user_directory import ensure_user_directory_schema
@@ -150,6 +151,7 @@ app.include_router(inventory_router, prefix="/api/inventory", tags=["Inventory"]
app.include_router(admin_surface_router, prefix="/api/admin-surface", tags=["Admin Surface"])
app.include_router(observability_router, prefix="/api", tags=["Observability"])
app.include_router(crm_imports_router, prefix="/api", tags=["CRM Canonical"])
app.include_router(comms_router, prefix="/api/comms", tags=["Comms"])
app.include_router(runtime_llm_router, prefix="/api/runtime/llm", tags=["Runtime LLM"])
# Public vault link (no /api prefix — shared externally with prospects)

View File

@@ -0,0 +1,90 @@
"""
Evolution API (https://github.com/EvolutionAPI/evolution-api) adapter.
"""
import httpx
from typing import Any, Dict, List, Optional
from .comms_provider import CommsProvider
class EvolutionProvider(CommsProvider):
def _headers(self) -> Dict[str, str]:
return {"Content-Type": "application/json", "apikey": self.api_key}
async def _request(self, method: str, path: str, json_data: Optional[Dict] = None) -> Dict[str, Any]:
url = f"{self.base_url}{path}"
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(method, url, headers=self._headers(), json=json_data)
resp.raise_for_status()
return resp.json()
async def send_message(self, phone: str, message: str, message_type: str = "text", **kwargs) -> Dict[str, Any]:
instance = self.instance_id or "default"
payload = {
"number": phone,
"text": message,
"options": {"delay": 1200, "presence": "composing"},
}
result = await self._request("POST", f"/message/sendText/{instance}", payload)
ext_id = result.get("key", {}).get("id") if isinstance(result, dict) else None
return {
"success": True,
"provider": "evolution",
"external_message_id": ext_id,
"status": "sent",
"raw": result,
}
async def normalize_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
Evolution webhook v2 shape:
{
"event": "messages.upsert",
"instance": "default",
"data": {
"key": {"remoteJid": "123@s.whatsapp.net", "fromMe": false, "id": "..."},
"message": {"conversation": "Hello"},
"messageTimestamp": 1710000000, ...
}
}
"""
event = payload.get("event", "")
data = payload.get("data", {})
key = data.get("key", {})
remote_jid = key.get("remoteJid", "")
phone = remote_jid.replace("@s.whatsapp.net", "").replace("@g.us", "")
msg_content = data.get("message", {})
body = msg_content.get("conversation", "") or msg_content.get("extendedTextMessage", {}).get("text", "")
direction = "outbound" if key.get("fromMe") else "inbound"
return {
"provider": "evolution",
"external_message_id": key.get("id"),
"phone_e164": phone,
"direction": direction,
"message_type": "text",
"body": body,
"media_url": None,
"raw": payload,
"timestamp": data.get("messageTimestamp"),
}
async def test_connection(self) -> Dict[str, Any]:
try:
instance = self.instance_id or "default"
info = await self._request("GET", f"/instance/connectionState/{instance}")
return {
"success": True,
"message": f"Evolution instance '{instance}' state retrieved.",
"account_info": info,
}
except Exception as exc:
return {
"success": False,
"message": f"Evolution connection failed: {exc}",
}
async def fetch_templates(self) -> List[Dict[str, Any]]:
return []

View File

@@ -0,0 +1,239 @@
"""Inbound communications ingestion for Velocity CRM."""
from __future__ import annotations
import json
import os
import re
from datetime import UTC, datetime
from typing import Any
from uuid import UUID
PHONEUTILS_AVAILABLE = False
try:
import phonenumbers
from phonenumbers import NumberParseException
PHONEUTILS_AVAILABLE = True
except ImportError:
phonenumbers = None # type: ignore[assignment]
NumberParseException = Exception # type: ignore[assignment]
DEFAULT_COUNTRY = os.getenv("COMMS_DEFAULT_COUNTRY_CODE", "91")
def normalize_phone(phone: str, default_region: str = DEFAULT_COUNTRY) -> str | None:
"""Return an E.164-like phone number suitable for provider and CRM matching."""
if not phone:
return None
cleaned = re.sub(r"[^\d+]", "", phone.strip())
if cleaned.startswith("00"):
cleaned = "+" + cleaned[2:]
if not cleaned.startswith("+"):
cleaned = f"+{default_region}{cleaned}"
if PHONEUTILS_AVAILABLE and phonenumbers is not None:
try:
parsed = phonenumbers.parse(cleaned, None)
if phonenumbers.is_valid_number(parsed):
return phonenumbers.format_number(parsed, phonenumbers.PhoneNumberFormat.E164)
except NumberParseException:
pass
return cleaned if re.match(r"^\+\d{7,15}$", cleaned) else None
def _phone_digits(phone: str) -> str:
return re.sub(r"\D+", "", phone or "")
def _crm_channel(channel: str) -> str:
allowed = {"whatsapp", "sms", "call", "email", "website", "walk_in", "other"}
return channel if channel in allowed else "other"
async def get_or_create_thread(
pool,
phone_e164: str,
provider: str,
external_thread_id: str | None = None,
display_name: str | None = None,
channel: str = "whatsapp",
) -> dict[str, Any]:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT thread_id, person_id, status, unread_count
FROM comms_threads
WHERE phone_e164 = $1 AND provider = $2
LIMIT 1
""",
phone_e164,
provider,
)
if row:
return dict(row)
person_id = None
try:
person_row = await conn.fetchrow(
"""
SELECT person_id
FROM crm_people
WHERE primary_phone = $1
OR regexp_replace(COALESCE(primary_phone, ''), '[^0-9]', '', 'g') = $2
LIMIT 1
""",
phone_e164,
_phone_digits(phone_e164),
)
person_id = person_row["person_id"] if person_row else None
except Exception:
person_id = None
new_id = await conn.fetchval(
"""
INSERT INTO comms_threads
(provider, external_thread_id, person_id, phone_e164, display_name, channel, status, unread_count)
VALUES ($1, $2, $3, $4, $5, $6, 'open', 1)
RETURNING thread_id
""",
provider,
external_thread_id,
person_id,
phone_e164,
display_name or phone_e164,
channel,
)
return {
"thread_id": new_id,
"person_id": person_id,
"status": "open",
"unread_count": 1,
"is_new": True,
}
async def store_message(
pool,
thread_id: UUID,
provider: str,
external_message_id: str | None,
direction: str,
message_type: str,
body: str,
media_url: str | None = None,
raw_payload: dict[str, Any] | None = None,
sent_at: datetime | None = None,
) -> UUID:
async with pool.acquire() as conn:
msg_id = await conn.fetchval(
"""
INSERT INTO comms_messages
(thread_id, provider, external_message_id, direction, message_type, body, media_url,
delivery_status, sent_at, raw_payload)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb)
RETURNING message_id
""",
thread_id,
provider,
external_message_id,
direction,
message_type,
body,
media_url,
"delivered" if direction == "inbound" else "sent",
sent_at or datetime.now(UTC),
json.dumps(raw_payload or {}),
)
unread_delta = 1 if direction == "inbound" else 0
await conn.execute(
"""
UPDATE comms_threads
SET last_message_at = NOW(), unread_count = unread_count + $2, updated_at = NOW()
WHERE thread_id = $1
""",
thread_id,
unread_delta,
)
return msg_id
async def maybe_create_crm_interaction(pool, person_id: UUID, body: str, channel: str = "whatsapp") -> None:
"""Mirror inbound comms into canonical CRM intelligence tables when present."""
if not person_id:
return
try:
async with pool.acquire() as conn:
exists = await conn.fetchval("SELECT to_regclass('public.intel_interactions') IS NOT NULL")
if not exists:
return
interaction_id = await conn.fetchval(
"""
INSERT INTO intel_interactions
(person_id, channel, interaction_type, happened_at, summary, source_ref, metadata_json)
VALUES ($1, $2::intel_channel, 'message', NOW(), $3, 'comms_ingest', $4::jsonb)
RETURNING interaction_id
""",
person_id,
_crm_channel(channel),
body[:500],
json.dumps({"source": "comms", "direction": "inbound"}),
)
if await conn.fetchval("SELECT to_regclass('public.intel_messages') IS NOT NULL"):
await conn.execute(
"""
INSERT INTO intel_messages
(interaction_id, sender_role, sender_name, message_text, delivered_at, metadata_json)
VALUES ($1, 'lead', NULL, $2, NOW(), $3::jsonb)
""",
interaction_id,
body,
json.dumps({"source": "comms"}),
)
except Exception:
return
async def ingest_inbound_message(pool, normalized_payload: dict[str, Any]) -> dict[str, Any]:
phone = normalize_phone(normalized_payload.get("phone_e164") or normalized_payload.get("phone") or "")
if not phone:
raise ValueError("Missing phone_e164 in payload")
provider = normalized_payload.get("provider", "unknown")
channel = normalized_payload.get("channel", "whatsapp")
thread = await get_or_create_thread(
pool,
phone_e164=phone,
provider=provider,
external_thread_id=normalized_payload.get("external_thread_id"),
display_name=normalized_payload.get("display_name") or phone,
channel=channel,
)
timestamp = normalized_payload.get("timestamp")
sent_at = datetime.fromtimestamp(timestamp, UTC) if timestamp else None
msg_id = await store_message(
pool,
thread_id=thread["thread_id"],
provider=provider,
external_message_id=normalized_payload.get("external_message_id"),
direction=normalized_payload.get("direction", "inbound"),
message_type=normalized_payload.get("message_type", "text"),
body=normalized_payload.get("body", ""),
media_url=normalized_payload.get("media_url"),
raw_payload=normalized_payload.get("raw"),
sent_at=sent_at,
)
if thread.get("person_id") and normalized_payload.get("direction", "inbound") == "inbound":
await maybe_create_crm_interaction(pool, thread["person_id"], normalized_payload.get("body", ""), channel)
return {
"thread_id": str(thread["thread_id"]),
"message_id": str(msg_id),
"person_id": str(thread["person_id"]) if thread.get("person_id") else None,
"is_new_thread": thread.get("is_new", False),
}

View File

@@ -0,0 +1,63 @@
"""
Abstract provider interface for Velocity Comms.
"""
import os
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional
class CommsProvider(ABC):
def __init__(self, base_url: str, api_key: str, instance_id: Optional[str] = None):
self.base_url = base_url.rstrip("/")
self.api_key = api_key
self.instance_id = instance_id
@abstractmethod
async def send_message(self, phone: str, message: str, message_type: str = "text", **kwargs) -> Dict[str, Any]:
"""Send a message. Return provider response dict."""
...
@abstractmethod
async def normalize_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Convert provider webhook payload to Velocity canonical format."""
...
@abstractmethod
async def test_connection(self) -> Dict[str, Any]:
"""Test provider connectivity. Return {success, message, account_info}."""
...
async def fetch_templates(self) -> List[Dict[str, Any]]:
"""Optional: fetch message templates."""
return []
async def get_media(self, media_id: str) -> Optional[bytes]:
"""Optional: download media bytes."""
return None
async def send_template(self, phone: str, template_name: str, language: str, components: Optional[List] = None) -> Dict[str, Any]:
"""Optional: send a template message."""
raise NotImplementedError("Templates not supported by this provider.")
class MockProvider(CommsProvider):
"""Mock provider for local development and UI previews."""
async def send_message(self, phone: str, message: str, message_type: str = "text", **kwargs) -> Dict[str, Any]:
return {
"success": True,
"provider": "mock",
"external_message_id": f"mock-{os.urandom(4).hex()}",
"status": "sent",
}
async def normalize_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
return payload
async def test_connection(self) -> Dict[str, Any]:
return {
"success": True,
"message": "Mock provider is always healthy.",
"account_info": {"mode": "mock"},
}

View File

@@ -0,0 +1,95 @@
"""
WAHA (https://github.com/devlikeapro/waha) adapter.
WAHA exposes a simple HTTP API for WhatsApp Web.
"""
import httpx
from typing import Any, Dict, Optional
from .comms_provider import CommsProvider
class WahaProvider(CommsProvider):
async def _request(self, method: str, path: str, json_data: Optional[Dict] = None) -> Dict[str, Any]:
url = f"{self.base_url}/api{path}"
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["X-Api-Key"] = self.api_key
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(method, url, headers=headers, json=json_data)
resp.raise_for_status()
return resp.json()
async def send_message(self, phone: str, message: str, message_type: str = "text", **kwargs) -> Dict[str, Any]:
chat_id = f"{phone}@c.us"
payload = {
"chatId": chat_id,
"text": message,
"session": self.instance_id or "default",
}
if message_type == "image" and kwargs.get("media_url"):
payload["caption"] = message
payload["media"] = kwargs["media_url"]
result = await self._request("POST", "/sendImage", payload)
else:
result = await self._request("POST", "/sendText", payload)
return {
"success": True,
"provider": "waha",
"external_message_id": result.get("id"),
"status": "sent",
"raw": result,
}
async def normalize_webhook(self, payload: Dict[str, Any]) -> Dict[str, Any]:
"""
WAHA webhook payload shape (v2024):
{
"event": "message",
"session": "default",
"payload": {
"id": "true_123@c.us_3EB0...",
"timestamp": 1710000000,
"from": "123@c.us",
"to": "456@c.us",
"body": "Hello",
"hasMedia": false, ...
}
}
"""
event = payload.get("event", "")
pl = payload.get("payload", {})
from_jid = pl.get("from", "")
phone = from_jid.replace("@c.us", "").replace("@g.us", "")
direction = "inbound" if event == "message" and not pl.get("fromMe") else "outbound"
return {
"provider": "waha",
"external_message_id": pl.get("id"),
"phone_e164": phone,
"direction": direction,
"message_type": "image" if pl.get("hasMedia") else "text",
"body": pl.get("body", ""),
"media_url": pl.get("mediaUrl"),
"raw": payload,
"timestamp": pl.get("timestamp"),
}
async def test_connection(self) -> Dict[str, Any]:
try:
sessions = await self._request("GET", "/sessions?all=true")
return {
"success": True,
"message": f"Connected to WAHA. Sessions: {len(sessions)}",
"account_info": {"sessions": sessions},
}
except Exception as exc:
return {
"success": False,
"message": f"WAHA connection failed: {exc}",
}
async def get_media(self, media_id: str) -> Optional[bytes]:
return None