forked from sagnik/Project_Velocity
Compare commits
3 Commits
4de2266f9d
...
acfc602157
| Author | SHA1 | Date | |
|---|---|---|---|
| acfc602157 | |||
|
|
59d398abc3 | ||
|
|
3623bacbac |
265
.Agent Context/COMMS_INTEGRATION_HANDOFF.md
Normal file
265
.Agent Context/COMMS_INTEGRATION_HANDOFF.md
Normal 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*
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
728
app/src/components/modules/Comms.tsx
Normal file
728
app/src/components/modules/Comms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
89
app/src/lib/commsApi.ts
Normal 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
121
app/src/types/commsTypes.ts
Normal 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;
|
||||
}
|
||||
@@ -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
588
backend/api/routes_comms.py
Normal 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,
|
||||
}
|
||||
@@ -216,7 +216,6 @@ class CreateReminderRequest(BaseModel):
|
||||
priority: str = "normal"
|
||||
|
||||
|
||||
<<<<<<< HEAD
|
||||
class ClientDataPatchRequest(BaseModel):
|
||||
full_name: str | None = Field(default=None, max_length=256)
|
||||
primary_email: str | None = Field(default=None, max_length=256)
|
||||
@@ -227,7 +226,8 @@ class ClientDataPatchRequest(BaseModel):
|
||||
lead_status: str | None = Field(default=None, max_length=64)
|
||||
budget_band: str | None = Field(default=None, max_length=128)
|
||||
urgency: str | None = Field(default=None, max_length=64)
|
||||
=======
|
||||
|
||||
|
||||
class UpdateReminderRequest(BaseModel):
|
||||
status: str = Field(..., min_length=1, max_length=32)
|
||||
due_at: str | None = None
|
||||
@@ -246,7 +246,6 @@ class UpdateOpportunityRequest(BaseModel):
|
||||
expected_close_date: str | None = None
|
||||
next_action: str | None = Field(default=None, max_length=1000)
|
||||
notes: str | None = Field(default=None, max_length=4000)
|
||||
>>>>>>> sayan-feat/#38
|
||||
|
||||
|
||||
# ── Import Endpoints ──────────────────────────────────────────────────────────
|
||||
|
||||
100
backend/db/schema_comms.sql
Normal file
100
backend/db/schema_comms.sql
Normal 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;
|
||||
@@ -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,7 +151,9 @@ 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"])
|
||||
app.include_router(auth_router)
|
||||
|
||||
# Public vault link (no /api prefix — shared externally with prospects)
|
||||
from backend.routers.vault import router as public_vault_router
|
||||
|
||||
90
backend/services/comms_evolution_provider.py
Normal file
90
backend/services/comms_evolution_provider.py
Normal 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 []
|
||||
239
backend/services/comms_ingest.py
Normal file
239
backend/services/comms_ingest.py
Normal 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),
|
||||
}
|
||||
63
backend/services/comms_provider.py
Normal file
63
backend/services/comms_provider.py
Normal 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"},
|
||||
}
|
||||
95
backend/services/comms_waha_provider.py
Normal file
95
backend/services/comms_waha_provider.py
Normal 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
|
||||
Reference in New Issue
Block a user