feat: Major WhatsApp integration update with Odoo and pause/resume

## Frontend
- Add media display (images, audio, video, docs) in Inbox
- Add pause/resume functionality for WhatsApp accounts
- Fix media URLs to use nginx proxy (relative URLs)

## API Gateway
- Add /accounts/:id/pause and /accounts/:id/resume endpoints
- Fix media URL handling for browser access

## WhatsApp Core
- Add pauseSession() - disconnect without logout
- Add resumeSession() - reconnect using saved credentials
- Add media download and storage for incoming messages
- Serve media files via /media/ static route

## Odoo Module (odoo_whatsapp_hub)
- Add Chat Hub interface with DOLLARS theme (dark, 3-column layout)
- Add WhatsApp/DRRR theme switcher for chat view
- Add "ABRIR CHAT" button in conversation form
- Add send_message_from_chat() method
- Add security/ir.model.access.csv
- Fix CSS scoping to avoid breaking Odoo UI
- Update webhook to handle message events properly

## Documentation
- Add docs/CONTEXTO_DESARROLLO.md with complete project context

## Infrastructure
- Add whatsapp_media Docker volume
- Configure nginx proxy for /media/ route
- Update .gitignore to track src/sessions/ source files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude AI
2026-01-30 20:48:56 +00:00
parent 1040debe2e
commit 5dd3499097
33 changed files with 3636 additions and 138 deletions

View File

@@ -17,6 +17,7 @@ class Settings(BaseSettings):
# WhatsApp Core
WHATSAPP_CORE_URL: str = "http://localhost:3001"
WHATSAPP_CORE_PUBLIC_URL: str = "http://localhost:3001" # URL accessible from browser
# Flow Engine
FLOW_ENGINE_URL: str = "http://localhost:8001"
@@ -24,6 +25,9 @@ class Settings(BaseSettings):
# Integrations
INTEGRATIONS_URL: str = "http://localhost:8002"
# Odoo Webhook
ODOO_WEBHOOK_URL: str = "" # e.g., "http://192.168.10.188:8069/whatsapp/webhook"
# CORS
CORS_ORIGINS: str = "http://localhost:5173,http://localhost:3000"

View File

@@ -62,11 +62,29 @@ async def create_account(
@router.get("/accounts", response_model=List[WhatsAppAccountResponse])
def list_accounts(
async def list_accounts(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
accounts = db.query(WhatsAppAccount).all()
# Sync status with WhatsApp Core for each account
async with httpx.AsyncClient() as client:
for account in accounts:
try:
response = await client.get(
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account.id}",
timeout=5,
)
if response.status_code == 200:
data = response.json()
account.qr_code = data.get("qrCode")
account.status = AccountStatus(data.get("status", "disconnected"))
account.phone_number = data.get("phoneNumber")
except Exception:
pass
db.commit()
return accounts
@@ -122,6 +140,63 @@ async def delete_account(
return {"success": True}
@router.post("/accounts/{account_id}/pause")
async def pause_account(
account_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Pause WhatsApp connection without logging out (preserves session)"""
account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}/pause",
timeout=10,
)
if response.status_code == 200:
account.status = AccountStatus.DISCONNECTED
db.commit()
return {"success": True, "status": "paused"}
else:
raise HTTPException(status_code=500, detail="Failed to pause session")
except httpx.RequestError as e:
raise HTTPException(status_code=500, detail=f"Connection error: {str(e)}")
@router.post("/accounts/{account_id}/resume")
async def resume_account(
account_id: UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_admin),
):
"""Resume paused WhatsApp connection"""
account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}/resume",
timeout=30,
)
if response.status_code == 200:
data = response.json()
session = data.get("session", {})
account.status = AccountStatus(session.get("status", "connecting"))
account.qr_code = session.get("qrCode")
db.commit()
return {"success": True, "status": account.status.value}
else:
raise HTTPException(status_code=500, detail="Failed to resume session")
except httpx.RequestError as e:
raise HTTPException(status_code=500, detail=f"Connection error: {str(e)}")
@router.get("/conversations", response_model=List[ConversationResponse])
def list_conversations(
status: ConversationStatus = None,
@@ -228,6 +303,7 @@ async def handle_whatsapp_event(
elif event.type == "message":
msg_data = event.data
phone = msg_data.get("from", "").split("@")[0]
is_from_me = msg_data.get("fromMe", False)
contact = db.query(Contact).filter(Contact.phone_number == phone).first()
if not contact:
@@ -256,19 +332,47 @@ async def handle_whatsapp_event(
db.refresh(conversation)
wa_message = msg_data.get("message", {})
media_url = msg_data.get("mediaUrl")
media_type = msg_data.get("mediaType", "text")
# Extract text content
content = (
wa_message.get("conversation") or
wa_message.get("extendedTextMessage", {}).get("text") or
"[Media]"
wa_message.get("imageMessage", {}).get("caption") or
wa_message.get("videoMessage", {}).get("caption") or
wa_message.get("documentMessage", {}).get("fileName") or
""
)
# Map media type to MessageType
type_mapping = {
"text": MessageType.TEXT,
"image": MessageType.IMAGE,
"audio": MessageType.AUDIO,
"video": MessageType.VIDEO,
"document": MessageType.DOCUMENT,
"sticker": MessageType.IMAGE,
}
msg_type = type_mapping.get(media_type, MessageType.TEXT)
# Build full media URL if present (use relative URL for browser access via nginx proxy)
full_media_url = None
if media_url:
# Use relative URL that nginx will proxy to whatsapp-core
full_media_url = media_url # e.g., "/media/uuid.jpg"
# Set direction based on fromMe flag
direction = MessageDirection.OUTBOUND if is_from_me else MessageDirection.INBOUND
message = Message(
conversation_id=conversation.id,
whatsapp_message_id=msg_data.get("id"),
direction=MessageDirection.INBOUND,
type=MessageType.TEXT,
content=content,
status=MessageStatus.DELIVERED,
direction=direction,
type=msg_type,
content=content if content else f"[{media_type.capitalize()}]",
media_url=full_media_url,
status=MessageStatus.DELIVERED if not is_from_me else MessageStatus.SENT,
)
db.add(message)
@@ -277,8 +381,8 @@ async def handle_whatsapp_event(
db.commit()
db.refresh(message)
# Process message through Flow Engine (if in BOT status)
if conversation.status == ConversationStatus.BOT:
# Process message through Flow Engine (only for inbound messages in BOT status)
if not is_from_me and conversation.status == ConversationStatus.BOT:
async with httpx.AsyncClient() as client:
try:
await client.post(
@@ -305,8 +409,53 @@ async def handle_whatsapp_event(
except Exception as e:
print(f"Flow engine error: {e}")
# Send webhook to Odoo if configured
if settings.ODOO_WEBHOOK_URL:
async with httpx.AsyncClient() as client:
try:
await client.post(
settings.ODOO_WEBHOOK_URL,
json={
"type": "message",
"account_id": str(account.id),
"data": {
"id": str(message.id),
"conversation_id": str(conversation.id),
"from": phone,
"contact_name": contact.name,
"content": content,
"type": media_type,
"direction": "outbound" if is_from_me else "inbound",
"media_url": full_media_url,
},
},
timeout=10,
)
except Exception as e:
print(f"Odoo webhook error: {e}")
return {"status": "ok"}
# Send account status to Odoo webhook
if settings.ODOO_WEBHOOK_URL and event.type in ["connected", "disconnected", "qr"]:
async with httpx.AsyncClient() as client:
try:
await client.post(
settings.ODOO_WEBHOOK_URL,
json={
"type": "account_status",
"account_id": str(account.id),
"data": {
"status": account.status.value if account.status else "disconnected",
"phone_number": account.phone_number,
"qr_code": account.qr_code,
},
},
timeout=10,
)
except Exception as e:
print(f"Odoo webhook error: {e}")
db.commit()
return {"status": "ok"}
@@ -448,3 +597,183 @@ def add_internal_note(
db.commit()
db.refresh(message)
return {"success": True, "message_id": str(message.id)}
# ============================================
# Odoo Internal Endpoints (no authentication)
# ============================================
class OdooSendMessageRequest(BaseModel):
phone_number: str
message: str
account_id: str
@router.get("/internal/odoo/accounts/{account_id}")
async def odoo_get_account(
account_id: UUID,
db: Session = Depends(get_db),
):
"""Get account status for Odoo (no auth required)"""
account = db.query(WhatsAppAccount).filter(WhatsAppAccount.id == account_id).first()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Sync with WhatsApp Core
async with httpx.AsyncClient() as client:
try:
response = await client.get(
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account_id}",
timeout=10,
)
if response.status_code == 200:
data = response.json()
account.qr_code = data.get("qrCode")
account.status = AccountStatus(data.get("status", "disconnected"))
account.phone_number = data.get("phoneNumber")
db.commit()
except Exception:
pass
return {
"id": str(account.id),
"phone_number": account.phone_number,
"name": account.name,
"status": account.status.value if account.status else "disconnected",
"qr_code": account.qr_code,
}
@router.get("/internal/odoo/accounts")
async def odoo_list_accounts(
db: Session = Depends(get_db),
):
"""List all accounts for Odoo (no auth required)"""
accounts = db.query(WhatsAppAccount).all()
async with httpx.AsyncClient() as client:
for account in accounts:
try:
response = await client.get(
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account.id}",
timeout=5,
)
if response.status_code == 200:
data = response.json()
account.qr_code = data.get("qrCode")
account.status = AccountStatus(data.get("status", "disconnected"))
account.phone_number = data.get("phoneNumber")
except Exception:
pass
db.commit()
return [
{
"id": str(a.id),
"phone_number": a.phone_number,
"name": a.name,
"status": a.status.value if a.status else "disconnected",
}
for a in accounts
]
@router.post("/internal/odoo/send")
async def odoo_send_message(
request: OdooSendMessageRequest,
db: Session = Depends(get_db),
):
"""Send WhatsApp message from Odoo (no auth required)"""
account = db.query(WhatsAppAccount).filter(
WhatsAppAccount.id == request.account_id
).first()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
# Find or create contact
phone = request.phone_number.replace("+", "").replace(" ", "").replace("-", "")
contact = db.query(Contact).filter(Contact.phone_number == phone).first()
if not contact:
contact = Contact(phone_number=phone)
db.add(contact)
db.commit()
db.refresh(contact)
# Find or create conversation
conversation = db.query(Conversation).filter(
Conversation.whatsapp_account_id == account.id,
Conversation.contact_id == contact.id,
Conversation.status != ConversationStatus.RESOLVED,
).first()
if not conversation:
conversation = Conversation(
whatsapp_account_id=account.id,
contact_id=contact.id,
status=ConversationStatus.BOT,
)
db.add(conversation)
db.commit()
db.refresh(conversation)
# Create message
message = Message(
conversation_id=conversation.id,
direction=MessageDirection.OUTBOUND,
type=MessageType.TEXT,
content=request.message,
status=MessageStatus.PENDING,
)
db.add(message)
db.commit()
db.refresh(message)
# Send via WhatsApp Core
async with httpx.AsyncClient() as client:
try:
response = await client.post(
f"{settings.WHATSAPP_CORE_URL}/api/sessions/{account.id}/messages",
json={
"to": phone,
"type": "text",
"content": {"text": request.message},
},
timeout=30,
)
if response.status_code == 200:
data = response.json()
message.whatsapp_message_id = data.get("messageId")
message.status = MessageStatus.SENT
else:
message.status = MessageStatus.FAILED
except Exception as e:
message.status = MessageStatus.FAILED
raise HTTPException(status_code=500, detail=f"Failed to send: {e}")
db.commit()
return {"success": True, "message_id": str(message.id)}
@router.get("/internal/odoo/conversations")
def odoo_list_conversations(
account_id: str = None,
db: Session = Depends(get_db),
):
"""List conversations for Odoo (no auth required)"""
query = db.query(Conversation)
if account_id:
query = query.filter(Conversation.whatsapp_account_id == account_id)
conversations = query.order_by(Conversation.last_message_at.desc()).limit(100).all()
return [
{
"id": str(c.id),
"contact_phone": c.contact.phone_number if c.contact else None,
"contact_name": c.contact.name if c.contact else None,
"status": c.status.value if c.status else None,
"last_message_at": c.last_message_at.isoformat() if c.last_message_at else None,
}
for c in conversations
]