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:
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user